| name | leaven-test-design |
| description | Use when changing Leaven tests, proof strategy, law/example/scenario/regression coverage, trait contract suites, fixtures, test placement, or local versus full verification. |
Tests: Collapsing the Remaining Space
Types decide what states you can even talk about.
Traits decide what capabilities can exist.
Errors decide how those capabilities can refuse.
Tests decide which implementations of that story we will actually allow to exist.
Types/traits/errors still leave you with an enormous space of possible programs that are âtechnically valid.â
A test suiteâs job is simple:
Collapse that remaining space until only a handful of implementations survive, ideally one.
No more, no less.
0. Forcing functions
Keep these four rules in your head whenever you write or review a test.
-
Every test must kill a family of wrong implementations.
If you canât point at a nonâcontrived implementation that:
- passes all existing tests,
- fails this one,
- and would be bad in production,
then the test is noise.
-
Tests are executable attempts to falsify claims.
A test is always of the form:
âUnder this model of the world, for these inputs, this relation between before/after must hold.â
No claim â no test.
-
Assert each fact at the lowest level where it can be expressed cleanly.
If something can be stated and checked:
- on a pure function instead of an HTTP endpoint,
- on a trait implementation instead of a full system,
do it there.
E2E is the last place a fact should appear, not the first.
-
A test that doesnât change a decision surface is decoration.
If this behavior regressed, what concrete decision would change?
- An HTTP code?
- A retry vs noâretry?
- A permission check?
- A money move?
If you canât answer, delete or rewrite the test.
1. What a test is (in our universe)
We treat each test as:
- a claim â a sentence you could write in English,
- a search space â which inputs / worlds it actually explores,
- an oracle â a predicate that says âthe claim held here.â
Mechanically:
Claim: decode(encode(x)) == x for all valid x
Space: 100 random x + a few hand-picked edge cases
Oracle: assert_eq!(decode(encode(x)), x)
If you canât name all three, you donât understand the test yet.
2. The only shapes of tests we use
To shrink the design space, we pretend there are only four kinds of tests.
Every test must be exactly one of these:
-
Law tests (â)
-
âFor all valid inputs, property P holds.â
-
Usually propertyâbased or small, carefullyâchosen sets of examples.
-
Lives at the level of types and traits.
-
Examples:
- once
Iterator::next returns None, it never returns Some;
- scoring is monotone in some dimension;
- decode(encode(x)) = x.
-
Example tests (â, canonical)
- âFor this specific, meaningful input, this output / error happens.â
- These are the examples youâd put in docs.
- Good for weird business rules, boundary cases, and explaining semantics.
-
Scenario tests (protocol/flow)
- âGiven this starting state and this series of actions, these observable outcomes happen.â
- Crossâcomponent behavior: HTTP â app â DB, queues, etc.
- Focused on externally visible behavior and contracts.
-
Regression tests (fossilized bug)
- âThis exact combination of inputs and world used to break. Never again.â
- Named after incidents / tickets when possible.
- Ideally get absorbed into a law or example test once you understand the underlying rule.
We donât distinguish âunit/integration/e2eâ by ceremony; those are just scopes:
- A âunit testâ is a law or example test at a small boundary.
- An âintegration testâ is a scenario over more parts of the system.
If you canât tag a test as Law / Example / Scenario / Regression, change it until you can.
2.1 The default suite is the real suite
Some property tests exercise expensive setup (git repos, IPC daemons, large payloads). These are
valuable and must stay covered by the canonical local and CI verification path.
Guidelines:
cargo xtest is the default proof loop for the full workspace surface.
- Heavy tests may keep feature gates for narrow local iteration, but those gates must be enabled by the canonical suite.
- Do not add a second slow lane. If a test is too slow for the default suite, improve the fixture, clock, data shape, or assertion layer until it fits.
Use exact filters while iterating, then finish with the full suite:
cargo test -p <crate> --features slow-tests <exact_test> -- --exact
cargo xtest
Totally fair. That section was a bit handâwavy for how central contracts are in your world.
Hereâs a beefedâup Section 3 you can drop in verbatim over the old one â still mechanical, but with enough shape that people will actually build the right thing.
3. Contract tests for traits
From the trait doc: traits are promises made to strangers.
Contract tests are how we enforce those promises across implementations.
Mechanically:
For every cold trait, we want one shared contract suite that every implementation must pass.
If a trait has no contract suite, itâs polymorphism without accountability.
3.1 Shape of a contract suite
For a cold trait T, we define:
-
A factory for implementations used in tests:
pub trait Make<T> {
fn make(&self) -> T;
}
or just impl Fn() -> T where thatâs enough.
-
A set of law tests that take a &Make<T> (or impl Fn() -> T) and only talk in terms of the trait:
pub fn storage_laws<S, M>(m: &M)
where
S: Storage,
M: Make<S>,
{
get_after_put_round_trips(m);
put_is_idempotent(m);
errors_match_spec(m);
}
-
Optional scenario tests for protocolâlevel guarantees that live at the trait boundary:
concurrency behavior, ordering, timeârelated semantics, etc.
Key constraint:
Contract tests talk only in terms of the traitâs types, methods, and documented error enums.
They never reach into implementationâspecific fields, DB schemas, or helper methods.
If a test needs to look behind the trait, that behavior doesnât belong in the trait contract.
3.2 Example: Storage
Suppose we have:
pub trait Storage {
type Key;
type Value;
type Error;
fn get(&self, key: &Self::Key) -> Result<Option<Self::Value>, Self::Error>;
fn put(&self, key: Self::Key, value: Self::Value) -> Result<(), Self::Error>;
}
A minimal contract suite might look like:
pub fn storage_laws<S, M>(m: &M)
where
S: Storage<Key = String, Value = String, Error = StorageError>,
M: Make<S>,
{
get_after_put_returns_value(m);
put_overwrites_previous_value(m);
get_on_missing_key_is_none(m);
put_is_idempotent_for_same_value(m);
invalid_key_surfaces_as_invalid_key_error(m);
}
Each helper:
-
gets a fresh S from the factory,
-
exercises the trait only through get/put,
-
asserts on:
- returned values,
StorageError variants and classification helpers,
- documented side effects (e.g. overwrites vs merges).
When you add a new backend (InMemoryStorage, PostgresStorage, RemoteStorage), you just:
#[test]
fn postgres_storage_satisfies_storage_laws() {
let maker = PostgresStorageMaker::new(test_db_url());
storage_laws::<PostgresStorage, _>(&maker);
}
If it canât pass, there are only two possibilities:
- The implementation is wrong; or
- The traitâs promise doesnât match reality and needs to change (split trait, adjust errors, etc.).
Both are useful outcomes.
3.3 Which traits get contracts?
We only bother with full contract suites for cold traits:
- used across modules/crates,
- expected to have multiple implementations or to survive for a long time,
- whose misuse would be expensive (storage, evaluation, auth, paymentsâŠ).
Hot traits (experiments, local abstractions) can live with local tests.
Once a trait goes cold, the contract suite is required.
3.4 Rules of thumb
When designing/using contract tests:
-
One suite per trait, not per implementation.
The suite lives next to the trait definition, not next to the backends.
-
Single source of truth for laws.
The laws written in the trait docs and the laws encoded in the contract tests should be the same set of statements.
-
Cold by default.
Contract tests are cold: changing them means changing the traitâs spec. That should be rare and deliberate.
-
No âimplementationâonlyâ tests that duplicate the contract.
Implementationâspecific tests should focus on things beyond the shared contract
(e.g. performance guarantees, migration behavior), not reâassert the same laws.
Traits without contract tests are polymorphism without accountability.
Implementations that donât run the contract suite are untrusted.
4. Hot vs cold tests
We reuse the temperature idea:
-
Cold tests
- Encode behavior youâre committing to longâterm: trait laws, protocol contracts, public HTTP semantics.
- These should rarely change. Breaking them is a spec change, not a refactor.
-
Hot tests
- Surround experimental code, unstable APIs, inâprogress refactors.
- Theyâre allowed to be looser and more exampleâoriented.
- They should either mature into cold tests or be deleted.
Rules:
- Donât pin hot behavior with cold tests.
- Donât run hot tests in the same gates / pipelines as cold ones without labeling; otherwise everything feels equally sacred.
5. Smells (short list)
Things that should trigger an immediate âwhy does this exist?â reaction:
-
Coverageâdriven noise
- Tests whose only purpose is bumping coverage.
- Names donât describe a claim; assertions are tautologies (âit returns somethingâ).
- Ask: which family of wrong implementations does this kill? If you canât answer, delete.
-
Interaction fetish
- Tests asserting âmethod X called method Y onceâ via mocks.
- The behavior you actually care about is at the boundary (what got returned / written / emitted).
- Fix: assert on observable outcomes, not call graphs, unless the call pattern is the spec.
-
Assertion on nonâspec details
-
Facts only asserted at the top
- Big E2E tests checking business rules that could be checked on a small pure function or trait.
- When they fail, you have no idea where the bug is.
- Fix: move the rule down to the smallest layer that can express it; keep only a thin smoke test up top.
6. Tiny checklist
When you write or review a test, you should be able to answer these quickly:
-
Whatâs the claim?
One sentence, in domain language.
-
Whatâs the shape?
Law / Example / Scenario / Regression â exactly one.
-
Whatâs the search space?
Single example, a few edge cases, generator, recorded trace?
-
Whatâs the oracle?
What exactly are we asserting, and is it about semantics or incidental details?
-
Which family of wrong implementations does this kill?
Name at least one real, plausible âbad implementationâ that this test would catch and that other tests wouldnât.
-
Is this the lowest level we can assert this fact?
If not, move it down.
-
Is this hot or cold?
Are we willing to treat this as âspecâ or is it scaffolding around moving targets?
If you canât answer these, youâre not designing tests yet â youâre just writing code that happens to live in tests/.
7. What tests are for in this codebase
Given the rest of our design philosophy:
- Types constrain what states can exist.
- Traits constrain what we can do with those states.
- Errors constrain how failure is expressed.
Tests constrain which implementations of all that are allowed to ship.
Practically, that means:
- We write law suites for cold traits and core types.
- We add a small number of example tests to pin weird domain behavior.
- We use scenario tests to enforce contracts at HTTP / job / integration boundaries.
- We add regression tests only when a real incident proved we were wrong about something.
And every time we add a test, we ask the same question:
âWhich bad program am I ruling out by doing this?â
If the answer is ânone that I care about,â the correct move is to not write the test.