nixosTest

Updated

October 31, 2026

nixosTest

Goal: Write and run a nixosTest VM test that exercises your module (proxy, hardened SSH, or role from the shared modules chapter)—not only a copy-paste hello world.

Why this day exists

nixos-rebuild on a pet proves little about the next pet. nixosTest boots one or more QEMU VMs from your modules, runs shell Python/Perl test scripts, and fails CI when regressions land. It is the highest leverage test type in NixOS land for system behavior.


Theory 1 — What a nixosTest is

NixOS configs (nodes) → build VM images → boot under QEMU → testScript → pass/fail
Piece Role
nodes Attrset of NixOS module stacks
testScript Python (modern) driving VMs
name Test identity
Driver nixos/lib/testing.nix machinery

Requires KVM (or slow emulation) and often builder feature kvm / nixos-test.

Loop engineering: the operating system as SUT

In autonomous agent workflows, the highest leverage loop does not test only an application process—it puts the entire operating system and network topology under test. With runNixOSTest, agents verify multi-node VLAN isolation, firewall drops, and systemd units before deployment, or drop into .#checks.<name>.driverInteractive for live inspection. See AI Agent Sandboxes, Loop Engineering, and Universal Overlays.


Theory 2 — Minimal single-node test

# tests/ssh-hardening.nix
{
  name = "ssh-hardening";
  nodes.machine = { pkgs, ... }: {
    imports = [ ../modules/profiles/hardened.nix ];
    users.users.alice = {
      isNormalUser = true;
      openssh.authorizedKeys.keys = [
        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyForTestOnly not-a-real-key"
      ];
    };
    services.openssh.enable = true;
  };

  testScript = ''
    machine.wait_for_unit("sshd.service")
    machine.succeed("grep -q 'PasswordAuthentication no' /etc/ssh/sshd_config")
    # or sshd -T output depending on version
    machine.succeed("sshd -T | grep -i 'passwordauthentication no'")
  '';
}

Wire into flake:

checks.x86_64-linux.ssh-hardening =
  nixpkgs.lib.nixos.runTest {
    imports = [ ./tests/ssh-hardening.nix ];
    hostPkgs = import nixpkgs { system = "x86_64-linux"; };
    # API: prefer current nixpkgs `runTest` / `nixosTest` helper for 26.05
  };
Note

nixpkgs has migrated helpers (pkgs.testers.runNixOSTest, lib.nixos.runTest, classic nixosTest). Use whatever your 26.05 pin documents; the shape (nodes + testScript) remains the skill.

Classic style still seen in blogs:

import nixpkgs {
  system = "x86_64-linux";
  config = { allowUnfree = false; };
} .lib.nixosSystem { } # not this

# classic:
pkgs.nixosTest {
  name = "example";
  nodes.machine = { ... };
  testScript = ''...'';
}

Theory 3 — testScript essentials

machine.wait_for_unit("multi-user.target")
machine.wait_for_open_port(22)
machine.succeed("true")
machine.fail("false")
machine.copy_from_host("file", "/tmp/file")
print(machine.succeed("uname -a"))

Multi-node:

nodes = {
  server = { ... };
  client = { ... };
};
server.wait_for_unit("caddy.service")
client.succeed("curl -f http://server/")

Network is usually a test VLAN; hostnames often equal node names.


Theory 4 — What to test (high value)

Good Weak
Module assertions about sshd config true only
Service reaches active + port open Only that evaluation succeeds
Client→server HTTP 200 Full browser suites
Firewall rejects unexpected port Entire Kubernetes

Test your contracts from the hardening chapter–38 / 61.


Theory 5 — Running tests

nix build .#checks.x86_64-linux.ssh-hardening -L
# or
nix flake check -L

Interactive debug (when supported by driver):

nix build .#checks… -L
# many setups: ./result/bin/nixos-test-driver with breakpoints in testScript
machine.shell_interact()  # if available in your nixpkgs test driver

Worked example — web role smoke test

# tests/web-role.nix
{
  name = "web-role";
  nodes.server = { ... }: {
    imports = [
      ../modules/profiles/baseline.nix
      ../modules/roles/web.nix
    ];
    roles.web = {
      enable = true;
      domain = "server"; # or localhost style per caddy config
    };
  };
  nodes.client = { pkgs, ... }: {
    environment.systemPackages = [ pkgs.curl ];
  };
  testScript = ''
    server.wait_for_unit("caddy.service")
    server.wait_for_open_port(80)
    client.succeed("curl -f --retry 5 --retry-delay 1 http://server/ | grep -q ok")
  '';
}

Adapt to your actual reverse proxy module from Stage IV.


Lab — multi-step

Suggested workspace: fleet flake tests/; notes in ~/lab/nixos/ch64

Step 1 — Confirm virtualization

ls /dev/kvm && echo kvm-ok || echo "need kvm or nested virt"

Step 2 — Hello test

Minimal machine with environment.etc."t".text = "ok"; and succeed("test -f /etc/t").

Step 3 — Port your module

SSH hardening or web role or custom Stage IV module.

Step 4 — Make it fail once

Break the module; observe red test; fix.

Step 5 — Wire checks

nix flake check runs it (may be heavy—ok).

Step 6 — Document runtime

Wall clock; note CI machine requirements (the CI pipelines chapter).

Step 7 — Multi-node stretch

Client/server only if single-node is green.



Theory 6 — Tests as flake checks

checks.${system}.lab-ssh = self.nixosConfigurations.lab.config.system.build.toplevel; # heavy; optional
# better: dedicated nixosTest derivation
checks.${system}.web-smoke = pkgs.nixosTest { … };

Wiring tests into checks makes nix flake check the Stage VI/CI gate. Keep at least one fast smoke and quarantine long tests behind explicit attrnames.


Theory 7 — Interactive vs pure test runs

nix build .#checks.x86_64-linux.web-smoke -L
# driver interactively (when supported by your setup):
# nix run .#checks.…driverInteractive

Interactive drivers help author tests; CI should run non-interactive builds.


Worked example — wait for unit + port

machine.wait_for_unit("caddy.service")
machine.wait_for_open_port(443)
machine.succeed("curl -k -sS https://127.0.0.1 | grep -q lab")

Prefer wait_for_unit / wait_for_open_port over sleep 30.


Lab — one negative assertion

machine.fail("curl -sS --max-time 2 http://127.0.0.1:5432 || true")
# better: assert postgres not on 0.0.0.0 if that is policy

Encode a security/policy property, not only happy path.


Lab — time the test

time nix build .#checks.x86_64-linux.web-smoke -L

Record duration. Tests that take 20+ minutes will not be run and thus do not protect you—split or slim.

Common gotchas

Symptom / mistake What to do
No /dev/kvm Nested virt; remote builder with kvm; or accept qemu slow
Hang on boot Serial logs; simplify modules; wait_for_unit
DNS names Use node names as hostnames
Testing wrong module version Same flake inputs as deploy
Over-large configs Slim test nodes; don’t import Desktop
Flaky timing wait_for_open_port, retries

Checkpoint

  • One nixosTest green
  • Tests your module behavior
  • Intentional red→green cycle
  • Flake checks wiring
  • KVM/runtime notes written

Commit

git add .
git commit -m "day64: nixosTest for own module"

Write three personal gotchas before continuing.