Test-Driven Development (TDD)
In TDD we first write a small test that fails. Then we write the simplest code that makes it green. Then we clean up the code. This short cycle builds tests and also makes the code design better.
Author: bezzad
The problem: the test that is never written
The sales team of our shop wants a new rule: “Shipping costs 50,000 toman. But an order of 500,000 toman or more ships for free.”
The usual way is this:
- We write all the code at once. Sometimes with a few extra cases that “may be needed later”.
- We try it by hand. It works. We are happy.
- We say we will write the test later. But the next task arrives and the test is never written.
- Even if we write it, it is hard. Because the class was not designed to be tested from the start. For example, it is connected directly to the database or the system clock.
The TDD idea: test first, then code
In Test-Driven Development (Test-Driven Development) we reverse the order. We write no line of main code, except to make a red test green. The work happens in a short, repeated cycle:
- Red (Red). Write a small test for one new behavior. Run it and see that it fails. If it is green, either the behavior already existed or the test is wrong.
- Green (Green). Write the simplest code that makes the test green. In this step, beautiful code does not matter. Only getting green matters.
- Refactor (Refactor). Now that all tests are green, clean up the code. Improve the names and remove repetition. After each small change, run the tests.
Live example
Build the shipping cost rule step by step with TDD. Watch the color of each step and the change in the code:
Look at step two. The code “always return 50,000” looks strange. But this is on purpose, for two reasons:
- The step stays small. If something breaks, we know exactly which change broke it.
- The next test forces the code to become general. Kent Beck (Kent Beck) calls this Triangulation. It means we push the code toward the general rule with two or more examples.
If the solution is fully clear from the start, you do not need to take these very small steps. You can write the right code directly. But when you are not sure, make the steps smaller.
The final code
After a few rounds, the tests and the code look like this:
public class ShippingCostTests
{
[Theory]
[InlineData(200_000, false, 50_000)]
[InlineData(499_999, false, 50_000)]
[InlineData(500_000, false, 0)]
[InlineData(200_000, true, 0)]
public void Shipping_cost_follows_the_rules(int total, bool isVip, int expected)
{
var cost = ShippingCost.For(total, isVip);
Assert.Equal(expected, cost);
}
}
public static class ShippingCost
{
private const decimal FreeFrom = 500_000m;
private const decimal Standard = 50_000m;
public static decimal For(decimal orderTotal, bool isVip) =>
isVip || orderTotal >= FreeFrom ? 0m : Standard;
}
Look at the two rows 499,999 and 500,000. These are the edges of the rule. Writing the test before the code forces us to think about these edges and ask the sales team: “Is exactly 500,000 free, or only above it?”
Why does TDD help?
- Better design. When you write the test first, you first look at the class from the view of its user. A class that is hard to test usually has a bad design. You find this out very early.
- Only the needed code. Every line of code was written because of a test. Less extra code “for a rainy day” gets written.
- A safety net. Every behavior has a test. So the next refactoring is not scary.
- Living documentation. The test names explain the business rules and always match the code.
- Small steps. You are always only a few minutes away from a healthy state. If you get lost, you undo the last change.
Two styles: inside-out, outside-in
Inside-out (the classic style)
- You start from the smallest rule, like calculating the shipping cost.
- Little by little you build bigger classes on top of it.
- You usually need fewer Mocks.
- Risk: you may build something the upper layer does not need.
Outside-in (the London style)
- You start from a test for the whole feature, for example the place-order Endpoint.
- You first assume the inner classes with Mocks, then build them one by one.
- The interfaces of the classes come from the real needs of their users.
- Risk: many Mocks tie the test to the implementation.
Important rules
- Only one red test at a time. Several red tests together mean the step is too big.
- Do not skip the refactor step. Without it, TDD only produces messy code with tests.
- Do not write extra code in the green step. If you think of a new behavior, write it in a list. Come back to it later with a new test.
- Test the behavior, not the implementation. Otherwise the refactor step breaks the tests, and TDD becomes painful.
- Tests must be fast. A TDD cycle is a few minutes. If running the tests takes a minute, the cycle is lost.
Common mistakes
| Mistake | Result | Right way |
|---|---|---|
| Writing ten tests, then writing the code | Several red tests together. It is not clear where to start. | One test, one step. |
| Not seeing red before green | A test that checks nothing stays green. | Always see the failure first. |
| Skipping the refactor step | The code works, but it is dirty and full of repetition. | After each green, clean up a little. |
| Testing private methods and details | Every refactoring breaks the tests. | Test only through public behavior. |
| Forcing TDD for everything | Time is wasted on experimental, throw-away code. | Where there is logic and rules. |
When to use TDD?
Good fit
- Business rules with many conditions and cases.
- Fixing a bug: first a test that shows the bug, then the fix.
- Calculations, conversions and algorithms.
- When the expected input and output are clear.
Bad fit
- Experimental code (Spike) to learn a library. After learning, throw it away and write it again with TDD.
- The layout and look of a page.
- When you do not yet know exactly what the problem is.
Summary in six lines
- In TDD, no main code is written without a red test.
- The cycle has three steps: red, green, refactor.
- In the green step, the simplest code is enough. The next tests make the code more general.
- Refactoring happens only when all tests are green.
- The main benefit of TDD is better design and a safety net for change, not only the number of tests.
- It is great for logic and rules. It is not needed for experimental code and the look of a page.