DEV Community

Anton Brilliantov
Anton Brilliantov

Posted on

TDD for Requirements

Write the checkable condition first - the requirement, the contract and the spec all obey it.


๐Ÿ‘‹ Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series has been about the order the work happens in: a wish becomes requirements, requirements become a contract, the contract becomes tasks and specs. This part is the shortest one in it, because it is one move applied four times. Running notes live on my GitHub: github.com/brilliant-almazov.

This is how I do it right now, with the price attached - maybe you already do it better, maybe you see it differently.


Primer, in one paragraph

Each step of that road ends in a gate: one thing that is accepted, and one artefact that proves it - a number, a run, a file. A spec is the self-contained brief one executor runs end to end; an executor is whoever performs it, usually an automated one here, and nothing below changes if it is a person. This part is about where the artefact in that third column comes from, because a gate is only as good as the condition it was given.

The thesis

Write the checkable condition first. Then write the thing that satisfies it.

That is the same move as writing a test before the code, lifted one level up - out of the code and onto the requirement, the contract and the spec. Nothing about it is new at the code level. What changed for me is that I stopped filing it under testing and started treating it as the order in which things get written down at all.

The reason is not purity. A condition written first is the only thing a gate can lean on. Written afterwards, it is a description of whatever was built, and it accepts whatever was built.

Two lanes of the same work - above, the thing is built and then judged, and the verdict is an opinion; below, the condition is written first and the thing is built to satisfy it, and the verdict is a run

The case: a condition that holds a direction and not a level

The honest example first, because it shows the limit of the idea rather than an advertisement for it.

What it was. "Coverage must not fall" lived as an intention. Everyone agreed with it and nobody could check it, which is the defining property of an intention: it has no output to read.

What was done. The intention was turned into a ratchet. The threshold is recorded in the repository, it can only move up, and an attempt to write a lower number in fails the pull request. That is a condition: it produces a verdict without anyone forming a view.

  the rule        the recorded threshold may only go up
  the check       a pull request that writes a lower number fails
  the reading     green  โ†’  nobody lowered the bar
                  red    โ†’  somebody tried, on the record
Enter fullscreen mode Exit fullscreen mode

What is honest about it. The threshold currently stands at 0, while actual line coverage is 86.7%. The ratchet is wired and it works, but the bar has never been raised to where the tree already stands. So what the condition holds today is the direction - nobody can quietly lower the recorded number - and not the level: coverage could drift well below 86.7% and the check would stay green, because it is being compared to zero.

A coverage track from 0 to 100 with the recorded threshold marked at 0 and actual line coverage marked at 86.7 percent, the span between them labelled as one the threshold claims none of, and a note that the direction is held and the level is not

That is unfinished work, not an achievement, and I would rather write it down that way than round it up into a story about discipline. The move was right and the follow-through has not happened yet.

The same move, in time

The second instance went the other way round, which is what it is supposed to look like.

