Writing a failing test first is easy; keeping a large test suite fast, trustworthy and easy to change is the hard part. These TDD best practices cover the FIRST principles, the test smells to remove, how to bring tests to legacy code, how TDD fits into CI, what code coverage does and doesn't tell you, and how senior engineers lead TDD adoption. For the fundamentals, see the TDD guide and interview questions.
FIRST Principles of Good TDD Tests
| Letter | Principle | Meaning | How to Achieve |
|---|---|---|---|
| F | Fast | Tests run in milliseconds — whole suite in seconds | No I/O, no network, no sleep. Use mocks. |
| I | Independent | Tests do not depend on each other or shared state | Each test sets up its own data. No static state. |
| R | Repeatable | Same result every run — no randomness or time dependency | Mock the clock, mock random. Control all inputs. |
| S | Self-Validating | Test clearly passes or fails — no manual inspection | Use assertions. Test output must be binary. |
| T | Timely | Tests written at the same time as the code (TDD style) | Write test first. Never defer test writing. |
TDD Anti-Patterns — Test Smells
| Anti-Pattern | Description | Problem | Fix |
|---|---|---|---|
| God Test | One test tests many things | Fails for multiple reasons — hard to diagnose | One test = one behaviour |
| Test Duplication | Same test written multiple times | Maintenance overhead — change ripples everywhere | Parameterised tests |
| Fragile Test | Breaks on any code change | Tests become a burden not an asset | Test behaviour, not implementation |
| Slow Test Suite | Tests take minutes to run | Developers skip running tests | Mock I/O, parallelise, categorise with @Tag |
| Mocking Everything | Even simple POJOs are mocked | Tests test the mocks, not the code | Only mock external dependencies |
| Testing Internals | Tests call private/internal methods | Tests break on every refactor | Test only public API of the class |
| Shared Fixtures | Tests share setUp state | Order dependency, flaky tests | @BeforeEach per test, not static state |
| Missing Assertion | Test has no assertion — always green | Gives false confidence | Every test must have at least one assertion |
TDD in Legacy Code
Legacy code is code without tests. Adding TDD to legacy code requires a different approach — you cannot simply start writing tests as the code was not designed for it.
- Identify the seam — find where you can inject a test double (constructor injection, method parameter, interface)
- Write a characterisation test — test what the code CURRENTLY does (even if wrong) to create a safety net
- Extract method / Extract class — refactor small pieces to make them independently testable
- Add new behaviour TDD-style — for any new feature, use TDD even if surrounding code has no tests
- Incrementally improve coverage — improve coverage per sprint, never decrease it
// Legacy code — hard to test (creates its own dependencies)
public class OrderProcessor {
public void process(Order order) {
DatabaseConnection db = new DatabaseConnection(); // hardcoded
EmailSender email = new EmailSender(); // hardcoded
db.save(order);
email.send(order.getEmail(), "Your order is placed");
}
}
// Refactored for testability — dependency injection
public class OrderProcessor {
private final OrderRepository repo;
private final EmailService email;
public OrderProcessor(OrderRepository repo, EmailService email) {
this.repo = repo; this.email = email; // injectable — mockable
}
public void process(Order order) {
repo.save(order);
email.send(order.getEmail(), "Your order is placed");
}
}
// Now perfectly testable with Mockito mocks
TDD in CI/CD Pipeline
# GitHub Actions — TDD pipeline
name: TDD Pipeline
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '17', distribution: 'temurin' }
# Unit tests (TDD fast suite)
- name: Run Unit Tests
run: mvn test -Dgroups=unit
# Integration tests
- name: Run Integration Tests
run: mvn test -Dgroups=integration
# Code coverage gate — fail if below 80%
- name: Coverage Check
run: mvn jacoco:check
# Publish coverage report
- name: Upload Coverage
uses: codecov/codecov-action@v3
Code Coverage with JaCoCo
<!-- pom.xml — JaCoCo coverage plugin -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
<!-- Fail build if coverage below threshold -->
<execution>
<id>check</id>
<goals><goal>check</goal></goals>
<configuration>
<rules><rule>
<limits><limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum> <!-- 80% minimum -->
</limit></limits>
</rule></rules>
</configuration>
</execution>
</executions>
</plugin>
// Run: mvn clean test jacoco:report
// Report at: target/site/jacoco/index.html
TDD Strategy for 5+ Year Engineers
Test Architecture Decisions
- Define the test pyramid for your project — unit:integration:e2e ratio (e.g., 70:20:10)
- Establish code coverage minimum per module — enforce in CI/CD with JaCoCo
- Separate fast (unit) and slow (integration) tests with @Tag — run separately in pipeline
- Define what is a unit — in your team's context (one class? one module? one microservice?)
- Choose between sociable tests (real dependencies) and solitary tests (mocked dependencies)
TDD at Scale — Microservices
- Each microservice has its own unit and integration test suite
- Use contract testing (Pact) to verify API contracts between services without full integration
- Consumer-Driven Contract Testing — consumer defines the contract, provider verifies it
- Use WireMock to stub external service calls in integration tests
- In-memory databases (H2) for integration tests — same schema as production
FAQs
What are the FIRST principles of unit tests?
Fast, Independent (or Isolated), Repeatable, Self-validating and Timely: tests run quickly, don't depend on each other, give the same result everywhere, pass or fail on their own, and are written at the right time (in TDD, before the code).
What is a test smell?
A sign of a problem in test code, such as tests that depend on each other, assert too much, mock everything, use sleeps, or break whenever implementation details change.
How do you start TDD in legacy code?
Add characterisation tests around the code you need to change, break dependencies with small safe refactorings (seams), then test-drive the new behaviour.