Retro-fitting BDD: Discovery, Analysis, Synthesis, Verification

A companion to BDD as Specification, which treats a scenario as something precise enough to derive an implementation from. This page is about the opposite direction: a large, real, actively-developed codebase where most functions were written without a BDD spec at all, and the task is recovering one — after the fact, from the code's actual observed behaviour, not from what anyone remembers intending it to do. The method described here is general; it isn't tied to any one language or codebase, though it was worked out against a real one. Nothing below names a specific bug, file, or project — the point is the shape of the exercise, not an incident report.

Why this situation exists at all

Most non-trivial codebases accumulate a long tail of functions that were never covered by a spec of any kind: small utilities added in passing, internal helpers nobody thought worth documenting, code that predates the team's adoption of BDD, code inherited from elsewhere. Individually each gap looks trivial to skip. In aggregate they're a real liability — a function nobody can currently state the contract of is a function nobody can safely refactor, safely reuse, or safely hand to an automated tool (a code-generation assistant, a planner composing existing pieces into a new capability, a static analyzer) without either re-reading its source from scratch every time or trusting an assumption that's never actually been checked.

Retrofitting BDD onto that long tail is not the same exercise as writing BDD scenarios during initial development, and pretending otherwise undersells both. Writing scenarios first is a design activity — you're deciding what the code should do before it exists. Retrofitting is forensic — you're recovering what the code actually does, which is sometimes not what a reasonable reader would guess from its name, and occasionally not even internally consistent across two implementations that happen to share one.

Four stages: Discovery, Analysis, Synthesis, Verification

The exercise breaks cleanly into four stages, and the reason to keep them cleanly separated — rather than folding straight from "found a function" to "wrote a test that passes" — is that each stage has a different failure mode, and conflating them hides which one actually went wrong when the result is untrustworthy.

1. Discovery: find what exists, without running any of it

The first pass is purely mechanical text scanning — walking the source tree, extracting every declaration, recording where each one lives. Crucially, this stage never executes anything it finds. In a codebase without static typing, a function's declaration tells you its name and its parameter count, not what values are safe to pass it — and a language with no exception-handling mechanism turns "call it and see what happens" into "call it and maybe crash the whole process." Discovery has to be safe by construction, which means it has to stay purely textual until a human (or a tightly bounded, separately-reviewed step) decides a specific candidate is worth actually invoking.

A useful side effect of doing discovery honestly and exhaustively: it surfaces facts about the codebase that nobody currently believed. Two independently-authored functions turning out to share a name is not a hypothetical risk to worry about in the abstract — a real enumeration pass will actually find the collisions that exist right now, today, in the code as it stands. That's worth surfacing on its own, before a single BDD scenario gets written, because it changes what "spec this function" even means: you may be about to spec two genuinely different things that happen to answer to the same call.

2. Analysis: prioritize, and read before you assume

Not every undocumented function is equally worth the effort of a hand-written spec, and treating them as uniformly urgent wastes the exercise's own credibility on low-value targets. A defensible prioritization signal: how often is this actually called elsewhere in the codebase right now — real usage is real exposure to real inputs, which is exactly the population most likely to have already been informally battle-tested (and most likely to break something else if it's ever changed without a spec in place).

The trap at this stage is judging a candidate's difficulty by where it lives rather than what it actually does. A function sitting inside a genuinely complex subsystem — a parser, a search engine, an inference system — is not automatically itself complex; it might be a three-line constructor that happens to share a directory with real complexity. The only reliable signal is reading the body. Skipping that read and defaulting to "this file is scary, defer it" produces a worse prioritization than the naive one it was trying to improve on.

3. Synthesis: write the scenario the behaviour actually supports

This is where a genuine "descriptive, not prescriptive" discipline has to be held onto deliberately, because it's the easiest discipline to lose without noticing. The scenario being written documents what the function demonstrably does — established by reading the implementation and then checking that reading against real executed output — not what its name suggests it should do, not what a docstring half-remembers, not what "surely" is intended. Those three things regularly disagree with each other in a large enough codebase, and a retrofit exercise that quietly writes the "should" version instead of the "does" version isn't retrofitting a spec at all — it's asserting an intention nobody has actually verified, dressed up as documentation.

