在actix-web应用用构建集成测试

6. 第一次集成测试

/health_check 是我们实现的第一个端点。此前,我们使用 curl 手动测试并验证它能够正常工作。

然而,如果项目变大,手动测试会变得低效且容易出错。每次修改代码后都要人工验证,不仅耗时,还可能因为疏忽而遗漏问题。

为了解决这个问题,我们希望尽可能实现自动化测试。这样一来,每次提交代码时,这些检查都能在持续集成(CI)流水线上自动运行,以防止出现回归问题。

这里提到的"回归(Regression)"指的是: 在修改或新增功能后,原本正常的功能因为这次改动而出现故障的情况。自动化测试的目标之一,就是防止功能回归。

虽然健康检查端点的逻辑非常简单,几乎不会变化,但在这里建立起测试脚手架,是构建自动化测试体系的一个理想起点。

被称为"黑盒测试",即通过检查给定的一组输入和输出来测试验证系统的行为,而无需了解内部实现细节。

6.1 如何对端点进行测试

API 是实现特定功能的一种手段,它向外部公开接口,以便执行某种任务(例如存储文档、发送电子邮件等)。

我们在 API 中公开的端点(Endpoint)定义了系统与客户端之间的契约------即对输入和输出的约定。

契约的变更类型

随着时间的推移,API 契约可能会发生变化。一般可以分为两种情况:

  1. 向后兼容的变更

    例如新增一个端点。这类变更不会影响现有客户端的正常使用。

  2. 破坏性变更

    例如删除某个端点,或从响应结构中移除字段。

    如果客户端依赖了被删除或修改的部分,那么这些集成可能会因此失效。

为什么要测试端点

虽然我们有时会有意 地对 API 契约进行重大调整,但我们必须确保不会无意间破坏 现有功能。

为了防止引入用户可见的回归问题,最可靠的方法是模拟用户的实际使用方式,对 API 进行测试。

6.2 黑盒测试的意义

它的核心思想是:只关心输入和输出,而不依赖内部实现细节。这种测试方式通常被称为 黑盒测试(Black-box Testing)

黑盒测试通过向系统发送一组特定的输入(例如 HTTP 请求),并根据系统返回的输出(响应结果)来验证行为是否符合预期。

它不依赖于内部实现细节,因此可以从用户视角确保整个 API 契约的正确性和稳定性。

遵循这个原则,我们不会满足于直接调用处理器函数的测试,例如

rust 复制代码
#[cfg(test)]
mod tests {
    use crate::health_check;

    #[tokio::test]
    async fn health_check_succeeds() {
        let response = health_check().await;
        //这需要将`health_check`函数的返回类型从impl Responder修改为HttpResponse才能编译通过
        // 你还需要通过 `use actix_web::HttpResponse` 导入这个类型
        assert!(response.status().is_success())
    }
}

这种测试方式有几个问题:

  1. 没有经过 HTTP 层

    你没有真正通过 GET 请求调用 /health_check,因此无法检查路由是否正确配置。

  2. API 契约可能被破坏

    如果处理器路径或请求方法发生变化,这类测试仍然可能通过,但实际上 API 已经不符合预期。

  3. 过于依赖框架实现

    Actix-web 的 App 结构体内部某些参数是私有的,直接在测试中共享初始化逻辑比较困难。

    如果应用重构或迁移到其他 Web 框架,现有的测试套件很可能完全失效。

6.3 完全黑盒的解决方案

为了避免上述问题,推荐真正的黑盒测试方法:

  1. 每次测试启动一个完整的应用程序实例
  2. 使用 HTTP 客户端(如 reqwest)发送请求
  3. 通过响应验证 API 的行为

6.4 更改项目结构以便于测试

在/tests下编写真正的测试之前,我们还有一些事要做

/tests下的任何东西最终都会编译成独立的二进制文件--所有测试代码都是以包的形式倒入的。但我们的项目目前是二进制文件,它应该被执行,而不是被共享。因此,不能像这样导入main函数

不妨测试一下


