Test-Driven Development (TDD) in Java

3 minute read

TDD flips the normal workflow: you write the test first, then write just enough code to make it pass. It sounds backwards, but it produces cleaner, more reliable code.


The Red-Green-Refactor Cycle

┌──────────┐     ┌──────────┐     ┌──────────┐
│   RED    │────▶│  GREEN   │────▶│ REFACTOR │
│          │     │          │     │          │
│ Write a  │     │ Write    │     │ Clean up │
│ failing  │     │ minimal  │     │ without  │
│ test     │     │ code to  │     │ breaking │
│          │     │ pass     │     │ tests    │
└──────────┘     └──────────┘     └─────┬────┘
     ▲                                   │
     └───────────────────────────────────┘
  1. RED — Write a test that fails (proves the feature doesn’t exist yet).
  2. GREEN — Write the simplest code that makes the test pass.
  3. REFACTOR — Improve the code without changing behavior. Tests stay green.

TDD Example: Shopping Cart

Step 1: RED — Empty cart has zero total

class ShoppingCartTest {

    @Test
    void emptyCartShouldHaveZeroTotal() {
        ShoppingCart cart = new ShoppingCart();
        assertEquals(BigDecimal.ZERO, cart.getTotal());
    }
}

This won’t compile — ShoppingCart doesn’t exist yet. That’s expected.

Step 2: GREEN — Minimal implementation

public class ShoppingCart {

    public BigDecimal getTotal() {
        return BigDecimal.ZERO;
    }
}

Test passes. ✅

Step 3: RED — Add one item

@Test
void shouldReturnPriceOfSingleItem() {
    ShoppingCart cart = new ShoppingCart();
    cart.addItem("Laptop", new BigDecimal("999.99"), 1);
    assertEquals(new BigDecimal("999.99"), cart.getTotal());
}

Step 4: GREEN

public class ShoppingCart {

    private final List<CartItem> items = new ArrayList<>();

    public void addItem(String name, BigDecimal price, int quantity) {
        items.add(new CartItem(name, price, quantity));
    }

    public BigDecimal getTotal() {
        return items.stream()
            .map(item -> item.price().multiply(BigDecimal.valueOf(item.quantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

record CartItem(String name, BigDecimal price, int quantity) {}

Step 5: RED — Multiple items

@Test
void shouldSumMultipleItems() {
    ShoppingCart cart = new ShoppingCart();
    cart.addItem("Mouse", new BigDecimal("25.00"), 2);
    cart.addItem("Keyboard", new BigDecimal("75.00"), 1);
    assertEquals(new BigDecimal("125.00"), cart.getTotal());
}

Already passes! ✅ (Our implementation handles it.)

Step 6: RED — Remove items

@Test
void shouldRemoveItem() {
    ShoppingCart cart = new ShoppingCart();
    cart.addItem("Mouse", new BigDecimal("25.00"), 1);
    cart.addItem("Keyboard", new BigDecimal("75.00"), 1);
    cart.removeItem("Mouse");
    assertEquals(new BigDecimal("75.00"), cart.getTotal());
}

Step 7: GREEN

public void removeItem(String name) {
    items.removeIf(item -> item.name().equals(name));
}

Step 8: REFACTOR

Look at the full class — is anything messy? Rename? Extract? The tests protect you.


TDD Rules (Uncle Bob)

  1. Don’t write production code unless it’s to make a failing test pass.
  2. Don’t write more of a test than is sufficient to fail (including compile errors).
  3. Don’t write more production code than is sufficient to pass the test.

Benefits of TDD

Benefit How
Better design Forces small, testable units
Instant feedback Know immediately if you broke something
Living documentation Tests describe what the code does
Regression safety Refactor with confidence
Less debugging Bugs caught as you code

When TDD Doesn’t Work Well

Scenario Why
Exploratory/prototype code You don’t know the design yet
UI/visual code Hard to test appearance
Infrastructure scripts Better tested manually or with integration tests
Trivial getters/setters Not worth testing
Third-party API wrappers Test the integration, not the wrapper

TDD vs Tests-After

Aspect TDD Tests After
When tests are written Before code After code
Design influence Tests shape design Code shapes tests
Coverage High (forced) Often lower
Developer discipline Required Optional
Refactoring confidence High Medium
Speed (initially) Slower Faster
Speed (long-term) Faster (fewer bugs) Slower (more bugs)

Practical Tips

  1. Start small — TDD a utility class first, not a complex service.
  2. One assertion concept per test — keep tests focused.
  3. Don’t skip REFACTOR — that’s where design improves.
  4. Name tests as specifications — shouldThrowWhenEmailIsDuplicate.
  5. Run tests frequently — after every small change.

Key Takeaways

  1. TDD = Red → Green → Refactor — test first, code second.
  2. Write the simplest code that passes the test — no more.
  3. Tests become living documentation of your system’s behavior.
  4. TDD forces good design — small methods, single responsibility, dependency injection.
  5. Use TDD where it adds value — skip it for trivial or exploratory code.

What’s Next?

Next: JaCoCo Code Coverage — measure how much of your code is tested and enforce minimum thresholds.

Updated: