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>
148 lines
7.7 KiB
Plaintext
148 lines
7.7 KiB
Plaintext
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 2–3 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.
|