Skip to content

测试、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 要花钱、要密钥、结果还不稳定。答案是本地起一个模拟替身,行为由测试说了算:

rust
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。

一条请求走完整条链路 ​

看最有代表性的一条——路由组的故障转移:

rust
#[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,守护的东西不同:

yaml
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 又加了两个开关:

toml
# 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 用编译时间换小体积。下一章是最后一章:带着这套读法去三个范式各自的地盘。

自检 ​

  1. 你要在另一个语言的项目里照抄"随机端口 stub"的做法。bind("127.0.0.1:0") 的 0 对应你熟悉的哪种写法?同事坚持用固定端口 3425,你会指出哪两个具体风险?
  2. 某次 push 后 Rust CI 变红:日志显示 cargo test 通过、cargo clippy -- -D warnings 失败。同事说"测试全过,警告而已,合并吧"。这个判断错在哪两处?
  3. "首字节已出则不再故障转移"这条语义,为什么单元测试(直接调用函数)验证不了?StubMode::StreamThenDie 精确制造了什么?
参考答案
  1. 0 表示"让操作系统分配一个空闲端口",等价于 Python HTTPServer(("127.0.0.1", 0), ...) 或 Go net.Listen("tcp", "127.0.0.1:0") 的写法,事后都从监听器读回真实端口。固定端口的两个风险:一是测试并行跑或上次进程没退干净时,第二次绑定同一端口直接失败;二是端口上可能恰好挂着真实服务(magpie 默认就监听 3425),stub 收不到请求,测试还可能把真服务的响应当成自己的。
  2. 一处:-D warnings 的契约就是"零警告即错误",CI 红说明这份 diff 没达到合入标准,不是"而已";二处:接下来的两条路都不成立——"不修就合并"会让 main 从此偏离零警告契约,"放宽或绕过 CI"则违反仓库约定(CI 文件不由写手改),正确修法只有一个:让代码(或写代码的 AI)消掉警告。
  3. 这条语义的前提是"字节已经离开网关进程、到达了客户端",这是跨进程的物理事实;函数级调用没有字节流、没有连接,复现不了它。StreamThenDie 先发出一个完整 delta(让首字节真的离开),100 毫秒后在流上报错断线,逼网关只能在"流内如实报错"或"偷偷换人重试"之间选;测试断言它选了前者,且 healthy stub 收到的请求数为零。