Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

FixtureUse it to
terminalOpen and interact with a local terminal application
machineConfigure and control one NixOS VM
machinesConfigure and control named NixOS VMs
filesystemPrepare mutable files and observe machine paths
serviceStart, stop, restart, and reload services
networkLocate endpoints and partition named machines
httpObserve idempotent requests or send one request once
userLocate users and run commands as a user
containerLocate and control declarative NixOS containers
browserInteract through accessibility-oriented browser locators
desktopLocate windows and text and send desktop input
resultInspect 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:

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 service
  • toBeOperational, 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

SymptomWhat to inspect
Text assertion times outAdd terminal.print or machine.print immediately before it
Service never becomes activeUse machine.journalctl("-u name.service") interactively
Command works manually but not in the testCheck its package is in the VM or use an explicit store path
Host cannot reach a forwarded portCheck the host port, guest firewall, service bind address, and that the driver is still running
SSH rejects the keyCheck the public key module path, private key permissions, forwarded port, and target user
VM changes disappearStart 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.

The library composes existing Nix infrastructure rather than replacing it:

  • NixOS tests
  • nixos/lib/test-driver
  • nixos/lib/testing
  • pkgs.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.