TDD Rules

Three Laws

  1. No production code without a failing test
  2. Write only enough test to fail
  3. Write only enough code to pass

Tidy-Red-Green-Refactor-Commit

TIDY     → Small structural improvement  → COMMIT (no behavior change)
RED      → Write failing test
GREEN    → Minimal code to pass          → COMMIT (tests green)
REFACTOR → Improve design                → COMMIT (tests still green)

Inspired by Kent Beck's Tidy First?: make the change easy, then make the easy change.

TIDY is an optional pre-step before starting a Red-Green cycle. A tidying is a small, structural-only change that makes the upcoming work easier — guard clauses, extract helper, rename for clarity, reorder for readability, delete dead code. Tidyings never change behavior and always get their own commit (tidy: type).

When to tidy:

TimingGuidance
Tidy FirstDefault. The code you're about to change is hard to read or extend
Tidy AfterYou understand the shape of the change better after implementing
Tidy LaterBatch structural cleanup into a separate branch/MR
NeverCode works, won't change again, and is readable enough

Commit early, commit often:

  • Commit tidyings separately before the RED step (tidy: commit)
  • Commit immediately after GREEN (tests pass for the first time)
  • Commit after each REFACTOR cycle (if tests still pass)
  • Small, atomic commits make rollback and review easier
  • Never commit with failing tests

Tidying Catalog

Small, safe, structural-only changes (from Tidy First?):

TidyingWhat it does
Guard ClausesReplace nested if/match with early returns
Dead CodeDelete unreachable or unused code
Normalize SymmetriesMake similar code use consistent patterns
New Interface, Old ImplementationWrap before replacing internals
Reading OrderReorder declarations top-down
Cohesion OrderGroup related items together
Move Declaration and Initialization TogetherClose the gap between let and first use
Explaining VariablesName intermediate results
Explaining ConstantsReplace magic numbers with named constants
Explicit ParametersPass values instead of relying on ambient state
Chunk StatementsAdd blank lines between logical blocks
Extract HelperPull reusable logic into a function
One PileInline before re-extracting with better structure
Explaining CommentsAdd "why" comments where intent isn't obvious
Delete Redundant CommentsRemove comments that repeat the code

Test Types

TypeScopeSpeedCoverage
UnitSingle function< 100ms90% min
IntegrationMultiple components< 5sCritical paths
E2EFull system< 60sAll Gherkin scenarios

Test Behavior, Not Implementation

Test what a module promises through its public API — not how it keeps that promise, and not its internal content. A good test pins a contract the module offers its callers: it survives any refactor that preserves that contract and fails only when the contract is broken.

Test through the module's API — the surface a real caller uses:

  • Exercise the module via its exported functions, types, and interfaces.
  • Assert on inputs and outputs, returned errors, and state observable through that surface.
  • Assert the outcome of an action, not the steps taken to reach it.

Do not couple tests to implementation or internal content:

  • Private functions, fields, or internal data structures — reach them only through the public API that exercises them.
  • The specific sequence or count of internal calls — unless the call is the contract (e.g. "sends exactly one network request").
  • The concrete algorithm or data layout chosen inside the module.
  • Log lines, intermediate variables, or rendering internals.

Litmus: could this test fail while the module still honors its API contract? If yes, it is testing implementation — it will break on healthy refactors and erode trust in the suite. Rewrite it to assert behavior through the API, or delete it.

Put a behavior test where the behavior lives. If a test asserts an outcome owned by another module — business logic reached through a UI, a value persisted by a core service — it belongs with that module's API, not the caller's. A behavior test stranded in a consumer breaks whenever the boundary shifts, even though nothing it cares about changed.

The unit of isolation is the test, not the function, class, or module. Isolate tests from each other — independent, order-independent, no shared mutable state — not each function or class from its collaborators. A single test may drive several functions, classes, or modules together through one public API; that is normal, not a smell. Don't add a mock just because a collaborator exists: mock only true boundaries (network, clock, external services — see Mocking Strategy below) and use real collaborators everywhere else. (Kent Beck's classical TDD: "unit" names the test's isolation, not the code under test.)

Naming

test_<function>_<scenario>_<expected>

Examples:
- test_encrypt_valid_key_returns_ciphertext
- test_decrypt_wrong_key_fails

Critical Rules

Crypto - Never mock. Test with real crypto:

  • Roundtrip (encrypt/decrypt, sign/verify)
  • Wrong key rejection
  • Tampered data rejection

Gherkin - Every scenario in features/ must have a test.

Coverage - 90% minimum for vauchi-core.

Forbidden

  • Writing code before tests
  • Mocking crypto operations
  • #[ignore] without tracking issue
  • Flaky/non-deterministic tests
  • Hardcoded test secrets

Mocking Strategy

ComponentApproach
CryptoReal (never mock)
NetworkMock transport
StorageIn-memory DB
TimeMockable clock

PR Checklist

  • Structural tidyings in separate tidy: commits (if any)
  • Tests written before code
  • All Gherkin scenarios covered
  • No ignored tests
  • Coverage ≥ 90%
  • Crypto has security tests