引言
单元测试保证函数正确,但系统配置的 bug 要跑起来才知道——服务起没起、端口通不通、两个服务会不会冲突。NixOS 的 nixosTest 框架把「一个完整系统」装进虚拟机,在干净环境里启动配置并断言结果:改配置前先跑系统级测试,比「改了直接上生产再救火」靠谱一个量级。本文从 nixosTest 基础到 CI 落地,覆盖系统级测试的完整工程。
前置:/nixos-configuration/(配置基础)、/nixos-services-containers/(服务声明)、/nix-flakes/(flake 与 checks)。
目录
- 1. 为什么需要系统级测试
- 2. nixosTest 基础:一个最小 VM 测试
- 3. test script 与断言
- 4. 多机器测试与网络
- 5. 检查服务状态与端口
- 6. runInLinuxVM:单包构建测试
- 7. 参数化测试与模块测试
- 8. 在 CI 里跑 NixOS 测试
- 9. 常见坑与调试
- 10. 速查表与一句话记忆
- 延伸阅读
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 测试库参考
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。