Test-Driven Development (TDD): Tutorial to Bulletproof Your Code

Introduction

Throughout my 7-year career as a Computer Science professional, the most critical challenge I've faced is ensuring code reliability. The 2024 State of DevOps Report from Puppet highlights the benefits of strong testing practices, reinforcing why teams should adopt approaches like Test-Driven Development (TDD). Integrating testing early reduces costly post-release defects and improves long-term maintainability.

TDD is not just a methodology; it's a mindset shift that fosters a culture of quality. By writing tests before code, developers design systems with explicit requirements, which leads to cleaner and more maintainable codebases. For example, while developing a payroll system for a startup that needed to support multiple jurisdictions, overtime rules, and concurrent payroll runs, I used TDD to drive the design of a tax-calculation service. Writing targeted tests for locale-specific rate lookup, rounding rules, and concurrency scenarios exposed edge cases early. The result was fewer production hotfixes and faster onboarding for new engineers since tests encoded the business rules.

In this tutorial, you will learn how to implement TDD in your workflow, starting with setting up a testing environment with JUnit 5. You’ll write unit tests for a simple calculator application, following full Red→Green→Refactor cycles (including additional examples for error handling). By the end, you’ll be able to apply TDD principles in your projects and integrate tests into CI/CD pipelines for continuous verification.

The TDD Cycle: Red, Green, Refactor

Understanding the Cycle

The TDD cycle consists of three stages: Red, Green, and Refactor. Initially, you write a failing test, which indicates the code does not yet fulfill the requirement. This phase helps clarify the expected behavior and edge cases. For example, when designing a payment processing feature, writing tests up front for different payment methods and error scenarios highlighted integration gaps early.

Once the failing test is in place, you move to the Green phase: implement the simplest code to pass the test. Keep changes minimal and focused. After ensuring the test passes, enter Refactor to improve code quality and structure without changing behavior.

  • Write a failing test to define requirements
  • Implement minimal code to pass the test
  • Refactor the code for clarity and efficiency
  • Repeat the cycle for continuous improvement

Here’s an example assertion used inside a test:

assertEquals(5, add(2, 3));
TDD Red-Green-Refactor Cycle Three-step Test-Driven Development cycle showing Red, Green, and Refactor with arrows Red Write failing test Green Make tests pass Refactor Improve design
Figure: Red → Green → Refactor cycle for TDD

Calculator TDD Example (Red → Green → Refactor)

This section walks through a minimal, real-world Red→Green→Refactor example using Java + JUnit 5 (JUnit 5.8.1) and expands the example to cover error handling. It demonstrates the discipline of writing the failing test first, implementing the simplest code to pass it, then refactoring.

Project setup (Maven)

Add JUnit 5 to your pom.xml (example uses JUnit 5.8.1):

<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>5.8.1</version>
  <scope>test</scope>
</dependency>

Optional: add Mockito to your pom.xml for mocking to isolate dependencies in later tests. See the Mockito repository at github.com/mockito/mockito for current artifacts and release notes.

Red: write the failing test first (addition)

Create CalculatorTest that expects an add method:

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

public class CalculatorTest {

    @Test
    public void testAddTwoNumbers() {
        Calculator calc = new Calculator();
        assertEquals(5, calc.add(2, 3));
    }
}

Green: implement the minimal code to pass

Implement the simplest Calculator to satisfy the test:

public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}

Refactor: improve design while keeping tests green

After adding more tests (subtract, multiply, divide), refactor to remove duplication, introduce helper methods, or extract interfaces—always running the test suite after each change. For example, you can extract validation logic for arithmetic inputs into a private method or introduce a CalculatorService interface for injection in larger apps.

Additional Red→Green→Refactor: Subtraction & Division (error handling)

To demonstrate handling negative results and division-by-zero, follow the same cycle. First, write failing tests for subtraction and division error behavior.

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

public class CalculatorAdvancedTest {

    @Test
    public void testSubtractWithNegativeResult() {
        Calculator calc = new Calculator();
        assertEquals(-1, calc.subtract(2, 3));
    }

    @Test
    public void testDivideByZeroThrows() {
        Calculator calc = new Calculator();
        assertThrows(ArithmeticException.class, () -> calc.divide(1, 0));
    }
}

