How TDD and BDD Actually Fit Into Spec-Driven Development

Prompting an AI agent to “use strict TDD” is not, by itself, a guarantee of trustworthy code. An agent can make a suite pass, or even drive line coverage high, while still leaving important decisions unprotected. It can implement several behaviors at once, add code where no failing tests exist or use vague assertions. In this post let us look at a how agents reward hack TDD and how we can progressively correct one testing failure at a time. The topics covered here are agnostic to any programming language, AI model, or coding agent.
The “Shipping Cost Calculator”
The calculator accepts standard and express shipping. Standard shipping costs $10 below 5kg and $20 at or above 5kg. An order strictly above $100 is free through 10kg, but not when it is strictly heavier than 10kg. Express adds $15. Results stay in USD without an exchange lookup; another currency multiplies the final fee by getRate("USD", targetCurrency).
Those rules have enough boundaries, validation, and one collaborator to reveal the difference between code that happens to pass and tests that actually specify behavior.
How Coding Agents Cheat
Broad batching creates horizontal slices
The first variation starts with a single test that crosses several independent behaviors. It verifies useful outcomes, but it permits one large implementation step to make all of them green together. That is horizontal slicing: a broad layer of behavior is specified across the system before any one narrow capability is finished.
it("calculates standard, free, express, and converted shipping", () => {
const exchangeRateClient = { getRate: vi.fn().mockReturnValue(1.5) };
const calculator = new ShippingCostCalculator({ exchangeRateClient });
expect(calculator.calculate({ orderTotalUsd: 30, weightKg: 4 })).toBe(10);
expect(calculator.calculate({ orderTotalUsd: 30, weightKg: 5 })).toBe(20);
expect(calculator.calculate({ orderTotalUsd: 101, weightKg: 10 })).toBe(0);
expect(calculator.calculate({ orderTotalUsd: 101, weightKg: 10.1 })).toBe(20);
expect(calculator.calculate({ orderTotalUsd: 30, weightKg: 4,
shippingMethod: "express" })).toBe(25);
expect(calculator.calculate({ orderTotalUsd: 30, weightKg: 4 }, "EUR"))
.toBe(15);
});
The test gives a reassuring green result, yet it does not force the small red-green-refactor loop that exposes design decisions one at a time.
Simply asking the agent to follow vertical slicing may not be enough. We can require it to give us the proof through Git commits where each commit represents a vertical slice.
Not following the process of writing code only to get a test passing strictly
In the second variation, the agent divides the examples into smaller tests. That is an improvement in readability, but the implementation still introduces branches beyond what the selected examples prove. Smaller test functions are not equivalent to strict TDD: the crucial constraint is that production behavior is added in response to a failing test, not merely that the finished suite contains many test names.
The way to catch this is through test coverage, and in particular branch coverage.
Red-green order is not enough when assertions are weak
In the third variation, agent follows a red-green sequence, but some assertions only say that an error occurred or that a numeric result is broadly acceptable. Those checks exercise code without pinning down the exact behaviour. A wrong exception type or message, the wrong collaborator arguments, and some arithmetic changes can remain undetected even though the tests are green.
Branch coverage only tell us if a condition was exercised, it does not answer, “Would this test fail if the important decision changed?” Mutation testing makes that second question concrete by deliberately changing operators, conditions, values, and calls. A surviving mutant is evidence that the suite did not distinguish the changed behavior.
Duplication in test code
In this variation, agent checks exact output, exact errors, and collaborator interactions—not just whether a call or throw happened. For example:
it("converts a non-USD fee using the requested exchange rate", () => {
expect(calculator.calculate({ orderTotalUsd: 30, weightKg: 4 }, "EUR"))
.toBe(15);
expect(exchangeRateClient.getRate).toHaveBeenCalledExactlyOnceWith(
"USD", "EUR",
);
});
it("rejects an unknown shipping method", () => {
expect(() => calculator.calculate({
orderTotalUsd: 1, weightKg: 1, shippingMethod: "x",
})).toThrow(new RangeError("method invalid"));
});
This is the kind of specification mutation-resistant tests provide: changing USD to another source currency, calling the collaborator twice, or replacing the documented validation error is observable.
However when test code is not refactored regularly, the test code contains repeated setup and test structure. We can identify this using tools such as jspcd for JavaScript.
Retaining precision while removing repeated structure
Here we reataint the mutation-clean behavior while centralizing the common fixture and using a table for the standard-fee boundaries. This is not abstraction for its own sake: the parameterized cases keep the boundary values visible while avoiding the same construction code in every test.
beforeEach(() => {
exchangeRateClient = { getRate: vi.fn(() => 1.5) };
calculator = new ShippingCostCalculator({ exchangeRateClient });
});
it.each([
["a $0, 0kg order", 0, 0, 10],
["an order below 5kg", 30, 4.9, 10],
["an order at 5kg", 30, 5, 20],
["a $100 order", 100, 4, 10],
["a $101 order at 10kg", 101, 10, 0],
["a $101 order at 10.1kg", 101, 10.1, 20],
])("charges $expected for %s", (_description, orderTotalUsd, weightKg, expected) => {
expect(calculator.calculate({ orderTotalUsd, weightKg })).toBe(expected);
});
The point is not that every test should be table-driven. Keep a test explicit when its setup tells an important story. Share only the structure that distracts from the behavior being specified.
A reusable strict-TDD harness
When asking an agent to implement a change, make these constraints part of the definition of done:
- Work one vertical slice at a time: introduce a single failing behavioral example, implement only enough to make it pass, then refactor before starting the next behavior.
- Cover each decision and its boundaries, not merely every line. Include the “equals” cases around thresholds and both sides of every meaningful branch.
- Run mutation testing and turn survivors into precise assertions about outputs, errors, and collaborator interactions.
- Keep test code maintainable: use shared setup and parameterization for genuinely repeated structure, while preserving readable behavior names and boundary data.
These checks make the desired outcome harder to reward-hack. They also produce useful feedback: a failed test points to a behavior, a surviving mutant points to an unprotected decision, and a duplication report points to a suite that may become brittle.
BDD macro, Mockist TDD micro
BDD and strict Mockist TDD solve different layers of the same problem. BDD supplies the macro constraints: the acceptance scenarios that define what a user or business process must be able to do. Strict Mockist TDD supplies the micro: small object-level contracts, outputs, errors, and collaboration boundaries that keep the implementation decoupled as it grows.

SDD anchors the work in a specification, BDD protects the macro behavior, and TDD improves the micro-level design.
SDD makes the specification the source of truth: it describes the intended outcome and the constraints the implementation must satisfy. BDD turns that intent into user-facing acceptance scenarios, creating the macro feedback loop that tells us whether the system behaves correctly from the outside.
An SDD and BDD workflow can satisfy every acceptance scenario while the internal implementation becomes a spaghetti of tightly coupled units, because macro scenarios do not by themselves force clear object boundaries. TDD provides the micro feedback loop: small failing tests drive narrow contracts and collaboration boundaries, and regular refactoring keeps those units decoupled.
Together, SDD defines what must be true, BDD verifies the observable behavior, and strict TDD improves how the implementation is structured. For the BDD/OpenSpec side of that workflow, see the related article in the references below.
Video Walkthrough
References
- Intent-Driven Dev’s TDD Skill
- Matt Pocock’s TDD skill
- Mutation Testing with Stryker
- JSCPD for code duplication
- Behavior-Driven Development with Spec-Driven Development using OpenSpec
- Claude Code Dynamic Workflows
- Intent-Driven Template
Read the full article on intent-driven.dev →
Originally published on intent-driven.dev on August 23, 2026