| name | lib0-testing |
| description | How to write tests and fuzz tests using lib0's homegrown testing framework (`lib0/testing`, `lib0/prng`, and `lib0/schema` for random value generation). Use this skill whenever writing or modifying `*.test.js` files in this repo, adding a new test module to `src/test.js`, debugging a failing test run, or writing fuzz / property-based tests with seeded PRNGs.
|
lib0 Testing Framework
lib0 ships its own test runner (src/testing.js) â there is no Jest / Mocha / Vitest. Tests are plain ESM modules that export functions whose names start with test. A seeded PRNG (lib0/prng) makes every random test reproducible: failures are replayable via --seed N.
Read this whole file before writing a new test module.
File layout
- Test files live next to the source:
src/foo.js â src/foo.test.js. Subdirectory modules follow the same rule (src/delta/delta.js â src/delta/delta.test.js).
- Every test module MUST be imported in
src/test.js and registered in the object passed to runTests({...}). If you forget, CI will not run it â src/test.js is the only entrypoint that drives the whole suite (and the coverage gate).
- Never hand-write anything into
types/ (generated by tsc from JSDoc).
- Don't create a
foo.test.ts â the codebase is JS + JSDoc.
Test function shape
A test file exports one or more functions named test<CamelCase>. The runner (src/testing.js:579) picks up any export whose name starts with test or benchmark and calls it with a fresh TestCase.
import * as t from 'lib0/testing'
import * as prng from 'lib0/prng'
import * as foo from './foo.js'
export const testFooBasic = tc => {
t.compare(foo.double(21), 42, 'double(21) is 42')
}
export const testFooAsync = async tc => {
const result = await foo.loadAsync()
t.assert(result != null)
}
Rules that matter:
- The function name IS the test identifier.
testFooBasic â displayed as foo basic. Rename carefully.
- The first character after
test must be uppercase (camelCase). testfoo is treated the same as testFoo by the prefix filter but the name splitting (string.fromCamelCase) will produce an odd-looking label.
testRepeat* or testRepeating* prefix = fuzz test: the runner re-runs the function with a fresh seed for up to --repetition-time ms (default 50ms). See src/testing.js:131,176. Use this for any property that should hold for random inputs.
- Always type the
tc parameter via @param {t.TestCase} tc â without it, tc.prng is any and tsc won't catch misuse.
- If you don't need the test case, call the param
_tc to silence the unused lint.
Running tests
npm test
npm run test:deno
npm run test-extensive
node ./src/test.js
node ./src/test.js --filter REGEX
node ./src/test.js --seed 12345
npm run debug
npm test gates on coverage (lines 97 / branches 96 / functions 94 / statements 97) â if your new module isn't hit, it'll fail even if all t.asserts pass. Run npm test once before committing.
How --filter actually works
The filter is a regex tested against the rendered line, not the function name. The rendered line is:
[<index>/<total>] <moduleName>: <uncamelized test name>
Where <uncamelized> = string.fromCamelCase(funcName.slice(4), ' '). So testRepeatRandomTextDeltaDiff in module delta renders as:
[219/235] delta: repeat random text delta diff
Consequences:
--filter testRepeatRandomTextDeltaDiff â matches NOTHING (the camelCase name never appears in the line).
--filter "repeat random text delta diff" â matches that one test.
--filter "random.*delta" â matches all tests whose uncamelized name has random then delta.
--filter "^\[219/" â matches test #219 exactly (this is what the "repeat:" hint printed after each test uses â see below).
--filter "delta:" â matches every test in the delta module.
When telling a user how to re-run a failing test, give them the uncamelized form or the [N/ index.
Output format â how to read it
Each test prints a collapsible group followed by a one-line verdict:
[183/235] delta: delta basic api
Success: delta basic api in 328.08Ξs
repeat: npm run test -- --filter "\[183/"
For testRepeat* tests the verdict includes stats:
[219/235] delta: repeat random text delta diff
Success: repeat random text delta diff - 197 repetitions in 51.54ms (best: 87.57Ξs, worst: 9.21ms, median: 157.57Ξs, average: 261.6Ξs)
repeat: npm run test -- --filter "\[219/" --seed 4067301200
Failures look like:
[42/235] foo: something specific
X one equals two
Failure: something specific in 512Ξs
repeat: npm run test -- --filter "\[42/" --seed 1234567890
Final line is always one of:
All tests successful! in 1.76s
or
> 3 tests failed
Grep patterns for efficient output filtering
When scanning a long test log, these stable patterns let you jump to what matters:
| Pattern | Finds |
|---|
^Failure: | failed tests (one line per failure) |
^Success: | passed tests (one line per pass) |
^\[\d+/\d+\] | every test's group header |
^ X | each assertion failure reason |
^> \d+ tests? failed$ | final failure count (if any) |
All tests successful! | the green summary at the bottom |
- \d+ repetitions in | fuzz-test stats lines only |
--seed \d+ | the repro commands for fuzz failures |
Typical debug loop: pipe the run into grep -E '^(Failure:| X |> )' to see only failing tests + their reason, then grab the --seed N --filter "\[K/" line under the failure to reproduce deterministically.
Assertions
From src/testing.js:
| Function | Use for |
|---|
t.assert(cond, message?) | Boolean assertion. Narrows types (asserts property). |
t.compare(a, b, message?, customCmp?) | Deep equality. Handles Object, Array, Map, Set, Uint8Array, ArrayBuffer, and types with the EqualityTraitSymbol. Preferred default. |
t.compareArrays(as, bs, message?) | Shallow === per element. Use when elements are primitives. |
t.compareStrings(a, b, message?) | Prints a colored diff on mismatch. Use for long strings. |
t.compareObjects(a, b, message?) | Flat object property comparison (no recursion). |
t.fails(() => { ... }) | Assert sync body throws. |
t.failsAsync(async () => { ... }) | Assert async body rejects. |
t.fail(reason) | Force-fail with a message. |
t.skip(cond = true) | Skip the rest of the test when cond. Prints Skipped:. |
t.promiseRejected(f) | Like failsAsync but for a promise-returning function. |
t.compare is almost always what you want for deep structures. Reach for compareStrings only when the visual diff matters.
Logging & instrumentation
| Function | Purpose |
|---|
t.describe(description, info?) | Log what's being tested. Prints blue text. |
t.info(info) | Log a state note (grey). |
t.group(description, f) | Collapsible nested section (sync). |
t.groupAsync(description, f) | Collapsible nested section (async). |
t.measureTime(message, f) => number | Log sync execution time; returns ms. |
t.measureTimeAsync(message, f) => number | Log async execution time; returns ms (awaited). |
t.printDom(dom) / t.printCanvas(cvs) | Embed DOM / canvas in browser test output. |
Output inside a test is auto-collapsed when no --filter is set (so a clean full run stays readable) and auto-expanded when a filter is set (so focused debugging shows everything). See src/testing.js:151.
Fuzz / property-based testing
The testRepeat prefix
Prefix a test with testRepeat (or testRepeating) and the runner loops it for --repetition-time ms, resetting the seed each iteration:
export const testRepeatVarUintRoundtrip = tc => {
const n = prng.uint32(tc.prng, 0, (1 << 28) - 1)
const enc = encoding.createEncoder()
encoding.writeVarUint(enc, n)
const dec = decoding.createDecoder(encoding.toUint8Array(enc))
t.compare(decoding.readVarUint(dec), n, `roundtrip ${n}`)
}
Behavior:
- Default: 50ms of iterations (often hundreds/thousands).
npm run test-extensive bumps repetition time to 1000ms.
- Each iteration gets a fresh seed; the first failing iteration's seed is printed in the
repeat: hint so you can re-run it exactly.
- Inside the test, every random decision must come from
tc.prng. Math.random() makes the failure unreproducible.
tc.prng and reproducibility
TestCase.prng is lazily created from tc.seed. tc.seed is either the CLI --seed N (if provided) or a fresh random.uint32() per iteration. So:
node src/test.js --filter "my fuzz" --seed 42 â deterministic single run.
- Plain
node src/test.js â random seeds; each Failure: line prints the seed used.
lib0/prng cheat-sheet
import * as prng from 'lib0/prng'
prng.bool(gen)
prng.int32(gen, min, max)
prng.uint32(gen, min, max)
prng.int53(gen, min, max)
prng.uint53(gen, min, max)
prng.real53(gen)
prng.char(gen)
prng.letter(gen)
prng.word(gen, minLen = 0, max = 20)
prng.utf16Rune(gen)
prng.utf16String(gen, maxLen = 20)
prng.uint8Array(gen, len)
prng.oneOf(gen, array)
Composing complex random inputs
Wrap domain-specific generators as plain functions that take a PRNG:
const genUser = gen => ({
id: prng.uint32(gen, 0, 1 << 20),
name: prng.word(gen, 3, 12),
active: prng.bool(gen),
tags: Array.from({ length: prng.int32(gen, 0, 5) }, () => prng.word(gen))
})
export const testRepeatUserSerialization = tc => {
const u = genUser(tc.prng)
t.compare(parse(serialize(u)), u)
}
Weighted / branching choices
prng.oneOf picks uniformly â for weighted choices, use prng.int32 plus a cutoff. For branching behavior in fuzz tests, a common pattern (from src/delta/delta.test.js) is an array of thunks selected by prng.oneOf:
prng.oneOf(gen, [
() => doThingA(),
() => doThingB(),
() => doThingC()
])()
Generating random values from a schema
lib0/schema defines schemas (s.$number, s.$string, s.$object({...}), s.$union(...), s.$array(...), s.$record(k,v), s.$literal(...), etc.) and ships a matching random generator:
import * as s from 'lib0/schema'
s.random
This recursively walks the schema and produces a conforming value (src/schema.js:1161-1243): numbers come from a salted set including -1, 0, 1, <random int53>, strings from prng.word, booleans, unions pick one branch, objects fill each key (optionals drop ~50%), arrays get 0â42 elements, records get 0â3 entries, literals pick one of their values. For types the generator doesn't natively support, pass a fallback(gen, schema) that returns a value.
import * as prng from 'lib0/prng'
import * as s from 'lib0/schema'
const $user = s.$object({
id: s.$number,
name: s.$string,
tags: s.$array(s.$string),
role: s.$union(s.$literal('admin'), s.$literal('member'))
})
export const testRepeatUserSchema = tc => {
const u = s.random(tc.prng, $user)
t.assert($user.check(u))
t.assert(typeof u.name === 'string')
}
This is the pattern used heavily throughout src/delta/delta.test.js â e.g. delta.random(tc.prng, $d, opts) produces random DeltaBuilder instances that match a schema, which are then exercised through diff / rebase / apply invariants. For domain types with their own random generator (like Delta), prefer the domain-specific one over s.random because it understands cross-field constraints (e.g. delta.random can take a source option so generated changes are compatible with a given base state).
--extensive mode
t.extensive (src/testing.js:63) is true when --extensive is passed. Use it to gate expensive additional checks:
export const testRepeatSomething = tc => {
if (t.extensive) {
}
}
npm run test-extensive turns this on and also bumps --repetition-time to 1000.
Registering a new test module
When you add src/foo.test.js:
- Add an import at the top of
src/test.js:
import * as foo from './foo.test.js'
- Add
foo to the object passed to runTests({...}).
- Run
node src/test.js --filter "^\[.*\] foo:" to confirm your tests appear and pass.
- Run
npm test once to confirm the coverage gate is still met.
Forgetting step 1 or 2 is the most common reason "my test passes locally but never ran in CI."
Checklist for writing a test file
- Create
src/<module>.test.js (or src/<dir>/<module>.test.js) next to the source.
import * as t from 'lib0/testing'. Add import * as prng from 'lib0/prng' if randomness is involved, import * as s from 'lib0/schema' for schema-based random values.
- Export
test<Name> (or testRepeat<Name> for fuzz) functions, each typed @param {t.TestCase} tc.
- Use
tc.prng â never Math.random() â so failures are replayable with --seed.
- Prefer
t.compare for structural equality; t.assert for booleans; t.compareStrings for long strings.
- Register the module in
src/test.js (import + entry in runTests({...})).
node src/test.js --filter "<module>:" â all green.
npm test â coverage gate still passes.