Next, implement the minimal methods to make these tests pass:

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 divide(int a, int b) {
        if (b == 0) {
            throw new ArithmeticException("Division by zero");
        }
        return a / b;
    }
}

Refactor example: if you expect fractional division in later tests, switch to a double API and add precise assertions with delta. Keep tests small and migrate them incrementally.

Complete Calculator class

Below is a consolidated, runnable Calculator implementation that includes basic operations and defensive checks used across the examples. Use this as the canonical class for the tutorial tests above.

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;
    }

    public int divide(int a, int b) {
        if (b == 0) {
            throw new ArithmeticException("Division by zero");
        }
        return a / b;
    }
}

Notes and troubleshooting for this example

  • If the test doesn’t run, ensure Surefire/FailSafe plugins are configured for JUnit 5 in Maven (standard Maven projects require the JUnit platform provider). Example Surefire snippet is shown in the JaCoCo section below (also required for JUnit).
  • If math edge cases fail (overflow, negative values), add targeted tests for those inputs before changing logic.
  • In CI, run tests with a reproducible JDK (e.g., OpenJDK 17) to avoid environment drift. Pin the JDK/runtime in your CI pipeline.
  • Be explicit about integer vs. floating-point expectations in tests to avoid assertion mismatches (use assertEquals(expected, actual, delta) for doubles).

User model (class definition)

Provide a minimal User model so examples are runnable and consistent across the tutorial. This class includes basic fields, accessors, and a simple email validation helper used in unit tests.

public class User {
    private final String email;
    private final String password;

    public User(String email, String password) {
        this.email = email;
        this.password = password;
    }

    public String getEmail() {
        return email;
    }

    public String getPassword() {
        return password;
    }

    /**
     * Simple, conservative email validation used for unit tests.
     * Not a production-grade validator; use javax.mail or Apache Commons for full validation.
     */
    public boolean isValidEmail() {
        if (email == null) return false;
        return email.matches("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
    }
}

Notes:

  • The isValidEmail() method uses a conservative regular expression suitable for tests; for production, prefer a vetted library.
  • The User class includes getters used by UserService in the service-level examples.

Advanced TDD Example: Service with Mockito

This example demonstrates TDD for a small service layer that depends on a repository. It shows how to use Mockito (version 4.x) with JUnit 5 to mock dependencies, assert interactions, and keep tests isolated and fast. The example uses a UserService that depends on a UserRepository.

TDD Service + Mocking Architecture Diagram showing Test -> UserService -> UserRepository -> Database with mocked repository in tests Test JUnit + Mockito UserService Business logic UserRepository DB access (mocked)
Figure: Service-under-test with a mocked repository to isolate unit tests

Recommended Maven dependencies (add to pom.xml):

<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-core</artifactId>
  <version>4.6.1</version>
  <scope>test</scope>
</dependency>

<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>5.8.1</version>
  <scope>test</scope>
</dependency>

Example production code (repository interface and service):

public interface UserRepository {
    User findByEmail(String email);
    void save(User user);
}

public class UserService {
    private final UserRepository repo;

    public UserService(UserRepository repo) {
        this.repo = repo;
    }

    public boolean register(User user) {
        if (repo.findByEmail(user.getEmail()) != null) {
            return false; // already exists
        }
        repo.save(user);
        return true;
    }
}

Test-first (Red) - write a failing test that specifies behavior, then implement minimal code to satisfy it:

import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

public class UserServiceTest {

    @Test
    public void shouldRegisterNewUser() {
        UserRepository repo = mock(UserRepository.class);
        when(repo.findByEmail("new@example.com")).thenReturn(null);

        UserService svc = new UserService(repo);
        User user = new User("new@example.com", "securePass");

        assertTrue(svc.register(user));
        verify(repo).save(user);
    }
}

Security & troubleshooting notes for service-level tests:

  • Avoid connecting to real databases in unit tests. Use mocks or an in-memory test fixture only for integration tests.
  • Verify interactions (verify(...)) to ensure side effects occur as expected, and prefer argument captors for detailed inspections.
  • Keep mocked behavior explicit; do not rely on default mock return values to avoid false positives.
  • When tests fail in CI but pass locally, check for differences in JDK, mockito/junit versions, and classpath ordering. Pin dependency versions in your build configuration.

Types of Tests: Unit, Integration, End-to-End

Understand where TDD fits in the testing pyramid. TDD primarily targets unit tests, but a balanced strategy includes integration and end-to-end (E2E) tests.

  • Unit tests: Fast, deterministic, isolated (mock external dependencies). TDD focuses here because of the fast feedback loop.
  • Integration tests: Verify interactions between components (e.g., service + real database). Run less frequently or in a dedicated pipeline stage.
  • End-to-End tests: Full-system validation (UI, network, persistence). These are slower and more brittle; reserve for high-value user journeys.

Guidance:

  • Run unit tests on every commit and pull request to keep the feedback loop tight.
  • Run integration tests in a CI stage that can provision test infrastructure (containers, test DBs) or in nightly pipelines if provisioning is expensive.
  • Use contract tests (consumer-driven contracts) when multiple services must agree on API schemas; this reduces reliance on slow E2E tests.

Measuring Test Coverage with JaCoCo

Use a coverage tool to identify untested code paths and guide meaningful improvements. JaCoCo is a widely used coverage tool for JVM projects—see www.jacoco.org for details.

Example Maven plugin configuration to generate coverage reports (add to pom.xml plugins):

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.8</version>
  <executions>
    <execution>
      <goals><goal>prepare-agent</goal></goals>
    </execution>
    <execution>
      <id>report</id>
      <phase>test</phase>
      <goals><goal>report</goal></goals>
    </execution>
  </executions>
</plugin>

Best practices interpreting coverage:

  • Use coverage to find risky untested code, not as a blind metric. High coverage does not guarantee correctness.
  • Track coverage trends over time and set realistic targets for critical packages (e.g., 80%+ for core business logic, lower for adapters).
  • Combine coverage results with mutation testing (if feasible) to check the effectiveness of tests rather than just quantity.

Setting Up Your TDD Environment

Tools and Frameworks

Setting up an effective TDD environment is crucial. Begin with selecting a testing framework that suits your programming language. For Java, JUnit is the go-to framework—see the official site at junit.org for guides and downloads. The examples in this tutorial use JUnit 5.8.1.

In addition to your testing framework, consider using a mocking library like Mockito to simulate dependencies and isolate the code under test. Reference the Mockito project at github.com/mockito/mockito. Mocking database connections, HTTP clients, and external services reduces test flakiness and speeds up execution.

  • Choose a testing framework (e.g., JUnit for Java)
  • Add dependencies to your project management tool (Maven, Gradle)
  • Consider using mocking frameworks like Mockito
  • Set up your IDE (IntelliJ IDEA, Eclipse, VS Code) to run tests and show stack traces inline

Security tip: do not embed secrets (API keys, DB passwords) in test code. Use test profiles or environment variables in CI (e.g., Jenkins at jenkins.io or GitHub Actions via github.com) and mask secrets in logs.

Writing Your First Test Case

Creating a Basic Test

Identify a single responsibility or function to test. Use focused assertions and avoid mixing behaviors in one test. For clarity and runnable examples, validate a small, deterministic method on the User model (provided earlier).

Example: self-contained JUnit 5 test for User.isValidEmail():

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

public class UserModelTest {
    @Test
    public void validEmailReturnsTrue() {
        User user = new User("test@example.com", "password123");
        assertTrue(user.isValidEmail());
    }

    @Test
    public void invalidEmailReturnsFalse() {
        User user = new User("invalid-email", "password123");
        assertFalse(user.isValidEmail());
    }
}

Tip: Keep tests deterministic — avoid time-based checks or calls to real network services. Use mocks/stubs for isolation and deterministic behaviour.

Best Practices for Effective TDD

Maintaining Quality in Tests

To truly benefit from TDD, maintain the quality of your tests. Keep tests simple and focused. Enforce a rule that every feature or bug fix includes tests. Regularly refactor both production and test code to remove duplication and improve readability.

