Testing with JUnit 5 & Mockito

Project: TDD a Small Feature

15 min Lesson 10 of 13

Project: TDD a Small Feature

Theory is only valuable when you can apply it under pressure. In this capstone lesson you will build a small but realistic feature — a discount pricing engine — entirely test-first, using JUnit 5 and Mockito. Every class and method emerges from a failing test. By the end you will have a full red-green-refactor cycle, mocked collaborators, parameterized edge cases, and a clean production design driven purely by tests.

The Feature Specification

Business rules for the discount engine:

  • A PricingService calculates the final price for a product given a customer.
  • Customers flagged as premium get a 20 % discount.
  • Any product marked as on-sale gets an additional 10 % off the already-discounted price.
  • The final price is never negative — it floors at 0.
  • Discount rules are fetched from an external DiscountRepository (a database call — we must mock it).
Why a repository collaborator? Real features almost always depend on I/O. Introducing a mocked dependency forces you to think about the boundary between your domain logic and infrastructure from the very first test — which is exactly where TDD shines.

Step 1 — Write the Failing Test First (Red)

Create the test class before any production code exists. Let the compiler errors guide you toward the types you need to define.

// src/test/java/com/example/pricing/PricingServiceTest.java package com.example.pricing; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class PricingServiceTest { @Mock private DiscountRepository discountRepository; @InjectMocks private PricingService pricingService; private Customer regularCustomer; private Customer premiumCustomer; private Product regularProduct; private Product saleProduct; @BeforeEach void setUp() { regularCustomer = new Customer("alice", false); premiumCustomer = new Customer("bob", true); regularProduct = new Product("WIDGET", 100.0, false); saleProduct = new Product("GADGET", 100.0, true); } // --- RED: this test will not even compile yet --- @Test void regularCustomer_regularProduct_paysFullPrice() { when(discountRepository.isPremium(regularCustomer)).thenReturn(false); when(discountRepository.isOnSale(regularProduct)).thenReturn(false); double price = pricingService.calculatePrice(regularCustomer, regularProduct); assertEquals(100.0, price, 0.001); } }

Run the test — it fails to compile. Good. Now create the minimal production types to make it compile and pass.

Step 2 — Make It Pass with Minimal Code (Green)

// Customer.java package com.example.pricing; public record Customer(String id, boolean premium) {} // Product.java public record Product(String sku, double basePrice, boolean onSale) {} // DiscountRepository.java public interface DiscountRepository { boolean isPremium(Customer customer); boolean isOnSale(Product product); } // PricingService.java public class PricingService { private final DiscountRepository discountRepository; public PricingService(DiscountRepository discountRepository) { this.discountRepository = discountRepository; } public double calculatePrice(Customer customer, Product product) { boolean premium = discountRepository.isPremium(customer); boolean onSale = discountRepository.isOnSale(product); double price = product.basePrice(); if (premium) price *= 0.80; if (onSale) price *= 0.90; return Math.max(0, price); } }

The first test is green. Note that the implementation is intentionally minimal — you write only what the tests demand.

Resist the urge to over-build. On a fresh green bar, stop. Only add complexity when a new failing test forces you. This keeps the codebase lean and every line traceable to a requirement.

Step 3 — Add More Behaviour Tests (Red → Green cycle)

Add the remaining business rules one test at a time:

@Test void premiumCustomer_regularProduct_gets20PercentOff() { when(discountRepository.isPremium(premiumCustomer)).thenReturn(true); when(discountRepository.isOnSale(regularProduct)).thenReturn(false); double price = pricingService.calculatePrice(premiumCustomer, regularProduct); assertEquals(80.0, price, 0.001); } @Test void premiumCustomer_saleProduct_getsStackedDiscount() { when(discountRepository.isPremium(premiumCustomer)).thenReturn(true); when(discountRepository.isOnSale(saleProduct)).thenReturn(true); // 100 * 0.80 * 0.90 = 72.0 double price = pricingService.calculatePrice(premiumCustomer, saleProduct); assertEquals(72.0, price, 0.001); } @Test void price_neverGoesNegative() { Product freeProduct = new Product("FREE", -50.0, false); when(discountRepository.isPremium(regularCustomer)).thenReturn(false); when(discountRepository.isOnSale(freeProduct)).thenReturn(false); double price = pricingService.calculatePrice(regularCustomer, freeProduct); assertTrue(price >= 0, "Final price must be non-negative"); }

Step 4 — Verify Interactions

Business requirement: the repository must be consulted exactly once per pricing call for each question. Test the contract, not just the output:

@Test void repositoryIsQueriedExactlyOncePerCall() { when(discountRepository.isPremium(regularCustomer)).thenReturn(false); when(discountRepository.isOnSale(regularProduct)).thenReturn(false); pricingService.calculatePrice(regularCustomer, regularProduct); verify(discountRepository, times(1)).isPremium(regularCustomer); verify(discountRepository, times(1)).isOnSale(regularProduct); verifyNoMoreInteractions(discountRepository); }

Step 5 — Parameterized Edge Cases (Refactor Phase)

Rather than copy-pasting near-identical tests for different price points, use @ParameterizedTest to express the full truth table cleanly:

import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @ParameterizedTest(name = "base={0}, premium={1}, onSale={2} => {3}") @CsvSource({ "100.0, false, false, 100.0", "100.0, true, false, 80.0", "100.0, false, true, 90.0", "100.0, true, true, 72.0", " 0.0, true, true, 0.0", }) void pricingTruthTable(double base, boolean premium, boolean onSale, double expected) { Customer customer = new Customer("test", premium); Product product = new Product("TEST", base, onSale); when(discountRepository.isPremium(customer)).thenReturn(premium); when(discountRepository.isOnSale(product)).thenReturn(onSale); double actual = pricingService.calculatePrice(customer, product); assertEquals(expected, actual, 0.001); }
Parameterized tests are not a shortcut — they are a design tool. Expressing five scenarios in one table forces you to think about the complete input space. Gaps in the table are gaps in your understanding of the spec.

Step 6 — Refactor with Confidence

With a full green suite you can safely refactor. Extract the discount calculation to a dedicated method and verify nothing breaks:

// Refactored PricingService.java public double calculatePrice(Customer customer, Product product) { boolean premium = discountRepository.isPremium(customer); boolean onSale = discountRepository.isOnSale(product); return Math.max(0, applyDiscounts(product.basePrice(), premium, onSale)); } private double applyDiscounts(double price, boolean premium, boolean onSale) { if (premium) price *= 0.80; if (onSale) price *= 0.90; return price; }

Run the full suite — all tests still green. The refactor is safe. The tests have done their job as a safety net.

The refactor step is not optional. Red-green without refactor accumulates technical debt just as fast as code written without tests. Clean code and comprehensive tests must go together.

What This Project Demonstrates

  • Emergent design — domain types (Customer, Product, DiscountRepository) were discovered by writing tests, not by upfront design.
  • Seam isolation — the DiscountRepository interface is a seam: it lets you swap a real DB implementation for a mock without touching PricingService.
  • Specification as tests — the test suite is the specification. A new developer can read the tests and fully understand the pricing rules without reading a word of documentation.
  • Safe refactoring — the green suite allowed a structural change to applyDiscounts with zero risk.

Summary

TDD is a discipline, not a technique. The red-green-refactor loop, combined with mocked boundaries and parameterized edge cases, produces code that is correct by construction, minimal by necessity, and maintainable by design. Apply this cycle to every new feature and you will find that debugging sessions become rare, design reviews become easier, and confidence in deployments grows steadily over time.