测试、CI 与发布
第 13 章你在 router.rs 的 #[cfg(test)] 里写过单元测试,第 14 章的网关多半还是靠 curl 手动戳。这一章把验证固化下来:写成测试、交给 持续集成(CI),最后打成一个能拷走的二进制。读完你能回答:
spawn_stub里TcpListener::bind("127.0.0.1:0")的端口0是什么意思,为什么集成测试非它不可;- CI 日志里
cargo test与cargo clippy -- -D warnings各自变红,分别该去改什么; cargo build --release的产物在哪,Cargo.toml里lto、strip两行各自在换什么。
测试放哪:#[test] 就近,tests/ 目录端到端
单元测试与被测代码同住一个文件,只测一个函数的输入输出;标了 #[test] 的函数由 cargo test 就地编译、就地运行。
但单元测试有边界。jackdaw 的价值不在某个函数,而在一层粘合:HTTP 进来 → 解析 JSON → 路由改写模型名 → 发往 provider → 响应连着 x-jackdaw-model 头还回去。这条链横跨多个模块,单测任何一环都答不出"粘在一起对不对"。于是有了 jackdaw/tests/integration.rs:Cargo 约定 tests/ 下每个 .rs 是独立的集成测试箱,只能用 jackdaw 公开的 API——正好扮演一个真实调用者。cargo test 两层都跑;全绿,意味着这条链以真实 HTTP 的方式完整走了一遍。
随机端口上的 stub provider
集成测试的第一个问题:上游 provider 从哪来?真调 OpenAI 要花钱、要密钥、结果还不稳定。答案是本地起一个模拟替身,行为由测试说了算:
async fn spawn_stub(mode: StubMode) -> Stub {
let received: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
// ...
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
Stub { addr, received }
}从 TcpListener::bind 那行看起:端口参数写 0,意思是"不指定,请操作系统从空闲端口里挑一个",下一行 local_addr() 把挑中的地址读回来。tokio::spawn 把 axum 服务器丢进后台任务,测试自己继续往下走。Stub 里还带一个 received:互斥锁包着的数组,stub 每收到一个请求体就记一条——测试结束后翻它,就能断言"上游到底看见了什么"。
为什么非 0 不可?固定端口在测试并行跑时会自己撞自己;更糟的是,它可能撞上你本机恰好真在跑的服务,把真服务错当 stub。
stub 的行为由 StubMode 挑:Echo 回显(改写后的)模型名,Fail 永远返回给定状态码,StreamOk 吐一条完整的 SSE 流,StreamThenDie 吐一块 delta 后掐断连接。配置那边同样干净:make_config 直接拼 TOML 字符串再解析,api_key_env 指向 HOME 这个测试环境必然存在的环境变量——stub 反正不理会 Authorization 头。文件开头的注释写明:流量全不离开 127.0.0.1。
一条请求走完整条链路
#[tokio::test]
async fn order_group_fails_over_to_the_next_member() {
let broken = spawn_stub(StubMode::Fail {
status: StatusCode::INTERNAL_SERVER_ERROR,
body: "upstream exploded",
})
.await;
let healthy = spawn_stub(StubMode::Echo).await;
// ...
let response = post_chat(
gateway,
TOKEN,
r#"{"model":"group/coder","messages":[{"role":"user","content":"hi"}]}"#,
)
.await;
assert_eq!(response.status(), 200);
assert_eq!(response.headers()["x-jackdaw-model"], "healthy/m-b");
}从两次 spawn_stub 看起:一个被设定成永远 500,一个正常回显;省略号处的 make_config 把两者组成 group/coder,order 策略,坏成员在前。#[tokio::test] 给测试一个 Tokio 运行时,函数体里才能 .await(第 7 章);post_chat 用 reqwest 扮演 agent,对网关发真实 HTTP。断言只有两行,含义却是端到端的:200 说明坏成员确实失败、转移确实发生;x-jackdaw-model 指向 healthy/m-b,说明响应来自第二个成员——第 14 章的透明响应头在这里顺手成了取证手段。
更刁钻的一条是 no_failover_after_the_first_byte_honest_failure_instead:dying stub 返回 200、吐出第一个 delta,然后在流中途断线。断言除了"partial"确实到达、错误在流内如实上报,还检查 healthy.received 为空——首字节已出,绝不允许偷偷换人重试。"字节已经离开进程"这种时序,函数级单测造不出来,只有真 TCP 连接能。
两条 workflow,两种守护
第 3 章的四个仲裁者、第 8 章的子命令表在这一节汇合:CI 就是把那些命令搬进服务器自动跑。本地全绿只是你机器上的事实,CI 把它变成服务器上的事实。仓库里有两条 workflow,守护的东西不同:
name: Rust CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install stable Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Run tests
run: cargo test --manifest-path jackdaw/Cargo.toml
- name: Run clippy
run: cargo clippy --manifest-path jackdaw/Cargo.toml -- -D warnings读法:先看 on 块——push 到 main 和所有 PR 都触发,所以 PR 阶段它就是门禁。四步:拉代码、装 stable 工具链、跑测试、跑 clippy。--manifest-path 是因为 Cargo.toml 在 jackdaw/ 子目录;-- -D warnings 把警告升级为错误——测试红是行为退化,clippy 红是不合惯例,两种红法对应两种修法,但都改代码、不改 CI。
deploy.yml 部署的则是本书的 VitePress 站点,不是 jackdaw。骨架一样(checkout → 装工具链 → 跑命令),差异全在配置:只在 push 到 main(加手动)触发,PR 期间不部署;job 拆成 build 与 deploy 两段,needs: build 保证先构建并上传站点产物,再发布到 GitHub Pages;permissions 显式授予 pages: write 与 id-token: write,此外只有 contents: read。会读一条 workflow,就会读所有:看 on 知道它何时醒,看 steps 知道它做什么。
TIP
push 之前先在本地跑一遍 cargo test 和 cargo clippy。CI 是复核,不是第一次运行测试的地方。
release profile 与产物
发布 profile 决定产物长什么样,默认两档:dev 编译快、带调试信息,给日常;release 优化激进,给交付。jackdaw 给 release 又加了两个开关:
# Shipping profile: small binaries beat last-percent compile speed for a
# teaching project you build once and copy around.
[profile.release]
lto = true
strip = true从注释读起——这段注释与 [profile.release] 第 8、9 章都出现过,取舍不再重读:教学项目编译一次、拷来拷去,小二进制比最后一点的编译速度更值;这里只看它落到产物上的效果。lto(链接期优化)允许编译器跨 crate 内联,代价是链接变慢;strip 删掉符号表,产物明显变小,代价是不能再拿它调试。cargo build --release 之后,可执行文件就在 target/release/jackdaw(dev 构建在 target/debug,第 8 章讲过 target/ 的分工),依赖的 Rust 代码都编在这一个文件里,拷到同平台机器就能跑。想给别的平台出包就是交叉编译:rustup target add 装上目标后 cargo build --release --target x86_64-unknown-linux-gnu,概念第 9 章讲过,参数用时再查。
小结
端口 0 请操作系统挑空闲端口,stub 才能并行、不碰撞地扮演 provider;cargo test 红改行为,clippy -D warnings 红改惯例,都不改 CI;release 产物在 target/release/jackdaw,lto 与 strip 用编译时间换小体积。下一章是最后一章:带着这套读法去三个范式各自的地盘。
自检
- 你要在另一个语言的项目里照抄"随机端口 stub"的做法。
bind("127.0.0.1:0")的0对应你熟悉的哪种写法?同事坚持用固定端口 3425,你会指出哪两个具体风险? - 某次 push 后 Rust CI 变红:日志显示
cargo test通过、cargo clippy -- -D warnings失败。同事说"测试全过,警告而已,合并吧"。这个判断错在哪两处? - "首字节已出则不再故障转移"这条语义,为什么单元测试(直接调用函数)验证不了?
StubMode::StreamThenDie精确制造了什么?
参考答案
0表示"让操作系统分配一个空闲端口",等价于 PythonHTTPServer(("127.0.0.1", 0), ...)或 Gonet.Listen("tcp", "127.0.0.1:0")的写法,事后都从监听器读回真实端口。固定端口的两个风险:一是测试并行跑或上次进程没退干净时,第二次绑定同一端口直接失败;二是端口上可能恰好挂着真实服务(magpie 默认就监听 3425),stub 收不到请求,测试还可能把真服务的响应当成自己的。- 一处:
-D warnings的契约就是"零警告即错误",CI 红说明这份 diff 没达到合入标准,不是"而已";二处:接下来的两条路都不成立——"不修就合并"会让 main 从此偏离零警告契约,"放宽或绕过 CI"则违反仓库约定(CI 文件不由写手改),正确修法只有一个:让代码(或写代码的 AI)消掉警告。 - 这条语义的前提是"字节已经离开网关进程、到达了客户端",这是跨进程的物理事实;函数级调用没有字节流、没有连接,复现不了它。
StreamThenDie先发出一个完整 delta(让首字节真的离开),100 毫秒后在流上报错断线,逼网关只能在"流内如实报错"或"偷偷换人重试"之间选;测试断言它选了前者,且healthystub 收到的请求数为零。