Two same-named functions with genuinely different bodies deserve genuinely different scenarios, not one scenario copy-pasted twice on the assumption that a shared name implies shared behaviour. Confirming that assumption — running the same probe against both and seeing whether it agrees — is itself part of the exercise, and when it disagrees, that disagreement is a real, useful finding: it tells a future reader, unambiguously, "these two are not interchangeable," something that was previously only true implicitly and undocumented in the code itself.

4. Verification: prove the scenario against the real, running code

A hand-written scenario is a hypothesis about behaviour until something actually executes the code and checks the result. This has to be a genuinely executed check — not "the function returned without crashing," not "the output looks plausible" — the same discipline the self-healing case study on the companion page had to learn from a real mistake: a check that merely observes something happened is not the same claim as a check that confirms the right thing happened, and conflating those two claims is exactly the failure mode retrofitting is supposed to eliminate, not reproduce under a different name.

Verification also has to be honest about when a candidate resists a straightforward automated probe entirely — a function whose real precondition is a large or stateful input shape a one-line call can't safely construct, a function that's part of the very machinery being used to do the verifying (a real "who verifies the verifier" question, worth naming rather than quietly assuming away), or a function that can only be meaningfully exercised inside a different execution context than the one available for probing. The right response to any of those is to say so explicitly and set the candidate aside for a differently-shaped pass — never to write a scenario anyway and mark it verified when it wasn't.

What this costs, and why it's a genuine cost, not a footnote

None of the four stages above is free, and a page arguing for retrofitting owes an honest account of what it actually takes: a mechanical, execution-safe discovery pass has to be built before any documentation work starts, and it has to be conservative by design (biased toward flagging a candidate as risky rather than confidently wrong). Prioritization needs a real usage-frequency signal, not a guess, or the effort goes to the wrong targets first. Synthesis requires reading — genuinely reading — every candidate's implementation, which does not scale the way a bulk-generation approach would, and resisting that temptation is exactly the discipline that keeps the result trustworthy. Verification needs a real, executed check per candidate, and for some candidates that check itself has to be built before it can even run. None of this is a weekend's work at any meaningful scale, and a codebase with hundreds of undocumented functions should expect this to be a sustained, incremental effort — batched and prioritized, not a single pass that claims completeness it hasn't earned.

The actual argument for BDD-first

Every one of the four stages above exists to recover something that a BDD-first discipline would simply never have lost in the first place. Discovery exists because nobody kept a running index of "what has a spec and what doesn't" — BDD-first makes that index the spec suite itself, always current by construction. Analysis's prioritization problem exists because coverage became uneven over time — BDD-first makes coverage a property of every change, not a debt that accrues. Synthesis's "read the real behaviour, don't assume the name" discipline exists because the scenario and the implementation drifted apart with nobody the wiser — BDD-first writes the scenario before the implementation, so there's no drift to later discover, and the name-collision risk gets caught the moment a second implementation is written against a spec that already exists under that name. Verification's "prove it against the real running code" requirement exists because nothing had previously proven it — BDD-first makes that proof part of the acceptance criteria from day one, not a retrofit years later.

This is the honest framing worth stating plainly, not just implying: retrofitting is real, valuable, and worth doing well when it's the situation you're actually in — a large body of pre-existing code doesn't disappear because you'd have preferred a different history. But it is strictly more expensive than not needing to do it, per function, every time, and every one of the four stages above is overhead that a codebase built BDD-first from the start would never have accumulated. The case for BDD-first isn't "it produces better documentation eventually" — retrofitting can eventually produce comparably good documentation too, at real cost. The case is that BDD-first is the version of this exercise that never has to happen.

A short checklist

  • Does your discovery step ever execute a candidate before a human (or a separately-reviewed, tightly bounded step) has decided it's safe to? If so, what happens the first time it's wrong?
  • Is your prioritization actually based on real usage, or on which files look important? Have you caught yourself deferring a candidate because of where it lives rather than what it does?
  • When two things share a name, have you actually checked whether they share behaviour — or assumed it because checking felt like extra work?
  • Does your verification step distinguish "it ran without error" from "it produced the specific, correct result" — and can you point to the exact line of your own tooling that makes that distinction, or is it just asserted?
  • If you're doing this retrofit right now: are you also asking why it was needed, and whether the answer changes how the next feature gets built?

See also

BDD as Specification for the companion case study this page's Verification section draws its central lesson from. TDD & BDD for the general Given/When/Then cycle this whole approach assumes as background. Shipping a Big Feature in Slices for a related discipline — verifying a large piece of work honestly in stages, rather than claiming completeness a single pass hasn't earned.