Files
atomic-design-poc/libs/shared/docs/bdd.mdx
T
ehoandClaude Sonnet 5 3652ff8d3f docs(test): make Given/When/Then the default BDD structure (WP-71)
bdd.mdx previously banned "Given/When/Then ceremony" outright, which
directly contradicted WP-70's own acceptance tests (Acceptance/
BesluitLifecycleTests.cs already used // Given/When/Then comments) and
the backend's organically-evolved PascalCase_snake_sentence convention,
which the doc gave zero guidance for. Reverses that rule: every test is
now structured Given -> When -> Then, with a genuinely empty phase
omitted rather than faked; present-tense declarative naming and the
one-behaviour-per-test rule are unchanged. ADR-0006 gets a cross-reference
so both documents agree everywhere, not just in acceptance tests.

Also closes out the doc's other named-but-unenforced rules found by the
audit: fixes the 5 files asserting rendered $localize copy instead of
the underlying tag/message-id (the compliant pattern already existed in
werkvoorraad-item-view.spec.ts), splits the multi-behaviour titles the
doc itself calls a smell (";", "and", "/"), and fixes bdd.mdx's own false
citation of registratie-wizard.machine.spec.ts as "one transition per
test" by actually splitting that test into one-transition-per-test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 20:25:30 +02:00

