Askr documentation
Guides

Testing Deterministic Applications

Testing Deterministic Applications: a worked guide from route registry through to a production build.

Example

Control clocks, requests, and route input in tests; assert rendered behavior and cleanup instead of implementation call counts.

it('should increment the quantity', () => {
  expect(incrementQuantity(1)).toBe(2);
});

Goal and architecture

`@askrjs/askr/testing` and `@askrjs/askr/fx` exist specifically so async and time-based code doesn't make your test suite flaky: `mockQuery`/`queryState` builder methods (`.loading()`, `.error()`, `.refreshing()`, `.stale()`, `.pendingWrite()`, or calling `mockQuery`/`queryState` directly for the fresh case) construct a query in any state without a real network call — these are builder names, not a literal list of `consistency` values; the actual `consistency` field is the narrower `'fresh' | 'stale' | 'refreshing' | 'pending-write'`, with `loading` as its own separate boolean and an errored query landing on `consistency: 'stale', staleReason: 'error'` rather than a `consistency: 'error'` value that doesn't exist. The `fx` timing helpers (`debounce`, `throttle`, `retry`, `timeout`) are pure functions you can control directly rather than fighting real timers, though their controls differ per function — see the implementation notes below.

Implementation

Use `mockQuery.error(error, previousData)` and `mockQuery.stale(data, reason)` — `reason` here is limited to `'aborted' | 'inconsistent'`, since `'error'` has its own dedicated `.error()` constructor — to exercise UI branches that are otherwise hard to hit reliably, and `createInvalidationRecorder()` to assert exactly which cache prefixes a mutation invalidated instead of re-running real queries to check for side effects. For route logic, `matchRoute(path, { registry })` and `getRouteWarnings({ registry })` let you assert on route resolution and catch route-collision warnings without booting a browser. For timing-dependent code, the controls differ per function: `retry()` just returns a `Promise<T>` you await — there's no handle to drive, only its own `delayMs`/`backoff` options to inject; `debounce()`/`throttle()` return a function with a `.cancel()` you can call directly; `.flush()` isn't on either of those — it's only on the separate `debounceEvent`/`throttleEvent` variants, which is what you want if flushing a pending call synchronously in a test matters to you.

Failure states

A test that mocks a query as `fresh` but never exercises the `loading` or `error` states is testing the easy 80% of the component; use the full `queryState` matrix deliberately so regressions in error and stale-data rendering get caught. Real timers (`setTimeout`, `requestAnimationFrame`) in tests are a common source of flakiness — prefer the `fx` primitives' explicit `cancel()`/`flush()` surface or a fake-timer setup over sprinkling `await timeout(50)` calls that pass locally and flake in CI.

Verification

Treat `createInvalidationRecorder()` assertions as part of your contract for mutations — a mutation that's supposed to invalidate a `'users'` prefix should have a test asserting exactly that prefix appears in `recorder.calls`, not just that the mutation resolved. Snapshot route warnings from `getRouteWarnings({ registry })` in CI so a newly introduced route collision fails the build instead of surfacing as a confusing 404 in production.