How TDD and BDD Actually Fit Into Spec-Driven Development

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:

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.

Diagram showing SDD leading to spec-first implementation, SDD plus BDD leading to spec-as-source implementation with tangled internal units, and SDD plus BDD plus TDD leading to a decoupled implementation with clear unit boundaries.

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


Read the full article on intent-driven.dev →

Originally published on intent-driven.dev on August 23, 2026

Hari Krishnan
Hari Krishnan — AI & Platform Strategist, Founder & CEO of Polarizer Technologies. Field notes on Spec-Driven Development, context engineering, and AI-native software delivery. About · harikrishnan.io · LinkedIn · GitHub