Test Driven Development: 9 Practices for Useful, Maintainable Tests
Test driven development (TDD) is a software development practice in which a developer writes a small test before writing the code that makes it pass. The cycle is often called red, green, refactor: create a failing test, implement the simplest change that passes it, then improve the design while keeping the tests green. TDD can provide fast feedback and clarify expected behavior, but it requires focused tests and ongoing maintenance.
This article explains the cycle, how TDD differs from simply adding tests later, where it works well, and how teams can avoid brittle test suites.
The red, green, refactor cycle
- Red: Write one small test for a behavior that the software does not yet support. Run it and confirm that it fails for the expected reason.
- Green: Add the simplest code that makes the test pass. Resist building speculative functionality that no test requires.
- Refactor: Improve naming, structure, or duplication while keeping the tests passing. Then choose the next behavior and repeat.
The Agile Alliance TDD glossary describes the interwoven activities of coding, testing, and design. The Practical Test Pyramid discusses test automation, fast feedback, and the role of tests across a delivery pipeline. Together, these ideas point to a key principle: tests should help teams reason about observable behavior, not merely increase a coverage percentage.
Why teams use test driven development
Clarify behavior before implementation
Writing a test first forces the developer to express what the code should do. A discussion about whether an empty order can be submitted or how a discount should be calculated becomes concrete before the implementation grows.
Get fast feedback
A focused automated test can reveal a mismatch close to where it was introduced. That gives a developer a smaller problem to investigate than a defect found after a full release.
Support refactoring
When tests cover the behavior that matters, developers can improve internal structure with more confidence. The tests act as a safety net, although they cannot prove that every possible behavior is correct.
Encourage smaller changes
The cycle naturally breaks work into small increments. Each test describes one step, which can make changes easier to review and failures easier to isolate.
What TDD is not
TDD does not mean testing every line or reaching a specific coverage number. A test that mirrors the implementation can pass while the user-facing behavior remains wrong. Nor does TDD remove the need for integration, acceptance, exploratory, performance, or security testing.
TDD also is not a promise that a team will write fewer defects in every project. The value depends on the problem, team familiarity, test quality, and how the suite is maintained. Agile Alliance notes that practitioner reports and empirical evidence do not always point to the same conclusions about every claimed benefit. Treat TDD as a technique to evaluate and adapt, not a guaranteed productivity formula.
How to write useful TDD tests
Start with observable behavior
Describe what a caller or user can see. For a pricing service, a test might say that a valid coupon reduces an eligible subtotal while an expired coupon does not. Avoid tests that require the code to call a private helper in a specific order unless that sequence is part of a real contract.
Keep each test focused
A test should explain one behavior and fail clearly when it breaks. Many teams use an arrange, act, assert structure: prepare inputs, run the action, and check the outcome. Clear setup and descriptive names help future maintainers understand the reason for the test.
Use realistic boundaries
Cover empty values, invalid values, limits, and meaningful alternatives. If an amount must be nonnegative, test zero and a negative value. If a process depends on a date, control the clock so the test is repeatable.
Avoid external dependencies in fast tests
Unit tests should usually run without a live network, production database, or third-party payment service. Use fakes or controlled test doubles where they provide stable feedback. However, add integration tests to verify that the components connect correctly, because isolated unit tests cannot check the entire contract.
Let failures explain themselves
Use useful assertions and concise setup. A test failure should make it clear what the expected result was and what actually happened. If a test is difficult to read, it may also be difficult to maintain.
Where TDD can fit well
TDD often works well for business rules with clear inputs and outputs: pricing, eligibility, permission checks, validation, and calculations. It can also help when building APIs or data transformations with well-defined contracts. A team can use the approach for a small component without applying it to every line of a large application.
It can be harder when behavior is uncertain, the interface is rapidly changing, or a problem depends on visual interaction, machine learning output, or a complex external system. In those cases, a prototype, exploratory test, or higher-level acceptance example may be a better first step. The team can then apply TDD to the stable rules that emerge.
TDD compared with behavior driven development
TDD usually expresses expected behavior in tests close to the code. Behavior driven development (BDD) often frames examples in domain language that product owners, testers, and developers can discuss together. They overlap in their use of examples, but BDD places more emphasis on shared understanding across roles. Teams can combine the two by agreeing on an example in plain language and implementing focused automated tests beneath it.
Common pitfalls and how to respond
- Writing large tests before running them: Keep steps small and verify each failure before adding more code.
- Testing implementation details: Assert externally meaningful behavior so refactoring does not break tests unnecessarily.
- Building a slow test suite: Keep fast checks narrow, move broad scenarios to appropriate stages, and remove redundant work.
- Ignoring flaky tests: Investigate time, ordering, randomness, and shared state. A test that fails unpredictably weakens trust.
- Using mocks for everything: Mocks can make tests tightly coupled to internal calls. Prefer simple fakes or integration coverage when they give clearer confidence.
- Treating coverage as the goal: Review whether important behavior and failure paths are covered, not only how many lines executed.
How to adopt TDD as a team
- Choose a small feature with clear rules and a supportive reviewer.
- Agree on the behavior with a product or domain expert before coding.
- Practice one red, green, refactor cycle at a time.
- Run the fast suite locally and in continuous integration.
- Review whether tests describe behavior clearly and remain easy to change.
- Expand to other components only when the approach helps the team.
For a larger project, a clear software requirements specification can provide the rules and examples that tests need to verify. Keep it concise and update it as the product decision changes.
A worked example: test a discount rule
Imagine a checkout service that accepts a fixed-value coupon only when the order subtotal meets a minimum. Start with a test for an eligible order and assert the resulting total. Run it and confirm that it fails because the feature does not exist. Add the smallest implementation that passes. Next, write tests for an expired coupon, an order just below the minimum, and a subtotal of zero.
Once those behaviors pass, refactor duplicated validation or clarify the names of the pricing rules. Then run the tests again. This sequence keeps the work connected to the business rule and gives reviewers concrete examples. The same method can expose unanswered questions: should shipping count toward the minimum? Should discounts apply before tax? A failing test can start a product discussion rather than hide ambiguity in code.
Fit TDD into a broader test portfolio
Unit tests give fast feedback on focused rules. Integration tests check that components communicate correctly. Contract tests help a provider and consumer agree on an interface. End-to-end tests exercise a user workflow, while exploratory testing can reveal unexpected interactions. TDD contributes to this portfolio; it does not replace it.
Run fast tests during local development and on every relevant code change. Place broader checks later in the pipeline when they need external services or more setup. Keep a clear path for diagnosing a failure so developers can fix it without rerunning an entire system unnecessarily.
When tests become hard to change
A test suite can become brittle if it mirrors class structure, checks private calls, or requires many mocks. When a refactor causes dozens of unrelated failures, revisit whether the tests capture observable behavior. Shared setup can also obscure the reason a test exists. Prefer explicit cases, simple fixtures, and stable boundaries.
Legacy systems can still benefit from TDD, but teams may first need characterization tests that record current behavior. Those tests create a baseline before a risky change. Once the behavior is understood, developers can use test-first cycles on new or refactored logic without pretending that every old component can be rewritten at once.
Product requirements and examples should be clear enough for the team to know what a passing test means. A practical requirements specification can connect user rules to examples that developers can automate.
Frequently Asked Questions
What are the three steps in test driven development?
Write a failing test, implement the smallest change that passes it, and refactor the design while preserving passing behavior. Developers repeat the cycle in small increments.
Is test driven development the same as unit testing?
No. Unit testing is a way to test a small part of a system. TDD is a workflow that uses tests to guide design and implementation before code is written.
Does TDD guarantee better software?
No technique guarantees quality. TDD can give useful feedback when tests cover meaningful behavior and remain maintainable, but teams still need broader testing and review.
Should teams use TDD for every project?
Not necessarily. Apply it where behavior is clear and tests help guide design. Use prototypes or exploratory methods when the problem or interface still needs discovery.
Conclusion
Test driven development makes expected behavior part of the design process. Use small tests, focus on observable outcomes, and keep the suite fast enough to trust. TDD works best as one part of a balanced engineering practice that also includes integration checks, user acceptance, monitoring, and thoughtful code review.