构建一个rust生产应用读书笔记7-确认邮件2

打印 上一主题 下一主题

主题 699|帖子 699|积分 2097

共享测试辅助

当每个集成测试文件都是一个独立的可执行文件时,共享测试辅助函数的一种常见方法是创建一个单独的模块,该模块可以被全部测试文件导入和使用。这个模块通常包罗全部测试需要共用的辅助函数、常量、配置和其他资源。如果遵循这种做法,你可以按照以下步骤操纵:
在你的 tests 目次下,创建一个名为 helpers 的模块

  1. //! tests/health_check.rs
  2. // [...]
  3. mod helpers;
  4. // [...]
复制代码
在 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 等),你可以重复上述步骤,为每种类型的测试创建各自的目次和辅助模块。
修改后的目次布局如下:
  1. tests
  2. └── api
  3.     ├── health_check.rs
  4.     ├── helpers.rs
  5.     ├── main.rs
  6.     └── subscriptions.rs
复制代码
在main.rs添加子模块

  1. mod health_check;
  2. mod helpers;
  3. mod subscriptions;
复制代码
health_check.rs 到helpers.rs

  1. // Ensure that the `tracing` stack is only initialised once using `once_cell`
  2. use secrecy::Secret;
  3. use sqlx::{Connection, Executor, PgConnection, PgPool};
  4. use std::net::TcpListener;
  5. use std::sync::LazyLock;
  6. use uuid::Uuid;
  7. use zero2prod::configuration::{get_configuration, DatabaseSettings};
  8. use zero2prod::email_client::EmailClient;
  9. use zero2prod::startup::run;
  10. use zero2prod::telemetry::{get_subscriber, init_subscriber};
  11. static TRACING: LazyLock<()> = LazyLock::new(|| {
  12.     let default_filter_level = "info".to_string();
  13.     let subscriber_name = "test".to_string();
  14.     if std::env::var("TEST_LOG").is_ok() {
  15.         let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout);
  16.         init_subscriber(subscriber);
  17.     } else {
  18.         let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink);
  19.         init_subscriber(subscriber);
  20.     };
  21. });
  22. pub struct TestApp {
  23.     pub address: String,
  24.     pub db_pool: PgPool,
  25. }
  26. pub async fn spawn_app() -> TestApp {
  27.     // The first time `initialize` is invoked the code in `TRACING` is executed.
  28.     // All other invocations will instead skip execution.
  29.     LazyLock::force(&TRACING);
  30.     let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");
  31.     // We retrieve the port assigned to us by the OS
  32.     let port = listener.local_addr().unwrap().port();
  33.     let address = format!("http://127.0.0.1:{}", port);
  34.     let mut configuration = get_configuration().expect("Failed to read configuration.");
  35.     configuration.database.database_name = Uuid::new_v4().to_string();
  36.     let connection_pool = configure_database(&configuration.database).await;
  37.     let sender_email = configuration
  38.         .email_client
  39.         .sender()
  40.         .expect("Invalid sender email address.");
  41.     let timeout = configuration.email_client.timeout();
  42.     let email_client = EmailClient::new(
  43.         configuration.email_client.base_url,
  44.         sender_email,
  45.         configuration.email_client.authorization_token,
  46.         timeout,
  47.     );
  48.     let server =
  49.         run(listener, connection_pool.clone(), email_client).expect("Failed to bind address");
  50.     let _ = tokio::spawn(server);
  51.     TestApp {
  52.         address,
  53.         db_pool: connection_pool,
  54.     }
  55. }
  56. pub async fn configure_database(config: &DatabaseSettings) -> PgPool {
  57.     // Create database
  58.     let maintenance_settings = DatabaseSettings {
  59.         database_name: "postgres".to_string(),
  60.         username: "postgres".to_string(),
  61.         password: Secret::new("password".to_string()),
  62.         ..config.clone()
  63.     };
  64.     let mut connection = PgConnection::connect_with(&maintenance_settings.connect_options())
  65.         .await
  66.         .expect("Failed to connect to Postgres");
  67.     connection
  68.         .execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str())
  69.         .await
  70.         .expect("Failed to create database.");
  71.     // Migrate database
  72.     let connection_pool = PgPool::connect_with(config.connect_options())
  73.         .await
  74.         .expect("Failed to connect to Postgres.");
  75.     sqlx::migrate!("./migrations")
  76.         .run(&connection_pool)
  77.         .await
  78.         .expect("Failed to migrate the database");
  79.     connection_pool
  80. }
