TDD is not about testing; it’s about design. Tests are the byproduct of thinking clearly about what your code should do before you write it.
What is TDD?
Test-Driven Development is a short, repeating development cycle based on writing tests before code. It comes from Extreme Programming (XP) and encourages simple design with a high level of confidence.
The Red-Green-Refactor Cycle
┌─────────────────────────────┐
│ │
│ RED → Write a failing │
│ test first │
│ ↓ │
│ GREEN → Write minimal code │
│ to make it pass │
│ ↓ │
│ REFACTOR → Improve code │
│ without │
│ changing │
│ behaviour │
│ ↓ │
│ (repeat with next test) │
└─────────────────────────────┘
- Red => Write a test that fails. You haven’t written the implementation yet, so it must fail.
- Green => Write the simplest code that makes the test pass. Don’t over-engineer.
- Refactor => With a green test as a safety net, improve both test code and production code. The behaviour must not change.
Worked TDD Kata: FizzBuzz
This is TDD applied to the FizzBuzz problem. Follow each step => write the test first, then only the code needed to pass it.
Step 1 Red: Write a failing test
@Testvoid returns_number_as_string_when_not_divisible_by_3_or_5() { assertThat(FizzBuzz.of(1)).isEqualTo("1");}// Doesn't compile yet — FizzBuzz class doesn't exist
Step 2 Green: Minimal code to pass
public class FizzBuzz { public static String of(int n) { return String.valueOf(n); // simplest possible implementation }}
Step 3 Red: Next failing test
@Testvoid returns_Fizz_when_divisible_by_3() { assertThat(FizzBuzz.of(3)).isEqualTo("Fizz");}// Fails — of(3) returns "3"
Step 4 Green: Add the Fizz case
public static String of(int n) { if (n % 3 == 0) return "Fizz"; return String.valueOf(n);}
Step 5 Continue the cycle
// Red: Buzz test@Testvoid returns_Buzz_when_divisible_by_5() { assertThat(FizzBuzz.of(5)).isEqualTo("Buzz");}// Green: add Buzzpublic static String of(int n) { if (n % 3 == 0) return "Fizz"; if (n % 5 == 0) return "Buzz"; return String.valueOf(n);}// Red: FizzBuzz test@Testvoid returns_FizzBuzz_when_divisible_by_15() { assertThat(FizzBuzz.of(15)).isEqualTo("FizzBuzz");}// Green: handle the combined casepublic static String of(int n) { if (n % 15 == 0) return "FizzBuzz"; // must check 15 first if (n % 3 == 0) return "Fizz"; if (n % 5 == 0) return "Buzz"; return String.valueOf(n);}
Refactor: The discovery TDD forces
By the end, TDD revealed the correct order: check 15 before 3 and 5. Without TDD, many developers would write the 15 case last and introduce a subtle bug that slips through.
Why TDD?
| Benefit | Explanation |
|---|---|
| Drives design | Forces you to think about interfaces and contracts before implementation |
| Minimum viable code | You only write code that satisfies a test, no speculative features |
| Safety net | Tests protect future changes; refactoring becomes safe |
| Living documentation | Tests describe exactly what the code is supposed to do in concrete terms |
| Finds edge cases early | Writing the Buzz test before FizzBuzz reveals the ordering bug |
Benefits of Test Coverage
Coverage is a side effect of good TDD, not the goal. The value of coverage comes from:
- Regression protection => changes that break existing behaviour are caught immediately
- Documentation => tests describe what the system does in concrete terms
- Confidence => you can refactor and deploy without fear
Warning
Don’t target a coverage number. Write tests that describe real behaviour. Coverage will follow naturally.
TDD Design Philosophy
Every line of production code must be understood, will have to be maintained, and is a source of potential defects. Simple designs require less code.
| Principle | Meaning |
|---|---|
| YAGNI | You Aren’t Gonna Need It; don’t build for hypothetical future requirements |
| KISS | Keep It Simple; complexity is a cost, not a feature |
| DRY | Don’t Repeat Yourself; duplication is a maintenance burden |
| LDUF | Little Design Up Front; design emerges from tests, not from upfront planning |
Mocking Strategy
| When | Use |
|---|---|
| Testing a unit in isolation | Mock all dependencies (Mockito) |
| Testing integration between two components | Use real implementations with an in-memory DB or WireMock |
| Testing an HTTP endpoint | Integration test with real Spring context, mock external services |
// ✅ Mock the dependency, test the unit@ExtendWith(MockitoExtension.class)class OrderServiceTest { @Mock PaymentGateway paymentGateway; @InjectMocks OrderService orderService; @Test void processes_payment_for_valid_order() { when(paymentGateway.charge(any())).thenReturn(PaymentResult.success()); orderService.processOrder(validOrder()); verify(paymentGateway).charge(any()); }}
Tip: Simple ≠ Easy
Simple designs are often harder to arrive at than complex ones. TDD is the discipline that forces simplicity, each test is a design constraint.
Danger: ANTI-PATTERN
Code-Driven Tests => writing tests after the fact to verify what the code already does.
This only confirms the code does what was written, not what was intended. Tests written this way miss edge cases and provide false confidence.
Danger: ANTI-PATTERN
Testing implementation details instead of behaviour.
// ❌ Tests how, not whatverify(repository, times(1)).save(order);// ✅ Tests the outcomeassertThat(orderService.processOrder(order).getStatus()).isEqualTo(Status.COMPLETE);Brittle tests break on every refactor even when behaviour is unchanged.
References
- Test-Driven Development by Example => Kent Beck (the definitive reference)
- TDD: The Best Thing That Has Happened to Software Design =>ThoughtWorks
- Red-Green-Refactor Explained => CodeAcademy
Leave a Reply