实现我们的第一个集成测试
我们对健康检查端点的规范是:当我们收到 /health_check 的 GET 请求时,我们会返回没有正文的 200 OK 响应。
这分为这几个部分
- GET 请求
- /health_check 端点
- 响应码 200
- 没有正文的响应
让我们将其转化为测试,并尽可能多地描述特征:
//! tests/health_check.rs
// `tokio::test` is the testing equivalent of `tokio::main`.
// It also spares you from having to specify the `#[test]` attribute.
//
// You can inspect what code gets generated using
// `cargo expand --test health_check` (<- name of the test file)
#[tokio::test]
async fn health_check_works() {
// Arrange
spawn_app().await.expect("Failed to spawn our app.");
// We need to bring in `reqwest`
// to perform HTTP requests against our application.
let client = reqwest::Client::new();
// Act
let response = client
.get("http://127.0.0.1:8000/health_check")
.send()
.await
.expect("Failed to execute request.");
// Assert
assert!(response.status().is_success());
assert_eq!(Some(0), response.content_length());
}
async fn spawn_app() -> std::io::Result<()> {
todo!()
}
当然别忘了添加 reqwest
依赖
cargo add reqwest --dev
请花点时间仔细看看这个测试用例。
spawn_app 是唯一一个合理地依赖于我们应用程序代码的部分。
其他所有内容都与底层实现细节完全解耦——如果明天我们决定放弃 Rust,用 Ruby on Rails 重写应用程序,我们仍然可以使用相同的测试套件来检查新堆栈中的回归问题,只要将 spawn_app 替换为合适的触发器(例如,使用 bash 命令启动 Rails 应用程序)。
该测试还涵盖了我们感兴趣的所有属性:
- 健康检查暴露在 /health_check;
- 健康检查使用 GET 方法;
- 健康检查始终返回 200;
- 健康检查的响应没有正文。
如果这个测试通过了,这就完成了。
测试还没来得及做任何有用的事情就崩溃了:我们缺少了 spawn_app,这是集成测试的最后一块拼图。
为什么我们不直接在那里调用 run 呢? 也就是说
async fn spawn_app() -> std::io::Result<()> {
zero2prod::run().await
}
让我们试试看!
cargo test
无论等待多久,测试执行都不会终止。这是怎么回事?
在 zero2prod::run 中,我们调用(并等待)HttpServer::run。HttpServer::run 返回一个 Server 实例 - 当我们调用 .await 时,它会无限期地监听我们指定的地址:它会处理传入的请求,但永远不会自行关闭或“完成”。
这意味着 spawn_app 永远不会返回,我们的测试逻辑也永远不会执行。
我们需要将应用程序作为后台任务运行。
tokio::spawn 在这里非常方便:tokio::spawn 接受一个 Future 并将其交给运行时进行轮询,
而无需等待其完成;因此,它与下游 Future 和任务(例如我们的测试逻辑)并发运行。
让我们重构 zero2prod::run,使其返回一个 Server 实例而不等待它:
//! src/lib.rs
// [...]
// Notice the different signature!
// We return `Server` on the happy path and we dropped the `async` keyword
// We have no .await call, so it is not needed anymore.
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();
// No .await here!
Ok(server)
}
我们需要相应地修改我们的 main.rs:
//! src/main.rs
use zero2prod::run;
#[tokio::main]
async fn main() -> std::io::Result<()> {
run()?.await
}
运行一下 cargo check
应该能让我们确信一切正常。
现在我们可以实现 spawn_app
方法
// No .await call, therefore no need for `spawn_app` to be async now.
// We are also running tests, so it is not worth it to propagate errors:
// if we fail to perform the required setup we can just panic and crash
// all the things.
fn spawn_app() {
let server = zero2prod::run().expect("Failed to bind address");
// Launch the server as a background task
// tokio::spawn returns a handle to the spawned future,
// but we have no use for it here, hence the non-binding let
let _ = tokio::spawn(server);
}
快速调整我们的测试以适应 spawn_app
方法签名的变化:
#[tokio::test]
async fn health_check_works() {
// [...]
spawn_app();
// [...]
现在是时候运行 cargo test
了!
running 1 test
test health_check_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
耶!我们的第一次集成测试通过了!
替我给自己鼓个掌,在一个章节内完成了第二个重要的里程碑。
Polishing
我们已经让它运行起来了,现在我们需要重新审视并改进它,如果需要或可能的话
清理
测试运行结束后,后台运行的应用会发生什么?
它会关闭吗? 它会像僵尸程序一样徘徊在某个地方吗?
嗯,连续多次运行 cargo test
总是会成功——这强烈暗示我们的 8000 端口会在每次运行结束时被释放,因此意味着应用已正确关闭。
再次查看 tokio::spawn
的文档,这支持了我们的假设:当一个 tokio 运行时关闭时,所有在其上生成的任务都会被丢弃。tokio::test 会在每个测试用例开始时启动一个新的运行时,并在每个测试用例结束时关闭。
换句话说,好消息是——无需实现任何清理逻辑来避免测试运行期间的资源泄漏。
选择随机端口
spawn_app
总是会尝试在 8000 端口上运行我们的应用——这并不理想:
- 如果 8000 端口正在被我们机器上的其他程序(例如我们自己的应用!)占用,测试就会失败;
- 如果我们尝试并行运行两个或多个测试,那么只有一个测试能够绑定端口,其他所有测试都会失败。
我们可以做得更好: 测试应该在随机可用的端口上运行它们的后台应用。
首先,我们需要修改 run
函数——它应该将应用地址作为参数,而不是依赖于硬编码的值:
让我们修改 lib.rs
//! src/lib.rs
// [...]
pub fn run(address: &str) -> Result<Server, std::io::Error> {
let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
.bind(address)?
.run();
// No .await here!
Ok(server)
}
然后,所有 zero2prod::run()
调用都必须更改为 zero2prod::run("127.0.0.1:8000")
才能保留相同的行为并使项目再次编译。
我们如何为测试找到一个随机可用的端口?
操作系统可以帮上忙:我们将使用端口 0。
端口 0 在操作系统层面是特殊情况:尝试绑定端口 0 将触发操作系统扫描可用端口,
然后该端口将被绑定到应用程序。
因此,只需将 spawn_app
更改为
//! tests/health_check.rs
fn spawn_app() {
let server = zero2prod::run("127.0.0.1:0").expect("Failed to bind address");
let _ = tokio::spawn(server);
}
注: 原作这里忘了提到要修改 main.rs 了, 请暂时将 main.rs 中的代码修改为
run("127.0.0.1:8000")?.await
这样就好了~ 现在,每次启动 Cargo 测试时,后台应用都会在随机端口上运行! 只有一个小问题...... 我们的测试失败了
running 1 test
test health_check_works ... FAILED
failures:
---- health_check_works stdout ----
thread 'health_check_works' panicked at tests/health_check.rs:19:10:
Failed to execute request.: reqwest::Error { kind: Request, url: "http://127.0.0.1:8000/health_check", source: hyper_util::client::legacy::Error(Connect, ConnectError("tcp connect error", 127.0.0.1:80
00, Os { code: 111, kind: ConnectionRefused, message: "Connection refused" })) }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
health_check_works
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
我们的 HTTP 客户端仍在调用 127.0.0.1:8000
,我们现在真的不知道该在那里放什么: 应用程序端口是在运行时确定的,我们无法在那里进行硬编码。
我们需要以某种方式找出操作系统分配给我们应用程序的端口,并将其从 spawn_app
返回。
有几种方法可以实现这一点——我们将使用 std::net::TcpListener。
我们的 HttpServer 目前承担着双重任务:给定一个地址,它会绑定它,然后启动应用程序。我们可以接手第一步:我们自己用 TcpListener 绑定端口,然后使用 listen 将其交给 HttpServer。
这样做有什么好处呢?
TcpListener::local_addr
返回一个 SocketAddr
对象,它暴露了我们通过 .port()
绑定的实际端口。
让我们从 run
函数开始:
//! src/lib.rs
// [...]
pub fn run(listener: TcpListener) -> Result<Server, std::io::Error> {
let server = HttpServer::new(|| App::new().route("/health_check", web::get().to(health_check)))
.listen(listener)?
.run();
Ok(server)
}
这项更改破坏了我们的 main
函数和 spawn_app
函数。main
函数就留给你处理吧,我们来重点处理 spawn_app
函数:
//! tests/health_check.rs
// [...]
fn spawn_app() -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("Faield to bind random port");
// We retrieve the port assigned to us by the OS
let port = listener.local_addr().unwrap().port();
let server = zero2prod::run(listener).expect("Failed to bind address");
let _ = tokio::spawn(server);
// We return the application address to the caller!
format!("http://127.0.0.1:{}", port)
}
现在我们可以在 reqwest::Client
中引用这个地址:
//! tests/health_check.rs
// [...]
#[tokio::test]
async fn health_check_works() {
// Arrange
let address = spawn_app();
let client = reqwest::Client::new();
// Act
let response = client
// Use the returned application address
.get(format!("{address}/health_check"))
.send()
.await
.expect("Failed to execute request.");
// Assert
assert!(response.status().is_success());
assert_eq!(Some(0), response.content_length());
}
// [...]