Part 1 of this series described an audit that found 44 copies of four ways to read rows from a database. The part that belongs here is not the refactor - it is that the closing condition was written while copies were still being moved, not after the last one:

  for rows.Next()   ยท   rows.Err()   ยท   rows.Close()   ยท   QueryRow(
  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  appear in the tree in exactly one package
  held by a test that fails the build the moment a second copy appears
Enter fullscreen mode Exit fullscreen mode

Because that sentence existed early, every iteration in the set had something to be accepted against, and the last iteration was not a judgement call about whether the job was finished. The test answered.

On performance, the condition is a number taken beforehand

The third instance is the one that is easiest to skip, because it costs work before the work.

A performance condition is a baseline measurement, taken on the old behaviour and frozen as a constant in the test. The new run is compared against that constant, and the comparison is printed. "It got better" without "what it was" is not proof - it is a recollection, and recollections about performance are usually about the last run rather than the first.

Old behaviour on the left producing a baseline, the baseline frozen as a constant in the test, and the new behaviour compared against that same constant, with the closing line that better without before is not proof

Taking that measurement means running the old code on purpose, after you already know how you want to change it. It is the least satisfying half-hour in the task, and it is the entire reason the result is arguable at all.

How this is normally done

None of this is new craft, and I am not claiming otherwise.

  • The test comes before the code - test-driven development has been the standard statement of the move for two decades.
  • Acceptance criteria are written on the story, before implementation, often in a given / when / then shape so the condition reads the same to both sides.
  • Non-functional requirements get their own section of the requirements document - that is what ISO/IEC/IEEE 29148 formalises.

All three work. What I keep from them is the ordering rule and nothing else: no template, no separate document, no role that owns it.

The four levels

The move is the same each time; the form of the condition is different, and that is the part worth being concrete about.

Four rows - requirement, contract, spec, performance - each with the form its condition takes: one command or a number, cases before fields, a command without substitutions, a baseline on the old behaviour

level the form the condition takes what it is without it
requirement one command, or one number an adjective two people read differently
contract the list of cases, before any field is named a message shape chosen by whoever typed first
spec an acceptance command with nothing left to substitute a transition settled by discussion
performance a baseline taken on the old behaviour a comparison against a memory

On a requirement

The condition is something that can be run as one command or read as one number. "Works fast" is not a criterion: nobody can say when it is met, so acceptance falls back to whoever is more insistent that day. "Holds N requests per second below M latency" is a criterion: one command runs it, or one number answers it, and both sides read the result the same way.

Two rows - the phrase works fast struck through and marked not a criterion, and the line N requests per second under M latency in a box marked a criterion

I am deliberately leaving N and M as letters. Those numbers belong to the system being specified, not to an article about the order the work is written in.

On a contract

The condition is the set of cases the contract has to be able to express, written down before a single field is named: success, refusal, conflict, an empty response, a page boundary. Five rows, minutes to write, and they settle most of the message shape - the empty case decides that an empty list is an answer rather than an error, the page-boundary case decides that the response carries a cursor, the refusal case decides that codes are an enum rather than prose in a message string.

On a spec

The condition is the acceptance command, written into the text of the spec with no substitutions left in it - no angle brackets, no "fill in the package here". And it runs against its own package only, not the whole tree, because an iteration accepted by a full run is being accepted by everybody else's work as well.

There is a second reading of the same rule, and it is the more useful one: an iteration that gets rewritten after it was accepted means the criterion was not checkable. The executor is not the finding there. The criterion is.

On performance

The condition is the baseline described above: taken on the old behaviour, frozen as a constant, compared against and printed.

One requirement, all the way down

Neutral domain, so the shape is the only thing visible.

The wish: the entity list should come back quickly. Not a requirement yet - there is nothing in it to check.

  requirement   the list returns within M for a page of N entities
                on an account of the size we actually have

  contract      cases before fields:
                  success ยท empty response ยท page boundary

  spec          go test ./internal/repository/entity/... -race -count=1

  performance   baseline taken on the current query, frozen in the test,
                the new query compared against it
Enter fullscreen mode Exit fullscreen mode

Four conditions, none of which needed the implementation to exist. And each one decided something about what came next: the case list decided that the response carries a cursor, the acceptance command decided where the boundary of the iteration is, the baseline decided which number the change would be about.

Why the order matters

Because a condition written first is not a check on the work - it is a description of the work's shape, produced before the shape exists and therefore able to choose it.

The case list decides which fields the contract needs. The acceptance command decides where one iteration ends and the next begins. The baseline decides which number the change is even about. Write any of them afterwards and they still read the same on the page, but each one now agrees with whatever was built, because it was written by somebody looking at it.

And then there is the gate. A transition needs an artefact rather than an opinion, and a condition written first is how one gets manufactured: it exists before there is anything to be defensive about.

What it costs

  • We do not have a separate artefact for this. There is no requirements test suite, no file where the conditions live as their own set. Checkability is expressed by the acceptance criterion in the spec and by forbidding tests in the tree - which is thinner than it sounds when I say "condition first", and I would rather name that than imply a system I do not have.
  • Writing the condition before the solution is harder than it looks. It is genuinely easy to sit for twenty minutes on one sentence, and it is not always twenty minutes well spent.
  • The baseline is work before the work. Running the old behaviour on purpose feels like a detour every single time.
  • A condition can be technically true and hold nothing. The ratchet at 0 is the example in this very article. A green check is not proof that the bar is where you think it is.

When not to do this

  • Research, where the point is that nobody yet knows what should be built. There is nothing to write a condition about, and forcing one produces a confident sentence about a system nobody understands.
  • "Make it nicer". Where no criterion exists, inventing one produces a criterion for the wrong thing, and then the work optimises for it.
  • A one-off change where the condition is longer than the fix. Two lines of code do not need a paragraph of acceptance ceremony, and I skip it.

The multiplier

A criterion that runs as one command is the only form of acceptance that reads identically to a person and to an automated executor. Everything else - a definition of done written in adjectives, a reviewer's sense that this looks about right, a claim that the iteration is finished - needs somebody to look at it with their own eyes, and that somebody does not scale and does not stay consistent across a set of twenty iterations. Speed of execution makes the condition matter more, not less: whatever the condition failed to say gets built twenty times, quickly.


From requirement to contract โ€” Part 6. Next: non-functional requirements decide the shape of the system - which layer takes the work, whether there is a queue in the middle, whether reads and writes are separated at all.

If you do this better, tell me what your conditions look like and where they live. If you have been through this, what did your version of the threshold at zero turn out to be - the check that was green and held nothing? If you see it differently, say where writing the condition first cost you more than it bought. How is it solved on your side, and what broke there?

Top comments (0)