
前言:
开发软件时, 我们为了要确保是在做对的事情, 并且是能将事情给做对; 我们必需要能做到这三件事情:
- 将复杂的需求, 拆解到 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, 就只有两个步骤:
- 在 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