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 次下载

MIT 许可证

195KB
4K SLoC

GitHub Workflow Status Crates.io GitHub

Let's Expect

Rust 的整洁测试。

expect(a + 2) {
    when(a = 2) {
        to equal(4)
    }
}

目录

  1. 简介
  2. 安装
  3. 用法
  4. 断言
  5. 支持的库
  6. 更多示例
  7. 已知问题和限制
  8. 调试
  9. 许可证

简介

当你看到 Rust 测试时,你经常想 "哇,这是一个非常漂亮的测试" 吗? 不经常吧? 经典的 Rust 测试除了测试函数本身之外,没有任何结构。这通常会导致大量的样板代码、临时的测试结构和整体低质量。

测试是验证在特定条件下运行的代码是否按预期工作。一个好的测试框架拥抱这种思维方式。它使得以反映它的方式结构化代码变得容易。其他社区的人已经用像 RSpecJasmine 这样的工具做了很长时间。

如果您希望拥有美观、高质量的测试,这些测试易于阅读和编写,您需要其他工具。使用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中定义一个代码片段可能是个好主意,这样可以避免每次都输入这些模板代码。

为了简洁,这里省略了宏的示例。

expectto

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!宏以及expectwhen代码块内部,您可以定义变量。

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())
        }
    }
}

beforeafter

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) }
}

expectwhen 显式标识符

因为 lets_expect 在底层使用标准的 Rust 测试,它必须为每个测试提供一个唯一的标识符。为了使这些标识符可读,lets_expect 使用 expectwhen 中的表达式来生成名称。这对于简单的表达式来说效果很好,但对于更复杂的表达式可能会有些杂乱。有时也可能导致名称重复。为了解决这些问题,您可以使用 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 块不能放置在循环或闭包中。它们必须是故事中的顶级项目。

可变变量和引用

对于某些测试,您可能需要使测试的值可变,或者可能需要将可变引用传递给断言。在 expecthavemake 中,您可以使用 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
}

letwhen 语句也支持 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"))
    }
}

OptionResult

lets_expectOptionResult 类型提供了一套断言。

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
}

panicnot_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)
}

断言

与其他类似的库相比,这个库提供的内置断言相对较少。这是因为使用 havemakematch_pattern! 允许表达灵活的条件,无需大量不同的断言。

断言的完整列表可在 assertions 模块 中找到。

支持的库

Tokio

lets_expectTokio 兼容。要在测试中使用 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