NixOS 虚拟机与集成测试:nixosTest 框架与系统级验证

NixOS 虚拟机与集成测试实战:为什么需要系统级测试、nixosTest 框架、test driver 与机器声明、VM 测试的断言(成功条件/服务状态/端口)、runInLinuxVM 单包测试、模块测试与参数化、CI 里跑 NixOS 测试、常见坑与调试。

引言

单元测试保证函数正确,但系统配置的 bug 要跑起来才知道——服务起没起、端口通不通、两个服务会不会冲突。NixOS 的 nixosTest 框架把「一个完整系统」装进虚拟机,在干净环境里启动配置并断言结果:改配置前先跑系统级测试,比「改了直接上生产再救火」靠谱一个量级。本文从 nixosTest 基础到 CI 落地,覆盖系统级测试的完整工程。

前置:/nixos-configuration/(配置基础)、/nixos-services-containers/(服务声明)、/nix-flakes/(flake 与 checks)。


目录


1. 为什么需要系统级测试

1.1 系统配置的 bug 是「跑出来」的

单元测试:验证函数逻辑 ✓
系统测试:验证"配置组成系统后能不能跑" ✗ ← 常被跳过
典型翻车:两个服务抢同一端口、systemd 依赖顺序错、防火墙挡了自己
这类问题静态看不出来,只有启动真实系统才暴露

1.2 NixOS 测试的优势

# 干净环境:每个测试从空系统启动,可复现
# 真系统:完整 systemd + 配置,不是 mock
# 幂等快速:VM 每次全新,失败即回滚重来
# 与 CI 结合:每笔配置改动都跑系统级验证

记忆:系统配置的 bug 要启动真实系统才暴露(端口冲突/依赖顺序/防火墙);nixosTest 在干净 VM 里启动完整配置并断言——单元测逻辑、系统测配置。


2. nixosTest 基础:一个最小 VM 测试

2.1 一个最小测试

{ pkgs, ... }:
pkgs.testers.runNixOSTest ({
  # 被测系统:这是"我们自己的配置"
  nodes.machine = {
    services.nginx.enable = true;
  };

  # 测试脚本:在 VM 里跑断言
  testScript = ''
    start_all()
    machine.wait_for_unit("nginx.service")
    machine.succeed("curl -f http://localhost/")
  '';
})

2.2 运行测试

# 直接跑(会进 VM)
nix-build -E 'with import <nixpkgs> {}; callPackage ./test.nix {}'
# 在 flake 里跑
nix build .#checks.${system}.my-test

记忆:nixosTest 最小形态 = nodes(被测系统)+ testScript(VM 内断言);start_all() 启动、wait_for_unit 等服务、succeed 断言命令成功。


3. test script 与断言

3.1 常用断言 API

start_all()             # 启动所有机器
machine.wait_for_unit("xxx.service")   # 等服务进 active
machine.wait_for_open_port(80)         # 等端口可连
machine.succeed("cmd")                 # 断言命令退出码为 0
machine.fail("cmd")                    # 断言命令失败(退出码非 0)
machine.wait_until_succeeds("cmd")     # 轮询直到成功
machine.wait_until_fails("cmd")        # 轮询直到失败

3.2 一个带轮询的断言

testScript = ''
  start_all()
  machine.wait_for_unit("postgresql.service")
  machine.wait_until_succeeds(
    "pg_isready -h localhost -p 5432"
  )
  machine.succeed(
    "createdb testdb && psql testdb -c 'select 1;'"
  )
'';

记忆:断言四件套——wait_for_unit 等服务、wait_for_open_port 等端口、succeed 断言成功、fail 断言失败;异步就绪用 wait_until_succeeds 轮询。


4. 多机器测试与网络

4.1 声明多台机器

nodes = {
  server = { services.nginx.enable = true; };
  client = { environment.systemPackages = [ pkgs.curl ]; };
};

4.2 机器间网络通信

testScript = ''
  start_all()
  server.wait_for_unit("nginx.service")
  client.wait_until_succeeds("curl -f http://server/")
  # 每台机器按名字访问,测试机间互联
'';

4.3 带防火墙的验证

# 场景:验证"服务器防火墙是否挡住了不该进的连接"
# 在 client 上断言端口不可达 → 防火墙配置的测试化验证

记忆:多机器测试用 nodes 声明多台、按名字在脚本里互相访问——server/client 架构、防火墙策略、网络隔离都能在 VM 里验证。


5. 检查服务状态与端口

5.1 深入断言服务

testScript = ''
  start_all()
  machine.wait_for_unit("sshd.service")
  # 查看服务状态与日志
  machine.succeed("systemctl is-active sshd")
  print(machine.succeed("systemctl status sshd | head -20"))
  # 端口与监听
  machine.wait_for_open_port(22)
  machine.succeed("ss -tlnp | grep :22")
  # 配置改动生效验证
  machine.succeed("sshd -T | grep -i passwordauthentication")
'';

5.2 断言「服务不应启动」

# 有时要断言一个被禁用/被降级的服务不存在
machine.fail("systemctl is-active some-unused.service")

记忆:深入断言 = is-active 查服务状态 + 端口监听查询 + 配置生效验证(sshd -T 等)——不止「起了」,还要「状态/配置对不对」。


