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.
// The logger is required but not relevant to this test@TestvoidcalculateTotal_ignoresLogger() {
LoggerdummyLogger= ;
(dummyLogger);
assertEquals(, calc.add(, ));
}
null
// Or a no-op logger
Calculator
calc
=
new
Calculator
10
4
6
2. Test Stub
Purpose: Provide predetermined responses.
// Stub returns canned data - no verificationpublicclassStubPriceServiceimplementsPriceService {
@OverridepublicdoublegetPrice(String productId) {
return99.99; // Always returns this
}
}
@Testvoidorder_calculatesWithPrice() {
PriceServicestubPrices=newStubPriceService();
Orderorder=newOrder(stubPrices);
order.addItem("WIDGET", 2);
assertEquals(199.98, order.getTotal(), 0.01);
}
3. Test Spy
Purpose: Record interactions for later verification.
@Testvoidtest1() {
Useruser= createTestUser(); // Fresh for this test// ...
}
@Testvoidtest2() {
Useruser= createTestUser(); // Fresh for this test too// ...
}
Shared Fixture (use carefully)
@BeforeEachvoidsetUp() {
this.testUser = createTestUser(); // Shared setup
}
@Testvoidtest1() {
// Uses testUser
}
@Testvoidtest2() {
// Uses same testUser structure, but reset state
}
Symptom: Test depends on external data not visible in test.
// MYSTERY: Where does "user-123" come from?@TestvoidfindUser_returnsUser() {
Useruser= userService.findById("user-123");
assertEquals("Alice", user.getName());
}
// EXPLICIT: Test data is visible@TestvoidfindUser_returnsUser() {
Useralice= userService.create("Alice");
Userfound= userService.findById(alice.getId());
assertEquals("Alice", found.getName());
}
Test Logic in Production
Symptom: Production code has if (testing) checks.
// WRONG: Test mode in productionpublicclassPaymentService {
publicvoidcharge(Card card, Money amount) {
if (System.getProperty("test.mode") != null) {
return; // Skip in tests
}
gateway.charge(card, amount);
}
}
// RIGHT: Inject the dependencypublicclassPaymentService {
privatefinal PaymentGateway gateway;
publicPaymentService(PaymentGateway gateway) {
this.gateway = gateway;
}
publicvoidcharge(Card card, Money amount) {
gateway.charge(card, amount);
}
}
// In tests: new PaymentService(fakeGateway)
Assertion Patterns
State Verification
// Verify the resulting state@Testvoiddeposit_increasesBalance() {
account.deposit(50);
assertEquals(150, account.getBalance()); // Check state
}
// Verify the change, not absolute value@Testvoiddeposit_increasesBalanceByAmount() {
intbefore= account.getBalance();
account.deposit(50);
assertEquals(before + 50, account.getBalance()); // Delta
}
Custom Assertion
// Encapsulate complex assertionspublicstaticvoidassertValidOrder(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");
}
@TestvoidcreateOrder_returnsValidOrder() {
Orderorder= orderService.create(items);
assertValidOrder(order);
}