Testing Guide
This guide covers how to run tests, what the different test types are, and how to
interpret their output. For the full technical reference (.ci format spec, .et
directives, fuzz target list), see docs/functional-tests.md.
First-time setup
./le setup install
./le verify current mode full
./le is the compiled repository development entry point. Each area exposes
its own verbs, for example ./le functional plugin,
./le qemu install-iso-test, and ./le test-unit bgp. Legacy command names
have no compatibility aliases.
Action words describe the contract: check is a read-only verdict, verify is
a composite policy gate, report is advisory output, and write or update
changes tracked state.
The escalation ladder
Use the narrowest test that covers your change. Escalate only when needed.
| Step | What you run | When | Time |
|---|---|---|---|
| 1 | ./le job run label one-test command go test ./pkg/... -run TestName |
Iterating on one test | seconds |
| 2 | ./le test-unit bgp |
Checking one component group | 10s to 1:30 |
| 3 | ./le functional plugin |
Checking the user-visible path | varies |
| 4 | ./le verify current mode full |
Ready to commit | about 2 minutes |
./le verify current mode full is the pre-commit gate. The narrower commands
are development tools. ./le job run admits an individual heavy command
through internal/le/job, so concurrent sessions do not oversubscribe the
machine.
Component groups for step 3
| Command | Scope | Time |
|---|---|---|
./le test-unit bgp |
BGP engine, wire, reactor | about 1:30 |
./le test-unit core |
Core libraries | about 30 seconds |
./le test-unit plugins |
All plugins | about 40 seconds |
./le test-unit config |
Config parsing and YANG | about 20 seconds |
./le test-unit cli |
CLI component | about 10 seconds |
./le test-unit installer |
Installer initrd logic behind the ze_installer tag |
about 10 seconds |
./le test-unit all |
The whole checkout, then the groups their build tags hide from it | minutes: it is every package under -race |
Pick the group matching your change. Each group is a subset of all, so a
green group says nothing about the rest of the tree.
The installer group runs the tests on Linux. On another host go test
cross-compiles a Linux binary it cannot start, so the group runs go vet
and type-checks the same files instead. ./le qemu all-tests runs them for
real inside the Alpine virtual machine.
Test types
Ze has four levels of testing, each covering a different concern.
Unit tests (*_test.go)
Standard Go tests. They validate logic in isolation: does the parser produce the right output, does the state machine transition correctly, does the encoder round-trip.
./le job run label bgp-message command go test ./internal/component/bgp/message/...
./le job run label parse-origin command go test ./internal/... -run TestParseOrigin
./le verify current mode full
Functional tests (.ci files)
These spin up real Ze processes and test behavior end-to-end: does the config parse, does the BGP session establish, does the plugin produce the right output.
Each test/ subdirectory has its own runner and format:
| Directory | What it tests | Runner |
|---|---|---|
test/encode/ |
BGP wire encoding | ze-test bgp encode |
test/decode/ |
Wire decoding | ze-test bgp decode |
test/parse/ |
Config parsing (valid/invalid) | ze-test bgp parse |
test/plugin/ |
Plugin behavior | ze-test bgp plugin |
test/reload/ |
Config reload | ze-test bgp reload |
test/ui/ |
CLI completion | ze-test ui |
test/editor/ |
TUI editor (.et files) |
ze-test editor |
test/managed/ |
Managed config | ze-test managed |
test/web/ |
Web UI | ze-test web |
test/l2tp/ |
L2TP daemon | ze-test l2tp |
test/firewall/ |
Firewall | ze-test firewall |
test/policy/ |
Policy routing | ze-test policy |
test/exabgp-compat/ |
ExaBGP compatibility | ze-test exabgp |
Run a single test by one-based ID or exact name, list the available IDs, or
resume from the last printed ID after an interrupted run. Queue the runner
through ./le job run label <label> command <argv...> to use the same admission
as a full suite.
./le job run label plugin-42 command bin/ze-test bgp plugin 42 # test id 42
./le job run label encode-list command bin/ze-test bgp encode --list # list N/TOTAL, id, and name
./le job run label plugin-from-42 command bin/ze-test bgp plugin --start 42 # id 42 and every later test
./le job run label editor-7 command bin/ze-test editor 7 # editor test id 7
./le job run label editor-nav command bin/ze-test editor -p nav # editor tests matching "nav"
./le job run label exabgp-from-20 command bin/ze-test exabgp --start 20 # resume ExaBGP compatibility
Run a full suite:
./le functional encode
./le functional plugin
./le functional gating
./le functional exabgp-test
Mutation tests (gomu)
Coverage tells you which lines ran. Mutation testing tells you whether the tests would notice if those lines did something different. gomu rewrites the AST (arithmetic, conditional, logical, bitwise, branch, return value, and error handling operators), runs the test suite against each mutation, and reports which mutations survived.
gomu uses overlay-based execution, so it never modifies source files on disk.
It is a Go tool recorded in tools.go. The native ./le mutation combine
command combines completed reports, and ./le mutation record-history appends
their per-package scores to the committed history.
Mutation testing is advisory. It never gates ./le verify current mode full or CI. A surviving mutant
is a signal that a test could be stronger, not a blocking failure.
One consumer reads a gomu report for a narrower question. ./le rfc discriminate <selector> report <path> scopes the report to ONE RFC-tagged test: it keeps the
KILLED mutants that lie in code that test's own coverage profile executes, and
offers them as candidate breaks. ./le rfc discriminate-record then applies one,
runs that test alone, and records the red it observed. gomu's status stays
unchanged, because nothing in ./le rfc check starts a mutation run: the gate
reads a stored record. See docs/contributing/rfc-conformance-gates.md, "The
discrimination record".
Files with custom build tags and cmd/ze/ are excluded via .gomuignore because
gomu has no --tags support. Reports land in tmp/ (gitignored). Mutation score
history is tracked in test/mutation/history.ndjson.
gomu is vendored and runs through go run, so it needs no install.
| Command | Purpose |
|---|---|
go run github.com/sivchari/gomu/cmd/gomu run --output json --incremental=false --fail-on-gate=false |
Full advisory mutation run |
go run github.com/sivchari/gomu/cmd/gomu run --output json --incremental --base-branch=main --fail-on-gate=false |
Changed-file advisory mutation run |
./le mutation combine |
Combine the per-package JSON reports |
Tuning is by flag on gomu run, not by environment variable. gomu reads only
GITHUB_TOKEN and GITHUB_REPOSITORY, both for its GitHub integration.
| Flag | Default | Effect |
|---|---|---|
--workers |
4 | Parallel workers |
--timeout |
30 | Test timeout in seconds |
--incremental |
true | Analyse changed files only |
--base-branch |
main |
Base branch for the incremental analysis |
--fail-on-gate |
true | Fail the build when the quality gate is not met |
--output |
console |
console, json, html, or text |
Interop and integration tests
These require external infrastructure (Docker, root/CAP_NET_ADMIN, QEMU, or internet). They are not part of the normal development cycle.
./le integration interop
./le integration iface
./le qemu all-tests
./le integration live-rpki
When a test must be weakened
A red test means the CODE is wrong by default. Fix the code. When the coverage is genuinely gone, because the feature it proved was removed or another test now proves it, the removal is recorded rather than silent.
The record is test/weakened/<session>.md, the ledger shard your own commit
session owns, where <session> is the eight hex characters ./le commit session prints. It holds one row per weakened test, | Test | Reason |. No
other session reads your shard and none can write it, so nobody has to be
careful about anybody else's rows.
Write the rows this commit owes and leave the rest to the gate: ./le commit create drops every row your shard holds whose text git already has at HEAD,
because a row whose commit landed explains a diff history holds. Git history is
where past rows are read, so git log -p -- test/weakened/ shows each one
beside the change it accepted.
The route, in order:
- Write the row first. The native write-edit hook reads the shard from disk, so a row added after the edit takes effect only after the edit is retried.
- Make the edit.
- Name
test/weakened/<session>.mdin the commit.internal/le/commit.Answerrefuses a commit that weakens a test and leaves the row in the working tree.
./le test-weakened check prints every session's shard and the rows in it, and
names yours. Read it when you want to know what the ledger holds without
preparing a commit.
The test name is the enclosing top-level func TestXxx for Go, and the file stem
for a .ci or a .et. Write package.TestName when the bare name matches two
weakened tests in one commit.
An edit that only lowers a COUNT lands with a notice and no row: consolidating three cases into one table lowers a count exactly as deleting a check does. The COMMIT still needs a row for it, and that row is where you say which of the two happened.
./le test-weakened check runs the checker over the file and is a stage of
./le verify current mode full in both modes. The rule is ai/rules/testing.md, and the design is
docs/architecture/testing/test-health.md.
How ./le verify current mode full works
./le verify current mode full is the pre-commit gate. It uses a two-pass strategy to stay fast:
- Lint (27 linters via golangci-lint)
- Vet evidence (cross-compile the Go evidence packages for Linux)
- Cached full pass (
go testwithout-race): Go caches by source hash, so when nothing changed this is instant. Catches logic regressions everywhere. - Changed-group pass: uses the test-only
CGO_ENABLED=1 go test -racepath on Linux and Darwin. Its test binaries are never release/build evidence. - Functional tests from
internal/le/functional/catalog.go - ExaBGP compatibility
Common case (one group changed): ~2 min total instead of 6+.
The builds the linter reads
golangci-lint analyzes ONE build for each run: one GOOS, one GOARCH, one tag
set. ./le verify lint run therefore runs more than one.
| Pass | Build | What only it reads |
|---|---|---|
| 1 | the host GOOS, .golangci.yml tags |
the shipped daemon |
| 2 | GOOS=linux, plus integration |
every kernel-facing //go:build integration test |
| 3..N | one for each row of FLAVORS. internal/le/verify/lint.Answer |
ze_installer, ze_distro, ze_appliance, ze_setup, tinygo, and the capability tags (debug, race, live, ...). Also the GOOS and GOARCH targets no other pass compiles: darwin, freebsd, openbsd, dragonfly, wasip1, linux/arm64 and linux/riscv64. Also the compile-out build, which drops every feature gate and keeps ze_core alone |
Waiting for the linter's own lock
golangci-lint takes one lock for the whole machine, at
$TMPDIR/golangci-lint.lock. Several sessions share this checkout, so a second
linter is normal here. Every command line Ze builds therefore carries
--allow-serial-runners, which makes the child WAIT for that lock. Without it
the child gives up after five seconds and exits parallel golangci-lint is running, and the stage turns that into a red that names no file.
A pass that waits prints nothing while it waits. A whole-tree pass holds the lock for about ten minutes on this workstation, so a pass behind one can stay silent for that long. That is the linter in a queue, not a hang.
The slot a lint holds
./le verify lint run claims the lint label in the shared job registry
(internal/le/job) before it plans anything. A lint uses cores allocated for
the whole machine, so admission decides how many run at once, and it prints
[lint] waiting: <holder> running (pid N, Ns elapsed): <the holder's last log line> while it queues. That last line is the holder's current stage, read from
the log the holder is writing (reportBusy, internal/le/job/registry.go), so
a waiter can tell a run that is progressing from one that is stuck. A holder
with no readable log gets the banner without it.
A second session asking for the SAME work over the SAME inputs does not queue.
It attaches: it replays the running lint's output, takes that run's verdict, and
the tree is linted once for both. [lint] attaching to the lint already running over these inputs is that answer. A full run and a scoped run are different
work, so they never share a verdict.
The inputs are the ones a lint READS, and not the whole checkout. Nine sessions
write this checkout. A journal row or a spec edit that one of them made while
your lint runs used to void the match, and the tree was linted a second time. Go
source, .golangci.yml, go.mod, go.sum and vendor/modules.txt each void a
share. A file under .claude/, ai/, backups/, docs/, plan/, rfc/ or
website/ does not.
That list is declared once, as lintIgnores beside job.LintLabel
(internal/le/job/treehash.go), and it EXCLUDES rather than includes. An input
nobody listed still voids the share, so a missing entry costs a duplicate run
and never a stale verdict. A tree is on the list because it holds no Go file at
all, and a //go:embed pattern cannot leave the directory of the package that
writes it, so nothing under such a tree can reach a Go package.
The findings a shared verdict names are read back out of the replay, so an attached red still says which files it was about. A red that names no file is charged to every commit in the checkout.
Each flavor pass lints only the packages holding a file the two passes above do
not load. That package set is DERIVED from the tree with go list on every run.
A hand-written list drifts the moment somebody adds a //go:build debug
file in a new package, and the drift is silent.
The driver then asserts coverage. Every tracked Go file must be loaded by some
pass. The exceptions are vendor/, gokrazy/modcache/, and the //go:build ignore files that belong to no build. ./le verify lint run executes the
native plan and returns its flavor rows as structured output; ./le verify lint run
retains the established target interface.
Two files are still outside it, and the driver names both on every run.
examples/plugin/go/main.go, which is a separate Go module. And tools.go,
whose imports are programs rather than packages.
The //go:build !ze_<feature> compile-out stubs -- the code an operator reaches
when a feature is OFF -- were a third population until 2026-08-24. No pass could
select one, because --build-tags only ADDS to the config's list. The
compile-out row reaches every stub: it runs against a derived copy of
.golangci.yml carrying no build tags at all (tagless_config), so the command
line is the whole tag set and that set is ze_core. One row is enough because
the gates are independent, so a build with none of them on satisfies every
negated term at once. A feature-only helper must therefore carry its consumer's
build constraint. Without it, the bare-core build reports the helper as
unused, which is what it is in a binary that compiles its only caller out.
The accounting every gate owes its population
The lint driver above is the shape every gate is held to, and
internal/le/population is that shape as a callable type. A gate states what it
governs, what it walked, and a reason for each member it deliberately skipped.
Claim.Assess then answers a Coverage. It goes red on two things: a member
neither walked nor excused, and an excuse that has stopped being needed.
Both halves matter. The first is the gate covering less than it says. The second is a stated exception nobody rechecked, which hides the next member to land on that same path. An empty population is an error rather than a clean report, because a walk that found nothing prints what a healthy tree prints.
A count floor -- refuse below N members -- is NOT this. It catches only the empty
case, and comparing sizes where the question is about sets is the same defect one
level up. plan/journal/gate-excludes-part-of-its-population.md records both.
The exemptions are a population too
A gate that either scans a member or exempts it balances by construction. Its own
accounting proves nothing, because every member it read fell into one of the two
buckets by definition. population.Exemptions asks the other question: of the
rules the gate declares, which ones still do work?
A rule earns its place only where it SUPPRESSED something. The weaker reading, that a file exists under it, stays true for a rule whose file stopped doing the thing it was excused for. So the walk scans the exempt file and then drops the result, rather than skipping it, and credits the rule only when it dropped something.
iface-resolution, plugin-boundary and fs-persistence each split Check
from CheckCheckout for this. Over a fixture, a rule that suppresses nothing
means the tree does not hold that code. Over the real checkout it means an
exemption nobody rechecked. Only the caller knows which tree it passed.
An empty rule set is a clean answer, not the refusal an empty population gets. The two empties are different facts. A walked population that came back empty is a walk that found nothing, which looks like a healthy tree. A rule set was written empty, in source a reader can see.
Feature-tag structural type check
./le staticcheck-feature-matrix check type-checks the working tree in N+2
configurations derived from the N unique features in feature-gates.txt: one
distro all-on row, one bare-core row, and one row that omits each feature.
Staticcheck includes selected _test.go files. This stage type-checks those
files without running their tests.
The matrix guarantees these direct single-feature omissions. It makes no guarantee for arbitrary combinations with two or more omitted features.
Inside a verify run the stage judges only the rows the change set can move: the
distro all-on and bare-core rows, plus one row per feature tag the change
reached. Typing the target yourself judges every row, because only a verify run
publishes the feature-tag answer it scopes by. What widens the scope back to
every row is ../architecture/testing/verify-freshness-scope.md.
A verify run also CUTS those rows. It runs six stages,
check part 1 of 6 through check part 6 of 6, and each one judges the rows
dealt to its piece. CI deals the stage list to its shards round robin, so the
six pieces run on six shards rather than on one job's clock. Each piece names
the rows it judged in its own log. Rerun one piece, or the whole matrix:
./le staticcheck-feature-matrix check part 3 of 6
./le staticcheck-feature-matrix check
The matrix checks package and test variants in the working tree.
./le repository tracked-build check remains the committed-tree final-link check for shipped
build flavors.
The one stage that does not read your working tree
Every stage above compiles and runs the files on your disk, uncommitted ones
included. ./le repository tracked-build check (internal/le/repository/trackedbuild.Answer) is the
exception: it extracts the commit with git archive and compiles the extracted
tree, so it sees only what git holds.
That is the population that breaks when a commit takes a consumer and leaves its producer uncommitted. The build is green on your disk and red for everybody who clones. Run it after the commit script when the commit carried Go:
./le repository tracked-build check
REV=7abe8a07e ./le repository tracked-build check
The action builds every flavor in internal/le/repository/trackedbuild/matrix.go over
./.... Each row pins its tags, operating system where required, and a
tag-gated anchor file that proves the flavor selected code. Naming the package
alone is insufficient because go build ./... can skip every constrained file
and still exit zero.
About 45 seconds warm. It does not compile _test.go files, because go build
never does.
Output is captured to tmp/ze-verify.log. On failure:
grep -E "^--- FAIL|^FAIL|TEST FAILURE" tmp/ze-verify.log
Interpreting output
Unit test failures
Standard Go test output. Look for --- FAIL: TestName lines:
--- FAIL: TestParseOrigin (0.00s)
origin_test.go:42: expected 0, got 1
FAIL github.com/ze-software/ze/internal/core/bgp/attribute 0.003s
Functional test failures
ze-test prints a summary at the end of each suite. Look for the test name
and the expectation that failed:
FAIL encode/addpath.ci
expect=bgp:conn=1:seq=1:hex=...
got: FFFF...0038...
Fuzz failures
Go places the failing input in testdata/fuzz/<TestName>/ under the package.
The file contains the input that triggered the crash. Fix the code, then the
fuzz corpus entry becomes a regression test automatically.
A test that exists is not a test that gates
A .ci or .et can pass whether or not the feature works when the observed
effect reaches the assertion by a path OTHER than the one under test. That is a
FALSE PASS. Three redistribute-late-join*.ci tests kept passing with the
late-join replay (handleReplayBatch) disabled: the route reached the peer by
some other path, so they guarded nothing and shipped green.
Mutation testing through the Go gomu binary runs unit tests only; it never
executes .ci or .et. Proving that a functional test gates is therefore the
discipline this page has always prescribed: disable the producing function,
confirm the test flips to red, and revert.
For an RFC-tagged carrier that walk now has a runner and a record.
./le rfc discriminate-record ... route revert producer <path>::<Func> disables
the named function, runs ONE .ci or ONE interop scenario, requires the red, and
puts the producer back byte for byte. What it writes is a
rfc/discrimination/<stem>.json record that ./le rfc check re-verifies on every
run, so the red is remembered rather than done once and forgotten. An untagged
.ci still owes the walk by hand.
When a behavior genuinely cannot be made to fail under mutation because it is
not observable end to end (the reactor suppresses a duplicate announce, so
per-peer targeting is wire-indistinguishable), guard it with a unit test that
inspects the producing value directly and say so in the test comment. Do not keep
a .ci that passes with the feature disabled.
Boundary values for common ranges
Every numeric range is tested at three points: the last valid value, the first invalid value below, and the first invalid value above.
| Range | Last valid | Invalid below | Invalid above |
|---|---|---|---|
| Port 1-65535 | 65535 | 0 | 65536 |
| Hold time 0, 3+ | 0, 3 | 1, 2 | none |
| Prefix IPv4 0-32 | 32 | none | 33 |
| Message length 19-4096 | 4096 | 18 | 4097 |
Flake shapes seen in this repository
Check each of these against the test before investigating a new race or
isolation flake: locked-write with unlocked-read, subscribe-before-broadcast,
gate-handler queue state, barrier FIFO order, cleanup-drains-work, a fixed port
behind an SO_REUSEPORT gate, and colliding test-fake pool IDs.
| Symptom | Root cause | Fix |
|---|---|---|
| Port reuse race in reactor tests | Stop() not waiting for cleanup |
Ensure cleanup goroutines complete before returning |
| Completion test fails intermittently | A real bug, not a flake | Check completeShowPath includes YANG schema children |
| Inter-message timing in plugin tests | Sleep too tight under load | Increase the inter-message delay, or synchronize |
A darwin FAIL caused by a _other.go stub returning ErrUnsupported is a
test-setup bug rather than a real failure. Keep the failure list meaningful.
Reproducing a load-dependent flake is
../architecture/testing/runner-architecture.md.
Cheat sheet
| I want to... | Run |
|---|---|
| Check my setup | ./le verify current mode full |
| Run one Go test | ./le job run label one-test command go test ./pkg/... -run TestName |
| Run one functional test | ./le job run label plugin-42 command bin/ze-test bgp plugin 42 |
| Run a component group | ./le test-unit bgp |
| Run the pre-commit check | ./le verify current mode full |
| Type-check every supported feature combination | ./le staticcheck-feature-matrix check |
| List native test actions | ./le help |
| List functional tests | ./le job run label encode-list command bin/ze-test bgp encode --list |
| Run one fuzz target | FUZZ=FuzzName PKG=./path/... TIME=30s ./le fuzz run |
| Run all fuzz targets | ./le fuzz run |
| Check the commit compiles | ./le repository tracked-build check |
| Check web behavior | ./le functional web |
Check that every *_templ.go matches its .templ source |
./le doc check templ-output, and ./le repository generate to bring it back in step. Both walk internal/ only. Run neither templ command by hand, and switch off an editor's on-save templ integration. A bare templ generate walks from the repo root. It writes that root into every generated file, and it reds the gate |