复制代码
注意:spawn_app() 函数需要改成pub,可以让其他类访问
subscriptions.rs

  1. use crate::helpers::spawn_app;
  2. #[tokio::test]
  3. async fn subscribe_returns_a_200_for_valid_form_data() {
  4.     // Arrange
  5.     let app = spawn_app().await;
  6.     let client = reqwest::Client::new();
  7.     let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
  8.     // Act
  9.     let response = client
  10.         .post(&format!("{}/subscriptions", &app.address))
  11.         .header("Content-Type", "application/x-www-form-urlencoded")
  12.         .body(body)
  13.         .send()
  14.         .await
  15.         .expect("Failed to execute request.");
  16.     // Assert
  17.     assert_eq!(200, response.status().as_u16());
  18.     let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
  19.         .fetch_one(&app.db_pool)
  20.         .await
  21.         .expect("Failed to fetch saved subscription.");
  22.     assert_eq!(saved.email, "ursula_le_guin@gmail.com");
  23.     assert_eq!(saved.name, "le guin");
  24. }
  25. #[tokio::test]
  26. async fn subscribe_returns_a_400_when_data_is_missing() {
  27.     // Arrange
  28.     let app = spawn_app().await;
  29.     let client = reqwest::Client::new();
  30.     let test_cases = vec![
  31.         ("name=le%20guin", "missing the email"),
  32.         ("email=ursula_le_guin%40gmail.com", "missing the name"),
  33.         ("", "missing both name and email"),
  34.     ];
  35.     for (invalid_body, error_message) in test_cases {
  36.         // Act
  37.         let response = client
  38.             .post(&format!("{}/subscriptions", &app.address))
  39.             .header("Content-Type", "application/x-www-form-urlencoded")
  40.             .body(invalid_body)
  41.             .send()
  42.             .await
  43.             .expect("Failed to execute request.");
  44.         // Assert
  45.         assert_eq!(
  46.             400,
  47.             response.status().as_u16(),
  48.             // Additional customised error message on test failure
  49.             "The API did not fail with 400 Bad Request when the payload was {}.",
  50.             error_message
  51.         );
  52.     }
  53. }
  54. #[tokio::test]
  55. async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() {
  56.     // Arrange
  57.     let app = spawn_app().await;
  58.     let client = reqwest::Client::new();
  59.     let test_cases = vec![
  60.         ("name=&email=ursula_le_guin%40gmail.com", "empty name"),
  61.         ("name=Ursula&email=", "empty email"),
  62.         ("name=Ursula&email=definitely-not-an-email", "invalid email"),
  63.     ];
  64.     for (body, description) in test_cases {
  65.         // Act
  66.         let response = client
  67.             .post(&format!("{}/subscriptions", &app.address))
  68.             .header("Content-Type", "application/x-www-form-urlencoded")
  69.             .body(body)
  70.             .send()
  71.             .await
  72.             .expect("Failed to execute request.");
  73.         // Assert
  74.         assert_eq!(
  75.             400,
  76.             response.status().as_u16(),
  77.             "The API did not return a 400 Bad Request when the payload was {}.",
  78.             description
  79.         );
  80.     }
  81. }