6. runInLinuxVM:单包构建测试

6.1 不是所有测试都要整套系统

只想测「某个包能不能在干净环境跑」用 runInLinuxVM——把包的构建+运行包进 VM:

{ pkgs, ... }:
let
  testScript = pkgs.runInLinuxVM (pkgs.writeScript "test.sh" ''
    # 在 VM 里测 mypkg 的行为
    ${pkgs.mypkg}/bin/mypkg --version
    ${pkgs.mypkg}/bin/mypkg selftest
  '');
in
testScript

6.2 场景

# 包依赖系统能力(systemd、/etc 文件、内核特性)时
# 比纯 derivation 构建测试更接近真实运行
# 代价:比纯 build 慢(要启动 VM)

记忆:runInLinuxVM 把单个包的运行放进 VM 测试——包依赖系统能力(systemd/内核/文件系统)时用它,比纯构建测试更真实,代价是慢。


7. 参数化测试与模块测试

7.1 参数化:同一测试跑多套配置

# 用函数把配置差异参数化
{ pkgs, version ? "stable" }:
pkgs.testers.runNixOSTest ({
  nodes.machine = {
    services.myapp.version = version;   # 参数传入
  };
  testScript = ''
    machine.succeed("myapp --version | grep ${version}")
  '';
})

7.2 模块测试:测自定义 NixOS 模块

# 你的团队可能写了自己的 NixOS 模块(options + 服务)
# 写一个 test.nix 声明模块 + 最小配置 + 断言
# 模块交付时附带测试 → 使用方也能复用

记忆:参数化测试用函数传入配置差异(多版本/多分支跑同一断言);自定义 NixOS 模块交付时附带 runNixOSTest 测试,使用方可直接复用。


8. 在 CI 里跑 NixOS 测试

8.1 挂进 flake checks

{
  checks.${system} = {
    my-config-test = pkgs.testers.runNixOSTest { ... };
    # 每个测试一个 attr,`nix flake check` 全跑
  };
}

8.2 CI 流程建议

# 1) PR 改动配置 → 触发相关测试
# 2) 与 Cachix 配合:测试构建产物缓存,CI 快速复用
# 3) 失败即阻断:系统级测试不过不合并
# 4) 分层:核心服务测试每 PR 跑,全量测试 nightly

记忆:CI 集成 = 测试挂进 flake checks(nix flake check 全跑)+ Cachix 缓存产物 + 失败阻断合并;核心服务每 PR 跑、全量 nightly。


9. 常见坑与调试

  • 忘 start_all():脚本直接操作未启动的机器会超时——先 start_all() 再操作。
  • 服务还没 ready 就断言:用 wait_for_unit/wait_until_succeeds 轮询,别直接 succeed。
  • VM 慢导致的超时:默认超时不够可调 testScriptTimeout。
  • 断言语义错:succeed 要的是退出码 0,grep 无匹配时退出 1——别把「无匹配」当成功。
  • 只测「能起」不测「对不对」:服务起来了但配置没生效,加配置生效断言(如 sshd -T)。
  • 测试与配置脱节:测试要引用真实配置模块,别复制一份「测试专用配置」。

记忆:调试系统测试先查三件事——start_all() 启动了吗、等服务 ready 用轮询了吗、断言语义对了吗;测试要测真实配置别复制一份假的。


10. 速查表与一句话记忆

API用途一句话
start_all()启动机器操作前先启动
wait_for_unit等服务 active异步就绪轮询
wait_for_open_port等端口可连服务真在听
succeed / fail断言退出码成功/失败判断
wait_until_succeeds轮询到成功慢任务等待
runInLinuxVM单包 VM 测试包依赖系统时用

一句话记忆:NixOS 系统级测试用 runNixOSTest——nodes 声明被测系统(服务/配置)、testScript 在 VM 里跑断言:start_all() 启动、wait_for_unit 等服务、wait_for_open_port 等端口、succeed/fail 断言退出码、异步就绪用 wait_until_succeeds 轮询;多机器用 nodes 声明多台按名字互访,能验证 server/client 架构与防火墙策略;单包测试用 runInLinuxVM(依赖系统能力时);参数化测试把差异做成函数参数、自定义 NixOS 模块附带测试;CI 里挂进 flake checks(nix flake check)+ Cachix 缓存 + 失败阻断——「系统配置的 bug 要启动真实系统才暴露」,把系统级验证变成每笔配置改动的前置门槛。


延伸阅读

  • /nixos-configuration/ — 声明式系统配置
  • /nixos-services-containers/ — systemd 服务与容器
  • /nix-flakes/ — flake 与 checks
  • /nix-ci-cachix/ — CI 与二进制缓存
  • /nixos-network-firewall/ — 网络与防火墙配置
  • [[testing]] — 测试方法论
  • NixOS 测试手册
  • NixOS 测试库参考

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nix」更多文章

  1. Nix 源码获取与 fetchers:fetchFromGitHub、哈希与私有源
  2. Nix 构建调试与错误排查:常见错误、trace 与诊断手段
  3. 可复现与封闭构建:固定哈希、网络隔离与确定性