TDD in Production Software: Where It Helps and Where I Do Not Force It
A practical guide to test-driven development in production software: where TDD improves design and confidence, where it creates friction, and how to use it without dogma.
Test-driven development is easy to turn into a loyalty test. One group presents it as the only professional way to write software. Another remembers a suite of brittle mocks and dismisses the whole practice.
Neither position helps me ship production systems.
I use TDD when a test can help me discover a small, stable piece of behaviour before implementation decisions make that behaviour harder to see. It is particularly effective for business rules, state transitions, data transformations, parsers, permission decisions, and regression fixes. I do not force it while the team is still discovering what an interface should be, when the useful assertion lives only at a real system boundary, or when a test would merely repeat a framework configuration.
That distinction matters. “Write tests” and “write every test before every line of production code” are different claims. I strongly prefer software with automated evidence. I choose the moment and level of that evidence according to risk.
This article explains where I find the red–green–refactor loop valuable, where I deliberately use another workflow, and how I keep the resulting suite useful after the code reaches production. The runnable examples were verified with Node.js 23.10.0 and use its stable built-in test runner.
What I mean by TDD
The core TDD loop is small:
- Red: write one test for the next behaviour and see it fail for the expected reason.
- Green: write the smallest honest implementation that makes it pass.
- Refactor: improve the code and tests while preserving the behaviour.
Martin Fowler's description of test-driven development adds an important step that short summaries often omit: create a list of test cases, then choose the next case that will drive the design toward its important decisions. TDD is not randomly adding assertions one at a time. The sequence is part of the reasoning.
Seeing the red result is essential. A test that passes immediately may be useful, but it has not demonstrated that it can detect the missing behaviour. It might be reaching the wrong code, relying on a default, or making an assertion that can never fail.
“Smallest implementation” also needs judgement. It does not mean adding a hard-coded special case that obviously cannot support the next known requirement. It means avoiding speculative infrastructure until a behaviour requires it. The result should be simple, not dishonest.
The refactor step is where much of the design value appears. Once the observable examples are green, I can rename concepts, remove duplication, split responsibilities, or replace an algorithm without simultaneously guessing whether behaviour changed. If a team always stops at green, it is practising test-first implementation without completing the TDD loop.
Where TDD pays for itself
TDD is most useful when examples can sharpen the design.
Rule-heavy domain logic
Pricing, permissions, entitlements, scheduling, tax rules, workflow transitions, retry policies, and validation often contain combinations that are difficult to hold in working memory. A test list turns vague prose into explicit examples:
- An active subscription grants the base entitlement.
- A cancelled subscription keeps access until the paid period ends.
- A suspended account loses access immediately.
- An administrator can inspect the account but cannot consume its entitlement.
Writing these cases first exposes missing product decisions before they become nested conditionals. It also encourages an interface expressed in domain language rather than framework objects.
Regression fixes
A reported production bug already provides an excellent red test: reproduce the failure at the smallest level that still contains its cause. Then fix it and keep the test.
This is more valuable than adding a test that simply visits the same function. The new test should preserve the exact boundary condition that escaped—an empty collection, duplicated event, daylight-saving transition, stale version, or concurrent update. The test becomes durable evidence that the team understood the incident.
Algorithms and transformations
Parsers, normalisers, allocation logic, calculations, and serializers have clear inputs and outputs. They are fast to exercise and rich in edge cases. TDD works well because each example can extend the design without requiring a database, browser, or network.
State machines and lifecycle decisions
Order, payment, import, and job lifecycles often permit only certain transitions. Test-first examples make forbidden transitions as visible as allowed ones. That is important because a transition function which handles only the happy path can still look complete in code review.
Public contracts
When I know what an API, event, or library should promise, starting with a consumer-visible example helps keep accidental implementation details out of the interface. The test might sit at an HTTP or message boundary rather than be a unit test. TDD does not require every useful test to run without infrastructure.
The common feature is not “a pure function.” It is a behaviour precise enough to describe before choosing all of its implementation details.
A production-shaped example
Imagine a notification worker receiving an HTTP response from an external provider. The product policy is:
- A
2xxresponse completes delivery. 408,429, and5xxresponses may be retried.- Other
4xxresponses are terminal because retrying the same request will not repair it. - A retryable response after the final attempt goes to the dead-letter path.
This is an application policy, not a universal interpretation of every provider. The provider's contract may require different treatment. That makes the decision a good candidate for a small, explicit test list.
I begin with the first behaviour:
// delivery-decision.test.js
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { decideDelivery } from './delivery-decision.js'
test('marks a successful provider response as delivered', () => {
assert.deepEqual(
decideDelivery({ statusCode: 202, attempt: 1, maxAttempts: 4 }),
{ outcome: 'delivered' }
)
})
Running node --test delivery-decision.test.js must fail because the module or function does not exist. That is the first red result. I then add enough implementation to make the example green and move to the next item on the test list.
After several cycles, the tests describe the policy:
test('retries a rate-limited response when attempts remain', () => {
assert.deepEqual(
decideDelivery({ statusCode: 429, attempt: 2, maxAttempts: 4 }),
{ outcome: 'retry' }
)
})
test('dead-letters a retryable response after the final attempt', () => {
assert.deepEqual(
decideDelivery({ statusCode: 503, attempt: 4, maxAttempts: 4 }),
{ outcome: 'dead-letter', reason: 'attempts-exhausted' }
)
})
test('rejects a request the provider considers invalid', () => {
assert.deepEqual(
decideDelivery({ statusCode: 422, attempt: 1, maxAttempts: 4 }),
{ outcome: 'rejected' }
)
})
The implementation can remain small:
export function decideDelivery({ statusCode, attempt, maxAttempts }) {
if (statusCode >= 200 && statusCode < 300) {
return { outcome: 'delivered' }
}
const retryable =
statusCode === 408 ||
statusCode === 429 ||
(statusCode >= 500 && statusCode <= 599)
if (!retryable) {
return { outcome: 'rejected' }
}
if (attempt >= maxAttempts) {
return { outcome: 'dead-letter', reason: 'attempts-exhausted' }
}
return { outcome: 'retry' }
}
The tests have driven a useful boundary: deciding what should happen is separate from performing it. The decision is deterministic. Queue scheduling, database updates, metrics, and provider calls can sit in an orchestration layer.
That does not mean the orchestration layer is now safe. Integration tests still need to prove that retry schedules one durable job, delivered persists completion, and a crash between those operations does not lose or duplicate work. Production monitoring must show retry volume and dead-letter growth. TDD improved one design decision; it did not certify the whole worker.
The built-in node:test documentation describes how synchronous, promise-returning, and callback tests are evaluated. Whichever runner I use, I keep this inner loop fast enough that running the focused test after each change is cheaper than reasoning about whether I should run it.
Test behaviour, not the route through the code
TDD becomes expensive when tests describe the current implementation instead of the promised result.
Suppose an application service completes a delivery. A fragile test might assert that validateResponse was called once, mapStatus was called with 202, and repository.update received an object built in a particular order. That test can fail after a harmless refactor even when the externally visible outcome is unchanged.
A stronger test asks what the caller can observe:
- What value is returned?
- What durable state changes?
- Which external message or request crosses a boundary?
- What must not happen?
Mocks are useful at expensive, slow, or dangerous boundaries. I use them to stop a unit test from charging a card, publishing a message, or sending an email. I avoid mocking every collaborator merely because the mocking framework makes it easy. If five internal mocks need coordinated expectations, the test may be coupled to the call graph rather than the behaviour.
This principle applies to interfaces too. Testing Library's guiding principles recommend interacting with components in ways that resemble their users and avoiding implementation details. Playwright makes the same argument for browser tests in its best-practices guide: prefer user-visible behaviour and resilient, user-facing locators.
There are exceptions. A security control, audit event, or idempotency key may be an internal interaction and still be part of the required contract. The deciding question is whether a future implementation is free to remove it without changing the system's obligations. If not, it deserves an assertion.
TDD does not decide the whole test strategy
The red–green–refactor loop describes how a piece of software may be developed. It does not tell a team which production risks require which kind of test.
I usually want several layers of evidence:
- Unit tests exercise decisions, transformations, and state transitions quickly. They provide the shortest TDD loop.
- Integration tests verify code against real boundaries such as a database, queue, filesystem, cache, or framework runtime.
- Contract tests check the assumptions shared between services or between an application and an external API representation.
- End-to-end tests protect a small number of critical user journeys through the deployed shape of the application.
The notification decision above belongs in a unit test. The repository needs integration tests against the actual database, including transaction and uniqueness behaviour. The provider adapter needs contract fixtures or a safe sandbox. A critical customer journey may need an end-to-end test proving that a user request eventually produces a visible delivery state.
No fixed percentage creates the correct mix. Risk, feedback speed, and failure cost decide it. A database constraint should be tested through the database. Browser focus behaviour should be tested in a browser. A pure allocation rule does not need a browser to prove its arithmetic.
I keep end-to-end coverage selective because failures have more possible causes and setup is more expensive. I also isolate tests so one test does not depend on data left by another. Playwright's isolation model creates a fresh browser context for each test; database and queue tests need an equivalent ownership strategy for their state.
The suite is a portfolio of confidence, not a competition to make every test a unit test or every workflow end to end.
Where I do not force test-first development
Choosing not to start with a test is not choosing to ship without evidence. In these situations I change the order or the verification technique.
Exploratory work and technical spikes
Sometimes the purpose of the code is to discover whether an SDK supports a workflow, how a browser API behaves, or where latency comes from. The interface is not known yet. Writing detailed tests first can freeze the first guess and make exploration slower.
I time-box the spike, keep it out of the production path, and decide what was learned. If the code will be retained, I rewrite or stabilise it around explicit behaviour and add tests before treating it as production code. “Prototype” should not become a permanent label that excuses an unverified service.
Visual and interaction discovery
During early UI work, designers and engineers may change layout, hierarchy, wording, and interaction rapidly. Test-driving DOM structure at this point creates churn without protecting a stable promise.
I first establish the interaction, then protect accessibility semantics, validation, state transitions, and critical journeys. Visual regression tests can help once the intended appearance is stable. Human review remains necessary for whether the interface is understandable and appropriate.
Thin framework wiring
A route that maps an HTTP request to an already-tested application command may contain almost no independent logic. A unit test that mocks the framework request, command bus, logger, and response can repeat the implementation line by line.
I prefer one integration test through the real route boundary, plus focused tests for the command's behaviour. If the route owns authentication, validation, serialization, or error mapping, those are real behaviours and should be tested.
Generated code and declarative configuration
I do not write unit tests for generated clients or repeat a framework's own tests. I test that my application can build the generated artifact and that a representative consumer interaction works. For infrastructure and configuration, validation, policy checks, deployment previews, smoke tests, and a safe rollout are often better evidence than a test that mirrors every line.
Non-deterministic and external behaviour
Search relevance, RAG answer quality, fraud models, and third-party provider behaviour are not usefully reduced to exact unit assertions. Their deterministic transformations can still use TDD, but overall quality needs representative evaluation sets, statistical thresholds, contract checks, observability, and controlled production rollout.
Mocking an AI model into returning the expected sentence proves the mock, not answer quality.
Emergency incident response
When production is actively harming customers, containment comes first. A feature flag, rollback, traffic block, or narrow hotfix may need to happen before a complete red–green–refactor cycle.
I still capture the failing request or condition where safe, verify the mitigation, and add the regression test as soon as the system is stable. Urgency can change the sequence. It should not erase the evidence or the follow-up work.
Using TDD in legacy code
Legacy code often resists a clean unit test because responsibilities and side effects are mixed together. Forcing a new abstraction before understanding current behaviour can make the change riskier.
I start with a characterisation test: an automated example that records what the system does today, including behaviour that may look strange. The first goal is not to declare that behaviour correct. It is to detect accidental change.
A practical sequence is:
- Reproduce the current or failing behaviour at the narrowest reachable boundary.
- Add a seam around the external dependency that prevents deterministic testing.
- Make the smallest required change.
- Add focused tests for the new rule.
- Refactor only as far as the evidence supports.
Sometimes the first useful test is an HTTP integration test because calling the domain code directly requires constructing half the application. That is acceptable. Once the change reveals a stable decision, I can extract it behind a smaller interface and continue with a faster loop.
I avoid a common trap: rewriting a module for testability before protecting its current behaviour. Testability is valuable, but the rewrite itself needs a safety net.
Failure modes that make TDD expensive
TDD produces poor results when the loop is treated mechanically.
Starting with a test that is too broad
If the first red test boots six services and drives a browser through an entire purchase, every green step is slow and every failure is ambiguous. Begin at the smallest boundary that can express the behaviour. Add broader tests where they cover a distinct integration risk.
Never observing the expected failure
A test written after the code often passes immediately. That is not automatically wrong, but I deliberately break or remove the behaviour once to prove the assertion can detect it. Otherwise a false-positive test can sit green for years.
Mirroring private implementation
Tests that assert private method calls, incidental object shapes, or exact query sequences create a tax on refactoring. Prefer outputs and committed boundary effects. Assert implementation only when that implementation characteristic is itself required, such as constant-time comparison or a mandated audit record.
Mocking what the system most needs to prove
Mocking the SQL query cannot prove the migration, constraint, transaction, or driver behaviour. Mocking serialization cannot prove that two services agree on an event. Replace dangerous external actions in focused tests, but run important boundaries for real somewhere in the suite.
Treating coverage as the objective
Coverage identifies code that did not execute during tests. It does not prove that assertions are meaningful or edge cases were chosen well. I use coverage to find suspicious gaps, not as a substitute for risk analysis.
Allowing a slow or flaky inner loop
Developers stop using a suite that takes too long or fails unpredictably. Focused unit tests should run locally in seconds. Integration setup should be reproducible. Flaky tests need an owner and diagnosis; silently retrying them can hide the signal while preserving the cost.
Skipping refactoring
The first green implementation often contains duplication or a weak name because its purpose was to make one behaviour work. Refactoring turns those examples into maintainable design. A team that skips it accumulates well-tested but unnecessarily difficult code.
A practical team workflow
I do not require a pull request to prove that every production line was preceded by a test. That history is difficult to audit and easy to game. I expect the change to arrive with appropriate evidence and a design that can be changed safely.
For work that suits TDD, this workflow is effective:
- Write a short behaviour list using product language.
- Choose the smallest valuable example, including an important failure case early.
- Run it red and confirm the failure explains the missing behaviour.
- Make it green without inventing unused flexibility.
- Refactor names, duplication, and boundaries while all tests stay green.
- Repeat until the behaviour list is complete.
- Add the integration or end-to-end evidence required by the production risk.
- Run the authoritative suite in CI and ship through an observable rollout.
During review, I ask questions that reveal test quality:
- Which customer or system promise does this test protect?
- Would it survive a valid internal refactor?
- Does it exercise the boundary where the bug could actually occur?
- Is an important failure path missing?
- What does the suite deliberately leave to monitoring, manual review, or another test layer?
Those questions are more useful than arguing whether a repository has the ideal number of unit tests.
My decision rule
I use TDD when three conditions are present:
- I can state the next behaviour with enough precision to know why a test should fail.
- The example helps shape or protect a meaningful interface or decision.
- The feedback loop is fast and deterministic enough to guide the work.
If one condition is missing, I first ask what kind of uncertainty I have. Product uncertainty may need a prototype. Integration uncertainty may need a test against the real dependency. Visual uncertainty may need an interactive review. Statistical uncertainty may need an evaluation set. Operational uncertainty may need a canary and telemetry.
I return to automated regression tests when the behaviour stabilises. The goal is not loyalty to a sequence. The goal is evidence that makes change safer.
TDD is one of the best tools I know for converting a clear requirement into a simple design through small, reversible steps. It becomes harmful only when the ritual is valued more than the feedback. Use it where examples clarify the software. Use another form of evidence where reality cannot be represented by a small test.
Frequently asked questions
Should every production feature be developed with TDD?
No. Every production feature needs appropriate verification, but not every useful verification can or should be written first. TDD is strongest when behaviour is clear and examples can guide a stable interface. Exploration, visual discovery, external systems, and non-deterministic features may need a different first step.
Does TDD mean writing only unit tests?
No. The loop can begin at an integration or acceptance boundary. Unit tests usually provide faster feedback, but the correct boundary is the smallest one that proves the behaviour and its relevant risk.
Is a test written after implementation less valuable?
Not necessarily. A well-designed regression or integration test remains valuable. Writing it first adds two benefits: it proves the test can detect the missing behaviour, and it can influence the design before implementation choices harden. When writing a test afterward, temporarily break the behaviour to verify that the test fails meaningfully.
How much mocking is too much?
Mocks become suspicious when the test mostly scripts internal calls, breaks during harmless refactoring, or replaces the boundary whose behaviour matters. Mock expensive or dangerous edges in focused tests, and cover important integrations with their real implementations elsewhere.
Can TDD work with database code?
Yes. Pure decisions around persistence can use unit tests, while queries, transactions, constraints, and migrations should be exercised against the real database. A reproducible database fixture or disposable instance makes that loop practical.
What should I do when TDD slows me down?
Identify the source of friction. An unclear requirement needs product discovery. A huge setup may reveal excessive coupling. Brittle assertions may be testing implementation details. A slow dependency may require a smaller inner boundary plus an integration suite. Do not remove tests reflexively, but do not preserve a low-value ritual either.