148 lines
7.7 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/BDD" />
# Behaviour-driven tests
Tests here read as **specifications of behaviour**, not checks of implementation. A test
says what the system _does_ — in the domain's own words — so a failing test names a broken
behaviour, and the suite doubles as living documentation. This is the BDD half of the
[Testing strategy](?path=/docs/foundations-testing-strategy--docs) (which owns _what to
test, by layer_); BDD owns _how each test is phrased and scoped_.
## Three rules
### 1. `describe` = the subject, `it` = one observable behaviour, structured Given → When → Then
The `describe()` block names the unit under test; each `it()` states a single behaviour in
**declarative present tense** — the implicit subject is "it" (no `should`). Present-tense
naming and Given/When/Then structure are not in tension — the _title_ stays a declarative
one-liner; the _body_ is what's organised as Given → When → Then:
```ts
describe('parsePostcode', () => {
it('normalises to "1234 AB" (uppercase, single space, trimmed)', () => {
// Given a postcode with mixed case, extra whitespace, and no gap before the letters.
// When it is parsed...
const result = parsePostcode(' 1234ab ');
// Then it comes back normalised.
expect(result).toEqual(ok('1234 AB'));
});
it('rejects malformed input', () => {
// (no Given — the input itself IS the setup) When a non-postcode string is parsed...
// Then it is rejected.
expect(parsePostcode('nope').ok).toBe(false);
});
});
```
Read top-to-bottom it _is_ the spec: "parsePostcode — normalises to 1234 AB; rejects
malformed input."
**A genuinely empty phase is omitted, not faked with an empty comment.** The rejection test
above has no Given worth writing — the malformed literal passed to `parsePostcode` already
is the setup — so it degenerates straight to When/Then. Never write `// Given (nothing)` to
keep three comments lined up; an omitted phase is the correct, honest shape for a test that
doesn't need it. The three phases stay in order (Given before When before Then) whichever
of them are present.
**This reverses this doc's earlier advice** ("No … Given/When/Then ceremony") — the team
decided explicit G/W/T structure earns its keep as the default for every test, not just
acceptance tests. What doesn't change: no `should`, present-tense titles, one behaviour per
test, ubiquitous-language naming (rules 23 below).
**The Elm-machine naming style is a sanctioned form of rule-1 naming, not an exception to
it.** A store/reducer spec titled after the `Msg` tag it drives —
`it('BriefLoaded moves loading to loaded', …)` — names the domain event the same way the
reducer's own `switch (msg.tag)` does; the tag IS ubiquitous language for a state machine,
so this reads as a present-tense behaviour statement exactly like `'rejects malformed
input'` does, not as a violation of rule 3.
### 2. One behaviour per test
A test asserts **one behaviour**, not one `expect()`. Several assertions that pin down the
_same_ behaviour belong together; assertions about _different_ behaviours belong apart.
| Keep together (one behaviour) | Split apart (separate behaviours) |
| -------------------------------------------------------------- | -------------------------------------------------------- |
| A `Result`'s `.ok` then its `.value` | The `ok` branch **and** the `err` branch of a transition |
| A whole-object `toEqual` | An invalid-input case **and** a valid-input case |
| A loop asserting one rule over many inputs | Two independent state transitions |
| A truth-table (`draft` → true, `approver` → false) of one rule | An authorization check **and** a rendering check |
A title that needs `/`, `;`, "then" or "and" to join two behaviours is the smell — split it,
and each half gets its own present-tense name.
### 3. Speak the ubiquitous language (the DDD tie-in)
Test names use the **domain vocabulary**, not technical jargon — the same words as the
[bounded contexts](?path=/docs/foundations-domain-driven-design--docs): a _behandelaar_
drafts, a _beoordelaar_ approves, a _herregistratie_ is _ingediend_. The test name is
readable by someone who knows the domain but not the code.
```ts
it('drafter cannot approve or reject even when submitted', …);
it('confirmed dutch proficiency requires taalvaardigheid proof', …);
```
## How it fits TDD & DDD
- **TDD** — the loop is red → green → refactor: write the behaviour as a failing `it`, make
it pass, then clean up. Because tests describe behaviour (not internals), a refactor that
preserves behaviour keeps them green. Pure domain logic is tested directly — no `TestBed`
(see [Testing strategy](?path=/docs/foundations-testing-strategy--docs)).
- **DDD** — behaviour is expressed in the ubiquitous language, so the spec and the code
share one vocabulary. Domain rules (reducers, value-object parsers, policies) are the
richest specs; the wire boundary is tested as "rejects malformed input", the UI as
Storybook stories.
## C#/xUnit shape
The three rules above are language-agnostic; xUnit follows them with its own idiom rather
than Vitest's `describe`/`it` nesting:
- **The method name is the title, in `PascalCase_snake_sentence`** — the same present-tense,
ubiquitous-language behaviour statement as a `describe`+`it`, folded into one identifier
because xUnit has no nested-description syntax: `Only_open_statuses_are_decidable`,
`Afwijzen_requires_a_toelichting`, `A_terminal_besluit_is_frozen`.
- **`// Given` / `// When` / `// Then` comments mark the three phases inside the test body** —
the same structure as rule 1, made explicit because C# has no BDD framework layered on
xUnit here (see ADR-0006 — the language's own test framework plus the builder is enough,
deliberately not a Gherkin runner). As in TypeScript, an empty phase is omitted rather than
commented for its own sake.
- **Fixtures go through the `Given` type-state builder** (ADR-0006 §1), never a field-by-field
object initializer — keeping the Given phase itself honest about which states are
reachable.
```csharp
[Fact]
public void A_terminal_besluit_is_frozen()
{
// Given a case already decided Goedgekeurd — terminal, per BeoordelingRules.CanDecide.
var aanvraag = Given.Concept(type: "registratie").Submitted().Decided(Besluit.Goedkeuren).Build();
Persist(aanvraag);
// When a behandelaar tries to record a further besluit on it...
var (outcome, updated) = ApplicationStore.RecordBesluit(aanvraag.Id, Besluit.Afwijzen, "te laat", DateTimeOffset.UtcNow);
// Then the write is refused, and the original decision still stands.
Assert.Equal(ApplicationStore.RecordBesluitOutcome.Conflict, outcome);
Assert.Null(updated);
}
```
See `Acceptance/BesluitLifecycleTests.cs` for the canonical shape (it already does this) and
`AuthzTests.cs` for the truth-table naming convention this predates — a `[Theory]` row set
stays one behaviour (rule 2's "loop asserting one rule over many inputs"), so it doesn't need
per-row G/W/T comments, just one clear method name.
## Where to look
Canonical behaviour specs in the repo: `registratie/domain/value-objects/postcode.spec.ts`
(parser behaviour), `registratie/domain/registratie-wizard.machine.spec.ts` (the
message-driven `describe` block — one reducer transition per test), and backend
`Acceptance/BesluitLifecycleTests.cs` (G/W/T-commented behaviour tests) and `AuthzTests.cs`
(rule truth-tables). The [Testing strategy](?path=/docs/foundations-testing-strategy--docs)
page maps which layer gets which kind of test.