复制代码
恭喜,你已经将测试套件分解为更小、更易管理的模块!这种新的布局带来了几个积极的副作用:

  • 递归性:如果 tests/api/subscriptions.rs 文件变得难以管理,你可以将其转换为一个模块。如许,tests/api/subscriptions/helpers.rs 可以生存订阅特定的测试辅助函数,并且可以有多个专注于特定流程或题目标测试文件。这使得纵然当某个部分变得复杂时,你仍旧能够保持精良的构造布局。
  • 封装:你的测试只需要知道关于 spawn_app 和 TestApp 的信息——没有须要袒露 configure_database 或 TRACING 如许的细节。通过将这些复杂性潜伏在 helpers 模块中,你可以使测试代码更加简便和易于理解。
  • 单个测试二进制文件:如果你有一个大型测试套件并且使用的是扁平文件布局,那么每次运行 cargo test 时,你大概会构建数十个可执行文件。固然每个可执行文件是并行编译的,但链接阶段却是完全顺序进行的!将全部测试用例捆绑到一个可执行文件中可以减少在连续集成(CI)情况中编译测试套件所花费的时间。
修改启动业务

从代码布局的角度来看,启动逻辑可以被筹划为一个函数,该函数接收 Settings 作为输入,并返回应用步伐实例作为输出。因此, main 函数应该尽量简便,主要负责调用这个启动逻辑函数并处理任何高层级的错误。
  1. //!main.rs
  2. use zero2prod::{
  3.     configuration::get_configuration,
  4.     telemetry::{get_subscriber, init_subscriber},
  5. };
  6. #[tokio::main]
  7. async fn main() -> std::io::Result<()> {
  8.     let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout);
  9.     init_subscriber(subscriber);
  10.     let configuration = get_configuration().expect("Failed to read configuration.");
  11.     let application = Application::build(configuration).await?;
  12.     application.run_util_stoped().await?;
  13.     Ok(())
  14. }
复制代码
startup.rs重构 

  1. //startup.rs
  2. use crate::configuration::{DatabaseSettings, Settings};
  3. use crate::email_client::EmailClient;
  4. use crate::routes::{health_check, subscribe};
  5. use actix_web::dev::Server;
  6. use actix_web::web::Data;
  7. use actix_web::{web, App, HttpServer};
  8. use sqlx::postgres::PgPoolOptions;
  9. use sqlx::PgPool;
  10. use std::net::TcpListener;
  11. use tracing_actix_web::TracingLogger;
  12. pub struct Application {
  13.     port: u16,
  14.     server: Server,
  15. }
  16. impl Application {
  17.     pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> {
  18.         let connection_pool = get_connection_pool(&configuration.database);
  19.         let sender_email = configuration
  20.             .email_client
  21.             .sender()
  22.             .expect("Invalid sender email address.");
  23.         let timeout = configuration.email_client.timeout();
  24.         let email_client = EmailClient::new(
  25.             configuration.email_client.base_url,
  26.             sender_email,
  27.             configuration.email_client.authorization_token,
  28.             timeout,
  29.         );
  30.         let address = format!(
  31.             "{}:{}",
  32.             configuration.application.host, configuration.application.port
  33.         );
  34.         let listener = TcpListener::bind(address)?;
  35.         let port = listener.local_addr().unwrap().port();
  36.         let server = run(listener, connection_pool, email_client)?;
  37.         Ok(Self { port, server })
  38.     }
  39.     pub fn port(&self) -> u16 {
  40.         self.port
  41.     }
  42.     pub async fn run_util_stoped(self) -> Result<(), std::io::Error> {
  43.         self.server.await
  44.     }
  45. }
  46. pub fn get_connection_pool(configuration: &DatabaseSettings) -> PgPool {
  47.     PgPoolOptions::new().connect_lazy_with(configuration.connect_options())
  48. }
  49. pub fn run(
  50.     listener: TcpListener,
  51.     db_pool: PgPool,
  52.     email_client: EmailClient,
  53. ) -> Result<Server, std::io::Error> {
  54.     let db_pool = Data::new(db_pool);
  55.     let email_client = Data::new(email_client);
  56.     let server = HttpServer::new(move || {
  57.         App::new()
  58.             .wrap(TracingLogger::default())
  59.             .route("/health_check", web::get().to(health_check))
  60.             .route("/subscriptions", web::post().to(subscribe))
  61.             .app_data(db_pool.clone())
  62.             .app_data(email_client.clone())
  63.     })
  64.     .listen(listener)?
  65.     .run();
  66.     Ok(server)
  67. }
