Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Testing in Rust vs C#
Rust 与 C# 中的测试

What you’ll learn: Built-in #[test] vs xUnit, parameterized tests with rstest (like [Theory]), property testing with proptest, mocking with mockall, and async test patterns.
本章将学到什么: 对照理解内建 #[test] 与 xUnit,学习如何用 rstest 写参数化测试,如何用 proptest 做性质测试,如何用 mockall 做 mock,以及异步测试的常见写法。

Difficulty: 🟡 Intermediate
难度: 🟡 进阶

Unit Tests
单元测试

// C# — xUnit
using Xunit;

public class CalculatorTests
{
    [Fact]
    public void Add_ReturnsSum()
    {
        var calc = new Calculator();
        Assert.Equal(5, calc.Add(2, 3));
    }

    [Theory]
    [InlineData(1, 2, 3)]
    [InlineData(0, 0, 0)]
    [InlineData(-1, 1, 0)]
    public void Add_Theory(int a, int b, int expected)
    {
        Assert.Equal(expected, new Calculator().Add(a, b));
    }
}
#![allow(unused)]
fn main() {
// Rust — built-in testing, no external framework needed
pub fn add(a: i32, b: i32) -> i32 { a + b }

#[cfg(test)]  // Only compiled during `cargo test`
mod tests {
    use super::*;  // Import from parent module

    #[test]
    fn add_returns_sum() {
        assert_eq!(add(2, 3), 5);
    }

    #[test]
    fn add_negative_numbers() {
        assert_eq!(add(-1, 1), 0);
    }

    #[test]
    #[should_panic(expected = "overflow")]
    fn add_overflow_panics() {
        let _ = add(i32::MAX, 1); // panics in debug mode
    }
}
}

Rust 自带的测试框架比不少 C# 开发者预想得更完整。
很多最常见的单元测试场景,光靠 #[test]assert_eq!#[should_panic] 就已经够用了,完全不用先抱一大坨外部测试框架进来。

