不要给未订阅用户发垃圾邮件

我们可以先编写一个集成测试,明确哪些情况不应该发生:未经确认的订阅者不应该收到新闻通讯。

在第七章中,我们选择了 Postmark 作为我们的电子邮件传递服务。如果我们没有调用 Postmark,就不会发送电子邮件。

我们可以基于此来设计一个场景,以验证我们的业务规则: 如果所有订阅者都未经确认,那么当我们发布新闻通讯时,就不会向 Postmark 发出任何请求。

让我们将其转化为代码:

//! tests/api/main.rs
// [...]
mod newsletter;
//! tests/api/newsletter.rs
tchers::{any, method, path}, Mock, ResponseTemplate};

use crate::helpers::{spawn_app, TestApp};

#[tokio::test]
async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
    // Arrange
    let app = spawn_app().await;
    create_unconfirmed_subscriber(&app).await;

    Mock::given(any())
        .respond_with(ResponseTemplate::new(200))
        .expect(0)
        .mount(&app.email_server)
        .await;

    // Act
    
    // A sketch of the newsletter payload structure
    // We might change it later on.
    let newsletter_request_body = serde_json::json!({
        "title": "Newsletter title",
        "content": {
            "text": "Newsletter body as plain text",
            "html": "<p>Newsletter body as HTML</p>",
        }
    });
    let response = reqwest::Client::new()
        .post(&format!("{}/newsletters", &app.address))
        .json(&newsletter_request_body)
        .send()
        .await
        .expect("Failed to execute request.");

    // Assert
    assert_eq!(response.status().as_u16(), 200);
    // Mock verifies on Drop that we haven't sent the newsletter email
}

/// Use the public API of the application under test to create
/// an unconfirmed subscriber.
async fn create_unconfirmed_subscriber(app: &TestApp) {
    let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
    
    let _mock_guard = Mock::given(path("/email"))
        .and(method("POST"))
        .respond_with(ResponseTemplate::new(200))
        .named("Create unconfirmed subscriber")
        .expect(1)
        .mount_as_scoped(&app.email_server)
        .await;
    app.post_subscriptions(body.into())
        .await
        .error_for_status()
        .unwrap();
}

正如预期的那样,它失败了:

thread 'newsletter::newsletters_are_not_delivered_to_unconfirmed_subscribers' pa
nicked at tests/api/newsletter.rs:36:5:
assertion `left == right` failed
  left: 404
 right: 200

我们的 API 中没有用于 POST /newsletters 的处理程序: actix-web 返回 404 Not Found,而不是测试预期的 200 OK。

使用公共 API 设置状态

让我们花点时间看一下我们刚刚编写的测试的 "Arrange" 部分。

我们的测试场景对应用程序的状态做了一些假设:我们需要一个订阅者,并且该订阅者必须是未确认的。

每个测试都会启动一个全新的应用程序,并在一个空数据库上运行。

let app = spawn_app().await;

我们如何根据测试需求填充它?

我们坚持第三章中描述的黑盒方法: 尽可能通过调用应用程序的公共 API 来驱动应用程序状态。

这就是我们在 create_unconfirmed_subscriber 中所做的:

//! tests/api/newsletter.rs
// [...]

async fn create_unconfirmed_subscriber(app: &TestApp) {
    let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
    
    let _mock_guard = Mock::given(path("/email"))
        .and(method("POST"))
        .respond_with(ResponseTemplate::new(200))
        .named("Create unconfirmed subscriber")
        .expect(1)
        .mount_as_scoped(&app.email_server)
        .await;
    app.post_subscriptions(body.into())
        .await
        .error_for_status()
        .unwrap();
}

我们使用在 TestApp 中构建的 API 客户端向 /subscriptions 端点发出 POST 调用。

作用域模拟

我们知道 POST /subscriptions 会发送一封确认邮件——我们必须确保我们的 Postmark 测试服务器已准备好处理传入的请求,为此我们需要设置相应的模拟。

匹配逻辑与测试函数体中的逻辑重叠:我们如何确保这两个模拟不会互相干扰?

我们使用一个作用域模拟:

let _mock_guard = Mock::given(path("/email"))
    .and(method("POST"))
    .respond_with(ResponseTemplate::new(200))
    .named("Create unconfirmed subscriber")
    .expect(1)
    // We are not using `mount`!
    .mount_as_scoped(&app.email_server)
    .await;

使用 mount 时,只要底层 MockServer 正常运行,我们指定的行为就会一直有效。

而使用 mount_as_scoped 时,我们会返回一个守护对象——MockGuard

MockGuard 有一个自定义的 Drop 实现: 当超出范围时, wiremock 会指示底层 MockServer 停止执行指定的模拟行为。换句话说,在 create_unconfirmed_subscriber 的末尾, 我们会停止向 POST /email 返回 200。

我们的测试助手所需的模拟行为仅对测试助手本身有效。

当 MockGuard 被丢弃时,还会发生另一件事——我们会积极地检查作用域模拟的期望是否已得到验证。

这会创建一个有用的反馈循环,以保持我们的测试辅助函数干净且最新。

我们已经见证了黑盒测试如何促使我们为自己的应用程序编写 API 客户端,以保持测试简洁。

随着时间的推移,您会构建越来越多的辅助函数来驱动应用程序状态——就像我们刚才对 create_unconfirmed_subscriber 所做的那样。这些辅助函数依赖于模拟,但随着应用程序的发展,其中一些模拟最终不再需要——例如某个调用被移除,您停止使用某个提供程序等等。

积极地评估作用域模拟的期望有助于我们控制辅助函数代码,并在可能的情况下主动进行清理。

Green 测试

我们可以通过提供 POST /newsletters 的虚拟实现来使测试通过:

//! src/routes.rs
// [...]
mod newsletters;

pub use newsletters::*;
//! src/routes/newsletters.rs
use actix_web::HttpResponse;

pub async fn publish_newsletter() -> HttpResponse {
    HttpResponse::Ok().finish()
}
//! src/startup.rs
// [...]
pub fn run(
    // [...]
) -> Result<Server, std::io::Error> {
    // [...]

    let server = HttpServer::new(move || {
        App::new()
            // [...]
            .route("/newsletters", web::post().to(publish_newsletter))
            // [...]
    })
    // [...]
}

cargo test 应该可以通过了。