6.4.1 创建一个新的tests文件夹
bash 复制代码
mkdir -p tests

6.4.2 创建一个新的tests/health_check.rs文件
rust 复制代码
// tests/health_check.rs
use zero2prod::main; // 错误的

#[test]
fn dummy_test() {
    main();
}

构建会失败


我们需要将项目重构为一个库和一个二进制文件;所有逻辑都存在于库中,而二进制文件(例如main.rs)本身是一个入口点,只包含轻量的main函数


6.4.2 首先,需要更改Cargo.toml
toml 复制代码
[package]
name = "zero2prod"
version = "0.1.0"
edition = "2024"

[lib]
path = "src/lib.rs"

[dependencies]
# ...

我们需要脱离自动配置后,一切简单明了

toml 复制代码
[package]
name = "zero2prod"
version = "0.1.0"
edition = "2024"

[lib]
path = "src/lib.rs"

[dependencies]
# ...

[[bin]]
path = "src/main.rs"
name = "zero2prod"

lib.rs项目目前还不存在,需要手动创建


6.4.3 将主函数逻辑移入库中

为了让应用程序代码更易于测试,我们将 main 函数中的主要逻辑迁移到库文件 lib.rs 中。

这样可以在测试中直接调用它,而不必依赖可执行文件。

rust 复制代码
//! main.rs
use zero2prod::run;
#[tokio::main]
async fn main()-> std::io::Result<()> {
    run().await
}

6.4.4 在 lib.rs 中定义 run 函数(防止与main.rs中的main命名冲突):
rust 复制代码
//! src/lib.rs
use actix_web::{App, HttpResponse, HttpServer, Responder, web};

async fn health_check() -> impl Responder {
    HttpResponse::Ok().finish()
}

pub async fn run() -> std::io::Result<()> {
    HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
        .bind("127.0.0.1:8000")?
        .run()
        .await
}

6.4.5 添加测试依赖

我们将使用 reqwest 来发送 HTTP 请求以测试端点。

Cargo.toml 中添加开发依赖:

toml 复制代码
[dev-dependencies]
reqwest = "0.12.23"

开发依赖仅在运行测试或示例时使用,不会被编译进最终的二进制文件中。


6.4.6 编写端点测试

我们希望验证 /health_check 端点的行为是否符合预期。

rust 复制代码
//! tests/health_check.rs
#[tokio::test]
async fn health_check_works() {
    // 启动应用(spawn_app 是自定义的启动函数)
    spawn_app().await.expect("Failed to spawn app");

    // 创建 HTTP 客户端
    let client = reqwest::Client::new();

    // 使用 reqwest 发起 HTTP GET 请求
    let response = client
        .get(&format!("http://127.0.0.1:8000/health_check"))
        .send()
        .await
        .expect("Failed to execute request.");

    // 验证返回结果
    assert!(response.status().is_success());
    assert!(Some(0), response.content_length());
}

async spawn_app()->std::io::Result<()> {
    todo!()
}

让我们补全spawn_app函数

rust 复制代码
async spawn_app()->std::io::Result<()> {
    zero2prod::run().await
}
6.4.7 测试挂起的问题

运行测试时你会发现一个问题:

测试永远不会结束,程序一直在运行。

为什么?

因为 HttpServer::run() 返回的是一个 Server 实例

当我们在 run() 中调用 .await 时,它会启动事件循环,不断监听地址并处理请求 ------ 这意味着它永远不会返回,测试代码因此也无法继续执行。


6.4.8 在后台运行应用程序

为了解决这个问题,我们希望让服务器在后台异步运行,而不是阻塞当前任务。

这时就可以使用 tokio::spawn

tokio::spawn 会启动一个新的异步任务,将指定的 Future 交给运行时(tokio)调度,而不会等待它完成。

这让我们的服务器和测试逻辑可以同时运行。

6.4.9 改进后的项目结构

src/main.rs

rust 复制代码
use zero2prod::run;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // 如果绑定失败,会立即返回错误;
    // 否则 run() 返回一个 Server 实例,调用 .await 启动事件循环
    run()?.await
}