Parameterized Tests (like [Theory])
参数化测试(类似 [Theory]

#![allow(unused)]
fn main() {
// Use the `rstest` crate for parameterized tests
use rstest::rstest;

#[rstest]
#[case(1, 2, 3)]
#[case(0, 0, 0)]
#[case(-1, 1, 0)]
fn test_add(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
    assert_eq!(add(a, b), expected);
}

// Fixtures — like test setup methods
#[rstest]
fn test_with_fixture(#[values(1, 2, 3)] x: i32) {
    assert!(x > 0);
}
}

如果已经习惯 xUnit 的 [Theory][InlineData],那 rstest 基本属于一眼就能上手的工具。
它把“同一条测试逻辑喂多组输入”这件事做得非常自然,还顺带补了 fixture 这类常用能力。

Assertions Comparison
断言写法对照

C# (xUnit)
C#(xUnit)
RustNotes
说明
Assert.Equal(expected, actual)
Assert.Equal(expected, actual)
assert_eq!(expected, actual)
assert_eq!(expected, actual)
Prints diff on failure
失败时会把差异打印出来。
Assert.NotEqual(a, b)
Assert.NotEqual(a, b)
assert_ne!(a, b)
assert_ne!(a, b)
Same intent
表达的是同一层意思。
Assert.True(condition)
Assert.True(condition)
assert!(condition)
assert!(condition)
Boolean assertion
布尔条件断言。
Assert.Contains("sub", str)
Assert.Contains("sub", str)
assert!(str.contains("sub"))
assert!(str.contains("sub"))
Compose from normal methods
通常直接和普通方法组合使用。
Assert.Throws<T>(() => ...)
Assert.Throws&lt;T&gt;(() =&gt; ...)
#[should_panic]
#[should_panic]
Or use std::panic::catch_unwind
也可以改用 std::panic::catch_unwind
Assert.Null(obj)
Assert.Null(obj)
assert!(option.is_none())
assert!(option.is_none())
No nulls, use Option
Rust 没有随处可见的 null,这里对应的是 Option

Rust 的断言体系很朴素,但也正因为朴素,读起来很利索。
多数时候没有那种“框架魔法味儿”很重的测试 DSL,测试代码和业务代码贴得更近,维护时反而省心。

Test Organization
测试组织方式

my_crate/
├── src/
│   ├── lib.rs          # Unit tests in #[cfg(test)] mod tests { }
│   └── parser.rs       # Each module can have its own test module
├── tests/              # Integration tests (each file is a separate crate)
│   ├── parser_test.rs  # Tests the public API as an external consumer
│   └── api_test.rs
└── benches/            # Benchmarks (with criterion crate)
    └── my_benchmark.rs
#![allow(unused)]
fn main() {
// tests/parser_test.rs — integration test
// Can only access PUBLIC API (like testing from outside the assembly)
use my_crate::parser;

#[test]
fn test_parse_valid_input() {
    let result = parser::parse("valid input");
    assert!(result.is_ok());
}
}

这套目录结构有个很重要的意思:单元测试和集成测试从工程边界上就是分开的。
src/ 里的测试更贴近实现细节,tests/ 则像外部使用者那样只碰公开 API,这种分层能逼着接口设计更清楚。

Async Tests
异步测试

// C# — async test with xUnit
[Fact]
public async Task GetUser_ReturnsUser()
{
    var service = new UserService();
    var user = await service.GetUserAsync(1);
    Assert.Equal("Alice", user.Name);
}
#![allow(unused)]
fn main() {
// Rust — async test with tokio
#[tokio::test]
async fn get_user_returns_user() {
    let service = UserService::new();
    let user = service.get_user(1).await.unwrap();
    assert_eq!(user.name, "Alice");
}
}

异步测试的心智模型和 C# 其实差得不大。
主要区别在于 Rust 需要先把运行时说清楚,例如这里用的是 tokio,所以测试属性也写成 #[tokio::test]

Mocking with mockall
使用 mockall 做 Mock

#![allow(unused)]
fn main() {
use mockall::automock;

#[automock]                         // Generates MockUserRepo struct
trait UserRepo {
    fn find_by_id(&self, id: u32) -> Option<User>;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn service_returns_user_from_repo() {
        let mut mock = MockUserRepo::new();
        mock.expect_find_by_id()
            .with(mockall::predicate::eq(1))
            .returning(|_| Some(User { name: "Alice".into() }));

        let service = UserService::new(mock);
        let user = service.get_user(1).unwrap();
        assert_eq!(user.name, "Alice");
    }
}
}
// C# — Moq equivalent
var mock = new Mock<IUserRepo>();
mock.Setup(r => r.FindById(1)).Returns(new User { Name = "Alice" });
var service = new UserService(mock.Object);
Assert.Equal("Alice", service.GetUser(1).Name);

如果之前常用 Moq,看 mockall 时最大的差异在于:Rust 往往先通过 trait 把边界切清楚,再围着 trait 生成 mock。
这件事表面上麻烦一点,实际上会逼着模块边界更明确,长期维护时挺值。

🏋️ Exercise: Write Comprehensive Tests
🏋️ 练习:编写覆盖更完整的测试

Challenge: Given this function, write tests covering: happy path, empty input, numeric strings, and Unicode.
挑战: 针对下面这个函数,补出能覆盖正常路径、空输入、数字字符串和 Unicode 文本的测试。

#![allow(unused)]
fn main() {
pub fn title_case(input: &str) -> String {
    input.split_whitespace()
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str().to_lowercase()),
                None => String::new(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}
}
🔑 Solution
🔑 参考答案
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn happy_path() {
        assert_eq!(title_case("hello world"), "Hello World");
    }

    #[test]
    fn empty_input() {
        assert_eq!(title_case(""), "");
    }

    #[test]
    fn single_word() {
        assert_eq!(title_case("rust"), "Rust");
    }

    #[test]
    fn already_title_case() {
        assert_eq!(title_case("Hello World"), "Hello World");
    }

    #[test]
    fn all_caps() {
        assert_eq!(title_case("HELLO WORLD"), "Hello World");
    }

    #[test]
    fn extra_whitespace() {
        // split_whitespace handles multiple spaces
        assert_eq!(title_case("  hello   world  "), "Hello World");
    }

    #[test]
    fn unicode() {
        assert_eq!(title_case("café résumé"), "Café Résumé");
    }

    #[test]
    fn numeric_words() {
        assert_eq!(title_case("hello 42 world"), "Hello 42 World");
    }
}
}

Key takeaway: Rust’s built-in test framework handles most unit testing needs. Use rstest for parameterized tests and mockall for mocking. There is usually no need to drag in a large framework just to get started.
这一节的重点: Rust 自带的测试框架已经能覆盖绝大多数单元测试需求;参数化测试用 rstest,mock 用 mockall,起步阶段通常完全没必要为了测试先背一个巨型框架。

Property Testing: Proving Correctness at Scale
性质测试:用规模化输入验证正确性

