Rust 测试驱动开发

前言:

   开发软件时, 我们为了要确保是在做对的事情, 并且是能将事情给做对; 我们必需要能做到这三件事情:

  • 将复杂的需求, 拆解到 User Story 的粒度; 使得我们能真正精准的理解需求。
  • 将 User Story 内的需求能转换为 “测试用例”; 使得我们能明确且完整的定义 User Story 的 “Definition of Done”。
  • 将测试用例从测试失败走向测试成功。

在本文中, 我们将以一 User Story 为例; 以 Rust 所编写的测试用例, 明确且完整的定义这个 User Story 的 “Definition of Done”。并且我们将使用 actix-web, 使得我们能以更少的代码、更高的质量、更短的时间, 使得测试用例能从失败走向测试成功。

 

本文:Rust 测试驱动开发

User Story:

   首先, 我们先来看看 User Story:

  • 我们需要让我们的用户能订阅我们产品的最新的信息; 用户需提交姓名与 e-mail, 才能订阅我们产品最新的信息。
  • 用户使用 application/x-www-form-urlencoded 的格式, 并且同时提交了姓名与 e-mail ;  User Story 将会返回个 200 OK 的状态值。
  • 用户使用 application/x-www-form-urlencoded 的格式, 提交时却遗漏了姓名或 e-mail;  User Story 将会返回 400 BAD REQUEST。

Rust 测试用例:

   针对上述的 User Story, 我们将以两个测试用例, 来定义此 User Story 的 “Definition of Done”:

  • async fn subscribe_returns_a_200_for_valid_from_data(); 用户使用 application/x-www-form-urlencoded 的格式, 并且同时提交了姓名与 e-mail ;  User Story 将会返回个 200 OK 的状态值。

代码如下:

// 此测试用例主要是 Assert User Story 返回 200 OK 的状态值。
#[tokio::test]
async fn subscribe_returns_a_200_for_valid_from_data() {
    // Arrange
    let app_address = spawn_app();
    let client = reqwest::Client::new();

    // Act
    let body = "name=ken%20fang&email=kenfang%40gmail.com";
    let response = client
        .post(&format!("{}/subscriptions", &app_address))
        .header("Content-Type", "application/x-www-form-urlencoded")
        .body(body)
        .send()
        .await
        .expect("Failed to send request");

    // Assert
    assert_eq!(200, response.status().as_u16());
}
  • async fn subscribe_returns_a_400_when_data_is_missing(); 用户使用 application/x-www-form-urlencoded 的格式, 提交时却遗漏了姓名或 e-mail;  User Story 将会返回 400 BAD REQUEST。

代码如下:

#[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() {
    // Arrange
    let app_address = spawn_app();
    let client = reqwest::Client::new();
    let test_cases = vec![
        ("name=ken%20fang", "missing the email"),
        ("email=kenfang%40gmail.com", "missing the name" ),
        ("", "missing both name and email")
    ];

    // Act & Assert
    for (invalid_body, error_message) in test_cases {
        // Act
        let response = client
            .post(&format!("{}/subscriptions", &app_address))
            .header("Content-Type", "application/x-www-form-urlencoded")
            .body(invalid_body)
            .send()
            .await
            .expect("Failed to send request.");

        // Assert
        assert_eq!(
            400, response.status().as_u16(),

            // Customized error message on test failure
            "The API did not fail with 400 Bad Request when the payload was {}.", error_message
        );
    }
}

此测试用例采用 “table-driven test” 的方式, 将会造成 User Story 返回 400 BAD REQUEST 的场景; 遗漏姓名、遗漏 e-mail、遗漏姓名与 e-mail; 都集中的写在 vec![ ] 当中。

测试用例采用 “table-driven test” 的方式, 将会使得测试用例能够避免掉重复处理相同测试逻辑代码的产生; 使得测试用例能够更简洁、更易于维护。

当然, 如我们所预期的: 这两个测试用例; async fn subscribe_returns_a_200_for_valid_from_data(), async fn subscribe_returns_a_400_when_data_is_missing(); 目前都是测试失败的。

从测试失败走向测试成功:

测试用例; async fn subscribe_returns_a_200_for_valid_from_data()

    如先前所述, 测试用例; async fn subscribe_returns_a_200_for_valid_from_data(); 同时提交了姓名与 e-mail, 并且预期 User Story (测试中单元 (Unit Under Test)) 将会返回个 200 OK 的状态值。

