在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)。

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

相关推荐
Victor3562 小时前
Redis(67)Redis的SETNX命令是如何工作的?
后端
Victor3562 小时前
Redis(66)Redis如何实现分布式锁?
后端
凤山老林3 小时前
新一代Java应用日志可视化与监控系统开源啦
java·后端·开源
muyouking1110 小时前
Tauri Android 开发踩坑实录:从 Gradle 版本冲突到离线构建成功
android·rust
Kiri霧10 小时前
Rust开发环境搭建
开发语言·后端·rust
间彧11 小时前
Spring事件监听与消息队列(如Kafka)在实现解耦上有何异同?
后端
间彧11 小时前
Java如何自定义事件监听器,有什么应用场景
后端
叶梅树11 小时前
从零构建A股量化交易工具:基于Qlib的全栈系统指南
前端·后端·算法
间彧11 小时前
CopyOnWriteArrayList详解与SpringBoot项目实战
后端