编写单元测试是确保代码质量、稳定性和可维护性的关键步骤,尤其是在采用测试驱动开发(TDD)方法时。对于 EmailClient
组件的测试,我们确实应该从小处着手,先保证组件本身的功能正确无误,然后再逐步集成到更大的系统中。这不仅可以提高我们的信心,还能简化调试和问题定位。
测试目标
为了验证 EmailClient::send_email
的功能,我们需要确保以下几点:
- HTTP 请求是否被正确发起:确认
send_email
方法确实发起了 HTTP 请求。 - 请求体和头信息是否符合预期:检查发送的请求体内容和头信息是否按照预期构建。
- 处理响应的能力:模拟不同的服务器响应,以验证客户端如何处理成功和失败的情况。
使用 Mock Server 进行测试
为了实现上述测试目标,我们可以使用一个 mock server 来拦截并验证 HTTP 请求。Mockito 是 Java 中常用的库,但在 Rust 生态中,我们可以选择 wiremock
或 mockito-rs
等工具来创建临时的 HTTP 服务器,并设置预期的行为。
Cargo.toml
[dev-dependencies]
quickcheck = "1.0.3"
quickcheck_macros = "1"
fake = "2.9"
rand = "0.8"
claims = "0.7"
reqwest = { version = "0.12", features = ["json"] }
wiremock = "0.6"
serde_json = "1.0.61"
//! src/email_client.rs
#[cfg(test)]
mod tests {use super::EmailClient;use crate::domain::SubscriberEmail;use claims::{assert_err, assert_ok};use fake::{faker::{internet::en::SafeEmail,lorem::en::{Paragraph, Sentence},},Fake, Faker,};use secrecy::Secret;use wiremock::{matchers::{any, header, header_exists, method, path},Mock, MockServer, ResponseTemplate,};struct SendEmailBodyMatcher;impl wiremock::Match for SendEmailBodyMatcher {fn matches(&self, request: &wiremock::Request) -> bool {let result: Result<serde_json::Value, _> = serde_json::from_slice(&request.body);if let Ok(body) = result {body.get("From").is_some()&& body.get("To").is_some()&& body.get("Subject").is_some()&& body.get("HtmlBody").is_some()&& body.get("TextBody").is_some()} else {false}}}fn subject() -> String {Sentence(1..2).fake()}fn content() -> String {Paragraph(1..10).fake()}fn email() -> SubscriberEmail {SubscriberEmail::parse(SafeEmail().fake()).unwrap()}fn email_client(base_url: String) -> EmailClient {EmailClient::new(base_url,email(),Secret::new(Faker.fake()),std::time::Duration::from_millis(200),)}#[tokio::test]async fn send_email_sends_the_expected_request() {// Arrangelet mock_server = MockServer::start().await;let email_client = email_client(mock_server.uri());Mock::given(header_exists("X-Postmark-Server-Token")).and(header("Content-Type", "application/json")).and(path("/email")).and(method("POST")).and(SendEmailBodyMatcher).respond_with(ResponseTemplate::new(200)).expect(1).mount(&mock_server).await;// Actlet _ = email_client.send_email(email(), &subject(), &content(), &content()).await;// Assert}#[tokio::test]async fn send_email_succeeds_if_the_server_returns_200() {let mock_server = MockServer::start().await;let email_client = email_client(mock_server.uri());Mock::given(any()).respond_with(ResponseTemplate::new(200)).expect(1).mount(&mock_server).await;// Actlet outcome = email_client.send_email(email(), &subject(), &content(), &content()).await;// Assertassert_ok!(outcome);}#[tokio::test]async fn send_email_fails_if_the_server_returns_500() {// Arrangelet mock_server = MockServer::start().await;let email_client = email_client(mock_server.uri());Mock::given(any())// Not a 200 anymore!.respond_with(ResponseTemplate::new(500)).expect(1).mount(&mock_server).await;// Actlet outcome = email_client.send_email(email(), &subject(), &content(), &content()).await;// Assertassert_err!(outcome);}#[tokio::test]async fn send_email_times_out_if_the_server_takes_too_long() {let mock_server = MockServer::start().await;let email_client = email_client(mock_server.uri());let response = ResponseTemplate::new(200).set_delay(std::time::Duration::from_secs(180));Mock::given(any()).respond_with(response).expect(1).mount(&mock_server).await;//Actlet outcome = email_client.send_email(email(), &subject(), &content(), &content()).await;//Assertassert_err!(outcome);}
}
重构和测试
重构测试代码以提高可发现性和维护性是一个重要的步骤,尤其是在项目规模逐渐增大时。根据你的描述,目前所有的测试和辅助函数都被放置在了tests/health_check.rs
文件中,这虽然方便了最初的开发过程,但随着项目的扩展,这种做法可能会导致混乱和不易维护的问题。为了改进这种情况,可以采取以下策略:
1. 文件与目录结构优化
创建一个合理的文件夹和文件结构可以帮助开发者更容易地找到相关的测试代码。例如,你可以按照API端点的功能将测试分组到不同的文件或目录中。对于你提到的情况,可以考虑如下结构:
/tests/api/health_check.rs // 健康检查相关的测试/subscriptions.rs // 订阅POST请求相关的测试/shared // 共享的设置步骤和其他帮助函数/setup.rs // 包含spawn_app, TestApp等/database.rs // 数据库配置相关/tracing.rs // 跟踪配置
这样,当开发者需要为特定端点编写新的测试用例时,他们只需要知道该端点是做什么的就可以直接前往相应的文件夹查找或添加测试代码。
2. 使用模块化测试辅助函数
确保共享的设置步骤和辅助函数易于访问且不重复。可以通过Rust的模块系统来组织这些代码,使得它们可以在多个测试文件之间轻松导入和使用。比如,在shared/setup.rs
中定义的所有函数都可以通过use crate::tests::shared::setup::*;
这样的语句被其他测试文件引用。
3. 提高测试覆盖率工具的利用
尽管良好的文件结构有助于改善代码的可发现性,但是测试覆盖率工具同样重要。它们可以帮助识别哪些部分的应用程序代码被测试覆盖到了,哪些没有。一些工具还支持标记功能(如coverage marks),允许你在源代码中标记出哪些测试应该触发哪些代码路径,从而建立更清晰的关联。
4. 文档与注释
最后,不要忽视文档的重要性。即使有了良好的文件结构和测试覆盖率工具的支持,清晰的注释和README文件也可以大大增加新成员理解现有测试框架的速度。确保每个测试文件顶部都有简短说明其目的,并对任何复杂的逻辑提供必要的解释。
确实,在Rust中,tests
文件夹具有特殊意义,Cargo会将该文件夹中的每个文件作为独立的crate来编译。这意味着如果你有多个测试文件,它们会被编译成多个二进制文件,每一个都代表一个独立的测试套件。这种设计允许每个测试文件拥有自己的依赖和模块结构,同时也意味着你可以为每个测试文件定义不同的环境或配置。
One Test File, One Crate 的含义
当你在tests
目录下创建一个新的测试文件时,例如health_check.rs
,Cargo会将其视为一个独立的crate。这有几个重要的含义:
- 独立编译:每个测试文件会被单独编译,因此可以包含特定于该测试文件的代码而不会影响其他测试。
- 独立依赖:虽然所有测试文件共享相同的库依赖(即项目本身的库),但每个测试文件可以有自己的外部依赖。
- 模块隔离:由于每个测试文件是一个独立的crate,它不能直接访问另一个测试文件中的私有项,除非这些项被公开(使用
pub
关键字)或者通过某种形式的共享库进行访问。 - 构建产物:运行
cargo build --tests
后,你可以在target/debug/deps
目录下找到编译后的测试文件。每个测试文件都会生成一个对应的可执行文件。
对重构的影响
考虑到这一点,当我们将测试文件拆分到多个文件中时,需要特别注意以下几点:
- 避免重复代码:确保不要在多个测试文件中重复定义相同的功能。可以考虑将共用的设置步骤、辅助函数等放置在一个公共模块中,并让各个测试文件引用这个模块。
- 模块化设计:为了方便跨测试文件共享代码,应该精心设计模块结构。比如,创建一个
shared
模块来存放常用的辅助函数、模拟数据等。 - 依赖管理:如果某些测试文件需要额外的依赖,确保在
Cargo.toml
中正确声明这些依赖,并且理解每个测试文件作为一个独立crate如何处理这些依赖。 - 路径引用:由于每个测试文件都是一个独立的crate,因此在引用项目库或其他模块时,可能需要使用相对路径或明确指定路径。
> cargo build --tests
Blocking waiting for file lock on build directory
Finished `dev` profile [unoptimized + debuginfo] target(s) in 23.72s
查看
ls target/debug/deps | grep health_check