为了使测试用例; async fn subscribe_returns_a_200_for_valid_from_data(); 可以从测试失败走向测试成功, 我们将在测试中单元 (Unit Under Test); src/lib.rs; 加入个  “匹配路由 (matching route)”; route(“/subscriptions”, web::post().to(subscribe)。

代码如下:

use actix_web::{web, App, HttpResponse, HttpServer};
use actix_web::dev::Server;
use std::net::TcpListener;

async fn health_check() -> HttpResponse {
    HttpResponse::Ok().finish()
}

// Let's start simple: We always return a 200 Ok
async fn subscribe()-> HttpResponse {
    HttpResponse::Ok().finish()
}

// Return 'Server' on the happy path
pub fn run(listener: TcpListener) -> Result<Server, std::io::Error> {
    let server = HttpServer::new(||  {
        App::new()
            .route("/health_check", web::get().to(health_check))
            // A new entry in our routing table for POST /subscriptions requests
            .route("/subscriptions", web::post().to(subscribe))
    })
        .listen(listener)?
        .run();

    // No .await here!
    Ok(server)
}

执行 cargo test 的结果如下:

running 3 tests
test health_check_works ... ok
test subscribe_returns_a_200_for_valid_from_data ... ok
test subscribe_returns_a_400_when_data_is_missing ... FAILED

测试用例; async fn subscribe_returns_a_200_for_valid_from_data(); 已经从测试失败走向测试成功。

接下来, 我们来看看, 如何的让测试用例; subscribe_returns_a_400_when_data_is_missing(); 从测试失败走向测试成功?

测试用例; subscribe_returns_a_400_when_data_is_missing()

如先前所述, 测试用例; subscribe_returns_a_400_when_data_is_missing(); 提交时遗漏了姓名或 e-mail;  User Story 将会返回 400 BAD REQUEST。

测试用例; subscribe_returns_a_400_when_data_is_missing(); 提交了请求正文 ( request body), 所以, 我们先来了解下, 测试中单元 (Unit Under Test); src/lib.rs; 是要如何的使用 actix-web 的 Extractors 来解析由测试用例; subscribe_returns_a_400_when_data_is_missing(); 所提交的请求正文 ( request body) 的?

关于 actix-web Extractors, 大家可参考: https://actix.rs/docs/extractors/

在测试中单元 (Unit Under Test); src/lib.rs; 使用 actix-web 的 Extractors, 就只有两个步骤:

  1. 在 Cargo.toml 加入 serde 的依赖。Cargo.toml 如下:
[package]
name = "zero2prod"
version = "0.1.0"
authors = ["Ken Fang kenfang@deva9.com"]
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
path = "src/lib.rs"

[[bin]]
path = "src/main.rs"
name = "zero2prod"

[dependencies]
actix-web = "4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1.0", features = ["derive"] }

[dev-dependencies]
reqwest = "0.11"

     2. 在测试中单元 (Unit Under Test); src/lib.rs; 定义好请求正文 ( request body) 的 struct。请求正文 ( request body) 的 struct 如下:

#[derive(serde::Deserialize)]
struct FormData {
    email: String,
    name: String,
}

actix-web Extractors 的请求正文 ( request body) 的 struct 对编程而言是有著极大的便利性的; 也就是说, 我们只需在测试中单元 (Unit Under Test); src/lib.rs; 定义好请求正文 ( request body) 的 struct, actix-web 便会帮我们做好、做满其他在请求处理上的工作, 例如:

  • Deserialize 由前端所提交的请求正文 ( request body) 到我们所定义的请求正文 ( request body) 的 struct; struct FormData。
  • 当测试用例; subscribe_returns_a_400_when_data_is_missing(); 提交的请求正文 ( request body) 中遗漏了姓名或 e-mail 的时候, actix-web 便会返回 400 BAD REQUEST。
  • 当测试用例; async fn subscribe_returns_a_200_for_valid_from_data(); 提交的请求正文 ( request body) 有著完整的姓名与 e-mail 的时候, actix-web便会返回 200 OK。   

测试中单元 (Unit Under Test); src/lib.rs; 的完整代码如下:

use actix_web::{web, App, HttpResponse, HttpServer};
use actix_web::dev::Server;
use std::net::TcpListener;

async fn health_check() -> HttpResponse {
    HttpResponse::Ok().finish()
}

#[derive(serde::Deserialize)]
struct FormData {
    email: String,
    name: String,
}

// Let's start simple: We always return a 200 Ok
async fn subscribe(_form: web::Form<FormData>) -> HttpResponse {
    HttpResponse::Ok().finish()
}

// Return 'Server' on the happy path
pub fn run(listener: TcpListener) -> Result<Server, std::io::Error> {
    let server = HttpServer::new(||  {
        App::new()
            .route("/health_check", web::get().to(health_check))
            // A new entry in our routing table for POST /subscriptions requests
            .route("/subscriptions", web::post().to(subscribe))
    })
        .listen(listener)?
        .run();

    // No .await here!
    Ok(server)
}

执行 cargo test 的结果如下:

running 3 tests
test subscribe_returns_a_200_for_valid_from_data ... ok
test health_check_works ... ok
test subscribe_returns_a_400_when_data_is_missing ... ok

结论:

在本文中, 我们…

  • 将 User Story 转化为 Rust 的测试用例; 使得 User Story 的 “开发完成的定义与 “质量, 可经由测试用例而获得了保障。
  • 我们 采用 “table-driven test” 的方式, 使得测试用例能够更简洁、更易于维护。
  • actix-web 大幅提升了我们开发的效率; 使得我们能以更少的代码、更高的质量、更短的时间, 使得测试用例能从失败走向测试成功

期待著你持续的关注与反馈。

完整的代码, 请参考: https://github.com/KenFang/RustTDD

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据

滚动至顶部