TDD Best Practices
TDD Best Practices
Overview
Test-Driven Development (TDD) best practices encompass the principles, workflows, and decision-making strategies that maximize TDD’s effectiveness. While the Red-Green-Refactor cycle is simple to understand, mastering when to use TDD, how to apply it in different contexts, and how to avoid common pitfalls requires experience and discipline. These practices help teams deliver higher quality code faster while maintaining comprehensive test coverage.
Effective TDD transforms testing from a chore into a design activity, making tests first-class citizens that drive architecture, catch bugs early, and enable confident refactoring.
Key Concepts
The Three Laws of TDD
┌─────────────────────────────────────────────────────────┐
│ Uncle Bob's Three Laws of TDD │
├─────────────────────────────────────────────────────────┤
│ │
│ 1️⃣ You may not write production code │
│ until you have written a failing test │
│ │
│ 2️⃣ You may not write more of a test │
│ than is sufficient to fail │
│ │
│ 3️⃣ You may not write more production code │
│ than is sufficient to pass the failing test │
│ │
└─────────────────────────────────────────────────────────┘
TDD in Different Contexts
| Context | Approach | Test Type | Examples |
|---|---|---|---|
| New Feature | Start with acceptance test, drill down | Outside-in TDD | API endpoint → Service → Repository |
| Bug Fix | Write failing test that reproduces bug | Unit/Integration | Regression test |
| Refactoring | Tests already exist, keep them green | Existing tests | Extract method, rename |
| Legacy Code | Add characterization tests first | Unit + Integration | Wrap and test before change |
| Spike/Research | Skip TDD initially, rewrite with TDD | None → Unit | Prototype then implement properly |
When to Use TDD vs When to Skip
Use TDD When:
- Implementing business logic with clear requirements
- Building APIs with well-defined contracts
- Fixing bugs (write test first to reproduce)
- Adding features to existing codebase
- Working in a team (tests as communication)
Consider Skipping TDD When:
- Doing exploratory programming/spikes
- Creating throwaway prototypes
- Working with unfamiliar technology (learn first)
- Building UI layouts (use manual testing)
- Dealing with non-deterministic systems (require different approach)
The Test Pyramid in TDD
▲
/ \
/ \
/ E2E \ ← Few, slow, brittle
/_______\
/ \
/ Integration\ ← Some, moderate speed
/_____________\
/ \
/ Unit \ ← Many, fast, isolated
/___________________\
TDD Emphasis:
- Unit tests: 70-80% of tests (TDD sweet spot)
- Integration tests: 15-25% (test boundaries)
- E2E tests: 5-10% (critical paths only)
Best Practices
1. Write the Simplest Test That Could Possibly Fail
Start with the most basic scenario. Don’t jump to complex edge cases.
2. Take Baby Steps
Each Red-Green-Refactor cycle should be small (1-10 minutes). Smaller steps mean faster feedback.
3. Refactor Mercilessly
Don’t accumulate technical debt. Clean code after each green phase.
4. Keep Tests Fast
Unit tests should run in milliseconds. Slow tests break the TDD flow.
5. Test Behavior, Not Implementation
Focus on what the code does, not how it does it. This makes tests resilient to refactoring.
Code Examples
Example 1: TDD for New Feature (Outside-In)
// Step 1: Start with acceptance test (API level)
@SpringBootTest
@AutoConfigureMockMvc
class OrderAPIAcceptanceTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldCreateOrderWithMultipleItems() throws Exception {
// 🔴 RED - API doesn't exist yet
String request = """
{
"customerId": "C123",
"items": [
{"productId": "P1", "quantity": 2},
{"productId": "P2", "quantity": 1}
]
}
""";
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.total").value(150.00))
.andExpect(jsonPath("$.status").value("PENDING"));
}
}
// Step 2: Create minimal controller to compile
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse createOrder(@RequestBody OrderRequest request) {
return null; // 🔴 Still RED - returns null
}
}
// Step 3: Now drill down to service layer with unit test
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private PricingService pricingService;
@Mock
private OrderRepository orderRepository;
@InjectMocks
private OrderService orderService;
@Test
void shouldCreateOrderWithCalculatedTotal() {
// 🔴 RED - service method doesn't exist
List<OrderItem> items = List.of(
new OrderItem("P1", 2),
new OrderItem("P2", 1)
);
when(pricingService.calculateTotal(items))
.thenReturn(Money.usd(150));
Order order = orderService.createOrder("C123", items);
assertThat(order.getTotal()).isEqualTo(Money.usd(150));
assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
}
}
// Step 4: Implement service
public class OrderService {
public Order createOrder(String customerId, List<OrderItem> items) {
Money total = pricingService.calculateTotal(items);
Order order = new Order(customerId, items, total, OrderStatus.PENDING);
return orderRepository.save(order);
}
}
// Step 5: Wire it up in controller
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse createOrder(@RequestBody OrderRequest request) {
Order order = orderService.createOrder(
request.getCustomerId(),
request.getItems()
);
return OrderResponse.from(order); // 🟢 GREEN - acceptance test passes
}
Example 2: TDD for Bug Fix
// Bug report: "Discount calculation fails for negative prices"
// Step 1: Write test that reproduces the bug
@Test
void shouldRejectNegativePrice() {
// 🔴 RED - this test currently fails (bug exists)
DiscountCalculator calculator = new DiscountCalculator();
Money negativePrice = Money.usd(-100);
assertThatThrownBy(() -> calculator.applyDiscount(negativePrice, 10))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Price cannot be negative");
}
// Step 2: Current implementation (buggy)
public Money applyDiscount(Money price, int discountPercent) {
// BUG: No validation for negative price
return price.multiply(1 - discountPercent / 100.0);
}
// Step 3: Fix the bug
public Money applyDiscount(Money price, int discountPercent) {
if (price.isNegative()) {
throw new IllegalArgumentException("Price cannot be negative");
}
return price.multiply(1 - discountPercent / 100.0);
}
// 🟢 GREEN - test passes, bug fixed
// Step 4: Add more tests for edge cases
@Test
void shouldHandleZeroPrice() {
Money result = calculator.applyDiscount(Money.usd(0), 10);
assertThat(result).isEqualTo(Money.usd(0));
}
@Test
void shouldRejectDiscountOver100Percent() {
assertThatThrownBy(() -> calculator.applyDiscount(Money.usd(100), 150))
.isInstanceOf(IllegalArgumentException.class);
}
Example 3: TDD in Legacy Code Context
// Legacy code - no tests, hard to test
public class LegacyOrderProcessor {
public void processOrder(int orderId) {
// Direct database calls, static methods, tight coupling
Connection conn = DatabaseConnection.getInstance().getConnection();
ResultSet rs = conn.executeQuery("SELECT * FROM orders WHERE id = " + orderId);
if (rs.next()) {
String status = rs.getString("status");
if (status.equals("PENDING")) {
EmailSender.send(rs.getString("customer_email"), "Order shipped");
conn.executeUpdate("UPDATE orders SET status = 'SHIPPED' WHERE id = " + orderId);
}
}
}
}
// Step 1: Add characterization tests (test current behavior, even if wrong)
@Test
void shouldProcessPendingOrder() {
// Setup in-memory database
testDb.execute("INSERT INTO orders VALUES (1, 'PENDING', 'test@example.com')");
LegacyOrderProcessor processor = new LegacyOrderProcessor();
processor.processOrder(1);
// Assert current behavior (characterization)
String status = testDb.query("SELECT status FROM orders WHERE id = 1");
assertThat(status).isEqualTo("SHIPPED");
}
// Step 2: Refactor to make testable (with tests protecting you)
public class OrderProcessor {
private final OrderRepository repository;
private final EmailService emailService;
// ✅ Now testable with dependency injection
public OrderProcessor(OrderRepository repository, EmailService emailService) {
this.repository = repository;
this.emailService = emailService;
}
public void processOrder(Long orderId) {
Order order = repository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
if (order.isPending()) {
emailService.send(order.getCustomerEmail(), "Order shipped");
order.markAsShipped();
repository.save(order);
}
}
}
// Step 3: Now write proper unit tests with TDD
@Test
void shouldMarkOrderAsShipped_whenOrderIsPending() {
Order pendingOrder = new Order(1L, OrderStatus.PENDING, "test@example.com");
when(repository.findById(1L)).thenReturn(Optional.of(pendingOrder));
processor.processOrder(1L);
assertThat(pendingOrder.getStatus()).isEqualTo(OrderStatus.SHIPPED);
verify(emailService).send("test@example.com", "Order shipped");
}
Example 4: TDD with Different Test Levels
// Level 1: Unit TDD (business logic)
class PricingEngineTest {
@Test
void shouldApplyVolumeDiscount_whenQuantityExceeds10() {
PricingEngine engine = new PricingEngine();
Money unitPrice = Money.usd(10);
Money total = engine.calculatePrice(unitPrice, 15);
// 15% discount for quantity > 10
assertThat(total).isEqualTo(Money.usd(127.50));
}
@Test
void shouldNotApplyDiscount_whenQuantityBelow10() {
PricingEngine engine = new PricingEngine();
Money unitPrice = Money.usd(10);
Money total = engine.calculatePrice(unitPrice, 5);
assertThat(total).isEqualTo(Money.usd(50.00));
}
}
// Level 2: Integration TDD (component interaction)
@SpringBootTest
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@Test
void shouldPersistOrderWithCalculatedPrice() {
List<OrderItem> items = List.of(new OrderItem("P1", 15, Money.usd(10)));
Order order = orderService.createOrder("C123", items);
Order saved = orderRepository.findById(order.getId()).orElseThrow();
assertThat(saved.getTotal()).isEqualTo(Money.usd(127.50));
}
}
// Level 3: E2E TDD (full system)
@SpringBootTest
@AutoConfigureMockMvc
class OrderE2ETest {
@Test
void shouldCompleteFullOrderWorkflow() throws Exception {
// Create order
String createRequest = """
{"customerId": "C123", "items": [{"productId": "P1", "quantity": 15}]}
""";
MvcResult createResult = mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(createRequest))
.andExpect(status().isCreated())
.andReturn();
String orderId = JsonPath.read(createResult.getResponse().getContentAsString(), "$.id");
// Verify order
mockMvc.perform(get("/api/orders/{id}", orderId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(127.50))
.andExpect(jsonPath("$.status").value("PENDING"));
}
}
Example 5: TDD Anti-Patterns and Solutions
// ❌ ANTI-PATTERN 1: Writing tests after code
// WRONG
public class Calculator {
public int add(int a, int b) { return a + b; }
public int subtract(int a, int b) { return a - b; }
public int multiply(int a, int b) { return a * b; }
}
// Then writing tests afterward
@Test void testAdd() { /* ... */ }
@Test void testSubtract() { /* ... */ }
// ✅ CORRECT: Test-first
@Test
void shouldAddTwoNumbers() {
Calculator calc = new Calculator(); // 🔴 Doesn't exist yet
assertThat(calc.add(2, 3)).isEqualTo(5);
}
// Then implement add()
// ❌ ANTI-PATTERN 2: Testing implementation details
// WRONG - test coupled to implementation
@Test
void shouldUseQuickSortForLargeLists() {
Sorter sorter = spy(new Sorter());
List<Integer> largeList = createListOfSize(1000);
sorter.sort(largeList);
verify(sorter).quickSort(any()); // Testing HOW, not WHAT
}
// ✅ CORRECT - test behavior
@Test
void shouldSortListInAscendingOrder() {
Sorter sorter = new Sorter();
List<Integer> unsorted = List.of(3, 1, 4, 1, 5, 9);
List<Integer> sorted = sorter.sort(unsorted);
assertThat(sorted).containsExactly(1, 1, 3, 4, 5, 9);
}
// ❌ ANTI-PATTERN 3: Giant steps in Red-Green-Refactor
// WRONG - trying to implement everything at once
@Test
void shouldHandleCompleteUserRegistrationWorkflow() {
// Test for validation, hashing, saving, email, analytics all at once
}
// ✅ CORRECT - small incremental steps
@Test void shouldCreateUser_withValidEmail() { }
@Test void shouldRejectUser_withInvalidEmail() { }
@Test void shouldHashPassword_beforeSaving() { }
@Test void shouldSendWelcomeEmail_afterRegistration() { }
// ❌ ANTI-PATTERN 4: Mocking everything
// WRONG
@Test
void shouldCalculateTotal() {
Money price1 = mock(Money.class);
Money price2 = mock(Money.class);
Money sum = mock(Money.class);
when(price1.add(price2)).thenReturn(sum);
// Mocking value objects - unnecessary complexity
}
// ✅ CORRECT - use real objects when simple
@Test
void shouldCalculateTotal() {
Money price1 = Money.usd(10);
Money price2 = Money.usd(20);
Money sum = price1.add(price2);
assertThat(sum).isEqualTo(Money.usd(30));
}
// ❌ ANTI-PATTERN 5: Ignoring refactor phase
// WRONG - accumulating duplication
@Test void test1() {
User user = new User();
user.setEmail("test1@example.com");
user.setPassword("password");
service.register(user);
// ...
}
@Test void test2() {
User user = new User();
user.setEmail("test2@example.com");
user.setPassword("password");
service.register(user);
// ... duplicated setup
}
// ✅ CORRECT - refactor tests too
class UserServiceTest {
private UserBuilder aUser() {
return new UserBuilder().withPassword("password");
}
@Test
void shouldRegisterNewUser() {
User user = aUser().withEmail("test1@example.com").build();
service.register(user);
// Clean, reusable
}
}
Anti-Patterns
❌ Test-Last Development
// WRONG - Writing code first, tests second
// 1. Implement feature completely
// 2. Write tests to match implementation
// 3. Tests always pass (no red phase)
// Result: Tests don't drive design, may not catch bugs
// ✅ CORRECT - True TDD
// 1. Write failing test
// 2. Minimal implementation
// 3. Refactor
// 4. Repeat
❌ Testing Through the GUI
// WRONG - Only testing via UI/E2E tests
// - Slow feedback (minutes instead of seconds)
// - Brittle (UI changes break tests)
// - Hard to test edge cases
// - Poor test coverage
// ✅ CORRECT - Test pyramid
// - Most tests at unit level (fast, focused)
// - Some integration tests (boundaries)
// - Few E2E tests (critical paths)
❌ 100% Coverage Obsession
// WRONG - Chasing coverage metrics instead of value
@Test
void shouldSetName() {
user.setName("John");
assertThat(user.getName()).isEqualTo("John");
}
// Testing trivial getters/setters adds no value
// ✅ CORRECT - Test behavior that matters
@Test
void shouldValidateName_whenSettingName() {
assertThatThrownBy(() -> user.setName(""))
.isInstanceOf(IllegalArgumentException.class);
}
Testing Strategies
TDD Workflow for Different Scenarios
// Scenario 1: New Feature Development
// 1. Write acceptance test (API level) - RED
// 2. Write unit test (logic level) - RED
// 3. Implement minimal logic - GREEN
// 4. Refactor - keep GREEN
// 5. Wire up to API - GREEN acceptance test
// 6. Refactor entire stack
// Scenario 2: Bug Fix
// 1. Write test that reproduces bug - RED (confirms bug)
// 2. Fix bug - GREEN
// 3. Add tests for related edge cases
// 4. Refactor if needed
// Scenario 3: Refactoring Existing Code
// 1. Ensure tests exist and pass
// 2. Refactor incrementally
// 3. Run tests after each change - must stay GREEN
// 4. Commit after each successful refactoring
// Scenario 4: Legacy Code Addition
// 1. Add characterization tests for existing behavior
// 2. Refactor to make testable (tests protect you)
// 3. Use TDD for new feature
// 4. Continue refactoring legacy code incrementally
Test Categorization for Different Speeds
// Fast tests - run always (< 100ms each)
@Tag("fast")
class UnitTests {
@Test void shouldCalculateDiscount() { }
@Test void shouldValidateEmail() { }
}
// Medium tests - run on commit (< 1s each)
@Tag("medium")
@SpringBootTest
class IntegrationTests {
@Test void shouldSaveToDatabase() { }
@Test void shouldCallExternalAPI() { }
}
// Slow tests - run on CI only (> 1s)
@Tag("slow")
@SpringBootTest
class E2ETests {
@Test void shouldCompleteFullWorkflow() { }
}
// Run fast tests continuously during TDD
// mvn test -Dgroups=fast
TDD Metrics to Track
// Track these metrics to improve TDD practice:
// 1. Test-to-Code Ratio
// - Healthy: 1:1 to 2:1 (test LOC : production LOC)
// - Calculate: wc -l src/test/**/*.java / wc -l src/main/**/*.java
// 2. Code Coverage
// - Aim for: 80-90% for business logic
// - Not 100% (diminishing returns)
// - Use: mvn test jacoco:report
// 3. Test Execution Time
// - Unit tests: < 10 minutes for entire suite
// - Individual test: < 100ms
// - Monitor: mvn test | grep "Time elapsed"
// 4. Defect Density
// - Bugs found in production vs caught by tests
// - Lower is better
// - Track: production bugs / KLOC
// 5. Refactoring Confidence
// - Can you refactor without fear?
// - If no: need better tests
When TDD is Challenging
// Challenge 1: UI Code
// Solution: Separate logic from presentation
// WRONG
public class LoginView {
public void onLoginClick() {
String username = usernameField.getText();
String password = passwordField.getText();
// Business logic mixed with UI
if (username.isEmpty() || password.isEmpty()) {
showError("Fields required");
return;
}
if (username.length() < 3) {
showError("Username too short");
return;
}
// Hard to test!
}
}
// ✅ CORRECT - Extract testable logic
public class LoginValidator {
public ValidationResult validate(String username, String password) {
if (username == null || username.isEmpty()) {
return ValidationResult.error("Username required");
}
if (username.length() < 3) {
return ValidationResult.error("Username too short");
}
if (password == null || password.isEmpty()) {
return ValidationResult.error("Password required");
}
return ValidationResult.success();
}
}
// Easy to test with TDD
@Test
void shouldRejectShortUsername() {
LoginValidator validator = new LoginValidator();
ValidationResult result = validator.validate("ab", "password");
assertThat(result.isValid()).isFalse();
assertThat(result.getMessage()).contains("too short");
}
// Challenge 2: Non-deterministic Code (time, random, external services)
// Solution: Inject dependencies
// WRONG - hard to test
public class ReportGenerator {
public Report generate() {
LocalDateTime now = LocalDateTime.now(); // Can't control time
return new Report(now, fetchData());
}
}
// ✅ CORRECT - inject clock
public class ReportGenerator {
private final Clock clock;
public ReportGenerator(Clock clock) {
this.clock = clock;
}
public Report generate() {
LocalDateTime now = LocalDateTime.now(clock);
return new Report(now, fetchData());
}
}
// Easy to test
@Test
void shouldGenerateReportWithTimestamp() {
Clock fixedClock = Clock.fixed(Instant.parse("2025-01-01T10:00:00Z"), ZoneId.of("UTC"));
ReportGenerator generator = new ReportGenerator(fixedClock);
Report report = generator.generate();
assertThat(report.getTimestamp()).isEqualTo("2025-01-01T10:00:00Z");
}
References
- Test Driven Development: By Example (Kent Beck)
- Growing Object-Oriented Software, Guided by Tests
- Clean Code (Robert C. Martin)
- Working Effectively with Legacy Code (Michael Feathers)