Skip to content
Home / Skills / Tdd / Test-First Development
TD

Test-First Development

Tdd core v1.0.0

Test-First Development

Overview

Test-First Development (TFD) is the practice of writing tests before production code. It’s the foundation of Test-Driven Development (TDD) and ensures that code is written with testability in mind from the start. By writing tests first, developers clarify requirements, design cleaner interfaces, and maintain high test coverage naturally.

Test-First Development shifts the mindset from “coding then testing” to “specifying then implementing,” resulting in more robust, maintainable software with fewer defects.


Key Concepts

The Test-First Cycle

┌─────────────────────────────────────────────────┐
│         Test-First Development Cycle            │
├─────────────────────────────────────────────────┤
│                                                 │
│  1. Write Test     → Test defines behavior      │
│         ↓                                       │
│  2. Run Test       → Verify test fails          │
│         ↓                                       │
│  3. Write Code     → Minimal implementation     │
│         ↓                                       │
│  4. Run Test       → Verify test passes         │
│         ↓                                       │
│  5. Refactor       → Improve design             │
│         ↓                                       │
│  6. Repeat         → Next behavior              │
│                                                 │
└─────────────────────────────────────────────────┘

Benefits of Test-First

  • Clear Requirements: Tests document expected behavior before implementation
  • Design Feedback: Writing tests first reveals design issues early
  • High Coverage: Natural 100% test coverage (every line written to pass a test)
  • Fewer Defects: Bugs caught during development, not in production
  • Refactoring Safety: Comprehensive test suite enables confident refactoring

Test-First vs Test-After

AspectTest-FirstTest-After
DesignDrives design decisionsTests existing design
CoverageNatural 100% coverageOften incomplete coverage
DefectsCaught immediatelyFound later in cycle
RefactoringSafe with full suiteRisky with gaps
MindsetSpecification-firstImplementation-first

Best Practices

1. Start with the Simplest Test

Begin with the most basic scenario to get the API design right.

2. Write One Test at a Time

Focus on a single behavior per test; don’t write multiple tests before implementing.

3. Make Each Test Fail First

Always run the test and see it fail before writing implementation code.

4. Write Minimal Code to Pass

Resist the urge to over-engineer; write just enough code to make the test pass.

5. Keep Tests Fast and Isolated

Tests should run in milliseconds and not depend on each other or external systems.


Code Examples

Example 1: Calculator with Test-First

// Step 1: Write the first test
@Test
void shouldAddTwoNumbers() {
    Calculator calculator = new Calculator();
    int result = calculator.add(2, 3);
    assertThat(result).isEqualTo(5);
}

// Step 2: Run test - it fails (Calculator doesn't exist)

// Step 3: Write minimal code
public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}

// Step 4: Run test - it passes

Example 2: User Registration with Test-First

// Step 1: Write test for basic registration
@Test
void shouldRegisterNewUser() {
    UserService service = new UserService();
    User user = service.register("john@example.com", "password123");
    
    assertThat(user).isNotNull();
    assertThat(user.getEmail()).isEqualTo("john@example.com");
}

// Step 2: Fails - UserService doesn't exist

// Step 3: Minimal implementation
public class UserService {
    public User register(String email, String password) {
        return new User(email, password);
    }
}

// Step 4: Test passes - now add validation test
@Test
void shouldRejectInvalidEmail() {
    UserService service = new UserService();
    
    assertThatThrownBy(() -> service.register("invalid-email", "password"))
        .isInstanceOf(InvalidEmailException.class)
        .hasMessage("Email format is invalid");
}

// Step 5: Implement validation
public User register(String email, String password) {
    if (!email.contains("@")) {
        throw new InvalidEmailException("Email format is invalid");
    }
    return new User(email, password);
}

Example 3: API Endpoint with Test-First

// Step 1: Write integration test first
@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Test
    void shouldCreateOrder() throws Exception {
        String orderJson = """
            {
                "customerId": "C123",
                "items": [
                    {"productId": "P456", "quantity": 2}
                ]
            }
            """;
        
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(orderJson))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").exists())
            .andExpect(jsonPath("$.status").value("PENDING"));
    }
}

// Step 2: Test fails - endpoint doesn't exist

// Step 3: Minimal controller implementation
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public OrderResponse createOrder(@RequestBody OrderRequest request) {
        // Minimal implementation
        return new OrderResponse("ORD-001", "PENDING");
    }
}

// Step 4: Test passes - now add business logic tests

Example 4: Test-First with Mocks