  • Keep tests small and single-purpose (one assert per behavior where practical)
  • Prefer descriptive test names: shouldReturnZeroWhenListEmpty()
  • Use mocking for external systems; avoid end-to-end tests for every change
  • Run tests locally and on every pull request in CI

Performance tip: categorize tests (unit vs. integration) and run the fast unit tests on every commit while scheduling slower integration tests on nightly builds or dedicated pipelines.

Testing Error Handling & Exceptions

Use TDD to drive correct error handling. Start by writing tests that assert an exception is thrown for invalid inputs, then implement the smallest change to satisfy that test. This ensures your code fails fast and predictably.

  • Use assertThrows() (JUnit) to verify exceptions and to capture the thrown exception for more detailed assertions.
  • Test both expected exception types and message content if the message is part of the contract.
  • Isolate error paths with mocks to force edge conditions (e.g., a mocked data source that throws).

Example: assert that division by zero results in a defined exception (shown earlier in the Calculator example). When refactoring, keep exception contracts stable so callers can rely on them.

Common Challenges and How to Overcome Them

Navigating Common Pitfalls

Teams often write tests that are too broad or replicate whole-service behavior. Break tests down to focus on units. In microservices projects (e.g., Spring Boot-based services), prefer contract tests and consumer-driven contracts for verifying interactions between services rather than large end-to-end suites that run slowly.

A common temptation is to skip tests for "small" changes; enforce a rule that every code change must include tests. This discipline reduces regressions and keeps the feedback loop tight.

  • Keep tests focused on single functionalities.
  • Do not skip tests for minor changes.
  • Refactor tests regularly to maintain clarity.
  • Use mocks and stubs to isolate components and speed test execution.

Example unit test for calculating the total of an order (wrapped and preserved):

public void testCalculateTotal() {
    Order order = new Order();
    order.addItem(new Item("Widget", 2.99));
    assertEquals(2.99, order.calculateTotal(), 0.01);
}

Troubleshooting tips:

  • When tests fail intermittently (flaky tests): look for shared mutable state, network I/O, or time-based logic. Isolate and mock these.
  • If tests pass locally but fail in CI: compare JDK versions, OS differences, and environment variables. Pin JDK/runtime versions in CI and ensure environment parity.
  • Use test coverage tools (e.g., JaCoCo) to monitor untested code paths, but don’t chase 100% coverage blindly—track meaningful coverage trends over time.

Key Takeaways

  • Test-Driven Development emphasizes writing tests before code, which keeps requirements explicit and reduces bugs.
  • Use JUnit 5 (see junit.org) and a mocking library like Mockito (github.com/mockito/mockito) to isolate units and keep tests fast.
  • Integrate tests into CI/CD pipelines (Jenkins: jenkins.io, GitHub: github.com) to run tests on each commit and catch regressions early.
  • Refactor safely: keep tests green while improving design, and avoid adding secrets or environment-specific configuration to tests.

Frequently Asked Questions

How do I start writing tests for my existing code?
Begin by identifying core behaviors and write focused tests around them. Use JUnit 5 for structure and add mocks for external systems. Start small: one method at a time, then expand coverage to edge cases and error paths. When refactoring, run tests frequently to verify behavior retention.
What if my tests are failing but my code works?
Check test assumptions, input fixtures, and environment differences. Ensure assertions reflect intended behavior, and add instrumentation/logging in tests to inspect actual vs. expected values. Confirm that numeric types (int vs double) and locales (decimal separators) are consistent between environments.

Conclusion

Implementing Test-Driven Development can significantly enhance the reliability and maintainability of your code. By prioritizing tests, developers create a safety net that catches regressions early. Start with small examples (like the calculator above), practice the Red→Green→Refactor cycle, and evolve your test strategy as the codebase grows. Focus on error handling early using tests to codify behavior for exceptional conditions.

For deeper reading, consult the official JUnit resources at junit.org and the Mockito project at github.com/mockito/mockito.

About the Author

Olivia Martinez

Olivia Martinez Olivia Martinez is a Computer Science graduate with 7 years of practical experience in computer architecture, graphics programming, and system design. Her academic background, combined with hands-on experience, allows her to explain complex technical concepts in an accessible manner. Olivia specializes in low-level computing concepts, graphics rendering, and the fundamental principles that power modern computing systems.


Published: Aug 31, 2025 | Updated: Jan 05, 2026