Nix Test
Nix Test is a declarative testing framework for user-facing behavior in Nix. Tests define the environment, interactions, and expectations together, then run as regular flake checks.
Use Nix Test to exercise:
- terminal applications through a real pseudo-terminal
- NixOS services and machines through the NixOS test driver
- files, users, containers, networks, and HTTP endpoints
- browser and desktop behavior
- custom fixtures and matchers with typed locators
{ pkgs, expect, ... }:
{
test."shows a greeting" = { terminal }: [
(terminal.open pkgs.hello)
(expect (terminal.getByText "Hello")).toBeVisible
];
}
Start with Getting Started, learn the test model in Writing Tests, or browse the API Reference.
Getting Started
This guide adds Nix Test to a flake, puts the test in its own file, and runs it as a normal flake check.
Add Nix Test
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
tests = {
url = "github:sand4rt/nix-test";
inputs.nixpkgs.follows = "nixpkgs";
inputs.flake-parts.follows = "flake-parts";
};
};
outputs = inputs:
inputs.flake-parts.lib.mkFlake { inherit inputs; } {
imports = [ inputs.tests.flakeModules.default ];
systems = [ "x86_64-linux" ];
perSystem = { ... }: {
imports = [ ./tests/hello.test.nix ];
};
};
}
Change the system when your machine uses another supported architecture.
Write A Test
Create tests/hello.test.nix:
{ pkgs, expect, ... }:
{
test."shows a greeting" = { terminal }: [
(terminal.open pkgs.hello)
(expect (terminal.getByText "Hello")).toBeVisible
];
}
The attribute name is both the test name and its flake check name. The callback requests the fixtures it needs and returns actions in execution order.
Run The Test
nix build '.#checks.x86_64-linux."shows a greeting"' --no-link -L
Run all checks for the current system with:
nix flake check
Next, read Writing Tests for steps, assertions, configuration, and separate test files. If you do not use flake-parts, see Running Tests.
Writing Tests
Tests are named attributes whose values are callbacks. A callback receives the fixtures it requests and returns an ordered list of actions.
test."saves the document" = { terminal, filesystem }: [
(filesystem.writeFile "document.txt" "draft\n")
(terminal.open "${pkgs.lib.getExe pkgs.neovim} ${filesystem.root}/document.txt")
(expect (terminal.getByText "draft")).toBeVisible
(terminal.press "<esc>")
];
Actions Run In Order
Each item in the returned list is an action. Setup, interaction, and assertions are written in the same order a user or operator would perform them.
[
(terminal.open application)
(terminal.press "<enter>")
(expect (terminal.getByText "ready")).toBeVisible
]
Fixtures
Fixtures describe the boundary under test. Request only what the test uses:
{ terminal, filesystem }:
Use terminal for local command-line applications and machine or machines
for NixOS VMs. Semantic fixtures such as service, filesystem, network,
http, and user build on the machine backend. See
Fixtures and Assertions.
Locators And Assertions
Locators describe observable state; matchers assert against it:
(expect (terminal.getByText "ready")).toBeVisible
(expect (machine.service "example.service")).toBeActive
(expect (machine.file "/run/example/ready")).toExist
Assertions retry observations until the configured timeout. Actions such as keyboard input, service restarts, and mutating requests execute once.
Steps
test.step groups actions under a diagnostic name in the test log:
(test.step "service becomes usable" [
(expect (machine.service "example.service")).toBeActive
((expect (machine.http.get "http://localhost/health")).toHaveStatus 200)
])
Steps may contain other steps.
Configuration
Set suite-wide defaults with test.configure:
test.configure = {
timeout = 30;
terminal = {
columns = 100;
rows = 30;
};
};
The default assertion timeout is 15 seconds. Standalone terminal tests default to 140 columns by 42 rows.
Separate Test Files
Colocate tests with the code they cover using the *.test.nix suffix:
src/
├── terminal.nix
└── terminal.test.nix
A test file is a per-system module:
{ pkgs, expect, ... }:
{
test."shows a greeting" = { terminal }: [
(terminal.open pkgs.hello)
(expect (terminal.getByText "Hello")).toBeVisible
];
}
Import it from perSystem:
perSystem = { ... }: {
imports = [ ./src/terminal.test.nix ];
};
Fixtures And Assertions
Fixtures are the interfaces available to a test callback. They keep tests focused on observable behavior instead of backend implementation details.
Request only the fixtures a test uses:
test."service is healthy" = { machine }: [
(machine.configure { modules = [ serviceModule ]; })
(machine.service "example.service").start
(expect (machine.service "example.service")).toBeActive
((expect (machine.http.get "http://localhost/health")).toHaveStatus 200)
];
Core Fixtures
| Fixture | Use it to |
|---|---|
terminal | Open and interact with a local terminal application |
machine | Configure and control one NixOS VM |
machines | Configure and control named NixOS VMs |
filesystem | Prepare mutable files and observe machine paths |
service | Start, stop, restart, and reload services |
network | Locate endpoints and partition named machines |
http | Observe idempotent requests or send one request once |
user | Locate users and run commands as a user |
container | Locate and control declarative NixOS containers |
browser | Interact through accessibility-oriented browser locators |
desktop | Locate windows and text and send desktop input |
result | Inspect saved command and HTTP results |
expect is a module argument, not a test fixture. Import it at module scope and
use it with targets returned by fixtures.
Locators
A locator describes what to observe without performing the assertion itself:
terminal.getByText "ready"
machine.service "example.service"
machine.file "/run/example/ready"
machine.http.get "http://localhost/health"
Prefer semantic locators over shell commands when both express the same public behavior. They produce clearer tests and diagnostics.
Matchers
Matchers live under expect and accept compatible locators or targets:
(expect (terminal.getByText "ready")).toBeVisible
(expect (machine.service "example.service")).toBeActive
(expect (machine.file "/run/example/state")).toHaveContent "ready"
(expect (machine.http.get "http://localhost/health")).toHaveStatus 200
Observable-state matchers retry until they pass or the timeout expires. They do not repeat preceding side effects.
Commands As An Escape Hatch
Use commands when no semantic locator describes the behavior:
(machine.command "example status")
(expect (machine.command "example is-ready")).toEventuallySucceed
(expect (machine.command "example forbidden-operation")).toFail
machine.command as a standalone action runs once. Command matchers retry, so
only use them for safe observations.
Saved Results
Use machine.run or http.send when an operation has side effects. Save its
result once, then make assertions without repeating the operation:
test."creates an item once" = { machine, result }: [
(machine.run {
command = "example create";
saveAs = "create";
})
((expect (result.command "create")).toHaveExitCode 0)
((expect (result.stdout "create")).toContainStdout "created")
];
See the guides for complete workflows and the API reference for exact signatures.
Guides
Choose the guide that matches the boundary you need to test:
- Terminal Applications
- NixOS Machines
- Multiple Machines
- Browser and Desktop
- Test Data and Results
- Extending Nix Test
These guides assume you have completed Getting Started and understand the basic test structure.
Terminal Applications
The terminal fixture runs a command in a real pseudo-terminal. Use it for CLI
and TUI behavior that depends on terminal dimensions, keyboard input, cursor
movement, or visible screen contents.
test."opens a document" = { terminal, filesystem }: [
(filesystem.writeFile "example.txt" "hello\n")
(terminal.open "${pkgs.lib.getExe pkgs.neovim} ${filesystem.root}/example.txt")
(expect (terminal.getByText "hello")).toBeVisible
(terminal.press "<esc>")
];
Open A Program
Pass a package to resolve its executable with lib.getExe, which uses
meta.mainProgram when set and otherwise the package’s main name:
terminal.open pkgs.hello
Use a command string when arguments are required:
terminal.open "${pkgs.lib.getExe pkgs.neovim} -u NONE ${filesystem.root}/example.txt"
Send Keyboard Input
Literal text and named keys may be combined:
(terminal.press "hello<enter>")
(terminal.press "<esc>")
(terminal.press "<esc>:wq<enter>")
Locate Visible Text
(expect (terminal.getByText "ready")).toBeVisible
((expect (terminal.getByRegion {
left = 0;
top = 0;
width = 12;
height = 1;
})).toEqual "Status: ready")
Text observations retry automatically. Add terminal.print to emit the current
screen in the build log while debugging.
NixOS Machines
Use machine when behavior depends on a NixOS VM. Machine actions automatically
create an empty default VM. Add machine.configure only when the test needs
NixOS modules.
test."service becomes healthy" = { machine }: [
(machine.configure {
modules = [
self.nixosModules.default
{ services.example.enable = true; }
];
})
(expect (machine.service "example.service")).toBeActive
(expect (machine.file "/run/example/ready")).toExist
((expect (machine.http.get "http://localhost/health")).toHaveStatus 200)
];
Semantic System State
Use semantic fixtures for services, filesystems, endpoints, HTTP, users, and containers. Their matchers retry until the expected state appears.
[
(expect (machine.service "example.service")).toBeActive
((expect (machine.service "example.service").logs).toContain "configuration loaded")
((expect (machine.file "/var/lib/example")).toBeOwnedBy "example")
(expect (machine.endpoint.tcp 8080)).toBeReachable
]
Terminal Interaction In A VM
machine also implements the terminal interface:
[
(machine.open "example-tui")
(machine.press "start<enter>")
(expect (machine.getByText "running")).toBeVisible
]
For exploratory access, guest shells, SSH, and forwarded ports, see Debugging.
Multiple Machines
Use machines when a scenario crosses machine boundaries.
test."client reaches server" = { machines, network }: let
server = machines.node "server";
client = machines.node "client";
in [
(machines.configure {
server.modules = [ serverModule ];
client.modules = [ clientModule ];
})
(expect (server.service "example.service")).toBeActive
(expect (client.command "example-client server")).toSucceed
]
Each node exposes the machine, terminal, service, filesystem, endpoint, HTTP,
user, and container APIs. Nodes also provide lifecycle actions such as start,
shutdown, reboot, and crash.
Network Partitions
Use the network fixture to model failures explicitly:
[
(network.partition { left = [ server ]; right = [ client ]; })
(expect (network.endpoint {
from = client;
host = "server";
port = 8080;
})).toBeUnreachable
(network.heal { left = [ server ]; right = [ client ]; })
]
Use unique host ports when forwarding ports from more than one VM. See Port forwarding.
Browser And Desktop
Browser tests favor accessibility-oriented locators rather than CSS selectors:
[
(machine.configure { modules = [ browserPageModule ]; })
machine.browser.start
(machine.browser.open "http://machine:8080/")
((machine.browser.getByLabel "Username").fill "example")
(machine.browser.getByRole "button" { name = "Sign in"; }).click
(expect (machine.browser.getByText "Welcome, example")).toBeVisible
]
Available browser locators include role, label, placeholder, text, and title. Choose the locator closest to how a user identifies the element.
Desktop tests locate windows or visible text and can send keyboard input, type text, and save screenshots:
[
(machine.configure { modules = [ desktopModule ]; })
(desktop.press machine "meta-ret")
(expect (desktop.getByWindow machine "Terminal")).toBeVisible
(desktop.type machine "hello")
(desktop.screenshot machine "desktop")
]
Both fixtures run on the machine backend and therefore require
machine.configure in the test.
Test Data And Results
Isolated Workspaces
filesystem prepares mutable files under an isolated runtime directory:
test."reads configuration" = { terminal, filesystem }: [
(filesystem.writeFile "config.toml" ''
greeting = "Hello"
'')
(terminal.open "${application} --config ${filesystem.root}/config.toml")
(expect (terminal.getByText "Hello")).toBeVisible
];
Paths are relative to the filesystem root. Absolute paths and parent traversal are
rejected during Nix evaluation. Other actions include makeDirectory,
copyFile, copyTree, symlink, setMode, and remove.
Saved Command Results
Run side-effecting commands once and save their output:
test."creates an item once" = { machine, result }: [
(machine.configure { })
(machine.run {
command = "example create";
saveAs = "create";
})
((expect (result.command "create")).toHaveExitCode 0)
((expect (result.stdout "create")).toContainStdout "created")
];
Saved HTTP Results
Use http.send for mutating requests:
test."creates an item through HTTP" = { machine, http, result }: [
(machine.configure { modules = [ apiModule ]; })
(http.send machine {
method = "POST";
url = "http://localhost/items";
body = ''{"name":"example"}'';
saveAs = "create-item";
})
((expect (result.command "create-item")).toHaveExitCode 0)
((expect (result.stdout "create-item")).toContainStdout "created")
];
Saved-result assertions never repeat the original side effect.
Extending Nix Test
Projects can add domain-specific fixtures and matchers through the mergeable
testing.fixtures and testing.matchers options.
This example adds:
app.status name, a locator for an application’s systemd servicetoBeOperational, a matcher that checks that service
Define The Extension
Create testing/app.nix:
{ inputs, ... }:
{
perSystem = { ... }: {
testing.fixtures.app = inputs.nix-test.lib.mkFixture (
{ machine, ... }:
{
status = name:
inputs.nix-test.lib.mkLocator {
type = "appStatus";
node = machine.name;
service = "${name}.service";
description = "application ${name}";
};
}
);
testing.matchers.toBeOperational = inputs.nix-test.lib.mkMatcher {
accepts = [ "appStatus" ];
run = { machine, expect, ... }: target:
(expect (machine.service target.service)).toBeActive;
};
};
}
mkFixture receives the complete runtime fixture set. Its returned value is
available under the configured name, so this factory creates the app fixture.
mkLocator gives the target a distinct type. The matcher’s accepts list
limits toBeOperational to that type, and its run function composes the built-in
service locator and matcher.
Import The Extension
Import the extension beside Nix Test in flake.nix:
imports = [
inputs.nix-test.flakeModules.default
./testing/app.nix
];
Use It In A Test
Create tests/app.test.nix:
{ expect, ... }:
{
test."starts the API" = { app, machine }: [
(machine.configure {
modules = [
{
systemd.services.api = {
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = "touch /run/api-ready";
};
}
];
})
(expect (app.status "api")).toBeOperational
];
}
expect is imported at module scope. app and machine are runtime fixtures,
so the test requests them in its callback.
Invalid combinations fail during Nix evaluation. For example,
(expect (machine.file "/run/api-ready")).toBeOperational is rejected because a
file locator is not an appStatus target.
See the Core API for the exact mkFixture, mkLocator,
and mkMatcher signatures.
Running Tests
Every named test is exposed as a check for its system.
Run One Test
nix build '.#checks.x86_64-linux."shows greeting"' --no-link -L
--no-link avoids creating a result symlink. -L streams the test log.
Run All Tests
nix flake check
Use nix flake show to list generated check names.
Without Flake-parts
Pass tests directly to lib.mkTests:
let
system = "x86_64-linux";
pkgs = inputs.nixpkgs.legacyPackages.${system};
in {
checks.${system} = inputs.tests.lib.mkTests {
inherit pkgs;
test = (import ./tests/hello.test.nix { inherit pkgs; }).test;
};
}
fixtures and matchers accept the same plugin values as their flake-parts
options.
Without Flakes
With Nix Test available at a local path:
# tests.nix
{ pkgs ? import <nixpkgs> { }, nix-test ? ./vendor/nix-test }:
(import "${nix-test}/core/mk-tests.nix") {
inherit pkgs;
test = (import ./tests/hello.test.nix { inherit pkgs; }).test;
}
Run one test with:
nix-build tests.nix -A 'shows a greeting'
Interactive Machine Tests
Machine checks expose the standard NixOS interactive driver:
nix build \
'.#checks.x86_64-linux."service starts".driverInteractive' \
-o result-driver
./result-driver/bin/nixos-test-driver
Continue with Debugging for VM shells, SSH, port forwarding, and persistent machine state.
Run From Neovim
neotest-nix discovers Nix Test
declarations directly from *.test.nix files and maps each test."name" to
its generated checks.${system}."name" output. Tests can therefore be browsed
and run through Neotest without
redeclaring them in flake.nix or enabling evaluated check discovery.
With NVF, add the adapter and its dependencies:
{
vim = {
extraPackages = [ pkgs.nix ];
treesitter = {
enable = true;
grammars = [ pkgs.vimPlugins.nvim-treesitter.grammarPlugins.nix ];
};
extraPlugins = {
neotest.package = pkgs.vimPlugins.neotest;
nvim-nio.package = pkgs.vimPlugins.nvim-nio;
neotest-nix = {
package = inputs.tests.packages.${system}.neotest-nix;
after = [
"neotest"
"nvim-nio"
];
setup = ''
require("neotest").setup({
adapters = {
require("neotest-nix"),
},
})
'';
};
};
};
}
Open a *.test.nix file and Neotest’s summary to browse its tests, then run one
test or the whole file with the usual Neotest commands. The package exported by
Nix Test applies the source-discovery patch until it is available in an upstream
neotest-nix release. See the
neotest-nix repository for adapter
options and keymap examples.
Debugging
Start with one focused check and stream its log:
nix build '.#checks.x86_64-linux."shows ready"' --no-link -L
Replace the system and test name with values from your flake. Use
nix flake show when you are unsure of the generated check name.
Inspect Terminal State
Add terminal.print or machine.print after the interaction you want to
inspect:
[
(terminal.press "<enter>")
terminal.print
]
Text failures include the missing text and visible terminal state. Region failures include both expected and observed cells. Read a completed build log with:
nix log /nix/store/<test-derivation>
Run A Machine Test Interactively
NixOS machine checks expose the standard interactive test driver. Build it instead of the test result:
nix build \
'.#checks.x86_64-linux."shows ready".driverInteractive' \
-o result-driver
./result-driver/bin/nixos-test-driver
This opens a Python prompt without running the generated test automatically. Start every configured VM:
start_all()
For one default machine, use machine. Named machines are also available by
their generated Python variables. Useful driver commands include:
machine.succeed("systemctl status example.service")
machine.execute("example-command")
machine.wait_for_unit("example.service")
machine.get_unit_info("example.service")
machine.journalctl("-u example.service")
Exit the Python prompt with Ctrl-D. The interactive driver is for exploration;
the declarative Nix Test case remains the source of truth.
Open A Shell In A VM
After start_all(), attach directly to the guest shell:
machine.shell_interact()
Use Ctrl-D to leave the guest shell and return to the Python driver prompt.
This is the fastest way to inspect files, run systemctl, or try a command in
the exact VM built for the test.
SSH Into A Test VM
For access from a separate host terminal, generate a disposable key beside the test configuration:
ssh-keygen -t ed25519 -N '' -f ./test-key
Keep test-key out of version control. Then enable OpenSSH and forward a local
host port to the guest:
(machine.configure {
modules = [{
services.openssh.enable = true;
users.users.root.openssh.authorizedKeys.keyFiles = [ ./test-key.pub ];
networking.firewall.allowedTCPPorts = [ 22 ];
virtualisation.forwardPorts = [{
from = "host";
host.address = "127.0.0.1";
host.port = 2222;
guest.port = 22;
}];
}];
})
Start the interactive driver and VMs, then connect from another terminal:
ssh -i ./test-key -p 2222 root@127.0.0.1
Keep the private key out of the Nix store and repository. The public key may be
part of the test configuration. If port 2222 is already in use, choose another
host port. If the guest firewall is enabled, port 22 must be allowed.
Forward A Service Port
The same virtualisation.forwardPorts option exposes an HTTP server, database,
or debugger to a host-side client:
virtualisation.forwardPorts = [{
from = "host";
host.address = "127.0.0.1";
host.port = 8080;
guest.port = 80;
}];
networking.firewall.allowedTCPPorts = [ 80 ];
Keep the interactive driver running, then open http://127.0.0.1:8080 or use a
host-side client. Forwarding uses QEMU’s user networking, supports IPv4, and
applies only while the VM is running.
Use different host ports for multiple named machines:
(machines.configure {
server.modules = [{
virtualisation.forwardPorts = [{
from = "host";
host.address = "127.0.0.1";
host.port = 8081;
guest.port = 80;
}];
}];
client.modules = [ ];
})
Preserve VM State
Interactive drivers reset VM disks by default. Pass --keep-machine-state when
you need changes to survive a driver restart:
./result-driver/bin/nixos-test-driver --keep-machine-state
Delete the state only when you intentionally want a clean VM. A normal Nix Test build always starts from its declared configuration and does not rely on this debug state.
Common Failures
| Symptom | What to inspect |
|---|---|
| Text assertion times out | Add terminal.print or machine.print immediately before it |
| Service never becomes active | Use machine.journalctl("-u name.service") interactively |
| Command works manually but not in the test | Check its package is in the VM or use an explicit store path |
| Host cannot reach a forwarded port | Check the host port, guest firewall, service bind address, and that the driver is still running |
| SSH rejects the key | Check the public key module path, private key permissions, forwarded port, and target user |
| VM changes disappear | Start the driver with --keep-machine-state, or declare the change in machine.configure |
Avoid Sleeps
Assertions already retry until the configured timeout. Synchronize on visible or semantic state instead of adding delays:
(expect (terminal.getByText "ready")).toBeVisible
(expect (machine.service "example.service")).toBeActive
((expect (machine.http.get "http://example.test/health")).toHaveStatus 200)
Use machine.command for one-shot commands. Use machine.run when a command has
side effects and you need to assert against its saved result. Retrying a
side-effecting command can hide bugs or perform the operation more than once.
Upstream Driver Reference
Nix Test’s machine backend compiles actions to the standard NixOS test driver. The upstream NixOS Tests manual documents the driver and VM model. Its dedicated Running NixOS tests interactively section covers the underlying interactive workflow in more detail.
API Reference
The generated API reference is grouped by public interface:
- Core API covers test declarations, plugins, and plain-flake usage.
- Terminal and Machine API covers the shared terminal interface, workspaces, and machine-only configuration and command assertions.
- Fixture API covers semantic fixtures, locators, and actions.
- Assertion API lists matcher signatures and accepted targets.
API pages are generated from documentation beside the Nix implementation. See Contributing to the Docs before editing the reference.
Core API
lib.fixtures
inputs.tests.lib.fixtures { inherit pkgs; }
Resolves the complete built-in fixture set for advanced integrations.
Most projects should use lib.mkTests or the flake-parts module instead.
lib.mkFixture
inputs.tests.lib.mkFixture ({ terminal, filesystem, ... }: {
open = file: [
(filesystem.writeFile file "")
(terminal.open file)
];
})
Registers a fixture factory. The factory receives the recursive fixture set and returns the value exposed to test callbacks.
lib.mkLocator
inputs.tests.lib.mkLocator {
type = "appStatus";
inherit name;
}
Creates a typed locator for custom fixtures and matchers. The type identifies
compatible matchers; all other attributes hold locator-specific data.
lib.mkMatcher
inputs.tests.lib.mkMatcher {
accepts = [ "appStatus" ];
run = { expect, ... }: target:
(expect (inputs.tests.lib.mkLocator {
type = "terminalText";
text = target.status;
})).toBeVisible;
}
Creates a fixture-aware matcher factory. accepts lists valid target types;
omit it for a matcher that accepts any tagged action or locator.
Invalid targets fail during Nix evaluation before a runner is built. Compose
matchers from runtime-backed locators and matchers unless a runner explicitly
supports the custom action type.
lib.mkTests
inputs.tests.lib.mkTests {
inherit pkgs test;
fixtures = { };
matchers = { };
}
Converts an attribute set of fixture callbacks into derivations suitable for
checks.${system}. Attribute names become check names. fixtures and
matchers use the same plugin format as the flake-parts module and default to
empty attribute sets. Each test must be a callback that receives only the
fixtures it requests. Reserve test.configure for suite-wide defaults.
lib.test.step
Plain-flake callers use lib.test.step name actions to create named
steps. Flake-parts users receive the same function as test.step.
test
test."shows greeting" = { terminal }: [
(terminal.open pkgs.hello)
(expect (terminal.getByText "Hello")).toBeVisible
];
test."shared machine" = { machine }: {
test.step."service starts" = [
(expect (machine.service "example.service")).toBeActive
];
};
A mergeable attribute set of integration-test fixture callbacks. Attribute
names become check names. A test callback may return an action list or a
test.step.<name> attribute set of named subtests. test and expect are
module arguments; runtime fixtures are callback arguments. test.configure
is reserved for suite-wide configuration.
test.configure
test.configure = {
timeout = 30;
terminal = {
columns = 80;
rows = 24;
};
};
Configures every test declared in the current perSystem scope. timeout
controls standalone terminal assertion retries and defaults to 15 seconds.
Standalone terminal dimensions default to 140 columns by 42 rows.
test.step
Groups actions into a named step inside a test. The step appears as a nested subtest in the test log, making longer scenarios easier to read and debug.
A test callback can alternatively return a test.step.<name> = actions
attribute set for declarative top-level steps. This function remains available
inside action lists for nested steps.
Usage
test.step "service becomes usable" [
(expect (machine.service "example.service")).toBeActive
((expect (machine.http.get "http://localhost/health")).toHaveStatus 200)
]
Arguments
name: Name shown in the test log.actions: Ordered list of actions in the step.
Returns an action that can be placed in a test’s action list. Steps may contain other steps.
testing.fixtures
testing.fixtures.app = inputs.tests.lib.mkFixture ({ terminal, filesystem, ... }: {
open = file: [
(filesystem.writeFile file "")
(terminal.open file)
];
});
A mergeable attribute set of fixtures created with lib.mkFixture. Each
factory receives the complete fixture set and returns the value injected
under its attribute name. Built-in fixture names cannot be replaced.
testing.matchers
testing.matchers.toBeReady = inputs.tests.lib.mkMatcher {
accepts = [ "appStatus" ];
run = { expect, ... }: target:
(expect (inputs.tests.lib.mkLocator {
type = "terminalText";
text = target.status;
})).toBeVisible;
};
A mergeable attribute set of custom matcher factories. Each factory
receives the complete fixture set and returns a matcher function exposed
on the value returned by expect target. Use lib.mkMatcher to validate
targets. Built-in matcher names cannot be replaced.
Terminal and Machine API
expect (terminal and machine)
These matchers receive locators created by either terminal interface implementation. They retry until they pass or the active backend times out.
toBeVisible
(expect (terminal.getByText "ready")).toBeVisible
(expect (machine.getByText "ready")).toBeVisible
Passes when the locator’s text appears in the visible terminal.
toEqual
(expect (terminal.getByRegion region)).toEqual expected
(expect (machine.getByRegion region)).toEqual expected
Passes when the selected terminal text equals expected. Trailing blank-cell
whitespace is ignored, as are the surrounding newlines in a Nix multiline
string.
getByRegion
terminal.getByRegion {
left = 0;
top = 0;
width = 80;
height = 10;
}
machine.getByRegion {
left = 0;
top = 0;
width = 80;
height = 10;
}
Selects a rectangle of terminal cells for use with (expect region).toEqual. Trailing
blank-cell whitespace is omitted from the selected text.
left and top default to 0; width and height default to the remaining
visible grid. Coordinates are zero-based.
getByText
terminal.getByText text
machine.getByText text
Locates literal text in the visible terminal for use with
(expect text).toBeVisible on either backend.
open
terminal.open commandOrPackage
machine.open commandOrPackage
Starts a command in a persistent terminal with the test filesystem root as its
working directory. Pass a package to resolve its executable with `lib.getExe`,
or a command string when arguments are needed. Only one terminal process is
active per test.
press
terminal.press keys
machine.press keys
Sends keys to the active terminal. Both backends recognize <leader>,
<space>, <esc>, <escape>, <enter>, <cr>, <tab>, and <bs>.
The machine backend additionally recognizes <c-w>.
print
terminal.print
machine.print
Prints the current terminal grid to the test log.
expect (machine command)
Machine matchers receive targets created by machine.command.
toEventuallySucceed
Retries the command until it succeeds or times out.
(expect (machine.command "test -e /run/example-ready")).toEventuallySucceed
toFail
Retries the command until it fails or the NixOS test driver times out.
(expect (machine.command "pgrep forbidden-process")).toFail
machine.configure
machine.configure {
modules = [ module ];
}
Selects the NixOS machine backend and configures its NixOS modules. modules
defaults to an empty list.
machine.getByPattern
machine.getByPattern "P.*ready"
Locates a regular expression in the visible machine terminal.
Fixture API
browser
Browser actions run through Playwright on the machine backend.
Access a bound browser through machine.browser.
machine.browser.start
machine.browser.open url
machine.browser.getByRole role { name ? ""; }
machine.browser.getByLabel label
machine.browser.getByPlaceholder placeholder
machine.browser.getByText text
machine.browser.getByTitle title
Locator methods return browser element locators. Action methods execute once.
container
container.locate machine name
(machine.container name).start
(machine.container name).stop
(machine.container name).restart
(machine.container name).run command
locate returns a declarative NixOS container locator. The remaining methods
create one-shot actions for that container.
desktop
desktop.getByWindow machine title
desktop.getByText machine text
desktop.press machine keys
desktop.type machine text
desktop.screenshot machine name
Desktop tests use the machine backend. Locators can be passed to visibility matchers; input and screenshot methods execute once.
expect
Call expect with a locator to obtain its built-in and custom matchers.
Matcher signatures are listed in the
Assertion API.
filesystem
filesystem.path machine path
filesystem.file machine path
filesystem.directory machine path
filesystem.symlink machine path
filesystem.mount machine path
filesystem.jsonFile machine path
filesystem.root
filesystem.writeFile relativePath content
filesystem.makeDirectory relativePath
filesystem.copyFile source relativeDestination
filesystem.copyTree source relativeDestination
filesystem.symlinkFile target relativeLinkPath
filesystem.setMode relativePath mode
filesystem.remove relativePath
Locator methods observe paths on a supplied machine. Mutation methods prepare
files under an isolated runtime root shared by terminal and default-machine
tests. Relative paths cannot be empty, absolute, ., or contain ...
http
http.get machine request
http.getJson machine request
http.request machine method request
http.send machine {
method = "POST";
url = "http://localhost/items";
headers = { };
body = null;
saveAs = "create-item";
}
A request may be a URL string or { url, headers ? { }, body ? null }.
Observation methods return retryable locators and must be idempotent.
send executes once and stores a command result under saveAs.
machine and machines
machine.configure { modules ? [ ]; }
machines.configure { server.modules = [ ]; client.modules = [ ]; }
machines.node name
machine.command command
machine.run { command, saveAs }
machine.service name
machine.userService user name
machine.file path
machine.directory path
machine.symlink path
machine.mount path
machine.user name
machine.container name
machine.endpoint.tcp portOrOptions
machine.endpoint.udp portOrOptions
machine.http.get request
machine.browser.start
machine.browser.open url
machine.browser.getByText text
machine.open commandOrPackage
machine.press keys
machine.print
machine.getByText text
machine.getByPattern pattern
machine.getByRegion { left ? 0, top ? 0, width ? null, height ? null }
machine.start
machine.shutdown
machine.reboot
machine.crash
machine addresses the default VM. machines.node name returns the same
per-machine interface for a named VM. Lifecycle properties and print are
actions, not functions. The default VM needs no explicit configuration.
Use machine.configure to add NixOS modules and machines.configure to define
named-machine topology.
network
network.endpoint {
from = machine;
host = "server";
port = 8080;
transport = "tcp";
}
network.partition { left = [ server ]; right = [ client ]; }
network.heal { left = [ server ]; right = [ client ]; }
host defaults to 127.0.0.1 and transport defaults to tcp.
Ports must be integers from 1 through 65535. Partition and heal execute once.
result
result.command name
result.stdout name
result.exitCode name
These methods locate results saved by machine.run or http.send. Assertions
inspect the saved value without repeating the original side effect.
service
(machine.service name).start
(machine.service name).stop
(machine.service name).restart
(machine.service name).reload
(machine.service name).logs
Targets come from machine.service, machine.userService, or user.service.
Lifecycle methods execute once; logs returns a matcher target.
user
user.locate machine name
user.run target command
user.service target serviceName
locate returns a user locator. run executes a command once as that user,
while service returns a user-level service locator.
Assertion API
Browser
(expect element).toBeVisible
(expect element).toBeEnabled
(expect element).toHaveValue expected
(expect machine.browser).toHaveLocation expectedSuffix
(expect machine.browser).toHaveTitle expectedTitle
Browser assertions use Playwright’s auto-waiting until timeout.
Containers
(expect container).toBeRunning
(expect container).toBeStopped
Desktop
(expect target).toBeVisible
Accepts desktop window and desktop text locators.
Filesystems and presence
(expect target).toExist
(expect target).toBeAbsent
(expect path).toBeFile
(expect path).toBeDirectory
(expect path).toBeSymlink
(expect path).toBeMounted
(expect target).toHaveContent expected
(expect target).toPointTo expected
(expect target).toHaveMode expected
(expect target).toBeOwnedBy user
Presence matchers accept path and user locators where applicable. Filesystem observations retry until the configured timeout.
HTTP
(expect response).toHaveStatus expected
(expect response).toHaveBody expected
(expect response).toHaveHeader { name, value }
(expect response).toHaveJsonValue { path, value }
HTTP matchers repeat the request until it matches or times out. Use them only
with idempotent requests; use http.send for mutating requests.
Network endpoints
(expect endpoint).toBeReachable
(expect endpoint).toBeUnreachable
Endpoint observations retry until the configured timeout.
Saved results
(expect result).toHaveExitCode expected
(expect result).toHaveStdout expected
(expect result).toContainStdout expected
Result assertions inspect values saved by machine.run or http.send and do
not repeat the original operation.
Services and command output
(expect service).toBeActive
(expect service).toBeInactive
(expect service).toBeFailed
(expect service).toHaveLog text
(expect target).toContain text
(expect machineCommand).toSucceed
Service targets may be system or user services. toContain accepts service
logs and machine commands. All observations retry until timeout.
Users
(expect user).toBeMemberOf group
Use toExist and toBeAbsent for user existence.
expect (machine command)
Machine matchers receive targets created by machine.command.
toEventuallySucceed
Retries the command until it succeeds or times out.
(expect (machine.command "test -e /run/example-ready")).toEventuallySucceed
toFail
Retries the command until it fails or the NixOS test driver times out.
(expect (machine.command "pgrep forbidden-process")).toFail
expect (terminal and machine)
These matchers receive locators created by either terminal interface implementation. They retry until they pass or the active backend times out.
toBeVisible
(expect (terminal.getByText "ready")).toBeVisible
(expect (machine.getByText "ready")).toBeVisible
Passes when the locator’s text appears in the visible terminal.
toEqual
(expect (terminal.getByRegion region)).toEqual expected
(expect (machine.getByRegion region)).toEqual expected
Passes when the selected terminal text equals expected. Trailing blank-cell
whitespace is ignored, as are the surrounding newlines in a Nix multiline
string.
Architecture
Nix test declaration
|
v
recursive fixture set
|
+-- terminal fixture --+
| +-- terminal fixture interface
+-- machine fixture ---+ (open, press, print, getByText, getByRegion)
| + semantic system-state fixtures
+-- named machines -------- per-node modules, actions, and locators
v
ordered action values
|
+-- no machineConfigure action --> JSON action document
| --> pexpect/pyte PTY runner
|
+-- machineConfigure action ----> NixOS modules and test-driver script
--> NixOS test driver
Nix functions construct ordered action values. A test containing the action
produced by machine.configure or machines.configure selects the machine
backend; every other test uses the standalone terminal backend. Terminal checks encode their actions as JSON and
execute generated Python runtime modules in a derivation. Machine checks add the
configured modules to a NixOS test and render the remaining actions into its
test-driver script.
Consequently, any executable machine-backed action requires one of those configuration actions. Merely constructing a machine locator does not select a backend.
Test orchestration and plugin resolution live under core. Every built-in
fixture owns a top-level directory containing its fixture, matchers, locators,
runtime support, and colocated tests as applicable. terminal and machine
provide the two execution boundaries; semantic fixtures such as service,
filesystem, http, and browser build on the machine boundary without being
implemented inside it.
The built-in fixture directories are:
browser/ container/ desktop/ expect/
filesystem/ http/ machine/ network/
result/ service/ step/ terminal/
user/ filesystem/
fixture.nix defines the injected fixture. A fixture directory may additionally
contain locators.nix, matchers.nix, runtime modules, and colocated tests.
machines remains in machine/fixture.nix because it is the named-node view of
the same NixOS driver backend, not an independent fixture boundary.
Terminal and machine are registered through the same fixture-factory mechanism
as user plugins. Fixture factories are evaluated against one recursive fixture
set, then locators are merged into their owning fixtures. At that boundary,
both built-in backends are checked for the terminal fixture interface: open,
press, print, getByText, and getByRegion. Machine extends that interface
with NixOS configuration, command assertions, and a pattern locator. Built-in
fixture names are reserved; custom fixtures join the same recursive set.
The contract specifies the public operation names and whether each operation is
callable or an action. During evaluation, the framework checks that both
fixtures provide the required names, that callable operations are functions,
and that print is an action. Each backend still implements observation through
its native runtime: the standalone backend reads a pyte cell grid, while the
machine backend captures a tmux pane through the NixOS test driver.
Custom fixtures, locators, and matcher factories are created with
lib.mkFixture, lib.mkLocator, and lib.mkMatcher. Actions and the runner
protocol remain internal. These constructors validate value shape and matcher
target compatibility during Nix evaluation. The terminal fixture interface is
an internal contract for the two built-in backends; custom fixtures are not
required to implement it.
Semantic machine locators compile to retrying NixOS driver predicates. Actions such as restart, reboot, input, and file staging execute once; only matcher observations retry. Named steps compile to nested driver subtests.
This separation keeps test declarations stable while allowing each backend to use the runtime best suited to its boundary.
Related Infrastructure
The library composes existing Nix infrastructure rather than replacing it:
- NixOS tests
nixos/lib/test-drivernixos/lib/testingpkgs.testers.runNixOSTest
Contributing to the Docs
Narrative guides live in docs/src. A Nix expression generates the API
reference from /** @doc name ... */ comments beside public declarations.
After changing an API comment, regenerate the reference:
nix run .#generate-docs
Build the site locally:
nix build .#docs
The generated site is available through the result symlink. Run all checks
before submitting a change:
nix flake check
Backend *.test.nix files are black-box integration tests. They use public
fixtures, locators, and matchers to verify behavior visible from a terminal or
NixOS machine. Core *.test.nix files are unit-style evaluation checks for
builders, validation errors, and test compilation edge cases.
The documentation check fails when the committed API pages are stale or when the site cannot be built.