Test Environments
Tests run on machines that differ in user, kernel, paths, network and load: these are the rules that keep a test correct on every one. Each comes from a test that passed on one machine and failed on another. A test or a fix that turns out to depend on the machine is fixed once, at its cause, and its rule is added here in the same pull request.
Check the selected environment
An environment has two axes: host capabilities and installed tooling. A test is eligible when both satisfy its requirements, as described by the workspace's build guide, its CI setup and the conditions below. Select tests for the available environment, then verify the operations they need. A pure unit test need not wait for unrelated infrastructure.
Host capabilities
The host determines the OS, architecture, kernel features, privileges, outer namespace and filesystem restrictions. Bubblewrap needs the host to permit its namespaces, and Landlock needs the relevant kernel operations. Root-only tests need their prescribed root environment. Installing a program does not grant any of those capabilities.
Installed tooling
Available executables, libraries and their versions determine which tools tests can use. Build the workspace programs the selection needs. Probe relevant installed tools: a database test needs working server and cluster-initialization programs, and an SSH test needs a client that accepts its configuration. Install a host package when it is needed for the component under test, rather than merely to run more tests.
Fixture state
Fixture homes, temporary folders and state locations must be writable and satisfy ownership and trust checks under the host's restrictions. Host settings and a read-only real home must not leak into fixtures. These are inputs to isolate, not reasons to install unrelated packages or change the user's machine configuration.
Missing prerequisites
Use an existing suitable environment or report the affected selection skipped or not run, with its reason. Unsupported tests need not run on every machine. Provision another environment only when the component under test needs it. CI requirements describe what its chosen profile must cover; they are not a demand to install every tool on each local machine. Preserve that contract when claiming equivalent coverage.
Required capabilities
A gate skips its test when the machine lacks what it needs, saying what
and why, and a LNK_REQUIRE_* variable turns that skip into a failure.
Set one only on an environment that provides every capability it names,
as CI does, so that a gap there is a real regression. A session's or a
person's machine leaves them unset, so a test it can't run is reported
skipped, never failed.
LNK_REQUIRE_SANDBOX: bubblewrap,sshd, a sandbox that the builtlnk-sandboxruns, and a checkout outside the temp folders.LNK_REQUIRE_NATIVE: the kernel's Landlock.LNK_REQUIRE_PROGRAMS: the host programs tests run, such aspython3,git,curl,ssh,zsh,Xvfb, Chromium andrust-analyzer.
A test that can't run here
A test that fails only because the machine lacks something needs a
gate, not a change to the machine. What it lacks is on one axis: a host
capability (OS, architecture, kernel feature, privilege), the host's
configuration (its user, root, or where the checkout and temp folders
are) or an installed program. Add the testing kit's gate for that axis,
or a suite's own through gate::lacks with the variable that requires
it, and say so on this page in the same pull request. When a skip is
right, how a gate makes one and every variable that decides it:
Skipping Tests.
Results and coverage
Report passed, failed and skipped from observed outcomes. Failures keep their original status when an environment error caused them. Ignored tests and explicit capability skips are not coverage, and a suite not run has no individual result counts. A capability gate can return early and still appear as passed to libtest, so unobserved gate output leaves coverage uncertain. Report that uncertainty rather than inventing counts.
Record a failure that depends on the machine
When a test passes on one machine and fails on another, record both before changing anything, then fix the cause:
- Record the machine: its OS and kernel, the user the tests ran as,
whether bubblewrap and Landlock work, the checkout's path,
TMPDIRand how many CPUs the build had. - Record the run: the package and test, alone or in a full
cargo test --workspace, and how many runs out of how many failed. To count, run the whole test program several times in a row on one build, not the one test. - Reproduce the input that differs on a machine that passed, and fix the boundary that owns it, never the test's expectation. Never weaken a requirement or count a test that couldn't run as a pass.
- Add what differs to the section below, as a rule that keeps code and tests correct on every machine. Record only what a later session could run into again.
Inputs that differ between machines
Each rule below names an input that differs between machines, and how code and tests stay correct whatever its value.
Unix identity
Who the tests run as, and who else the machine has.
- A test sets the child environment it needs, including
HOME,USER,LOGNAME, XDG variables andPATH. One machine setsUSERand another doesn't. - Root reads every file whatever its mode, so a permission a test checks
as root proves nothing for an ordinary user. A root test checks as
nobody, or as a user of its own, withsetpriv. - A step run as another user must reach every folder above its own. A
TempDiris made 0700, and a test that opens its own folder for that user opens each parent too, or works under/tmp, which lets anyone through. - GitHub's runners run as an ordinary user. A change to code that only root tests cover is run as root before it merges.
- A system user a root test makes is the machine's, seen by every test running at once: each test names its users after itself, never a name another test could make, and removes them at the end, whatever happened.
Folders and paths
Where a test's files are, and what those places allow.
- Keep checkouts outside
/tmp, which sandbox specifications cannot read. A test that needs the sandbox to read its folder makes it outside/tmp, andout_of_tmpsays when it can't. - A Unix socket's path has a limit, 104 bytes on macOS and 108 on Linux.
A Mac's
TMPDIRand a long worktree'sCARGO_TARGET_TMPDIRcan pass it, so a socket goes underTempDir::short, which uses/tmpon Unix. - A socket fixture whose paths must stay outside the sandbox's
/tmpusesTempDir::short_out_of_tmp: a shortLNK_TEST_STATE_DIRwhen set, else this user's cache folder. It checks that place; moving the fixture into the replaced/tmpwould weaken its assertions. - A sandbox grants a fixture's folders, never the folder holding them.
Under
/tmpevery parent is readable, so a program that opens a granted folder's parent passes there and is refused anywhere else. It opens the granted folder by its own path instead. - Filesystems can differ: a rename across mounts returns
EXDEV. - A test's folder may be inside a checkout (
CARGO_TARGET_TMPDIR, a checkout'starget/), so a test of what's found up to a repository's root makes its own.gitin its folder.
Programs and processes
The programs a test writes and runs, and the processes it starts.
- A program a test writes is written with
write_programorcopy_program, neverstd::fs::writeorstd::fs::copybefore running it. Otherwise a child another test forks meanwhile can hold it open, and running it fails with "Text file busy", only when the machine is busy (why). - Plugins are found at run time by file name,
lnk-<plugin>. Renaming one breaks every caller that builds the name as a string, with no compile error, so search for the old name's strings. - An SSH fake keeps control-master state per destination, for checks and exits as well as opens. Short test paths can enable multiplexing: one host's connection must never make another host appear connected.
- A fake that answers a question reads the question first, as the program it stands in for does. One that answers and exits without reading leaves the asker's write to a closed pipe, a failure only when the fake happens to end first.
- Rustup picks a toolchain by the folder a program runs in and by
RUSTUP_TOOLCHAIN, which Cargo sets for every test. A program a test runs in a sandbox runs elsewhere and without it, so it can get the machine's default toolchain, and a probe for it runs as it will. - A process inherits the signals its parent ignores. A run started
under
nohupignores SIGHUP, and one started in the background by a script ignores SIGINT and SIGQUIT, so a test that signals a child it starts first sets those signals back to their defaults for the child. - A check that combines several conditions reports one cause for all of them. "bubblewrap can't run here" can mean that a program is missing, so test each condition alone before believing the message.
- Process numbers are reused, and a busy machine has thousands. A fake
that reports a process, such as a fake
systemctl'sMainPID, numbers it past the most a process can be (2^22), or code that waits for it to end waits on whatever of the machine's has that number. - A process reported by
pscan already be a zombie: it has exited, waiting for its parent to reap it. Cleanup waits for live states and has a deadline. A container whose main process does not reap adopted children can retain zombies until it ends. Signal zero counts those too;process::left_underchecks whether any owned process is still running. - Tests run the programs in
target/debug, and a build in the same checkout replaces them while a test runs one. Each concurrent task uses a worktree of its own.
Settings and network
What a child reads of the machine's settings, and what it can reach.
Fixture rebuilds
A test never downloads: the VM fixtures' committed manifests pin what a
rebuild made, and their tests read only the checkout. Rebuilding a
fixture fetches its recipe's archives from deb.debian.org, so a change
to a fixture's recipe or guest source is rebuilt on a machine whose
network reaches that host, and a manifest is never edited by hand.
JSON fixtures
Cargo feature unification can change JSON object key order in a full workspace build. Protocol fixtures accept keys in any order; a single package's serialization order is never part of the wire contract.
Child settings
Each child gets only the settings and network inputs its test owns.
- A child given no
HOMEuses the real user's settings in~/.lnk, shared with the machine and every other test. A test clears the child's environment (env_clear) and gives it aHOMEof its own. - A bridge that a proxy starts gets a clean environment. In a debug
build, it also gets the proxy's
LNK_TEST_*variables, and nothing else of a test's, such asSSL_CERT_FILE. - Claude Code on the web reaches the internet only through an HTTPS
proxy, so a test gives every name it reaches an address with
LNK_TEST_RESOLVEand answers it itself (Write a test).
Kernel confinement
What the kernel lets the sandbox do, which differs between machines.
- bubblewrap needs unprivileged user namespaces. Ubuntu's AppArmor
withholds them until
kernel.apparmor_restrict_unprivileged_userns=0, which CI sets. WithLNK_REQUIRE_SANDBOX=1, as in CI, a test that can't run it fails rather than skips. - Landlock depends on the host kernel and on what the container's runtime lets through. Without it, executable allowlist tests skip, saying why (Agent Sessions).
- The Linux release links musl, whose libc differs from glibc where the
sandbox leans on it (
O_PATH). The sandbox's unit tests run on musl too.
Load and timing
What changes when the machine is busy, or many tests run at once.
cargo test --workspaceruns many tests at once, and a test that passes alone can fail there. Reproduce it by running its whole test program several times in a row, not the one test.- A failure that comes only under load is usually a race in the code under test, such as an answer dropped when the other side closes early. Fix the race. Never add retries, a longer timeout alone or one test thread to hide it.
- A test waits through the testing kit, which
polls with backoff and fails naming what it waited for and what it
last saw. A machine slower than most sets
LNK_TEST_TIME_SCALE(such as3) once, and every deadline grows with it. - A test that needs one step done before the next starts waits until the first is seen (the stand-in asked, its file written), never for a fixed pause: on a loaded machine a task spawned first can run second.
- State the code under test keeps for a whole process, such as a static set of chats being worked on, is shared by every test of the program: one test's entry can make another's work skip. Such state belongs to the object that serves, a harness say, so each test's is its own.
- A test that probes a port free and binds it later can lose it to another process under load ("Address already in use"): a server is given a listener the test already holds. Ports a program the test starts binds itself go to one test of the program only, and never among the ports the code's own lookup gives out, which every test's home starts from.
- A number a program the test starts takes itself, such as an X
display or an agent's block of ports, is held before it's probed
free: another test program, or another run of the same one, can probe
the same number before either program takes it. The kit's
HeldNumberlocks a file of the number's own withflock, which the kernel lets go however the test ends. - A single port such a program binds, an sshd's or a harness's, comes
from
HeldNumber::port(), 12000 to 15999: below the system's ports and Link's own. - A check that something was refused waits for the refusal itself, never a deadline: a busy machine can answer later than any limit, and a silence read as "let through" is then a leak that isn't. A limit may only shorten the wait for what's let through.
- A test that checks an action left something alone compares only what that action could touch, such as the processes the code under test started and recorded. A user's other processes, a program's helpers and their children, start and end on their own under load.
- A file another process appends to is read holding its lock, and only its whole lines: a read can end partway through a line still being written.
- Each run of a test starts from files of its own. A file reused between two runs in one test can still hold the first run's lines when the second reads it.
- A test that counts a stand-in provider's requests waits for the request it means, matched by content. A runtime can send several per turn (a title, a summary), and one sent just before the test kills the runtime can reach the stand-in after the kill, where it looks like the restarted runtime's.
What local fakes do not establish
Local tests use stand-ins for the macOS Keychain, TPM, systemd and launchd, cloud accounts, paid models and payment providers. Passing a fake does not certify those systems: they are checked on real machines and accounts.