Testing architecture¶
For: contributors deciding where a new test belongs, and reviewers deciding whether a PR has enough of them. For reproducing the published numbers against a live binary, see End-to-end testing — that page is a transcript, this one is the map.
Mira has 318 cargo tests, 22 UI tests and 36 chart tests, and every one of them
runs from a make target that CI also calls. There is no CI-only test step. If
make check is green on your machine, the only things left that can turn CI red
are the four gates that need something a pre-push check should not assume (a
second toolchain, a Docker daemon, a Trivy database, minutes rather than
seconds) — and make ci runs those too, on this host. Both are described in
Contributing.
The levels¶
Eight of them, and the ordering is by how much of the real system each one holds, not by how they are usually named. "Integration test" is the label with the least agreement in the industry, so it does not appear here.
| # | Level | Count | Lives in | Runs from |
|---|---|---|---|---|
| 1 | Unit, in-source | 124 core + 157 bin | #[cfg(test)] in the module under test |
make test |
| 2 | Differential vs a reference model | 1 test, thousands of queries | crates/mira-core/tests/differential.rs |
make test |
| 3 | In-process end-to-end | 34 | crates/mira/src/e2e.rs |
make test |
| 4 | Subprocess CLI | 2 | crates/mira/tests/cli.rs |
make test |
| 5 | Generator self-check | 1 binary flag | crates/mira/examples/loadgen.rs |
make test |
| 6 | Browser-free UI | 22 | crates/mira/ui/src/lib/*.test.js |
make ui-check |
| 7 | Chart rendering | 36 in 5 suites | charts/mira/tests/*_test.yaml |
make helm-unittest |
| 8 | Live, over real sockets | asserted, not counted | docs/e2e/compose.yaml |
make e2e |
Levels 1–5 are one cargo test --workspace. That is deliberate: the loop a
contributor stays in has to be one command and a few seconds, or it stops being
the loop they stay in.
1. Unit, and why they are in-source¶
Rust's convention, a #[cfg(test)] mod tests at the bottom of the file it
tests, is followed here for the reason the convention exists: a unit test that
can see private state can assert the invariant rather than a proxy for it. The
zero-copy read path is the sharpest example — the test walks every buffer of
every column of a decoded block and requires each pointer to fall inside the
mmap, which is not a thing a public API can be asked.
2. Differential, and why there is no proptest¶
A fixture test proves the path it happens to walk. differential.rs builds a
random store, computes the answer with forty lines of Vec::filter that share
no code with the engine, and asserts the two agree over a few thousand random
queries. What that pins down is everything the model does not reimplement:
that time-range pruning never drops a block holding a match, that the Bloom
sidecar never prunes one either — the single failure mode an index is not
allowed to have — that the cross-block merge is ordered, that a cursor visits
every row exactly once, and that rows_matched counts the match set rather
than the page.
No proptest. The dependency budget is a stated product property
(architecture section 11), shrinking is the only thing it
would add, and a failure here prints its seed: MIRA_DIFF_SEED=<n> replays the
run exactly.
3. In-process end-to-end, the one that catches wiring¶
e2e.rs drives the real Router — the same value main hands to
axum::serve — with OTLP protobuf in one end and query JSON out the other. It
is the only test in the tree that would catch a receiver wired to the wrong
flusher, a block written where the reader does not look, a query that parses but
never matches, or an acknowledgement returned before the data is findable. The
only thing it does not exercise is the TCP socket.
Nothing in it sleeps. A 200 on /v1/logs is a read-your-writes promise, so
the next query can already see the data; a test that sleeps to make that true is
a test that has stopped asserting the promise. Under the shipped default the
mechanism is the open-block read path (architecture section
4) — the export is a frame in the WAL and a row in a
builder, and the query reaches into the builder. boot_sealing is the other
contract, for the handful of tests that are about the block directory itself.
This is the level most new behaviour should land at. It is fast, it needs no sockets and no cleanup, and it is the closest thing to the real thing a unit test can be.
4. Subprocess CLI, for what only a process has¶
main, run, load and shutdown are reachable only by exec'ing the binary:
a unit test inside the bin crate never calls its own main, -h and -V end
the process rather than returning a value, and a signal handler needs a process
to signal. Two tests, argv in and exit code out, with a SIGTERM in the middle.
Coverage still counts them — the child inherits LLVM_PROFILE_FILE and writes a
profraw that gets merged.
5. The generator's own invariants¶
cargo test compiles examples but does not run their #[test]s; an example
target defaults to test = false. loadgen's invariants — a trace that crosses a
service boundary, histogram buckets that sum to their count, exemplars naming
traces that exist — therefore ride behind --selftest rather than in a test
module, and make test invokes it as a second command. If the generator is
wrong, every number measured with it is wrong, so it is not optional.
6. UI, without a browser¶
api.test.js and replay.test.js are vitest over the two modules with logic in
them: the query-document builder and the recorded-snapshot replay the docs site
serves at /play. There is no jsdom, no component renderer and no headless
Chrome, because the components are thin and a browser runner is a toolchain the
build does not otherwise need. The gate that actually protects the UI is a
different one — make ui-check rebuilds crates/mira/ui/dist and fails on
git diff --exit-code, because that directory is include_bytes!d into the
binary and a fix that is not in dist is a fix nobody gets.
7. The chart, rendered¶
helm-unittest over five suites, one per template. It asserts the rendering
decisions that are cheap to break and invisible until something is deployed:
that the image tag defaults to the chart's appVersion, that the headless
Service stays on the container ports however the public Service's move, that a
config change rolls the pods, that SIGTERM gets long enough to seal the open
blocks, and that alert rules are refused on more than one replica. Four
more chart gates sit beside it — helm-lint, helm-template across the
permutations that change the chart's shape, helm-schema (the defaults are
admitted and a typo is refused), and helm-docs-check. make chart is all
five.
8. Live, over real sockets¶
make e2e stands up a stock OpenTelemetry Collector in front of a real Mira
container and asserts all three signals made the full trip. It is the only level
with a network, a container runtime and someone else's binary in it, which is
exactly why it is the last one and why it does not run on every PR — see
End-to-end testing for the manual version, with loadgen,
telemetrygen and the numbers.
Gates that test the repository, not the code¶
These are the other half of make check, and they fail more first PRs than the
tests do. Each exists because something got through.
| Target | What it refuses |
|---|---|
section |
A U+00A7 section sign anywhere authored — including the PR title and body |
fmt-check |
Anything unformatted |
lint |
A clippy warning anywhere in the workspace, over all targets |
features |
webhook-tls, which nothing else compiles, failing to lint |
doc |
A rustdoc warning, private items included — a dead intra-doc link is a dead link |
reference-check |
A generated reference page (docs/reference/, docs/config.md) that the code has moved past |
ui-check |
A .svelte change whose rebuilt bundle was not committed |
ui-demo |
The /play snapshot bundle going stale the same way |
deps |
An advisory, licence, ban or source cargo-deny refuses, or a dependency nothing imports |
drift |
The README's crate count or binary size no longer matching the tree that builds |
workflows |
A CI job that cannot block a merge, an unpinned action, a missing permissions:, a run: step that is not a make ci-* call |
install-script |
The published one-liner no longer parsing, linting or running |
docs |
A dead link, a dead anchor, a page outside the nav, or a route with a capital letter in it |
Listed in make check's own order, which is the order they fail fastest. It
runs test, chart and coverage alongside these — the levels above and the
ratchet below — and leaves out the four make ci picks up: msrv refuses a
construct newer than the declared minimum Rust, scan-image a fixable HIGH or
CRITICAL in the release image, dist a Linux binary that will not start on the
glibc the README promises (through glibc-floor), and e2e is level 8.
section has a --selftest, because every way that gate can break makes it
pass. workflows runs two things that do not subsume each other: actionlint
will not notice that a job nothing depends on cannot fail a PR, and
check_ci.py will not notice a typo in a ${{ }} expression.
Coverage is a ratchet¶
COVERAGE_MIN in the Makefile is line coverage, and it is the coverage that
existed when that line was last edited. It may only go up. Raise it in the same
diff that raises coverage — a one-line change a reviewer can see — and never
lower it to make a red build green. If a change genuinely cannot be covered, say
so in the PR; the number is a floor under a conversation, not the conversation.
Coverage runs take the same target-dir lock as a normal build, so give them their own:
CARGO_TARGET_DIR=/tmp/mira-cov make coverage
make coverage-report # per-file, worst first — what to write next
The ratchet is a floor, and the README's badge does not show it. make
coverage-json writes the measured figure to
miradb.dev/coverage.json as part of the
deploy that publishes this page, and the badge is shields reading that file at
render time — so it is the coverage of the commit the site was built from,
never a number someone remembered to edit. It moves by a hundredth between runs
because differential.rs picks a fresh seed each time, which is the honest
behaviour: that is what the measurement does.
What CI adds¶
Three things, and they are all in ci.mk: which legs a diff needs, the tool
versions everyone has to agree on, and the grouping of gates into legs.
ci.yml is a dispatcher over that file — every
run: in it is a make ci-* target, and check_ci.py fails the build if one
is not — so make ci runs the whole pipeline on one host and a red leg is
reproducible with one command.
ci.yml computes a changes matrix first and every job is conditional on it,
so a docs-only PR does not build the workspace. Two aggregate jobs — required
and security-required — sit downstream of everything and are the contexts the
branch ruleset requires; check_ci.py enforces that no job can escape their
needs closure, which is how a silently-skipped gate is caught statically at
PR time rather than noticed later.
The four gates that make check leaves to make ci are msrv, scan-image,
e2e and dist (as release-dry-run, which runs the real tarball, SBOM and
checksum targets on every code PR). Two of them a Mac cannot run at all —
ci-e2e needs a Linux binary in a Linux container, ci-image needs a Docker
daemon — and they say so rather than passing quietly.
Choosing a home for a new test¶
Work down; stop at the first level that can fail for the reason you care about.
- Can it be an assertion inside the module? Then it is a unit test, in that file, and you are done.
- Is it "the engine agrees with what the answer obviously is"? Extend the
reference model in
differential.rsrather than adding a fixture. A fixture proves one query; the model proves the class. - Does it cross a layer — receiver to storage, storage to query, query to
MCP?
e2e.rs. This is where a bug fix belongs when the bug was a wiring mistake, which most of them are. - Does it need a process — argv, an exit code, a signal?
cli.rs, and expect it to be slower than everything above it. - Does it need a socket, a container, or someone else's binary?
make e2e, and consider whether the thing you are testing is really Mira's behaviour or the Collector's.
A bug fix arrives with the test that would have caught it, at the level where it would have caught it. A fix at level 3 for a bug that a level 1 assertion would have caught is a test that will not be maintained.