Rust 的测试系统功能强大,官方推荐的测试方式也非常灵活。但很多初学者和团队在项目发展到一定规模时都会遇到这样的疑问:
为什么 Rust 的单元测试常常写在实现代码的同一个文件中?这样不会导致文件很臃肿吗?
如果我们模块比较大,有多层子模块,该如何组织测试代码更利于维护?
这篇文章将用通俗易懂的方式,带你深入理解 Rust 的测试组织原则、模块结构的最佳实践,并结合实际案例介绍企业级项目中如何拆分测试与模块结构。
类型 | 文件位置 | 适合场景 | 是否可访问私有函数 |
---|---|---|---|
单元测试 | 实现代码同一个文件或模块内 | 单个函数/模块的细节测试 | ✅ 可以访问私有项 |
集成测试 | tests/ 目录,完全独立的文件 |
模块之间的协作 & 对外接口 | ❌ 只能访问 pub 接口 |
最常见、推荐的写法是在实现代码下方写一个内联测试模块:
fn square(x: i32) -> i32 {
x * x
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_square() {
assert_eq!(square(3), 9);
}
}
#[cfg(test)]
控制,发布时不会被编译入最终产物当模块变多、测试增多时,将所有测试写在同一个文件会越来越混乱。这时就该使用模块划分 + 文件分离策略。
foo
,它有多个逻辑组件:src/
├── lib.rs
└── foo/
├── mod.rs // 声明模块
├── model.rs // 业务逻辑:数据结构等
├── service.rs // 实际业务处理逻辑
└── tests.rs // 单元测试:可测试 model + service
// src/foo/mod.rs
pub mod model;
pub mod service;
#[cfg(test)]
mod tests;
// src/foo/model.rs
#[derive(Debug)]
pub struct User {
pub id: u32,
pub name: String,
}
fn parse_name(raw: &str) -> String {
raw.trim().to_string()
}
// src/foo/tests.rs
use super::model::*;
use super::service::*;
#[test]
fn test_parse_name() {
assert_eq!(parse_name(" Tom "), "Tom");
}
#[test]
fn test_user_creation() {
let user = User { id: 1, name: "Alice".to_string() };
assert_eq!(user.name, "Alice");
}
以如下结构为例:
src/
└── auth/
├── mod.rs // 声明模块
├── domain/
│ ├── mod.rs
│ ├── model.rs
│ └── tests.rs
└── logic/
├── mod.rs
├── login.rs
└── tests.rs
pub mod model;
#[cfg(test)]
mod tests;
tests.rs
中,方便聚焦某一层的逻辑测试。project-root/
├── src/
│ └── lib.rs
├── tests/
│ ├── auth_api.rs
│ └── user_flow.rs
集成测试就是在 crate 外部重新导入你库中的接口:
// tests/auth_api.rs
use your_crate::auth::login;
#[test]
fn test_login_success() {
let result = login("alice", "password123");
assert!(result.is_ok());
}
场景 | 测试写在哪? | 原因 |
---|---|---|
小模块、逻辑简单 | 实现文件底部的 mod tests 中 |
快速反馈 |
模块较大,多个逻辑文件 | 拆出 tests.rs 放入模块同级 |
清晰职责,仍可访问私有 |
项目结构复杂,多级目录结构 | 每个子模块下都建自己的 tests.rs |
局部测试、易扩展 |
用户行为、模块协作、对外接口验证 | 项目根目录的 tests/ 中 |
黑盒视角,模拟实际使用者 |
小而精,本地测;大而全,拆模块;对外测,放 tests!