作为一门系统级编程语言,Rust近年来因其卓越的内存安全性和并发性能受到开发者社区的广泛关注。但Rust独特的所有权系统和生命周期概念,常常成为新手学习路上的"拦路虎"。rustlings项目正是为解决这一问题而生——它通过一系列精心设计的练习,帮助开发者以实践方式逐步掌握Rust的核心特性。
这个速通教程聚焦于rustlings的第27个主题:测试(Testing)。在真实开发场景中,完善的测试体系是保证代码质量的生命线。Rust语言原生集成了强大的测试框架,而掌握这些工具的使用方法,对于构建可靠、可维护的Rust项目至关重要。
Rust的测试代码通常与被测代码共存于同一文件中,通过#[cfg(test)]属性进行区分。这种设计遵循了Rust的"约定优于配置"哲学:
rust复制// 生产代码
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
// 测试模块
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 2), 4);
}
}
关键点解析:
#[cfg(test)]:编译器指令,表示该模块仅在测试模式下编译mod tests:约定俗成的测试模块命名use super::*:导入父模块的所有项,便于访问被测函数#[test]:标记测试函数的属性宏Rust提供了一系列断言宏来验证代码行为:
基本断言:
assert!(expr):表达式必须为trueassert_eq!(left, right):左右值必须相等(使用==运算符)assert_ne!(left, right):左右值必须不等自定义失败信息:
所有断言宏都支持格式化字符串:
rust复制assert!(
result.is_ok(),
"Function failed with {:?}",
result.unwrap_err()
);
should_panic:
测试预期会panic的代码:
rust复制#[test]
#[should_panic(expected = "值不能为负")]
fn test_negative() {
validate_input(-1);
}
假设我们要实现一个简单的购物车系统,核心需求包括:
首先创建测试骨架:
rust复制#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_cart() {
let cart = ShoppingCart::new();
assert_eq!(cart.total(), 0);
}
#[test]
fn test_add_item() {
let mut cart = ShoppingCart::new();
cart.add_item("Rust编程书", 1, 99.99);
assert_eq!(cart.total(), 99.99);
}
#[test]
fn test_clear_cart() {
let mut cart = ShoppingCart::new();
cart.add_item("鼠标", 2, 50.0);
cart.clear();
assert_eq!(cart.total(), 0);
}
}
根据测试驱动开发(TDD)原则,我们先让测试失败,再逐步实现功能:
rust复制pub struct ShoppingCart {
items: Vec<CartItem>,
}
struct CartItem {
name: String,
quantity: u32,
price: f64,
}
impl ShoppingCart {
pub fn new() -> Self {
ShoppingCart { items: Vec::new() }
}
pub fn add_item(&mut self, name: &str, quantity: u32, price: f64) {
self.items.push(CartItem {
name: name.to_string(),
quantity,
price,
});
}
pub fn total(&self) -> f64 {
self.items.iter().map(|item| item.price * item.quantity as f64).sum()
}
pub fn clear(&mut self) {
self.items.clear();
}
}
完善的测试应该覆盖各种边界情况:
rust复制#[test]
fn test_add_zero_quantity() {
let mut cart = ShoppingCart::new();
cart.add_item("免费手册", 0, 0.0);
assert_eq!(cart.total(), 0.0);
}
#[test]
#[should_panic(expected = "价格不能为负")]
fn test_negative_price() {
let mut cart = ShoppingCart::new();
cart.add_item("瑕疵品", 1, -10.0);
}
Rust允许测试模块访问父模块的私有项,这是测试私有函数的推荐方式:
rust复制// 生产代码
fn internal_helper(a: i32) -> i32 {
a * 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_internal() {
assert_eq!(internal_helper(2), 4);
}
}
对于更大的测试范围,可以在项目根目录创建tests目录:
code复制my_project/
├── src/
│ └── lib.rs
└── tests/
└── integration_test.rs
集成测试示例:
rust复制// tests/integration_test.rs
use my_project::ShoppingCart;
#[test]
fn test_integration() {
let mut cart = ShoppingCart::new();
cart.add_item("键盘", 1, 200.0);
assert!(!cart.is_empty());
}
Rust支持通过#[bench]属性进行性能测试(需要nightly工具链):
rust复制#![feature(test)]
extern crate test;
use test::Bencher;
#[bench]
fn bench_add_item(b: &mut Bencher) {
let mut cart = ShoppingCart::new();
b.iter(|| {
cart.add_item("测试商品", 1, 10.0);
});
}
Cargo提供多种测试执行方式:
bash复制# 运行所有测试
cargo test
# 运行单个测试模块
cargo test test_add_item
# 运行名称包含"cart"的测试
cargo test cart
# 显示测试输出(默认捕获成功测试的输出)
cargo test -- --nocapture
Rust默认并行运行测试,需要注意:
#[serial]属性(需要serial_test crate)使用tarpaulin工具生成覆盖率报告:
bash复制cargo install cargo-tarpaulin
cargo tarpaulin --ignore-tests
当测试失败时,检查以下方面:
减少重复代码:
使用工厂函数创建测试数据:
rust复制fn sample_cart() -> ShoppingCart {
let mut cart = ShoppingCart::new();
cart.add_item("商品A", 2, 10.0);
cart.add_item("商品B", 1, 5.0);
cart
}
使用测试特征:
为测试环境实现特定行为:
rust复制trait TestClock {
fn now() -> SystemTime;
}
#[cfg(test)]
struct MockClock;
#[cfg(test)]
impl TestClock for MockClock {
fn now() -> SystemTime {
SystemTime::UNIX_EPOCH
}
}
处理浮点数比较:
使用approx crate处理浮点误差:
rust复制use approx::assert_relative_eq;
#[test]
fn test_float() {
assert_relative_eq!(calculate(), 3.14159, epsilon = 0.001);
}
rustlings的测试部分包含以下关键练习:
测试属性标记:
rust复制#[test]
fn test_fail() {
// 这个测试应该失败
assert!(false);
}
should_panic应用:
rust复制#[test]
#[should_panic]
fn test_panic() {
panic!("这个panic是预期的");
}
模块组织:
rust复制#[cfg(test)]
mod tests {
#[test]
fn test_private() {
assert_eq!(super::internal_func(), 42);
}
}
遵循清晰的命名约定:
test_[功能]_[条件]:如test_add_item_empty_carttest1、test2等命名在.github/workflows/ci.yml中配置测试流水线:
yaml复制name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- run: cargo test --verbose
- run: cargo test --release --verbose
使用proptest crate进行属性测试:
rust复制use proptest::prelude::*;
proptest! {
#[test]
fn test_add_commutative(a in 0..100i32, b in 0..100i32) {
assert_eq!(add(a, b), add(b, a));
}
}
使用mockall crate创建测试替身:
rust复制#[automock]
trait Database {
fn get_user(&self, id: i32) -> Option<String>;
}
#[test]
fn test_mock_db() {
let mut mock = MockDatabase::new();
mock.expect_get_user()
.with(predicate::eq(42))
.returning(|_| Some("Alice".to_string()));
assert_eq!(mock.get_user(42), Some("Alice".to_string()));
}
使用criterion.rs进行更精确的测量:
rust复制use criterion::{criterion_group, criterion_main, Criterion};
fn bench_add(c: &mut Criterion) {
c.bench_function("add 2+2", |b| b.iter(|| add(2, 2)));
}
criterion_group!(benches, bench_add);
criterion_main!(benches);
使用assert_elapsed宏检查执行时间:
rust复制#[test]
fn test_performance() {
let result = std::time::Duration::from_secs(1);
assert_elapsed!(result, max = std::time::Duration::from_millis(1100));
}
使用setup/teardown管理测试环境:
rust复制struct TestFixture {
cart: ShoppingCart,
}
impl TestFixture {
fn new() -> Self {
let mut cart = ShoppingCart::new();
cart.add_item("固定商品", 1, 10.0);
TestFixture { cart }
}
}
#[test]
fn test_with_fixture() {
let fixture = TestFixture::new();
assert_eq!(fixture.cart.total(), 10.0);
}
使用test-case crate实现数据驱动测试:
rust复制use test_case::test_case;
#[test_case(2, 2, 4)]
#[test_case(0, 5, 5)]
#[test_case(-3, 3, 0)]
fn test_add_cases(a: i32, b: i32, expected: i32) {
assert_eq!(add(a, b), expected);
}
使用pact-rs测试服务间契约:
rust复制#[tokio::test]
async fn test_service_contract() {
let mut pact = PactBuilder::new("Consumer", "Provider");
pact.interaction("get user", "", |mut i| {
i.given("user exists");
i.request.path("/user/42");
i.response
.status(200)
.json_body(json!({"id": 42, "name": "Alice"}));
i
});
let mock_server = pact.start_mock_server();
let client = Client::new(mock_server.url());
let user = client.get_user(42).await.unwrap();
assert_eq!(user.name, "Alice");
}
评估测试套件的关键指标:
遵循健康的测试比例:
保持测试代码质量:
| 库名称 | 特点 | 适用场景 |
|---|---|---|
| mockall | 功能全面,支持复杂模拟 | 需要精细控制的模拟场景 |
| mockers | 轻量级,简单易用 | 快速原型开发 |
| galvanic-mock | 类型安全,编译时检查 | 对类型安全要求高的项目 |
编写可测试代码的关键:
良好的测试应该:
有效的测试名称应该:
使用tokio::test处理异步:
rust复制#[tokio::test]
async fn test_async() {
let result = async_func().await;
assert!(result.is_ok());
}
使用testcontainers隔离数据库:
rust复制#[tokio::test]
async fn test_db() {
let docker = clients::Cli::default();
let postgres = Postgres::default();
let node = docker.run(postgres);
let conn_str = &format!(
"postgres://postgres:postgres@localhost:{}/postgres",
node.get_host_port_ipv4(5432)
);
let pool = PgPool::connect(conn_str).await.unwrap();
// 执行测试...
}
使用reqwest测试API端点:
rust复制#[tokio::test]
async fn test_api() {
let app = spawn_app().await;
let client = reqwest::Client::new();
let response = client
.get(&format!("{}/health", app.address))
.send()
.await
.expect("请求失败");
assert_eq!(response.status(), 200);
}
平衡投入与收益:
在实际项目中,我发现以下测试策略特别有效:
一个特别有用的技巧是创建test_utils模块,集中存放测试辅助函数,但要注意:
#[cfg(test)]对于测试数据构建,推荐使用builder模式:
rust复制struct TestUser {
name: String,
age: u32,
active: bool,
}
impl TestUser {
fn new() -> Self {
TestUser {
name: "默认用户".into(),
age: 30,
active: true,
}
}
fn with_age(mut self, age: u32) -> Self {
self.age = age;
self
}
fn inactive(mut self) -> Self {
self.active = false;
self
}
}
#[test]
fn test_user() {
let user = TestUser::new().with_age(25).inactive();
// 测试逻辑...
}