跳转到主内容测试 | 物联网民工

测试

C 项目里测试常从一个额外的 main 开始:

#include <assert.h>

int main(void)
{
    assert(checksum((uint8_t[]){1, 2, 3}, 3) == 6);
    return 0;
}

或者写几个小程序、shell 脚本、Makefile 目标:

test:
	./test_checksum
	./test_parser

Rust 把这个入口直接放进 Cargo:

cargo test

第一层可以先记住:Rust 的测试不是另外一套工程系统,而是 crate 的一部分。

最小单元测试

一个普通函数:

fn checksum(data: &[u8]) -> u32 {
    data.iter().map(|b| *b as u32).sum()
}

旁边可以写测试:

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

    #[test]
    fn checksum_sums_bytes() {
        assert_eq!(checksum(&[1, 2, 3]), 6);
    }
}

运行:

cargo test

这里有几个点:

  • #[test] 标记测试函数
  • assert_eq! 比较左右值
  • #[cfg(test)] 表示这段模块只在测试构建里编译
  • use super::* 让测试能访问当前模块里的函数

这相当于把 C 里的测试 main 收进源文件附近。

测试可以访问私有函数

C 里测试静态函数常有点麻烦:要么暴露到头文件,要么把 .c 文件包含进测试程序。

Rust 模块内测试可以访问同一模块的私有项:

fn parse_len(data: &[u8]) -> Option<u16> {
    if data.len() < 2 {
        return None;
    }

    Some(u16::from_be_bytes([data[0], data[1]]))
}

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

    #[test]
    fn parse_len_reads_big_endian() {
        assert_eq!(parse_len(&[0x01, 0x02]), Some(258));
    }
}

这让你不用为了测试而随便把内部函数改成公开 API。

断言和失败信息

Rust 常用断言:

assert!(value > 0);
assert_eq!(actual, expected);
assert_ne!(left, right);

可以加失败信息:

assert_eq!(actual, expected, "bad packet length for {case_name}");

测试返回 Result 也可以:

#[test]
fn load_config() -> Result<(), Box<dyn std::error::Error>> {
    let text = std::fs::read_to_string("testdata/config.toml")?;
    assert!(text.contains("port"));
    Ok(())
}

这样测试里可以用 ?。失败会被测试框架报告出来。

测试错误路径

C 测试容易只覆盖成功路径。Rust 的 Result 和 Option 很适合直接测错误分支:

#[derive(Debug, PartialEq, Eq)]
enum ParseError {
    TooShort,
    BadVersion(u8),
}

fn parse_packet(data: &[u8]) -> Result<(), ParseError> {
    if data.len() < 2 {
        return Err(ParseError::TooShort);
    }

    if data[0] != 1 {
        return Err(ParseError::BadVersion(data[0]));
    }

    Ok(())
}

#[test]
fn parse_rejects_short_packet() {
    assert_eq!(parse_packet(&[1]), Err(ParseError::TooShort));
}

为了比较错误值,给错误类型派生 PartialEq / Eq 很常见。错误类型如果包含 io::Error 这类不方便直接比较的字段,可以只检查分类或消息中稳定的部分。

集成测试放 tests/

模块内测试适合测内部逻辑。对外 API 可以放到 tests/:

mycrate/
├── src/
│   └── lib.rs
└── tests/
    └── parser_test.rs

tests/parser_test.rs 像外部用户一样使用 crate:

use mycrate::parse_packet;

#[test]
fn parses_valid_packet() {
    let packet = [1_u8, 0];
    assert!(parse_packet(&packet).is_ok());
}

集成测试只能访问公开 API。这和 C 里用头文件暴露出来的接口做黑盒测试很像。

如果你的项目最初只有 src/main.rs,想写更容易测试的代码,可以把核心逻辑挪到 src/lib.rs,让 main.rs 只处理 CLI 边界。

测试 CLI 工具

最小方式是把业务逻辑放到 run 或库函数里测试,不直接测试进程:

pub fn line_count(text: &str) -> usize {
    text.lines().count()
}

#[test]
fn counts_lines() {
    assert_eq!(line_count("a\nb\n"), 2);
}

CLI 的 stdout、stderr、退出码可以后面用集成测试或专门工具测。第一层先避免把所有逻辑都塞进 main,否则测试会变难。

这个结构和 C 项目里“核心逻辑做成库,main 只负责参数和输出”是同一个经验。

testdata 放固定输入

解析器、协议、文件格式通常需要固定样本:

tests/
├── parser_test.rs
└── testdata/
    ├── valid.bin
    └── bad-version.bin

测试里读取:

#[test]
fn parses_sample() -> Result<(), Box<dyn std::error::Error>> {
    let data = std::fs::read("tests/testdata/valid.bin")?;
    assert!(parse_packet(&data).is_ok());
    Ok(())
}

测试数据应该稳定、可版本管理。不要让测试依赖开发者机器上的临时文件、当前目录里的偶然状态或网络服务。

运行部分测试

常用命令:

cargo test
cargo test checksum
cargo test -- --nocapture

含义:

cargo test              -> 运行所有测试
cargo test checksum     -> 只运行名字包含 checksum 的测试
-- --nocapture          -> 显示测试里的 stdout/stderr

如果测试较多,给测试函数起清楚的名字很重要:

#[test]
fn parse_rejects_bad_version() {}

#[test]
fn parse_rejects_short_packet() {}

失败时,测试名就是第一条定位线索。

这一层先记住什么

C 到 Rust 的测试迁移第一层:

手写 test_main.c        -> #[test] + cargo test
assert / assert.h       -> assert! / assert_eq! / assert_ne!
测试静态函数             -> 模块内 #[cfg(test)] tests
黑盒测试公开 API         -> tests/ 集成测试
测试文件样本             -> tests/testdata/
测试可失败操作           -> 测试函数返回 Result
只测 main 很困难         -> 把核心逻辑放进 lib.rs / 普通函数

Rust 的测试入口从项目一开始就存在。对 C 开发者来说,最重要的迁移不是学会 #[test] 这个属性,而是把“可测试的核心逻辑”和“进程边界 main”分开。

继续阅读