| name | test-doubles |
| description | xUnit test patterns, test doubles, and test smells |
Gerard Meszaros - xUnit Test Patterns
Apply Gerard Meszaros' patterns for writing maintainable, reliable tests.
Core Philosophy
Goals of Test Automation
- Tests as Documentation - Tests show how code is meant to be used
- Tests as Safety Net - Catch regressions immediately
- Tests as Design Feedback - Hard-to-test code is poorly designed
- Defect Localization - When a test fails, you know exactly what broke
The Ideal Test
- Fully automated - No manual steps
- Self-checking - Pass/fail is obvious
- Repeatable - Same result every run
- Independent - No test affects another
- Deterministic - No flaky tests
Test Doubles
The Test Double Taxonomy
Test Double (generic term)
├── Dummy - Passed but never used
├── Stub - Provides canned answers
├── Spy - Records calls for verification
├── Mock - Verifies expected interactions
└── Fake - Working implementation (simplified)
1. Dummy Object
Purpose: Fill required parameters that won't be used.
@Test
void calculateTotal_ignoresLogger() {
Logger dummyLogger = null;
Calculator calc = new Calculator(dummyLogger);
assertEquals(10, calc.add(4, 6));
}
2. Test Stub
Purpose: Provide predetermined responses.
public class StubPriceService implements PriceService {
@Override
public double getPrice(String productId) {
return 99.99;
}
}
@Test
void order_calculatesWithPrice() {
PriceService stubPrices = new StubPriceService();
Order order = new Order(stubPrices);
order.addItem("WIDGET", 2);
assertEquals(199.98, order.getTotal(), 0.01);
}
3. Test Spy
Purpose: Record interactions for later verification.
public class SpyEmailService implements EmailService {
private final List<String> sentTo = new ArrayList<>();
@Override
public void send(String to, String message) {
sentTo.add(to);
}
public boolean wasSentTo(String email) {
return sentTo.contains(email);
}
public int getSendCount() {
return sentTo.size();
}
}
@Test
void orderConfirmation_sendsEmail() {
SpyEmailService spy = new SpyEmailService();
OrderService orders = new OrderService(spy);
orders.complete(testOrder);
assertTrue(spy.wasSentTo("customer@test.com"));
assertEquals(1, spy.getSendCount());
}
4. Mock Object
Purpose: Verify expected behavior (pre-programmed expectations).
@Test
void checkout_chargesCard() {
PaymentGateway mockGateway = mock(PaymentGateway.class);
when(mockGateway.charge(any(), any())).thenReturn(true);
Checkout checkout = new Checkout(mockGateway);
checkout.process(order, card);
verify(mockGateway).charge(card, Money.of(99.99));
}
5. Fake Object
Purpose: Working but simplified implementation.
public class FakeUserRepository implements UserRepository {
private final Map<Long, User> users = new HashMap<>();
private long nextId = 1;
@Override
public User save(User user) {
if (user.getId() == null) {
user.setId(nextId++);
}
users.put(user.getId(), user);
return user;
}
@Override
public Optional<User> findById(Long id) {
return Optional.ofNullable(users.get(id));
}
@Override
public List<User> findAll() {
return new ArrayList<>(users.values());
}
}
@Test
void userService_savesAndRetrieves() {
UserRepository fakeRepo = new FakeUserRepository();
UserService service = new UserService(fakeRepo);
User saved service.createUser();
service.findById(saved.getId());
assertEquals(, found.getEmail());
}
When to Use Which
| Double | Use When | Verify |
|---|
| Dummy | Parameter needed but irrelevant | Nothing |
| Stub | Need controlled indirect inputs | State only |
| Spy | Need to verify calls happened | After exercise |
| Mock | Behavior verification is primary | During exercise |
| Fake | Need realistic behavior | State only |
Test Patterns
Four-Phase Test
@Test
void withdraw_reducesBalance() {
Account account = new Account();
account.deposit(100);
account.withdraw(30);
assertEquals(70, account.getBalance());
}
Fresh Fixture
Each test creates its own test data.
@Test
void test1() {
User user = createTestUser();
}
@Test
void test2() {
User user = createTestUser();
}
Shared Fixture (use carefully)
@BeforeEach
void setUp() {
this.testUser = createTestUser();
}
@Test
void test1() {
}
@Test
void test2() {
}
Minimal Fixture
Only set up what the test actually needs.
@Test
void getName_returnsName() {
User user = new User();
user.setId(1L);
user.setEmail("test@example.com");
user.setName("Alice");
user.setAge(30);
user.setCreatedAt(new Date());
user.setRole(Role.ADMIN);
assertEquals("Alice", user.getName());
}
@Test
void getName_returnsName() {
User user = new User();
user.setName("Alice");
assertEquals("Alice", user.getName());
}
Test Smells
Fragile Test
Symptom: Test breaks when unrelated code changes.
@Test
void order_hasItems() {
order.addItem(item);
assertEquals(1, order.items.size());
}
@Test
void order_hasItems() {
order.addItem(item);
assertTrue(order.containsItem(item));
}
Obscure Test
Symptom: Can't understand what test does without reading deeply.
@Test
void test1() {
X x = new X(1, 2, 3, "a", true, null);
assertEquals(6, x.calc());
}
@Test
void calc_sumsPriceQuantityAndTax() {
Product product = aProduct()
.withPrice(1)
.withQuantity(2)
.withTax(3)
.build();
assertEquals(6, product.calculateTotal());
}
Eager Test
Symptom: One test verifies too many things.
@Test
void userWorkflow() {
User user = userService.create("alice@test.com");
assertNotNull(user.getId());
assertEquals("alice@test.com", user.getEmail());
user.setName("Alice");
userService.update(user);
assertEquals("Alice", userService.findById(user.getId()).getName());
userService.delete(user.getId());
assertNull(userService.findById(user.getId()));
}
@Test void create_assignsId() { }
@Test void create_setsEmail() { }
@Test void update_changesName() { }
@Test void delete_removesUser() { }
Mystery Guest
Symptom: Test depends on external data not visible in test.
@Test
void findUser_returnsUser() {
User user = userService.findById("user-123");
assertEquals("Alice", user.getName());
}
@Test
void findUser_returnsUser() {
User alice = userService.create("Alice");
User found = userService.findById(alice.getId());
assertEquals("Alice", found.getName());
}
Test Logic in Production
Symptom: Production code has if (testing) checks.
public class PaymentService {
public void charge(Card card, Money amount) {
if (System.getProperty("test.mode") != null) {
return;
}
gateway.charge(card, amount);
}
}
public class PaymentService {
private final PaymentGateway gateway;
public PaymentService(PaymentGateway gateway) {
this.gateway = gateway;
}
public void charge(Card card, Money amount) {
gateway.charge(card, amount);
}
}
Assertion Patterns
State Verification
@Test
void deposit_increasesBalance() {
account.deposit(50);
assertEquals(150, account.getBalance());
}
Behavior Verification
@Test
void transfer_callsBothAccounts() {
Account from = mock(Account.class);
Account to = mock(Account.class);
transferService.transfer(from, to, 50);
verify(from).withdraw(50);
verify(to).deposit(50);
}
Delta Assertion
@Test
void deposit_increasesBalanceByAmount() {
int before = account.getBalance();
account.deposit(50);
assertEquals(before + 50, account.getBalance());
}
Custom Assertion
public static void assertValidOrder(Order order) {
assertNotNull(order.getId(), "Order must have ID");
assertFalse(order.getItems().isEmpty(), "Order must have items");
assertTrue(order.getTotal() > 0, "Order total must be positive");
}
@Test
void createOrder_returnsValidOrder() {
Order order = orderService.create(items);
assertValidOrder(order);
}
Code Review Checklist
Test Structure
Test Doubles
Test Smells
Independence
Quick Reference
| Smell | Solution |
|---|
| Fragile test | Test behavior, not structure |
| Obscure test | Better names, test builders |
| Eager test | Split into focused tests |
| Mystery guest | Inline test data |
| Slow test | Use fakes instead of real deps |
| Erratic test | Remove shared state |
Resources