nixosTest

Updated

July 30, 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.


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/90daysofx/02-nixos/day64

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.