src/lib.rs

rust 复制代码
use actix_web::{App, HttpResponse, HttpServer, Responder, dev::Server, web};

async fn health_check() -> impl Responder {
    HttpResponse::Ok().finish()
}

// 在正常情况下返回Server,并删除了Server关键字
pub fn run() -> Result<Server, std::io::Error> {
    let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
        .bind("127.0.0.1:8000")?
        .run(); //这里没有.await
    Ok(server)
}

优化后的启动函数如下:

rust 复制代码
// 在后台某处启动应用程序
fn spawn_app() {
    let server = zero2prod::run().expect("Failed to bind address");
    //启动服务器作为后台任务
    //tokio::spawn返回一个指向spawned future的handle
    //但是这里没有用它
    let _ = tokio::spawn(server);
}

总体代码
tests/health_check.rs

rust 复制代码
#[tokio::test]
async fn health_check_works() {
    spawn_app();
    // 需要引入reqWest对应用程序执行HTTP请求
    let client = reqwest::Client::new();

    //执行
    let response = client
            .get("http://127.0.0.1:8000/health_check")
            .send()
            .await
            .expect("Failed to execute request.");

    // 断言
    assert!(response.status().is_success());
    assert_eq!(Some(0), response.content_length());
}

// 在后台某处启动应用程序
fn spawn_app() {
    let server = zero2prod::run().expect("Failed to bind address");
    //启动服务器作为后台任务
    //tokio::spawn返回一个指向spawned future的handle
    //但是这里没有用它
    let _ = tokio::spawn(server);
}
6.4.10 改进要点与设计思路

将逻辑从 main 移到 lib.rs

  • 这样可以在测试中直接 use zero2prod::run() 来启动服务;
  • 测试时无需执行完整的二进制程序(即不依赖 main());
  • 模块化更好,职责更清晰:
    • lib.rs:定义应用逻辑
    • main.rs:只是启动入口。

run() 返回 Server 而非 Result<()>

旧写法

rust 复制代码
pub async fn run() -> std::io::Result<()> {
    HttpServer::new(...).run().await
}

这种写法一旦调用 .await,就会阻塞执行,测试中无法同时运行其他任务。

改进后:

rust 复制代码
pub fn run() -> Result<Server, std::io::Error> {
    Ok(HttpServer::new(...).bind(...).run())
}

我们得到一个 Server 对象,可以:

  • main.rs.await 启动;
  • 在测试中 tokio::spawn(run().unwrap()) 后台运行;
  • 更灵活地控制服务器。
测试中的优势
rust 复制代码
async fn spawn_app() -> Result<(), std::io::Error> {
    let server = zero2prod::run()?;
    tokio::spawn(server); // 后台运行服务器
    Ok(())
}

不会阻塞测试逻辑,后续的 reqwest 请求可以并发执行。

6.5 测试目标总结

通过该测试,我们验证了以下行为:

  • /health_check 端点是否已正确暴露;
  • 是否使用了 GET 方法;
  • 是否返回了状态码 200 OK
  • 是否没有响应体(即 Content-Length = 0)。

一旦这些断言全部通过,就说明我们的健康检查端点工作正常。

相关推荐
止语Lab1 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活1 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
程序员天天困1 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn1 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
用户298698530141 小时前
Python 实现 Excel 与 Markdown 互转的实用指南
后端·python·excel
用户77283104908402 小时前
krono-job:零侵入、单二进制交付的分布式任务调度平台(开源)
后端
Conan在掘金2 小时前
鸿蒙报错速查:Function lacks ending return statement,返回路径漏 return 就炸,根因 + 真解法
后端
人间凡尔赛2 小时前
AI-Native 云原生架构:2026 年从容器编排到智能体编排的范式革命
后端·云原生·架构
IT_陈寒2 小时前
Vue的响应式让我加班到凌晨3点,原来问题出在这
前端·人工智能·后端
卷无止境2 小时前
提升 Python 代码健壮性的方法大盘点
后端·python