共享测试辅助
当每个集成测试文件都是一个独立的可执行文件时,共享测试辅助函数的一种常见方法是创建一个单独的模块,该模块可以被所有测试文件导入和使用。这个模块通常包含所有测试需要共用的辅助函数、常量、配置和其他资源。如果遵循这种做法,你可以按照以下步骤操作:
在你的 tests
目录下,创建一个名为 helpers
的模块
//! tests/health_check.rs
// [...]
mod helpers;
// [...]
在 Rust 项目中,如果你将 helpers
模块作为子模块捆绑到像 health_check
这样的测试可执行文件中,那么你可以在你的测试用例中访问它暴露的函数。这种方法一开始工作得很好,但它会导致“函数从未被使用”的警告,因为 helpers
是作为一个子模块捆绑的,而不是作为第三方 crate 调用的。Cargo 会独立编译每个测试可执行文件,并且如果对于特定的测试文件,helpers
中的一个或多个公共函数从未被调用,就会发出警告。随着你的测试套件的逐渐增长,这种情况几乎是不可避免的——并不是所有的测试文件都会使用所有的辅助方法。
为了避免这些未使用的函数警告,你可以采用第二种方法,充分利用每个 tests
目录下的文件都是一个独立的可执行文件这一特性:为每个测试创建作用域限定的子模块。具体来说,你可以为不同的测试创建特定的辅助模块,这样只有当测试确实需要时才会包含这些辅助函数,从而避免了全局辅助模块带来的未使用函数警告问题。
让我们创建一个 api
文件夹放在 tests
下,并在里面放置一个 main.rs
文件:
- 创建
tests/api
目录。 - 在
tests/api
目录下创建main.rs
文件。这个文件将作为api
测试的入口点。 - 如果有与
api
测试相关的辅助函数,你可以在tests/api
下创建一个helpers.rs
或者mod.rs
文件来存放这些辅助函数。 - 确保你在
main.rs
中正确引用了这些辅助函数,例如通过mod helpers;
来声明helpers
模块(假设你创建的是mod.rs
)或者直接通过use api::helpers;
引用它们(如果你创建的是helpers.rs
并且已经在main.rs
中声明了mod helpers;
)。 - 对于其他不同类型的测试(如
db_tests
,service_tests
等),你可以重复上述步骤,为每种类型的测试创建各自的目录和辅助模块。
修改后的目录结构如下:
tests
└── api├── health_check.rs├── helpers.rs├── main.rs└── subscriptions.rs
在main.rs添加子模块
mod health_check;
mod helpers;
mod subscriptions;
health_check.rs 到helpers.rs
// Ensure that the `tracing` stack is only initialised once using `once_cell`
use secrecy::Secret;
use sqlx::{Connection, Executor, PgConnection, PgPool};
use std::net::TcpListener;
use std::sync::LazyLock;
use uuid::Uuid;
use zero2prod::configuration::{get_configuration, DatabaseSettings};
use zero2prod::email_client::EmailClient;
use zero2prod::startup::run;
use zero2prod::telemetry::{get_subscriber, init_subscriber};static TRACING: LazyLock<()> = LazyLock::new(|| {let default_filter_level = "info".to_string();let subscriber_name = "test".to_string();if std::env::var("TEST_LOG").is_ok() {let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout);init_subscriber(subscriber);} else {let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink);init_subscriber(subscriber);};
});pub struct TestApp {pub address: String,pub db_pool: PgPool,
}pub async fn spawn_app() -> TestApp {// The first time `initialize` is invoked the code in `TRACING` is executed.// All other invocations will instead skip execution.LazyLock::force(&TRACING);let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");// We retrieve the port assigned to us by the OSlet port = listener.local_addr().unwrap().port();let address = format!("http://127.0.0.1:{}", port);let mut configuration = get_configuration().expect("Failed to read configuration.");configuration.database.database_name = Uuid::new_v4().to_string();let connection_pool = configure_database(&configuration.database).await;let sender_email = configuration.email_client.sender().expect("Invalid sender email address.");let timeout = configuration.email_client.timeout();let email_client = EmailClient::new(configuration.email_client.base_url,sender_email,configuration.email_client.authorization_token,timeout,);let server =run(listener, connection_pool.clone(), email_client).expect("Failed to bind address");let _ = tokio::spawn(server);TestApp {address,db_pool: connection_pool,}
}pub async fn configure_database(config: &DatabaseSettings) -> PgPool {// Create databaselet maintenance_settings = DatabaseSettings {database_name: "postgres".to_string(),username: "postgres".to_string(),password: Secret::new("password".to_string()),..config.clone()};let mut connection = PgConnection::connect_with(&maintenance_settings.connect_options()).await.expect("Failed to connect to Postgres");connection.execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str()).await.expect("Failed to create database.");// Migrate databaselet connection_pool = PgPool::connect_with(config.connect_options()).await.expect("Failed to connect to Postgres.");sqlx::migrate!("./migrations").run(&connection_pool).await.expect("Failed to migrate the database");connection_pool
}
注意:spawn_app() 函数需要改成pub,可以让其他类访问
subscriptions.rs
use crate::helpers::spawn_app;#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {// Arrangelet app = spawn_app().await;let client = reqwest::Client::new();let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";// Actlet response = client.post(&format!("{}/subscriptions", &app.address)).header("Content-Type", "application/x-www-form-urlencoded").body(body).send().await.expect("Failed to execute request.");// Assertassert_eq!(200, response.status().as_u16());let saved = sqlx::query!("SELECT email, name FROM subscriptions",).fetch_one(&app.db_pool).await.expect("Failed to fetch saved subscription.");assert_eq!(saved.email, "ursula_le_guin@gmail.com");assert_eq!(saved.name, "le guin");
}#[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() {// Arrangelet app = spawn_app().await;let client = reqwest::Client::new();let test_cases = vec![("name=le%20guin", "missing the email"),("email=ursula_le_guin%40gmail.com", "missing the name"),("", "missing both name and email"),];for (invalid_body, error_message) in test_cases {// Actlet response = client.post(&format!("{}/subscriptions", &app.address)).header("Content-Type", "application/x-www-form-urlencoded").body(invalid_body).send().await.expect("Failed to execute request.");// Assertassert_eq!(400,response.status().as_u16(),// Additional customised error message on test failure"The API did not fail with 400 Bad Request when the payload was {}.",error_message);}
}#[tokio::test]
async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() {// Arrangelet app = spawn_app().await;let client = reqwest::Client::new();let test_cases = vec![("name=&email=ursula_le_guin%40gmail.com", "empty name"),("name=Ursula&email=", "empty email"),("name=Ursula&email=definitely-not-an-email", "invalid email"),];for (body, description) in test_cases {// Actlet response = client.post(&format!("{}/subscriptions", &app.address)).header("Content-Type", "application/x-www-form-urlencoded").body(body).send().await.expect("Failed to execute request.");// Assertassert_eq!(400,response.status().as_u16(),"The API did not return a 400 Bad Request when the payload was {}.",description);}
}
恭喜,你已经将测试套件分解为更小、更易管理的模块!这种新的结构带来了几个积极的副作用:
-
递归性:如果
tests/api/subscriptions.rs
文件变得难以管理,你可以将其转换为一个模块。这样,tests/api/subscriptions/helpers.rs
可以保存订阅特定的测试辅助函数,并且可以有多个专注于特定流程或问题的测试文件。这使得即使当某个部分变得复杂时,你仍然能够保持良好的组织结构。 -
封装:你的测试只需要知道关于
spawn_app
和TestApp
的信息——没有必要暴露configure_database
或TRACING
这样的细节。通过将这些复杂性隐藏在helpers
模块中,你可以使测试代码更加简洁和易于理解。 -
单个测试二进制文件:如果你有一个大型测试套件并且使用的是扁平文件结构,那么每次运行
cargo test
时,你可能会构建数十个可执行文件。虽然每个可执行文件是并行编译的,但链接阶段却是完全顺序进行的!将所有测试用例捆绑到一个可执行文件中可以减少在持续集成(CI)环境中编译测试套件所花费的时间。
修改启动业务
从代码结构的角度来看,启动逻辑可以被设计为一个函数,该函数接收 Settings
作为输入,并返回应用程序实例作为输出。因此, main
函数应该尽量简洁,主要负责调用这个启动逻辑函数并处理任何高层级的错误。
//!main.rs
use zero2prod::{configuration::get_configuration,telemetry::{get_subscriber, init_subscriber},
};#[tokio::main]
async fn main() -> std::io::Result<()> {let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout);init_subscriber(subscriber);let configuration = get_configuration().expect("Failed to read configuration.");let application = Application::build(configuration).await?;application.run_util_stoped().await?;Ok(())
}
startup.rs重构
//startup.rs
use crate::configuration::{DatabaseSettings, Settings};
use crate::email_client::EmailClient;
use crate::routes::{health_check, subscribe};
use actix_web::dev::Server;
use actix_web::web::Data;
use actix_web::{web, App, HttpServer};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::net::TcpListener;
use tracing_actix_web::TracingLogger;pub struct Application {port: u16,server: Server,
}impl Application {pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> {let connection_pool = get_connection_pool(&configuration.database);let sender_email = configuration.email_client.sender().expect("Invalid sender email address.");let timeout = configuration.email_client.timeout();let email_client = EmailClient::new(configuration.email_client.base_url,sender_email,configuration.email_client.authorization_token,timeout,);let address = format!("{}:{}",configuration.application.host, configuration.application.port);let listener = TcpListener::bind(address)?;let port = listener.local_addr().unwrap().port();let server = run(listener, connection_pool, email_client)?;Ok(Self { port, server })}pub fn port(&self) -> u16 {self.port}pub async fn run_util_stoped(self) -> Result<(), std::io::Error> {self.server.await}
}pub fn get_connection_pool(configuration: &DatabaseSettings) -> PgPool {PgPoolOptions::new().connect_lazy_with(configuration.connect_options())
}pub fn run(listener: TcpListener,db_pool: PgPool,email_client: EmailClient,
) -> Result<Server, std::io::Error> {let db_pool = Data::new(db_pool);let email_client = Data::new(email_client);let server = HttpServer::new(move || {App::new().wrap(TracingLogger::default()).route("/health_check", web::get().to(health_check)).route("/subscriptions", web::post().to(subscribe)).app_data(db_pool.clone()).app_data(email_client.clone())}).listen(listener)?.run();Ok(server)
}
helpers重构
pub async fn spawn_app() -> TestApp {LazyLock::force(&TRACING);let configuration = {let mut c = get_configuration().expect("Failed to read configuration.");c.database.database_name = Uuid::new_v4().to_string();c.application.port = 0;c};configure_database(&configuration.database).await;let application = Application::build(configuration.clone()).await.expect("Failed to build application.");let address = format!("http://localhost:{}", application.port());let _ = tokio::spawn(application.run_util_stoped());TestApp {address,db_pool: get_connection_pool(&configuration.database),}
}
构建API Client
helpers.rs
//[..]
impl TestApp {pub async fn post_subscriptions(&self, body: String) -> reqwest::Response {reqwest::Client::new().post(&format!("{}/subscriptions", &self.address)).header("Content-Type", "application/x-www-form-urlencoded").body(body).send().await.expect("Failed to execute reuest.")}
}//[..]
subscriptions.rs
use crate::helpers::spawn_app;#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {// Arrangelet app = spawn_app().await;let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";// Actlet response = app.post_subscriptions(body.into()).await;// Assertassert_eq!(200, response.status().as_u16());let saved = sqlx::query!("SELECT email, name FROM subscriptions",).fetch_one(&app.db_pool).await.expect("Failed to fetch saved subscription.");assert_eq!(saved.email, "ursula_le_guin@gmail.com");assert_eq!(saved.name, "le guin");
}#[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() {// Arrangelet app = spawn_app().await;let client = reqwest::Client::new();let test_cases = vec![("name=le%20guin", "missing the email"),("email=ursula_le_guin%40gmail.com", "missing the name"),("", "missing both name and email"),];for (invalid_body, error_message) in test_cases {// Actlet response = app.post_subscriptions(invalid_body.into()).await;// Assertassert_eq!(400,response.status().as_u16(),// Additional customised error message on test failure"The API did not fail with 400 Bad Request when the payload was {}.",error_message);}
}#[tokio::test]
async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() {// Arrangelet app = spawn_app().await;let test_cases = vec![("name=&email=ursula_le_guin%40gmail.com", "empty name"),("name=Ursula&email=", "empty email"),("name=Ursula&email=definitely-not-an-email", "invalid email"),];for (body, description) in test_cases {// Actlet response = app.post_subscriptions(body.into()).await;// Assertassert_eq!(400,response.status().as_u16(),"The API did not return a 400 Bad Request when the payload was {}.",description);}
}
目前为止,我们完成了第二阶段的重构,代码结构看起来视乎更清晰,接下来继续.....