C# developers familiar with FsCheck will recognize property-based testing: instead of writing individual test cases, you describe properties that must hold for all possible inputs, and the framework generates thousands of random inputs to try to break them.
如果接触过 FsCheck,那对性质测试应该不会陌生。它不是手写一堆孤立样例,而是先描述“对所有可能输入都必须成立的性质”,然后让框架自动生成海量随机输入,专门找茬。

Why Property Testing Matters
为什么性质测试很重要

// C# — Hand-written unit tests check specific cases
[Fact]
public void Reverse_Twice_Returns_Original()
{
    var list = new List<int> { 1, 2, 3 };
    list.Reverse();
    list.Reverse();
    Assert.Equal(new[] { 1, 2, 3 }, list);
}
// But what about empty lists? Single elements? 10,000 elements? Negative numbers?
// You'd need dozens of hand-written cases.
#![allow(unused)]
fn main() {
// Rust — proptest generates thousands of inputs automatically
use proptest::prelude::*;

fn reverse<T: Clone>(v: &[T]) -> Vec<T> {
    v.iter().rev().cloned().collect()
}

proptest! {
    #[test]
    fn reverse_twice_is_identity(ref v in prop::collection::vec(any::<i32>(), 0..1000)) {
        let reversed_twice = reverse(&reverse(v));
        prop_assert_eq!(v, &reversed_twice);
    }
    // proptest runs this with hundreds of random Vec<i32> values:
    // [], [0], [i32::MIN, i32::MAX], [42; 999], random sequences...
    // If it fails, it SHRINKS to the smallest failing input!
}
}

普通单元测试擅长保住已知边界,性质测试擅长把未知角落翻出来。
尤其是解析、序列化、排序、编解码、校验器这类逻辑,用性质测试往往特别划算,因为很多 bug 根本不是某个具体值的问题,而是“某个规律在某些输入族里失效了”。

Getting Started with proptest
快速接入 proptest

# Cargo.toml
[dev-dependencies]
proptest = "1.4"

Common Patterns for C# Developers
适合 C# 开发者理解的常见模式

#![allow(unused)]
fn main() {
use proptest::prelude::*;

// 1. Roundtrip property: serialize → deserialize = identity
// (Like testing JsonSerializer.Serialize → Deserialize)
proptest! {
    #[test]
    fn json_roundtrip(name in "[a-zA-Z]{1,50}", age in 0u32..150) {
        let user = User { name: name.clone(), age };
        let json = serde_json::to_string(&user).unwrap();
        let parsed: User = serde_json::from_str(&json).unwrap();
        prop_assert_eq!(user, parsed);
    }
}

// 2. Invariant property: output always satisfies a condition
proptest! {
    #[test]
    fn sort_output_is_sorted(ref v in prop::collection::vec(any::<i32>(), 0..500)) {
        let mut sorted = v.clone();
        sorted.sort();
        // Every adjacent pair must be in order
        for window in sorted.windows(2) {
            prop_assert!(window[0] <= window[1]);
        }
    }
}

// 3. Oracle property: compare two implementations
proptest! {
    #[test]
    fn fast_path_matches_slow_path(input in "[0-9a-f]{1,100}") {
        let result_fast = parse_hex_fast(&input);
        let result_slow = parse_hex_slow(&input);
        prop_assert_eq!(result_fast, result_slow);
    }
}

// 4. Custom strategies: generate domain-specific test data
fn valid_email() -> impl Strategy<Value = String> {
    ("[a-z]{1,20}", "[a-z]{1,10}", prop::sample::select(vec!["com", "org", "io"]))
        .prop_map(|(user, domain, tld)| format!("{}@{}.{}", user, domain, tld))
}

proptest! {
    #[test]
    fn email_parsing_accepts_valid_emails(email in valid_email()) {
        let result = Email::new(&email);
        prop_assert!(result.is_ok(), "Failed to parse: {}", email);
    }
}
}

这四类模式特别值得记住:往返一致性、恒成立约束、快慢实现对拍、自定义领域数据生成。
只要脑子里先装下这四把锤子,后面很多测试问题都能很快找到能敲的位置。

proptest vs FsCheck Comparison
proptest 与 FsCheck 对照

