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 through useCauslNode.

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:

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.

TierTrials per propertyTypical lane
default1,000Local dev, push CI
pr5,000Pull-request gate
nightly100,000Scheduled 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. tieredPropertyTrials throws if a caller passes numRuns below 1000 without an explicit unsafeTrials escape hatch. The causl/no-unsafe-trials lint rule rejects unsafeTrials without 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:

  1. 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 uses tieredPropertyTrials or propertyTrials. Putting them anywhere else silently bypasses the floor enforcement.
  2. Set testTimeout high enough for the nightly tier. 100,000 trials of a non-trivial property can take minutes. The default 5-second vitest timeout will abort. A typical vitest.config.ts sets testTimeout: 600_000 on the property-test project.
  3. Pin the seed in CI on failure. The failure reporter shipped with causl's propertyOptions prints a CAUSL_FUZZ_SEED=… line; copy it into a rerun job so the same shrunk counter-example replays without rebuilding the matrix.
  4. 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.assert wrapper, 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:

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.