Get Hands-On with Test-Driven Development in C#
Test-driven development, or TDD, is a powerful technique that can help you write more reliable, maintainable code. With TDD, you write failing tests first, before any implementation code. Then you write the minimal code required to make the test pass, and refactor to improve the design. By incrementally building up functionality one test at a time, you are forced to think through requirements and edge cases upfront, resulting in code that is thoroughly tested by design.
While TDD requires more effort and discipline compared to diving straight into implementation code, it pays off in spades. Studies have shown that TDD substantially reduces bug density compared to code written with a test-after or no-tests approach. And the extensive test suite that you naturally build up provides a safety net, giving you confidence to refactor and extend the code without fear of breaking existing functionality.
In this tutorial, we‘ll get some hands-on practice with TDD by implementing a basic calculator app in C#. We‘ll use the popular NUnit testing framework, but the concepts apply to other frameworks like xUnit or MSTest as well. To keep things simple, we‘ll build up the calculator functionality incrementally, starting with a failing test and making it pass, before adding the next test for the next piece of functionality. By the end, not only will we have a working calculator, but also a comprehensive test suite to go with it.
Setting Up the Project
First, let‘s create a new Class Library project in Visual Studio (or your C# IDE of choice) for our Calculator logic. We‘ll also add a separate NUnit Test Project for our tests.
In your Calculator class library, define an interface for the functionality we want our calculator to have:
public interface ICalculator
{
int Add(int a, int b);
int Subtract(int a, int b);
int Multiply(int a, int b);
int Divide(int a, int b);
}
Next, create a stub implementation of the interface that we‘ll flesh out as we go:
public class Calculator : ICalculator
{
public int Add(int a, int b)
{
throw new System.NotImplementedException();
}
public int Divide(int a, int b)
{
throw new System.NotImplementedException();
}
public int Multiply(int a, int b)
{
throw new System.NotImplementedException();
}
public int Subtract(int a, int b)
{
throw new System.NotImplementedException();
}
}
Now let‘s switch over to our test project and write our first test.
Addition Tests
We‘ll start by testing the Add method. Create a new test class and add the following test:
[TestFixture]
public class CalculatorTests
{
private ICalculator _calculator;
[SetUp]
public void SetUp()
{
_calculator = new Calculator();
}
[Test]
public void Add_WhenCalled_ReturnsSumOfArguments()
{
var result = _calculator.Add(1, 2);
Assert.That(result, Is.EqualTo(3));
}
}
This test creates an instance of the Calculator class, calls the Add method with arguments 1 and 2, and asserts that the result is 3. If you run the test now, it will fail with a NotImplementedException since we haven‘t implemented Add yet.
Let‘s make the test pass by implementing the method with the simplest code possible:
public int Add(int a, int b)
{
return 3;
}
Amazingly, this naive implementation does indeed make the test pass! However, it clearly isn‘t a generalized solution. To drive that out, we need more test cases:
[Test]
public void Add_WhenCalledWithDifferentNumbers_ReturnsSumOfArguments()
{
Assert.That(_calculator.Add(1, 2), Is.EqualTo(3));
Assert.That(_calculator.Add(-1, -1), Is.EqualTo(-2));
Assert.That(_calculator.Add(100, -50), Is.EqualTo(50));
}
These additional assertions check the Add method with different combinations of positive and negative numbers. Running the tests again, we now have a failure since our previous implementation was too simplistic.
To make the tests pass, let‘s implement Add properly:
public int Add(int a, int b)
{
return a + b;
}
And with that, all our tests pass! We have our first calculator function implemented using TDD. By writing the tests first, we were forced to consider both the expected result and potential edge cases (like negative numbers), resulting in a robust implementation.
Let‘s continue the process for the other calculator operations.
Subtraction Tests
[Test]
public void Subtract_WhenCalled_ReturnsDifferenceOfArguments()
{
Assert.That(_calculator.Subtract(3, 2), Is.EqualTo(1));
Assert.That(_calculator.Subtract(-1, 1), Is.EqualTo(-2));
Assert.That(_calculator.Subtract(10, -5), Is.EqualTo(15));
}
To make these pass:
public int Subtract(int a, int b)
{
return a - b;
}
Multiplication Tests
[Test]
public void Multiply_WhenCalled_ReturnsProductOfArguments()
{
Assert.That(_calculator.Multiply(2, 3), Is.EqualTo(6));
Assert.That(_calculator.Multiply(-2, 3), Is.EqualTo(-6));
Assert.That(_calculator.Multiply(10, 0), Is.EqualTo(0));
}
Implementation:
public int Multiply(int a, int b)
{
return a * b;
}
Division Tests
Division has an important edge case we need to consider – division by zero. Let‘s write a test for that:
[Test]
public void Divide_WithZeroDivisor_ThrowsDivideByZeroException()
{
Assert.That(() => _calculator.Divide(10, 0), Throws.TypeOf<DivideByZeroException>());
}
Here we assert that calling Divide with a divisor of zero throws a DivideByZeroException. Running the test, it fails initially because no exception is thrown. Let‘s fix that:
public int Divide(int a, int b)
{
if (b == 0)
{
throw new DivideByZeroException();
}
return a / b;
}
And let‘s not forget some regular cases:
[Test]
public void Divide_WithNonZeroDivisor_ReturnsQuotientOfArguments()
{
Assert.That(_calculator.Divide(10, 5), Is.EqualTo(2));
Assert.That(_calculator.Divide(10, 3), Is.EqualTo(3)); // truncated division
Assert.That(_calculator.Divide(-10, 2), Is.EqualTo(-5));
}
With the implementation we already have, all tests now pass! The key point is that by writing the tests first, we were forced to consider the divide by zero case upfront and handle it explicitly.
Putting It All Together
We now have a fully functioning calculator class, complete with a comprehensive test suite. Here‘s the final code:
public class Calculator : ICalculator
{
public int Add(int a, int b)
{
return a + b;
}
public int Subtract(int a, int b)
{
return a - b;
}
public int Multiply(int a, int b)
{
return a * b;
}
public int Divide(int a, int b)
{
if (b == 0)
{
throw new DivideByZeroException();
}
return a / b;
}
}
And here are all the tests:
[TestFixture]
public class CalculatorTests
{
private ICalculator _calculator;
[SetUp]
public void SetUp()
{
_calculator = new Calculator();
}
[Test]
public void Add_WhenCalledWithDifferentNumbers_ReturnsSumOfArguments()
{
Assert.That(_calculator.Add(1, 2), Is.EqualTo(3));
Assert.That(_calculator.Add(-1, -1), Is.EqualTo(-2));
Assert.That(_calculator.Add(100, -50), Is.EqualTo(50));
}
[Test]
public void Subtract_WhenCalled_ReturnsDifferenceOfArguments()
{
Assert.That(_calculator.Subtract(3, 2), Is.EqualTo(1));
Assert.That(_calculator.Subtract(-1, 1), Is.EqualTo(-2));
Assert.That(_calculator.Subtract(10, -5), Is.EqualTo(15));
}
[Test]
public void Multiply_WhenCalled_ReturnsProductOfArguments()
{
Assert.That(_calculator.Multiply(2, 3), Is.EqualTo(6));
Assert.That(_calculator.Multiply(-2, 3), Is.EqualTo(-6));
Assert.That(_calculator.Multiply(10, 0), Is.EqualTo(0));
}
[Test]
public void Divide_WithZeroDivisor_ThrowsDivideByZeroException()
{
Assert.That(() => _calculator.Divide(10, 0), Throws.TypeOf<DivideByZeroException>());
}
[Test]
public void Divide_WithNonZeroDivisor_ReturnsQuotientOfArguments()
{
Assert.That(_calculator.Divide(10, 5), Is.EqualTo(2));
Assert.That(_calculator.Divide(10, 3), Is.EqualTo(3)); // truncated division
Assert.That(_calculator.Divide(-10, 2), Is.EqualTo(-5));
}
}
We built this up incrementally, one failing test at a time, and now have a solid foundation that we can extend further as needed.
Challenges and Best Practices
TDD is simple in concept but can be challenging to apply in practice. It requires a mindset shift from thinking about implementation details first to focusing on desired behaviors and outcomes. It can feel counterintuitive to write tests for code that doesn‘t exist yet.
Some key things to keep in mind:
- Keep tests and production code separate. Tests are a different concern and should not be mixed with implementation.
- Tests should be deterministic – they should always produce the same result for the same inputs. Avoid dependencies on mutable global state or I/O operations in tests.
- Try to keep each test focused on one thing. Tests that verify too much can become fragile and harder to reason about.
- Resist the urge to write more implementation code than needed to pass the current test. Let the tests drive incremental development.
- Refactor frequently to keep the code clean, but make sure tests keep passing. Well-factored code is easier to extend and maintain.
- Tests should run fast. Favor in-memory "unit" tests over slow, brittle end-to-end tests. Keep test data small and focused.
Conclusion
We‘ve now seen how test-driven development can help us produce reliable, well-tested code in C#. By writing tests first, we are forced to think through requirements, edge cases, and design before jumping into implementation. The resulting tests provide living documentation of the code‘s intended behavior and a safety net against regressions.
Test-driven development is a powerful technique, but it does require practice and discipline to master. I encourage you to try applying TDD to your own C# projects, starting small at first. Over time, it will start to feel more natural, and you will likely see significant improvements in your code quality and maintainability as a result.
Happy test-driven coding!