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

LetterPrincipleMeaningHow to Achieve
FFastTests run in milliseconds — whole suite in secondsNo I/O, no network, no sleep. Use mocks.
IIndependentTests do not depend on each other or shared stateEach test sets up its own data. No static state.
RRepeatableSame result every run — no randomness or time dependencyMock the clock, mock random. Control all inputs.
SSelf-ValidatingTest clearly passes or fails — no manual inspectionUse assertions. Test output must be binary.
TTimelyTests written at the same time as the code (TDD style)Write test first. Never defer test writing.

TDD Anti-Patterns — Test Smells

Anti-PatternDescriptionProblemFix
God TestOne test tests many thingsFails for multiple reasons — hard to diagnoseOne test = one behaviour
Test DuplicationSame test written multiple timesMaintenance overhead — change ripples everywhereParameterised tests
Fragile TestBreaks on any code changeTests become a burden not an assetTest behaviour, not implementation
Slow Test SuiteTests take minutes to runDevelopers skip running testsMock I/O, parallelise, categorise with @Tag
Mocking EverythingEven simple POJOs are mockedTests test the mocks, not the codeOnly mock external dependencies
Testing InternalsTests call private/internal methodsTests break on every refactorTest only public API of the class
Shared FixturesTests share setUp stateOrder dependency, flaky tests@BeforeEach per test, not static state
Missing AssertionTest has no assertion — always greenGives false confidenceEvery 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.

  1. Identify the seam — find where you can inject a test double (constructor injection, method parameter, interface)
  2. Write a characterisation test — test what the code CURRENTLY does (even if wrong) to create a safety net
  3. Extract method / Extract class — refactor small pieces to make them independently testable
  4. Add new behaviour TDD-style — for any new feature, use TDD even if surrounding code has no tests
  5. 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

Advertisement
  • 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.