// Step 1: Write test with collaborator
@Test
void shouldSendWelcomeEmailAfterRegistration() {
    EmailService emailService = mock(EmailService.class);
    UserService userService = new UserService(emailService);
    
    User user = userService.register("jane@example.com", "password");
    
    verify(emailService).send(
        eq("jane@example.com"),
        eq("Welcome!"),
        anyString()
    );
}

// Step 2: Test fails

// Step 3: Add email sending to registration
public class UserService {
    private final EmailService emailService;
    
    public UserService(EmailService emailService) {
        this.emailService = emailService;
    }
    
    public User register(String email, String password) {
        User user = new User(email, password);
        emailService.send(email, "Welcome!", "Thank you for registering!");
        return user;
    }
}

// Step 4: Test passes

Example 5: Test-First with Edge Cases

// Step 1: Test happy path
@Test
void shouldCalculateDiscount() {
    DiscountCalculator calculator = new DiscountCalculator();
    BigDecimal price = new BigDecimal("100.00");
    BigDecimal discountPercent = new BigDecimal("10");
    
    BigDecimal result = calculator.apply(price, discountPercent);
    
    assertThat(result).isEqualByComparingTo("90.00");
}

// Implement to pass

// Step 2: Add edge case test
@Test
void shouldHandleZeroDiscount() {
    DiscountCalculator calculator = new DiscountCalculator();
    BigDecimal price = new BigDecimal("100.00");
    BigDecimal discountPercent = BigDecimal.ZERO;
    
    BigDecimal result = calculator.apply(price, discountPercent);
    
    assertThat(result).isEqualByComparingTo("100.00");
}

// Step 3: Add boundary test
@Test
void shouldRejectNegativeDiscount() {
    DiscountCalculator calculator = new DiscountCalculator();
    BigDecimal price = new BigDecimal("100.00");
    BigDecimal discountPercent = new BigDecimal("-10");
    
    assertThatThrownBy(() -> calculator.apply(price, discountPercent))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessage("Discount cannot be negative");
}

// Enhance implementation with validation
public BigDecimal apply(BigDecimal price, BigDecimal discountPercent) {
    if (discountPercent.compareTo(BigDecimal.ZERO) < 0) {
        throw new IllegalArgumentException("Discount cannot be negative");
    }
    if (discountPercent.compareTo(BigDecimal.ZERO) == 0) {
        return price;
    }
    BigDecimal discount = price.multiply(discountPercent).divide(new BigDecimal("100"));
    return price.subtract(discount);
}

Anti-Patterns

❌ Writing Multiple Tests Before Implementation

// WRONG - writing many tests at once
@Test void testAdd() { /* ... */ }
@Test void testSubtract() { /* ... */ }
@Test void testMultiply() { /* ... */ }
@Test void testDivide() { /* ... */ }

// Then writing all implementation

// ✅ CORRECT - one test at a time
@Test void testAdd() { /* ... */ }
// Implement add()
// Then next test

❌ Not Running Tests Before Implementation

// WRONG - assuming test will fail
// Write test, immediately write code without running test

// ✅ CORRECT - always verify test fails first
// 1. Write test
// 2. Run test → RED
// 3. Write code
// 4. Run test → GREEN

❌ Over-Engineering the First Implementation

// WRONG - complex first implementation
public int add(int a, int b) {
    // Validating, logging, caching, etc.
    logger.info("Adding {} and {}", a, b);
    if (cache.contains(a, b)) return cache.get(a, b);
    // ...
}

// ✅ CORRECT - simplest possible
public int add(int a, int b) {
    return a + b;
}

Testing Strategies

Unit Test First

// Start with unit tests for core logic
@Test
void shouldCalculateShippingCost() {
    ShippingCalculator calculator = new ShippingCalculator();
    Money cost = calculator.calculate(Weight.kg(5), Country.USA);
    assertThat(cost).isEqualTo(Money.usd(15.00));
}

Integration Test First (When Appropriate)

// For API contracts, start with integration tests
@Test
void shouldReturnOrderById() throws Exception {
    mockMvc.perform(get("/api/orders/{id}", "ORD-123"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.id").value("ORD-123"));
}

Test Data Builders

// Use builders for complex test data
public class UserBuilder {
    private String email = "default@example.com";
    private String password = "password";
    
    public UserBuilder withEmail(String email) {
        this.email = email;
        return this;
    }
    
    public User build() {
        return new User(email, password);
    }
}

@Test
void shouldValidateEmail() {
    User user = new UserBuilder()
        .withEmail("invalid")
        .build();
    
    assertThat(validator.isValid(user)).isFalse();
}

References