Feature
能力点
C# FsCheckRust proptest
Random input generation
随机输入生成
Arb.Generate&lt;T&gt;()
Arb.Generate&lt;T&gt;()
any::&lt;T&gt;()
any::&lt;T&gt;()
Custom generators
自定义生成器
Arb.Register&lt;T&gt;()
Arb.Register&lt;T&gt;()
impl Strategy&lt;Value = T&gt;
impl Strategy&lt;Value = T&gt;
Shrinking on failure
失败后收缩样例
Automatic
自动进行
Automatic
自动进行
String patterns
字符串模式
Manual
通常需要手写
"[regex]" strategy
可以直接用 "[regex]" 策略
Collection generation
集合生成
Gen.ListOf
Gen.ListOf
prop::collection::vec(strategy, range)
prop::collection::vec(strategy, range)
Composing generators
组合生成器
Gen.Select
Gen.Select
.prop_map(), .prop_flat_map()
.prop_map().prop_flat_map()
Config (# of cases)
配置测试样例数
Config.MaxTest
Config.MaxTest
#![proptest_config(ProptestConfig::with_cases(10000))] inside proptest! block
proptest! 块里用 #![proptest_config(ProptestConfig::with_cases(10000))] 配置

When to Use Property Testing vs Unit Testing
什么时候用性质测试,什么时候用单元测试

Use unit tests when
适合用单元测试的场景
Use proptest when
适合用 proptest 的场景
Testing specific edge cases
验证明确已知的边界样例
Verifying invariants across all inputs
验证跨输入集合都必须成立的不变量
Testing error messages/codes
校验报错信息或错误码
Roundtrip properties (parse ↔ format)
验证往返性质,例如 parse ↔ format
Integration/mock tests
做集成测试或 mock 场景
Comparing two implementations
对拍两套实现
Behavior depends on exact values
行为强依赖某些特定值
“For all X, property P holds”
“对所有 X,性质 P 都成立”这一类问题

Integration Tests: the tests/ Directory
集成测试:tests/ 目录

Unit tests live inside src/ with #[cfg(test)]. Integration tests live in a separate tests/ directory and test your crate’s public API. That is very similar to how C# integration tests reference the project as an external assembly.
单元测试通常放在 src/ 里,配合 #[cfg(test)] 使用;集成测试则单独放进 tests/ 目录,只测试 crate 的公开 API。这点和 C# 里把项目当作外部程序集来引用做测试,非常像。

my_crate/
├── src/
│   ├── lib.rs          // public API
│   └── internal.rs     // private implementation
├── tests/
│   ├── smoke.rs        // each file is a separate test binary
│   ├── api_tests.rs
│   └── common/
│       └── mod.rs      // shared test helpers
└── Cargo.toml

Writing Integration Tests
编写集成测试

Each file in tests/ is compiled as a separate crate that depends on your library:
tests/ 里的每个文件都会被编译成一个独立 crate,并依赖当前库:

#![allow(unused)]
fn main() {
// tests/smoke.rs — can only access pub items from my_crate
use my_crate::{process_order, Order, OrderResult};

#[test]
fn process_valid_order_returns_confirmation() {
    let order = Order::new("SKU-001", 3);
    let result = process_order(order);
    assert!(matches!(result, OrderResult::Confirmed { .. }));
}
}

Shared Test Helpers
共享测试辅助代码

Put shared setup code in tests/common/mod.rs rather than tests/common.rs, because the latter would be treated as its own test file:
公共测试准备代码适合放在 tests/common/mod.rs 里,而不是 tests/common.rs。后者会被当成独立测试文件来编译,容易把目录结构搞拧巴。

#![allow(unused)]
fn main() {
// tests/common/mod.rs
use my_crate::Config;

pub fn test_config() -> Config {
    Config::builder()
        .database_url("sqlite::memory:")
        .build()
        .expect("test config must be valid")
}
}
#![allow(unused)]
fn main() {
// tests/api_tests.rs
mod common;

use my_crate::App;

#[test]
fn app_starts_with_test_config() {
    let config = common::test_config();
    let app = App::new(config);
    assert!(app.is_healthy());
}
}

Running Specific Test Types
运行指定类型的测试

cargo test                  # run all tests (unit + integration)
cargo test --lib            # unit tests only (like dotnet test --filter Category=Unit)
cargo test --test smoke     # run only tests/smoke.rs
cargo test --test api_tests # run only tests/api_tests.rs

Key difference from C#: Integration test files can only access your crate’s pub API. Private functions are invisible, which pushes tests through the public interface and usually leads to cleaner design.
和 C# 很关键的一点差异: 集成测试文件只能看到 crate 的 pub API,私有函数根本够不着。这种约束看起来更严格,实际上经常能把测试方式和接口设计一起拽回更健康的方向。