9 个版本 (4 个重大更改)
0.5.1 | 2023年2月20日 |
---|---|
0.5.0 | 2023年2月15日 |
0.4.0 | 2023年1月10日 |
0.3.1 | 2022年11月19日 |
0.1.1 | 2022年10月27日 |
#325 in 测试
每月 42 次下载
195KB
4K SLoC
Let's Expect
Rust 的整洁测试。
expect(a + 2) {
when(a = 2) {
to equal(4)
}
}
目录
简介
当你看到 Rust 测试时,你经常想 "哇,这是一个非常漂亮的测试" 吗? 不经常吧? 经典的 Rust 测试除了测试函数本身之外,没有任何结构。这通常会导致大量的样板代码、临时的测试结构和整体低质量。
测试是验证在特定条件下运行的代码是否按预期工作。一个好的测试框架拥抱这种思维方式。它使得以反映它的方式结构化代码变得容易。其他社区的人已经用像 RSpec 和 Jasmine 这样的工具做了很长时间。
如果您希望拥有美观、高质量的测试,这些测试易于阅读和编写,您需要其他工具。使用Rust的过程宏lets_expect
引入了一种语法,可以让您清晰地声明您正在测试什么,在什么条件下,以及期望的结果是什么。
结果是
- 易于阅读,遵循DRY原则,适用于TDD的测试
- 更少模板代码,更少代码
- 更友好的错误信息
- 更有趣
示例
expect(posts.create_post(title, category_id)) {
before { posts.push(Post {title: "Post 1" }) }
after { posts.clear() }
when(title = valid_title) {
when(category_id = valid_category) to create_a_post {
be_ok,
have(as_ref().unwrap().title) equal(valid_title),
change(posts.len()) { from(1), to(2) }
}
when(category_id = invalid_category) to return_an_error {
be_err,
have(as_ref().unwrap_err().message) equal("Invalid category"),
not_change(posts.len())
}
}
when(title = invalid_title, category_id = valid_category) to be_err
}
现在让我们比较一下经典的Rust测试,它们做的是同样的事情
fn run_setup<T>(test: T) -> ()
where T: FnOnce(&mut Posts) -> () + panic::UnwindSafe
{
let mut posts = Posts { posts: vec![] };
posts.push(Post { title: "Post 1" });
let posts = Mutex::new(posts);
let result = panic::catch_unwind(|| {
test(posts.try_lock().unwrap().deref_mut())
});
posts.try_lock().unwrap().clear();
assert!(result.is_ok());
}
#[test]
fn creates_a_post() {
run_setup(|posts: &mut Posts| {
let before_count = posts.len();
let result = posts.create_post(VALID_TITLE, VALID_CATEGORY);
let after_count = posts.len();
assert!(result.is_ok());
assert_eq!(VALID_TITLE, result.unwrap().title);
assert_eq!(after_count - before_count, 1);
})
}
#[test]
fn returns_an_error_when_category_is_invalid() {
run_setup(|posts: &mut Posts| {
let before_count = posts.len();
let result = posts.create_post(VALID_TITLE, INVALID_CATEGORY);
let after_count = posts.len();
assert!(result.is_err());
assert_eq!("Invalid category", result.unwrap_err().message);
assert_eq!(after_count, before_count);
})
}
#[test]
fn returns_an_error_when_title_is_empty() {
run_setup(|posts: &mut Posts| {
let result = posts.create_post("", VALID_CATEGORY);
assert!(result.is_err());
})
}
安装
将以下内容添加到您的Cargo.toml
[dev-dependencies]
lets_expect = "0"
用法
它是如何工作的?
在底层,lets_expect
为每个to
块生成一个经典的测试函数。它会自动根据您要测试的内容命名这些测试,并将这些测试组织成模块。这意味着您可以使用cargo test
来运行这些测试,并且可以使用所有cargo test
功能。IDE扩展也将按预期工作。
在哪里放置我的测试?
lets_expect
测试需要放置在lets_expect!
宏内部,该宏又需要放置在tests
模块内部
#[cfg(test)]
mod tests {
use super::*;
use lets_expect::lets_expect;
lets_expect! {
expect(subject) {
to expectation
}
}
}
在IDE中定义一个代码片段可能是个好主意,这样可以避免每次都输入这些模板代码。
为了简洁,这里省略了宏的示例。
expect
和 to
expect
设置测试的主题。它可以是任何Rust表达式(包括一个代码块)。to
引入期望。它后面可以跟一个期望或者一个期望的代码块。在后一种情况下,您必须提供一个测试名称,它需要是一个有效的Rust标识符。
expect(2) {
to equal(2)
}
如果to
块中有多个断言,它们需要用逗号分隔。
expect({ 1 + 1 }) {
to be_actually_2 {
equal(2),
not_equal(3)
}
}
一个to
块生成一个测试。这意味着主题将执行一次,然后在该to
块内运行所有断言。如果您想生成多个测试,可以使用多个to
块
expect(files.create_file()) {
to make(files.try_to_remove_file()) be_true
to make(files.file_exists()) be_true
}
如果您的expect
包含单个项,您可以省略花括号
expect(a + 2) when(a = 2) {
to equal(4)
}
let
在顶层lets_expect!
宏以及expect
和when
代码块内部,您可以定义变量。
expect(a) {
let a = 2;
to equal(2)
}
变量可以在嵌套块中重写。新定义可以使用外部块中的值。
expect(a) {
let a = 2;
when a_is_4 {
let a = a + 2;
to equal(4)
}
}
变量的定义顺序不必与使用顺序相同。
expect(sum) {
let sum = a + b;
let a = 2;
when b_is_three {
let b = 3;
to equal(5)
}
}
when
when
为给定块设置一个或多个变量的值。这是本库的秘诀。它允许您以简洁和可读的方式定义多个测试中的变量值,而无需在每个测试中重复定义。
expect(a + b + c) {
let a = 2;
when(c = 5) {
when(b = 3) {
to equal(10)
}
when(a = 10, b = 10) {
to equal(25)
}
}
}
您可以使用与let
类似的语法来定义变量。唯一的区别是省略了let
关键字本身。
expect(a += 1) {
when(mut a: i64 = 1) {
to change(a.clone()) { from(1), to(2) }
}
}
您还可以使用when
与一个标识符一起使用。这将简单地创建一个具有给定标识符的新上下文。不定义新变量。
expect(login(username, password)) {
when credentials_are_invalid {
let username = "invalid";
let password = "invalid";
to be_false
}
}
如果when
只包含一个项,可以省略花括号
expect(a + 2) when(a = 2) to equal(4)
when
块不必放置在expect
块内。它们的顺序可以颠倒。
when(a = 2) {
expect(a + 2) to equal(4)
}
have
have
用于测试主题的属性值或方法返回值。
let response = Response { status: 200, content: ResponseContent::new("admin", "123") };
expect(response) {
to be_valid {
have(status) equal(200),
have(is_ok()) be_true,
have(content) {
have(username) equal("admin".to_string()),
have(token) equal("123".to_string()),
}
}
}
可以通过将断言用花括号括起来并用逗号分隔来提供给 have
。
make
make
用于测试任意表达式的值。
expect(posts.push((user_id, "new post"))) {
let user_id = 1;
to make(user_has_posts(user_id)) be_true
}
可以通过将断言用花括号括起来并用逗号分隔来提供给 make
。
change
change
用于测试在主体执行后值是否以及如何变化。将给 change
传递的表达式评估两次。一次在主体执行之前,一次在执行之后。然后将这两个值提供给 change
块中指定的断言。
expect(posts.create_post(title, category_id)) {
after { posts.clear() }
when(title = valid_title) {
when(category_id = valid_category) {
to change(posts.len()) { from(0), to(1) }
}
when(category_id = invalid_category) {
to not_change(posts.len())
}
}
}
before
和 after
before
块的内容在主体评估之前执行,但在 let
绑定执行之后。而 after
块的内容在主体评估和断言验证之后执行。
before
块按照定义的顺序执行。父 before
块在子 before
块之前执行。对于 after
块,情况相反。即使断言失败,也保证 after
块会执行。但是,如果 let
语句、before
块、主体评估或断言崩溃,则不会执行它们。
let mut messages: Vec<&str> = Vec::new();
before {
messages.push("first message");
}
after {
messages.clear();
}
expect(messages.len()) { to equal(1) }
expect(messages.push("new message")) {
to change(messages.len()) { from(1), to(2) }
}
为 expect
和 when
显式标识符
因为 lets_expect
在底层使用标准的 Rust 测试,它必须为每个测试提供一个唯一的标识符。为了使这些标识符可读,lets_expect
使用 expect
和 when
中的表达式来生成名称。这对于简单的表达式来说效果很好,但对于更复杂的表达式可能会有些杂乱。有时也可能导致名称重复。为了解决这些问题,您可以使用 as
关键字为测试提供一个显式名称。
expect(a + b + c) as sum_of_three {
when(a = 1, b = 1, c = 1) as everything_is_one to equal(3)
}
这将创建一个 test_named
。
expect_sum_of_three::when_everything_is_one::to_equal_three
而不是
expect_a_plus_b_plus_c::when_a_is_one_b_is_one_c_is_one::to_equal_three
故事
lets_expect
提倡一次只测试一小段代码的测试。到目前为止,我们看到的测试都定义了一个主体,运行该主体并验证结果。然而,有时我们可能希望按顺序运行和测试多个代码段。这可能是因为执行一段代码可能很耗时,我们不想在多个测试中多次执行它。
为了解决这个问题,lets_expect
提供了 story
关键字。故事与经典测试有些相似,因为它们允许在断言中交错任意语句。
请注意,在故事中,expect
关键字后面必须跟 to
而不能打开一个块。
story login_is_successful {
expect(page.logged_in) to be_false
let login_result = page.login(&invalid_user);
expect(&login_result) to be_err
expect(&login_result) to equal(Err(AuthenticationError { message: "Invalid credentials".to_string() }))
expect(page.logged_in) to be_false
let login_result = page.login(&valid_user);
expect(login_result) to be_ok
expect(page.logged_in) to be_true
}
注意:目前,
expect
块不能放置在循环或闭包中。它们必须是故事中的顶级项目。
可变变量和引用
对于某些测试,您可能需要使测试的值可变,或者可能需要将可变引用传递给断言。在 expect
、have
和 make
中,您可以使用 mut
关键字来实现这一点。
expect(mut vec![1, 2, 3]) { // make the subject mutable
to have(remove(1)) equal(2)
}
expect(mut vec.iter()) { // pass a mutable reference to the iterator to the assertion
let vec = vec![1, 2, 3];
to all(be_greater_than(0))
}
expect(vec![1, 2, 3]) {
to have(mut iter()) all(be_greater_than(0)) // pass a mutable reference to the iterator to the assertion
}
let
和 when
语句也支持 mut
。
断言
bool
expect(2 == 2) to be_true
expect(2 != 2) to be_false
相等
expect(2) to be_actually_two {
equal(2),
not_equal(3)
}
数字
expect(2.1) {
to be_close_to(2.0, 0.2)
to be_greater_than(2.0)
to be_less_or_equal_to(2.1)
}
match_pattern!
match_pattern!
用于测试值是否匹配一个模式。它的功能与 matches!
宏类似。
expect(Response::UserCreated) {
to match_pattern!(Response::UserCreated)
}
expect(Response::ValidationFailed("email")) {
to match_email {
match_pattern!(Response::ValidationFailed("email")),
not_match_pattern!(Response::ValidationFailed("email2"))
}
}
Option
和 Result
lets_expect
为 Option
和 Result
类型提供了一套断言。
expect(Some(1u8) as Option<u8>) {
to be_some_and equal(1)
to be_some {
equal(Some(1)),
be_some
}
}
expect(None as Option<String>) {
to be_none {
equal(None),
be_none
}
}
expect(Ok(1u8) as Result<u8, ()>) {
to be_ok_and equal(1)
to be_ok {
be_ok,
equal(Ok(1)),
}
}
expect(Err(2) as Result<(), i32>) {
to be_err_and equal(2)
to be_err {
be_err,
equal(Err(2)),
}
}
panic!
expect(panic!("I panicked!")) {
to panic
}
expect(2) {
to not_panic
}
panic
和 not_panic
断言可以是一个 to
块中唯一存在的断言。
迭代器
expect(vec![1, 2, 3]) {
to have(mut iter()) all(be_greater_than(0))
to have(mut iter()) any(be_greater_than(2))
}
自定义断言
lets_expect
提供了一种定义自定义断言的方法。断言是一个接收主题引用并返回 AssertionResult
的函数。
以下是两个自定义断言的示例
use lets_expect::*;
fn have_positive_coordinates(point: &Point) -> AssertionResult {
if point.x > 0 && point.y > 0 {
Ok(())
} else {
Err(AssertionError::new(vec![format!(
"Expected ({}, {}) to be positive coordinates",
point.x, point.y
)]))
}
}
fn have_x_coordinate_equal(x: i32) -> impl Fn(&Point) -> AssertionResult {
move |point: &Point| {
if point.x == x {
Ok(())
} else {
Err(AssertionError::new(vec![format!(
"Expected x coordinate to be {}, but it was {}",
x, point.x
)]))
}
}
}
以下是它们的使用方法
expect(Point { x: 2, y: 22 }) {
to have_valid_coordinates {
have_positive_coordinates,
have_x_coordinate_equal(2)
}
}
请记住,在测试模块中导入您的自定义断言。
自定义更改断言
类似地,可以定义自定义更改断言
use lets_expect::*;
fn by_multiplying_by(x: i32) -> impl Fn(&i32, &i32) -> AssertionResult {
move |before, after| {
if *after == *before * x {
Ok(())
} else {
Err(AssertionError::new(vec![format!(
"Expected {} to be multiplied by {} to be {}, but it was {} instead",
before,
x,
before * x,
after
)]))
}
}
}
并像这样使用
expect(a *= 5) {
let mut a = 5;
to change(a.clone()) by_multiplying_by(5)
}
断言
与其他类似的库相比,这个库提供的内置断言相对较少。这是因为使用 have
、make
和 match_pattern!
允许表达灵活的条件,无需大量不同的断言。
断言的完整列表可在 assertions 模块 中找到。
支持的库
Tokio
lets_expect
与 Tokio 兼容。要在测试中使用 Tokio,您需要在您的 Cargo.toml
中添加 tokio
功能。
lets_expect = { version = "*", features = ["tokio"] }
然后,每次您想在使用测试中使用 Tokio 时,您需要将 tokio_test
属性添加到您的 lets_expect!
宏中,如下所示
lets_expect! { #tokio_test
}
这将使 lets_expect
使用 #[tokio::test]
而不是 #[test]
在生成的测试中。
以下是使用 Tokio 的测试示例
let value = 5;
let spawned = tokio::spawn(async move {
value
});
expect(spawned.await) {
to match_pattern!(Ok(5))
}
更多示例
lets_expect
仓库包含可能作为库使用示例的测试。您可以在 这里 找到它们。
已知问题和限制
- rust-analyzer 的自动导入似乎在宏内部不起作用。可能需要手动添加来自模块外部的类型
use
语句。 - 语法高亮显示与
lets_expect
语法不兼容。目前,Rust 宏无法将它们的语法导出给语言工具。 - 共享上下文(类似于 RSpec)似乎没有 eager macro expansion 就无法实现。
调试
如果您在测试中遇到问题,可以使用 cargo-expand 来查看由 lets_expect
生成的代码。生成的代码可能不容易阅读,并且版本之间不保证稳定。尽管如此,它对于调试可能很有用。
许可证
本项目根据 MIT 许可证授权。
依赖项
~1–12MB
~108K SLoC