Test-Driven Development
All implementation work at Arda is test-driven. The tests come first, they are watched failing, and the implementation is written until they pass.
This page is the rule and the reasoning behind each part of it. The reasoning matters because every step has an obvious shortcut that preserves the shape and removes the value.
The rule
Section titled “The rule”- Write tests that express the desired behaviour. Not the implementation you intend — the behaviour a caller will observe.
- Run them and watch them fail. In a compiled language this happens twice; see What counts as seeing it fail.
- Write the implementation, incrementally, until the tests pass.
- Commit the tests and the implementation separately, and push them together.
- Keep formatting changes in their own commits.
Why each step exists
Section titled “Why each step exists”Writing the test first fixes what you are building before you are attached to how. A test written after the implementation describes what the code does. That is a different document, and it passes for a reason no one has checked.
Watching it fail is the step people skip, and it is the one that catches the instrument. A test that has never been seen failing is a test whose failure mode is unverified. It might pass because the behaviour exists, or because the fixture answers for it, or because the assertion cannot fire. Those look identical from a green run, and only a red run distinguishes them.
Incremental implementation keeps the red set small enough to read. A failing suite of three is a description of what remains. A failing suite of three hundred is a wall.
Separate commits keep the record legible. A reviewer can see what was specified and what was built, in that order. Pushing them together means CI runs on the pair, not on a tree with tests and no implementation.
Formatting in its own commit keeps a real change from arriving inside four hundred lines of whitespace.
What counts as seeing it fail
Section titled “What counts as seeing it fail”In a dynamically typed language this is simple: the test runs and the assertion fails.
In a statically typed language, a test that does not compile is the first red, and it is a real one. The test names a contract that does not exist yet, and the compiler saying so is the specification being rejected by the code. Do not stub the contract into existence just to reach a running test — the compile error is the test failing.
It is not the last red, and stopping there is the common mistake. A compile failure proves nothing about the assertion. It says the name is missing, not that the behaviour is absent. So once the skeleton compiles, run the test again and see it fail on behaviour before writing the behaviour.
That gives two reds where the language allows: one from the compiler, one from the assertion.
Stubs are how you reach the second red, not a prerequisite for the first. When the language forces the whole suite to compile before anything runs, stub the missing contract so the suite compiles — and stub it with something that throws, never with something that returns a plausible value. A stub returning a plausible value produces a green test and no warning at all.
One red instead of two, where the second cannot run. A branch that will not compile until conversions land above it cannot reach a behavioural red at all. There, record the compile red with its predicted failure set — the count, the files, the kinds — before the run, and defer the behavioural red to the branch where it can execute. A predicted failure set that matches on every axis is what makes a red build evidence rather than a broken branch.
What a red run has to look like
Section titled “What a red run has to look like”Three things a single change taught, all of them on the frontend card-lifecycle work of 2026-09-09. One worked example, not three findings — but each corrects a different way the rule above gets misread, and a rule published without them gets followed wrongly.
Not every new test should be red. A rule read as “every new test must fail first” makes people break something to satisfy it, which is worse than the rule it replaced. The distinction is what the test describes: a test for behaviour the change adds must be red first, and if it passes beforehand then either the behaviour already exists or the test is not testing what it claims. A test for behaviour that already exists is a regression guard, and it passes from the start — correctly. That change added four tests: two described new behaviour and were red until the implementation landed; two pinned that timeouts and rate-limit responses stay retryable, and were green from the first run precisely so the change could not quietly remove them.
A red-first test that is green on its first run means the fixture is wrong, not that the behaviour exists. One test written to be red passed immediately, because the fixture answered the first request as specified and then fell through to a default success for everything after — so the test measured the fixture’s fallback rather than the code. Had the implementation been written first, that test would have gone green at the end and been believed. When this happens, the fixture is the first suspect. Not the code, and not the assertion.
The strongest evidence came from two tests the author never wrote or read — older tests, in another file, about error toasts. The change altered a retry predicate, and the obvious rewrite reads as equivalent and is not: where the status is not a number at all, an unreadable response compares false against both bounds and lands silently in the do-not-retry branch. A response nobody could read would have been treated as proof that retrying is pointless. You cannot write the tests that catch what you did not think of; the suite already contains them, put there by people solving other problems — and only if you run the whole thing, not the file you are working in.
Increments
Section titled “Increments”Implementation is incremental. Each increment makes some subset of the red set green without turning any green test red.
Two habits keep the increments honest. Enumerate the branches and write a test for each. The recurring failure is to verify the path you were thinking about and not its complement — the else, the empty case, the value that arrives as neither. And fix the class, not the instance: a finding names one site, and the same defect is usually present at its siblings. Write the test at the level the fix will live.
Commit discipline
Section titled “Commit discipline”- Tests in one commit, implementation in another, in that order.
- Push them together, so CI runs on the pair rather than on a tree that has specified something and built nothing.
- Formatting and linter-driven reformats in their own commits, never mixed with either.
Exceptions, and how to declare them
Section titled “Exceptions, and how to declare them”Three cases do not follow the rule. Each is declared in the pull request body, in a sentence — an undeclared exception is indistinguishable from a shortcut.
- Spikes and prototypes. Code written to answer a question rather than to ship. Say so in the body, and say what happens to it: discarded, or re-driven by tests before it merges.
- Generated code. Tests belong to the generator’s contract, not to its output.
- Migrations verified by their own harness. The harness is the test; say which one and what it asserts.
Anti-patterns
Section titled “Anti-patterns”A test that could not have failed. Written after the fact, or asserting on a value the fixture supplies. A green run from such a test is not coverage of the risk; it is coverage of nothing.
A filter that narrows the run. Two shapes, one failure. Running only your own file removes the part of the suite most likely to catch you. And a filter naming a class the file does not declare matches nothing and passes — a run of nothing has no failures. Verify that the name in the filter is the name the file declares, and prefer running the suite whole.
A check that ran over an artifact its own build failed to produce. The sharpest version of a
check that cannot fail, because nothing about it looks wrong. A documentation gate ran its three
stages as separate recorded steps — build, link check, smoke. The build failed on a missing native
binary. The link check then reported 0 errors over roughly 1.4 million links, because it read the dist/
directory a previous run had left on disk: an older tree, built before a rebase, missing pages that
had since merged.
An artifact on disk has no opinion about whether the build that should have produced it succeeded. Chaining the stages so a failure stops the run prevents this; running them as separate steps, which is what makes the stage list auditable, is exactly what lets a stale input through. So do one of two things: delete the artifact before the run, so a failed build leaves a missing input rather than an old one, or make the check refuse an artifact older than the build started.
What exposed it was arithmetic, not suspicion. The honest re-run reported 1207 pages where the stale one said 1202, and 1207 reconciles as the previous count plus the four pages the merged change had added. A count that reconciles against its own history is a cheap way to ask whether a run measured the tree you think it did. An exit code of zero is not.
Reproducing a timing-sensitive failure with load. Control the clock or the async boundary instead: fake timers, a manually resolved promise, a shortened window. Repetition is not evidence of determinism.
A reviewer’s checklist
Section titled “A reviewer’s checklist”- Are there separate commits for tests and implementation, in that order?
- For each test of new behaviour: is there evidence it was seen failing — a recorded red, a quoted assertion error, a predicted failure set?
- For each test that was green from the start: is it a regression guard for behaviour that already existed?
- Were the tests run whole, or filtered to the changed file?
- Do the tests cover the complement of each branch, or only the path the change was about?
- Is every exception declared in the body with its reason?
Sources
Section titled “Sources”The rule is the operator’s, stated 2026-08-12: “For all implementation work, adopt a Test-Driven-Development approach. First write tests that express the desired behaviors, run them to see them fail, then work on the implementation until all is green. Separate local commits for tests and implementation which get pushed together (so that CI can run on every push). Separate linter driven reformats into their own commits.”
Extended 2026-09-09: “First write tests that will fail, even not compile. See them fail by attempting to run them. Then write the implementation (can be incrementally) so that the tests written end up passing.”
The two-red reading of “even not compile” — a compile failure is the first red, a behavioural red follows once the skeleton compiles, and one red where the second cannot run — is the coordinator’s reading of that extension, recorded here for the operator’s confirmation rather than presented as settled.
Industry practice. Kent Beck’s red / green / refactor cycle, and his insistence on watching the test fail before making it pass. Martin Fowler’s account of the same cycle and of what a test written afterwards does not tell you. Freeman and Pryce’s outside-in approach, where a failing acceptance test frames the work before any unit test is written.
The worked examples are from the frontend card-lifecycle change of 2026-09-09: the four tests in moveCard.stopped.test.ts under what a failure is retried for, the green-by-fixture test, the two regression guards that passed from the start, and the two pre-existing error-toast tests that caught the predicate rewrite.
See also
Section titled “See also”- What a Green Run Proves — what a passing suite establishes, and what it does not.
- Checks That Cannot Fail — the failure mode step 2 exists to catch.
- What a Gate Must Declare — the same discipline applied to a gate’s own verdict.
- Backend Testing — the Kotlin tools these steps are executed with.
Copyright: © Arda Systems 2025-2026, All rights reserved