复制代码
helpers重构

  1. pub async fn spawn_app() -> TestApp {
  2.     LazyLock::force(&TRACING);
  3.     let configuration = {
  4.         let mut c = get_configuration().expect("Failed to read configuration.");
  5.         c.database.database_name = Uuid::new_v4().to_string();
  6.         c.application.port = 0;
  7.         c
  8.     };
  9.     configure_database(&configuration.database).await;
  10.     let application = Application::build(configuration.clone())
  11.         .await
  12.         .expect("Failed to build application.");
  13.     let address = format!("http://localhost:{}", application.port());
  14.     let _ = tokio::spawn(application.run_util_stoped());
  15.     TestApp {
  16.         address,
  17.         db_pool: get_connection_pool(&configuration.database),
  18.     }
  19. }
复制代码
构建API Client

helpers.rs

  1. //[..]
  2. impl TestApp {
  3.     pub async fn post_subscriptions(&self, body: String) -> reqwest::Response {
  4.         reqwest::Client::new()
  5.             .post(&format!("{}/subscriptions", &self.address))
  6.             .header("Content-Type", "application/x-www-form-urlencoded")
  7.             .body(body)
  8.             .send()
  9.             .await
  10.             .expect("Failed to execute reuest.")
  11.     }
  12. }
  13. //[..]
复制代码
subscriptions.rs

  1. use crate::helpers::spawn_app;
  2. #[tokio::test]
  3. async fn subscribe_returns_a_200_for_valid_form_data() {
  4.     // Arrange
  5.     let app = spawn_app().await;
  6.     let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
  7.     // Act
  8.     let response = app.post_subscriptions(body.into()).await;
  9.     // Assert
  10.     assert_eq!(200, response.status().as_u16());
  11.     let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
  12.         .fetch_one(&app.db_pool)
  13.         .await
  14.         .expect("Failed to fetch saved subscription.");
  15.     assert_eq!(saved.email, "ursula_le_guin@gmail.com");
  16.     assert_eq!(saved.name, "le guin");
  17. }
  18. #[tokio::test]
  19. async fn subscribe_returns_a_400_when_data_is_missing() {
  20.     // Arrange
  21.     let app = spawn_app().await;
  22.     let client = reqwest::Client::new();
  23.     let test_cases = vec![
  24.         ("name=le%20guin", "missing the email"),
  25.         ("email=ursula_le_guin%40gmail.com", "missing the name"),
  26.         ("", "missing both name and email"),
  27.     ];
  28.     for (invalid_body, error_message) in test_cases {
  29.         // Act
  30.         let response = app.post_subscriptions(invalid_body.into()).await;
  31.         // Assert
  32.         assert_eq!(
  33.             400,
  34.             response.status().as_u16(),
  35.             // Additional customised error message on test failure
  36.             "The API did not fail with 400 Bad Request when the payload was {}.",
  37.             error_message
  38.         );
  39.     }
  40. }
  41. #[tokio::test]
  42. async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() {
  43.     // Arrange
  44.     let app = spawn_app().await;
  45.     let test_cases = vec![
  46.         ("name=&email=ursula_le_guin%40gmail.com", "empty name"),
  47.         ("name=Ursula&email=", "empty email"),
  48.         ("name=Ursula&email=definitely-not-an-email", "invalid email"),
  49.     ];
  50.     for (body, description) in test_cases {
  51.         // Act
  52.         let response = app.post_subscriptions(body.into()).await;
  53.         // Assert
  54.         assert_eq!(
  55.             400,
  56.             response.status().as_u16(),
  57.             "The API did not return a 400 Bad Request when the payload was {}.",
  58.             description
  59.         );
  60.     }
  61. }
复制代码
目前为止,我们完成了第二阶段的重构,代码布局看起来视乎更清楚,接下来继续.....

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

梦见你的名字

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表