Testing with JUnit 5 & Mockito

Test-Driven Development (TDD)

15 min Lesson 8 of 13

Test-Driven Development (TDD)

Test-Driven Development is a discipline, not just a technique. You write a failing test before any production code, make it pass with the smallest possible change, then clean up. This sequence — Red → Green → Refactor — is the heartbeat of TDD. When done consistently it produces code that is correct by construction, minimal in scope, and easy to change because every behaviour is covered by a test the moment it is introduced.

The Red-Green-Refactor Cycle in Detail

  1. Red — Write a test for the next small piece of behaviour. Run it. It must fail, proving the test is actually exercising something that does not yet exist. A test that passes immediately is not a red step; it is a sign the test is wrong or the behaviour already exists.
  2. Green — Write the minimum production code required to make the test pass. Resist the urge to write more than the test demands. Hardcoding a return value is legitimate here if it makes the test pass; the next test will force you to generalise.
  3. Refactor — With all tests green, improve the design: remove duplication, clarify names, extract methods, apply patterns. The test suite is your safety net. If any test goes red during refactoring you have introduced a regression — revert and try again.
TDD is a design tool first. The tests are a side-effect. The real output is code that was forced into a testable shape from the very beginning, which means well-defined responsibilities, small methods, and honest dependencies.

A Worked Example: Money Arithmetic

We will TDD a simple Money class that can add amounts of the same currency. We start with nothing but the test file.

Iteration 1 — Red: a Money object knows its amount

// MoneyTest.java import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class MoneyTest { @Test void amountIsRetained() { Money m = new Money(10, "USD"); assertEquals(10, m.amount()); } }

Running this fails with a compilation error: Money does not exist. That is the red step.

Iteration 1 — Green: write the bare minimum

// Money.java record Money(int amount, String currency) {}

The test is now green. We resisted adding add(), validation, or anything else the test does not demand.

Iteration 2 — Red: adding money of the same currency

@Test void addSameCurrency() { Money a = new Money(10, "USD"); Money b = new Money(5, "USD"); Money result = a.add(b); assertEquals(15, result.amount()); assertEquals("USD", result.currency()); }

Red: add() does not exist.

Iteration 2 — Green

// Money.java record Money(int amount, String currency) { Money add(Money other) { return new Money(this.amount + other.amount, this.currency); } }

Green. Now the refactor step: is the name amount clear? For a record accessor it is fine. No duplication. Move on.

Iteration 3 — Red: mismatched currencies must throw

@Test void addDifferentCurrenciesThrows() { Money usd = new Money(10, "USD"); Money eur = new Money(5, "EUR"); assertThrows(IllegalArgumentException.class, () -> usd.add(eur)); }

Iteration 3 — Green

record Money(int amount, String currency) { Money add(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException( "Cannot add " + other.currency + " to " + this.currency); } return new Money(this.amount + other.amount, this.currency); } }

Iteration 3 — Refactor

All three tests pass. We can now extract the guard into a private method to make the intent clearer:

record Money(int amount, String currency) { Money add(Money other) { requireSameCurrency(other); return new Money(this.amount + other.amount, this.currency); } private void requireSameCurrency(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException( "Cannot add " + other.currency + " to " + this.currency); } } }

All tests still green. The refactor was safe.

Take the smallest step that changes the test colour. Experienced TDD practitioners shrink their iterations until each cycle takes a minute or less. Small steps mean small debugging sessions when something goes wrong.

The Triangulation Technique

When the green step is "cheat" (e.g. return 15;), you triangulate by writing a second test that forces a real implementation. For example, a test for add(3, 5) returning 8 and another for add(7, 2) returning 9 forces you to write return a + b rather than hardcode 8.

TDD and the Design Pressure

TDD exerts continuous design pressure. If writing a test is painful — you need to construct many collaborators, reach deep into internals, or mock a static method — the test is telling you the design is wrong. Common smells and their TDD-driven fixes:

  • Hard to instantiate — the class has too many responsibilities. Split it.
  • Must mock the database in a unit test — the domain logic is coupled to persistence. Introduce a repository interface and inject it.
  • Testing a private method — private methods are implementation detail. Test the public contract; if the private logic is complex enough to need its own test, extract it into a collaborator class.
  • Test setup is 30 lines long — the subject has too many dependencies. Apply the Single Responsibility Principle.
TDD does not mean 100% coverage is the goal. It means every behaviour you chose to implement has a test that drove it. Infrastructure glue (main method, DI wiring, logging) is rarely worth TDD-ing. Exercising judgement about what to test is a professional skill.

Applying TDD to a Service Layer

Real-world TDD often involves injected dependencies. The key is to write the test first with a mock, which forces you to define the interface of the collaborator before you implement it.

// OrderServiceTest.java import org.junit.jupiter.api.*; import org.mockito.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class OrderServiceTest { @Mock OrderRepository repo; @Mock PaymentGateway gateway; @InjectMocks OrderService service; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } // RED: write this test first — OrderService does not exist yet @Test void placeOrder_chargesCustomerAndPersistsOrder() { Order order = new Order("customer-1", 200); when(gateway.charge("customer-1", 200)).thenReturn(true); service.placeOrder(order); verify(gateway).charge("customer-1", 200); verify(repo).save(order); } }

This test defines the public API of OrderService (placeOrder), the collaborator interfaces (OrderRepository, PaymentGateway), and the expected interaction — all before a single line of OrderService is written.

Summary

TDD is a tight feedback loop: Red → Green → Refactor, repeated many times per hour. It keeps code minimal, forces good design, and ensures every behaviour is tested from the moment it exists. The discipline is hardest to learn at first but becomes natural with practice. The worked examples here — the Money record and the OrderService — show the same pattern at the value-object and service layer. Carry this rhythm into every new feature and you will write less buggy, more maintainable Java.