Test-Driven Development (TDD)

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

@Test
void 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

@Test
void 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
@Test
void returns_Buzz_when_divisible_by_5() {
assertThat(FizzBuzz.of(5)).isEqualTo("Buzz");
}
// Green: add Buzz
public static String of(int n) {
if (n % 3 == 0) return "Fizz";
if (n % 5 == 0) return "Buzz";
return String.valueOf(n);
}
// Red: FizzBuzz test
@Test
void returns_FizzBuzz_when_divisible_by_15() {
assertThat(FizzBuzz.of(15)).isEqualTo("FizzBuzz");
}
// Green: handle the combined case
public 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?

BenefitExplanation
Drives designForces you to think about interfaces and contracts before implementation
Minimum viable codeYou only write code that satisfies a test, no speculative features
Safety netTests protect future changes; refactoring becomes safe
Living documentationTests describe exactly what the code is supposed to do in concrete terms
Finds edge cases earlyWriting 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.

PrincipleMeaning
YAGNIYou Aren’t Gonna Need It; don’t build for hypothetical future requirements
KISSKeep It Simple; complexity is a cost, not a feature
DRYDon’t Repeat Yourself; duplication is a maintenance burden
LDUFLittle Design Up Front; design emerges from tests, not from upfront planning

Mocking Strategy

WhenUse
Testing a unit in isolationMock all dependencies (Mockito)
Testing integration between two componentsUse real implementations with an in-memory DB or WireMock
Testing an HTTP endpointIntegration 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 what
verify(repository, times(1)).save(order);
// ✅ Tests the outcome
assertThat(orderService.processOrder(order).getStatus()).isEqualTo(Status.COMPLETE);

Brittle tests break on every refactor even when behaviour is unchanged.

References

Balaji G
Written by
Balaji G

Leave a Reply

Discover more from 2G

Subscribe now to keep reading and get access to the full archive.

Continue reading