Testing Strategies
Causl graphs are deterministic state machines: given the same sequence of commits, a graph always
produces the same observed values. That property makes them unusually pleasant to test — you
do not need a DOM, a network mock, or a fake clock to exercise interesting behaviour. This guide
covers the four layers a causl-backed application typically tests at:
unit tests over individual graphs, property-based fuzz via
fast-check and tieredPropertyTrials, the CAUSL_FUZZ_TIER
env-var that scales fuzz budgets in CI, and parity tests that compare a causl graph
against a reference implementation — the pattern @causl/migration-check uses to
validate Redux-to-causl migrations.
Every example below assumes vitest as the runner; the patterns translate cleanly to Jest or Node's built-in test runner if you prefer.
1. Unit testing a causl graph
A unit test for a causl graph is the same shape as a unit test for a pure function: construct a graph, commit a sequence of writes, read the observable values, assert. There is no asynchrony, no setup or teardown, no module-level state. A graph is just a value.
import { describe, it, expect } from 'vitest';
import { createCausl, input, derived } from '@causl/core';
describe('counter graph', () => {
it('doubles the input', () => {
const graph = createCausl();
const count = input(graph, 0);
const doubled = derived(graph, g => g.read(count) * 2);
graph.commit(() => {
graph.set(count, 5);
});
expect(graph.read(doubled)).toBe(10);
});
it('atomically rolls back on commit failure', () => {
const graph = createCausl();
const count = input(graph, 0);
expect(() => {
graph.commit(() => {
graph.set(count, 7);
throw new Error('rollback');
});
}).toThrow('rollback');
expect(graph.read(count)).toBe(0);
});
});
Two things to notice. First, every assertion runs against
graph.read(node), not against an internal field — the read is the public
observation, and it is what subscribers in Phase G ultimately see. Second,
the rollback test exercises an invariant rather than an
implementation detail: a thrown error inside commit() guarantees the graph reverts
to its prior state, per SPEC §3. Tests that exercise invariants survive refactors.
Detailed explanation — reference identity.graph.read()does not contractually preserve reference identity across commits even when the value is structurally unchanged (SPEC §15.1). Tests that rely on===for object values are an H1-hazard; use deep-equality (expect(value).toEqual(...)) or compare specific fields instead. The same warning applies to React renderers: do not memoise on a causl-read object's identity unless you have opted into the typed-array fast path throughuseCauslNode.
Testing subscribers
Subscribers fire in Phase G after a commit finalises. A subscriber test should spy on the callback and assert the dispatched value — not the number of times the underlying derived node was recomputed (which the engine is free to elide).
import { vi } from 'vitest';
it('notifies subscribers on commit', () => {
const graph = createCausl();
const count = input(graph, 0);
const cb = vi.fn();
const unsubscribe = graph.subscribe(count, cb);
graph.commit(() => graph.set(count, 1));
graph.commit(() => graph.set(count, 2));
unsubscribe();
graph.commit(() => graph.set(count, 3));
expect(cb).toHaveBeenCalledTimes(2);
expect(cb).toHaveBeenLastCalledWith(2);
});
2. Property-based fuzz with fast-check
Unit tests cover hand-picked inputs. They are good at confirming the path you remembered to write down and bad at finding the one you forgot. Property-based tests — using fast-check — flip the polarity: describe a property that should hold for all valid inputs, then let the framework generate hundreds or thousands of inputs and shrink any failures down to a minimal counter-example.
Causl's testing seam ships tieredPropertyTrials from
@causl/core/testing — a fast-check configuration helper that enforces a
minimum trial count (1000, per SPEC §15.2) and routes trial budgets through the
CAUSL_FUZZ_TIER env-var so CI can scale fuzz on different lanes.
import fc from 'fast-check';
import { describe, it } from 'vitest';
import { createCausl, input, derived } from '@causl/core';
import { tieredPropertyTrials } from '@causl/core/testing';
describe('doubled is always 2x count', () => {
it('holds for any commit sequence', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (writes) => {
const graph = createCausl();
const count = input(graph, 0);
const doubled = derived(graph, g => g.read(count) * 2);
for (const v of writes) {
graph.commit(() => graph.set(count, v));
}
return graph.read(doubled) === graph.read(count) * 2;
}),
tieredPropertyTrials('doubled-invariant'),
);
});
});
The string label ('doubled-invariant') is folded into failure logs and seed
reports, so a CI failure tells you which property fired without spelunking through stack
traces. If a property fails, fast-check's shrinker minimises the counter-example and prints a
reproduction hint of the form
CAUSL_FUZZ_SEED=1234567 pnpm test:run; setting that env-var on a developer machine
replays the exact shrunk failure.
What kinds of properties to write
The most valuable properties are ones the type system cannot encode. A few archetypes:
-
Glitch-freedom. For any commit sequence, no derived node ever observes an
intermediate value. Use
glitchDetectorfrom@causl/core/testing. -
Atomicity. A thrown error inside
commit()leaves the graph identical to its pre-commit state. -
Determinism. Two graphs built with the same arbitrary-generated
DagSpecand fed the same writes produce identical reads on every node. - Subscriber dispatch order. Subscribers fire in registration order, exactly once per affected node per commit (Phase G).
Several of these are pre-built helpers in @causl/core/testing:
propertyDag generates random valid DAGs, arbAdversarialValue
generates corner-case numbers (NaN, signed zero, INT32 boundaries) and pathological strings,
and recomputeCounter instruments a derived to count its recomputations.
3. The CAUSL_FUZZ_TIER budget
The 1000-trial floor is the right budget for the local pnpm test loop, but it is
not the right budget for a nightly CI job that has eight hours to find rare races. Causl
routes fuzz budgets through the CAUSL_FUZZ_TIER environment variable, and
tieredPropertyTrials picks up the tier automatically.
| Tier | Trials per property | Typical lane |
|---|---|---|
default | 1,000 | Local dev, push CI |
pr | 5,000 | Pull-request gate |
nightly | 100,000 | Scheduled nightly run |
cargo-fuzz | (skip marker) | Rust fuzz harness handles it |
The tier is picked from CAUSL_FUZZ_TIER first, then a numeric
CAUSL_FUZZ_TRIALS override, then the default floor. A typical CI workflow:
# .github/workflows/pr.yml
- name: Run PR fuzz tier
run: pnpm test:run
env:
CAUSL_FUZZ_TIER: pr
# .github/workflows/nightly.yml
- name: Run nightly fuzz tier
run: pnpm test:run
env:
CAUSL_FUZZ_TIER: nightly
For a local "bump the budget for an afternoon" run, set the env-var inline:
CAUSL_FUZZ_TIER=pr pnpm test:run
# or, for an ad-hoc custom budget:
CAUSL_FUZZ_TRIALS=20000 pnpm test:run
Detailed explanation — why the floor is a structural check.tieredPropertyTrialsthrows if a caller passesnumRunsbelow 1000 without an explicitunsafeTrialsescape hatch. Thecausl/no-unsafe-trialslint rule rejectsunsafeTrialswithout an ESLint-disable comment documenting why. The floor is part of the engine's race-detection commitment (SPEC §15.2): properties are not a smoke test, they are the replacement for runtime race detection on the JS side, so under-trialling them silently weakens the safety net.
4. Parity testing against a reference implementation
When you migrate an existing store to causl — or when you rewrite a derivation for performance and want to be sure the new shape behaves identically — the cleanest regression-proofing strategy is a parity property: pick a message vocabulary, define both the old and new implementations as pure functions over it, and assert they produce the same observed view for every sequence of messages fast-check can generate.
The @causl/migration-check package ships this exact pattern in
test/properties/parity.property.test.ts — it validates a Redux-to-causl
migration of a counter+todos store by running both sides in-process and comparing the
derived view.
import fc from 'fast-check';
import { describe, it } from 'vitest';
import { tieredPropertyTrials } from '@causl/core/testing';
import { reduxOracle, causlMigrated } from './oracle';
type Msg =
| { kind: 'inc' }
| { kind: 'dec' }
| { kind: 'set'; value: number }
| { kind: 'addTodo'; text: string };
const arbMsg: fc.Arbitrary<Msg> = fc.oneof(
fc.constant({ kind: 'inc' } as const),
fc.constant({ kind: 'dec' } as const),
fc.integer().map(value => ({ kind: 'set', value } as const)),
fc.string().map(text => ({ kind: 'addTodo', text } as const)),
);
describe('redux ↔ causl parity', () => {
it('produces the same view for any Msg sequence', () => {
fc.assert(
fc.property(fc.array(arbMsg, { maxLength: 200 }), (msgs) => {
const oracle = msgs.reduce(reduxOracle, INITIAL_VIEW);
const migrated = msgs.reduce(causlMigrated, INITIAL_VIEW);
return JSON.stringify(oracle) === JSON.stringify(migrated);
}),
tieredPropertyTrials('redux-causl-parity'),
);
});
});
The two encodings are structurally distinct — mutation-on-state vs. graph commit plus derivation — which is exactly why the property is meaningful. A parity-breaking regression in either path surfaces as a shrunk Msg-sequence counter-example you can paste directly into a unit test.
The same pattern works for non-Redux references: a hand-written reducer, a snapshot of the pre-causl code, or even a second causl graph with a different DAG shape. The only contract is that both sides accept the same message vocabulary and project to the same view shape.
5. Integration with vitest
Causl tests are plain vitest tests. There are a few conventions worth adopting:
-
Keep property tests in a
test/properties/directory. The conformance meta-test (spec-15.2-conformance.test.ts) walks that directory to confirm every file usestieredPropertyTrialsorpropertyTrials. Putting them anywhere else silently bypasses the floor enforcement. -
Set
testTimeouthigh enough for thenightlytier. 100,000 trials of a non-trivial property can take minutes. The default 5-second vitest timeout will abort. A typicalvitest.config.tssetstestTimeout: 600_000on the property-test project. -
Pin the seed in CI on failure. The failure reporter shipped with causl's
propertyOptionsprints aCAUSL_FUZZ_SEED=…line; copy it into a rerun job so the same shrunk counter-example replays without rebuilding the matrix. -
Co-locate unit and property tests. A property test that finds a shrunk
counter-example becomes a unit test — paste the values verbatim, drop the
fc.assertwrapper, and check it in. The property keeps fuzzing for new failures while the unit test guards the specific regression.
A minimal vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
testTimeout: 600_000,
// Property tests log the seed on failure; let vitest pass stdout through.
silent: false,
},
});
What this guide does not cover
A few testing concerns live in adjacent packages and have their own pages:
-
Async resource lifecycles — race rows S-1 and S-2, the open-set
invariant, the conflict state machine: see the testing strategy in
@causl/sync. The pattern is the same (property tests undertest/properties/) but the arbitraries model async events rather than synchronous writes. -
Static analysis —
@causl/checkercatches a class of races at lint time (forbidden transitions, missing precondition phases). It complements runtime tests rather than replacing them. -
Snapshot/restore round-trips —
@causl/persistenceships its own snapshot-roundtrip property; the testing pattern is identical but the arbitraries model storage adapters. -
React rendering tests — mount a component with
@testing-library/reactand letuseCauslNodedrive the renders. Causl itself does not need React test utilities.
The four-layer stack — unit, property, tiered fuzz, parity — covers the vast majority of correctness questions a causl-backed application needs to answer. Start with unit tests for the paths you care about today; promote the ones that matter to properties; turn the tier knob up in CI; and use parity whenever you have a reference implementation lying around.