Skip to content

Tests and the gate ​

bash
bun test           # everything
bun run test:gate  # everything, plus the coverage gate
bun run lint       # biome
bun run typecheck  # tsc

The gate ​

src/core/** must be 100% covered. Not the whole project - core specifically, and the gate is a script rather than a coverage threshold because a project-wide percentage lets a large, well-tested core hide an untested adapter.

Core is pure, so full coverage costs little: no containers, no fixtures on disk, no mocking. If a line is hard to cover, that is usually a sign it belongs in an adapter.

Adapters are covered by contract tests that drive the real thing - the compiled binary over stdio for the MCP server, real backends for the environment ports.

What a test is for ​

A test here is expected to say why it exists. The ones worth reading name the failure:

prepare-test survives, because it is the one that decides the design - a suite run without it fails against an unmigrated database, and that is a false failure somebody then reports as a broken test.

That habit pays when a test fails two years later and nobody remembers what it was protecting.

Which suite to run ​

bun run test:unitcore and CLI only: fast, hermetic, no external processes
bun run test:contractasks the installed backend binaries whether they still know the commands the adapters call, and drives the interactive prompts through a real pty. Skips what is absent, which is why a machine without ddev reports skips rather than failures
bun testboth
bun run test:fastboth, spread over every CPU core: about 80 s instead of 7 minutes. For while you work. Its coverage report is not the gate's: Bun's parallel workers shift some files' line numbers by one when their reports are merged, so the gate would name lines that are fine
bun run test:gatethe build, every test spread over the CPU cores, then tests/core on its own for the coverage gate. What CI runs: about 100 s instead of 9.5 minutes

Why the gate runs the core tests twice

Run in parallel, every test takes about 100 s instead of 9.5 minutes, but its coverage report cannot be the gate's (the line shift above). So the gate runs the whole suite in parallel first, with its report in coverage/parallel/, and then tests/core alone, one file after another, which takes six seconds and writes the coverage/lcov.info the gate reads.

That second run measures src/core by its own tests only. A core line that only a contract or adapter test reached used to pass the gate; now it fails it, which is the rule as written: core is pure, so its own tests can cover it. Two files were caught that way when the gate changed (dataCopyRefusal in snapshot-verdict.ts, versionFloors in requires.ts) and got unit tests.

Running in parallel also exposes timing tests that only hold on an idle machine. Time the fastest of several runs, not the median of a few: a busy machine only ever adds time. Even that lost under the parallel run: an API call came out slower than a CLI spawn, and a linear function 16x slower at 4x the input. So a test that compares timings is skipped in a parallel worker (test.skipIf(underParallelLoad), from tests/helpers/parallel.ts) and the gate runs it again on its own: the core ones in the tests/core stage, tests/adapters/containers-engines.test.ts in a stage of its own before that one, because every run rewrites the lcov.info the gate reads.

Why the scripts pass --timeout 30000

Nineteen test files spawn a real process - the CLI itself, openssl, a pty - and bun runs test files concurrently. Locally a CLI cold start is 0.16 s and openssl 0.03 s; on a two-core CI container under that much contention, two of them crossed bun's 5 s default and the build failed on tests that had nothing wrong with them.

A timeout in bunfig.toml is silently ignored by bun 1.4 - measured, a test sleeping 6 s still failed at 5000 ms with timeout = 30000 in the file - so it has to be the flag, which is why it is on every script rather than in the config.

Guards ​

Some tests are not about behaviour but about a class of mistake:

  • The www/ guard - no file outside a small allow-list may assume a project's code sits under www/. Written after the same wrong assumption appeared in four places, none of which failed loudly.
  • The client-name guard - no page of this documentation site may name a client. The list lives in one place, and the build fails rather than the deploy succeeding.
  • Never-exposed tools - no MCP tool name may resolve to a destructive command at any flag.

Each exists because the mistake is silent and a human re-reading a few thousand lines misses one.

Rules learned the hard way ​

Each of these is here because a test was green and the thing it tested was broken.

  • bun test green is not enough. Run bun run typecheck too: an embedded text import needs its declaration in src/text-imports.d.ts, and only the typechecker says so. A missing one is a runtime ReferenceError in a command nobody ran yet.
  • Anything that embeds a stub file gets a case in tests/cli/parity.test.ts, which runs it from source and from dist/dqx. The compiled binary carries its assets differently, so "it works from source" is half an answer. That suite compares stdout only.
  • Where output crosses a tool boundary, assert on the text. A YAML file another parser reads is judged by what is written, not by what the same library parses back - which is a round trip that agrees with itself and with nothing else.
  • Byte-for-byte goldens only from files that were never hand-edited. Everywhere else, assert the structure: a generated file that legitimately gains a key should not fail thirty tests.
  • Smoke-test writes against a copy. A real project in a scratch directory, or --dry-run in the real one. Never a write against something somebody is working in.

Walking it for real ​

Unit tests do not catch everything, and the plan records what the walks found: a stack that started before its database was ready, a certificate that did not cover a new domain, a copy that copied nothing and exited 0. Every one of those passed the test suite.

So a change to something that touches the machine gets walked on a real project before it is called done.

dqx v1.78.0