测试

上文我们大致了解了"文字地牢"小游戏的模块化与可见性,那么我们本章的目标是掌握单元测试与集成测试;了解断言宏与常见属性;构建可测试的设计。

基本概念

单元测试

单元测试是针对程序中最小可测试单元(通常是函数或方法)的测试,用于验证其行为是否符合预期。

单元测试的特点:

  1. 快速执行:通常在毫秒级完成
  2. 隔离性:测试单个函数或方法,不依赖外部系统
  3. 自动化:可以自动运行并报告结果
  4. 可重复性:每次运行都应该产生相同的结果

集成测试

集成测试是针对多个模块或组件协同工作的测试,验证它们之间的交互是否正确。

集成测试的特点:

  1. 端到端验证:测试整个功能流程
  2. 外部依赖:可能涉及文件系统、网络等外部系统
  3. 真实场景:更接近实际使用情况
  4. 较慢执行:通常比单元测试慢

断言宏

Rust提供了多种断言宏,如assert!assert_eq!assert_ne!,用于在测试中验证条件是否满足。

常用的断言宏:

  1. assert!:验证布尔条件
  2. assert_eq!:验证两个值是否相等
  3. assert_ne!:验证两个值是否不相等
  4. matches!:验证值是否匹配模式
  5. panic!:在测试中故意触发panic

测试属性

Rust提供了多种测试相关的属性来控制测试行为:

  1. #[test]:标记测试函数
  2. #[cfg(test)]:条件编译测试代码
  3. #[should_panic]:期望测试函数panic
  4. #[ignore]:标记忽略的测试

如何使用

编写单元测试

// 基本单元测试结构
#[cfg(test)]
mod tests {
    use super::*; // 导入被测试模块的所有项
    
    #[test]
    fn test_addition() {
        assert_eq!(2 + 2, 4);
    }
    
    #[test]
    fn test_string_processing() {
        let input = "hello world";
        let expected = "HELLO WORLD";
        assert_eq!(input.to_uppercase(), expected);
    }
    
    #[test]
    #[should_panic(expected = "index out of bounds")]
    fn test_panic() {
        let vec = vec![1, 2, 3];
        let _ = vec[10]; // 这会panic
    }
    
    #[test]
    #[ignore]
    fn expensive_test() {
        // 这个测试会被忽略,除非使用 --ignored 标志运行
        std::thread::sleep(std::time::Duration::from_secs(10));
    }
}

使用测试辅助工具

// 自定义断言消息
#[test]
fn test_with_custom_message() {
    let left = 5;
    let right = 10;
    assert_eq!(left, right, "Left value {} should equal right value {}", left, right);
}

// 使用matches!宏进行模式匹配
#[test]
fn test_enum_variant() {
    let result: Result<i32, &str> = Ok(42);
    assert!(matches!(result, Ok(42)));
}

// 测试错误情况
#[test]
fn test_error_handling() {
    let result = divide(10.0, 0.0);
    assert!(result.is_err());
    assert_eq!(result.unwrap_err(), "Division by zero");
}

集成测试

// tests/integration_test.rs
use dungeon_explorer::game::{Game, GameState, Player, Position};
use dungeon_explorer::map::Map;

#[test]
fn test_full_game_flow() {
    // 创建游戏状态
    let map = Map::new(10, 10);
    let player = Player {
        position: Position { x: 1, y: 1 },
        hp: 100,
    };
    let state = GameState { map, player };
    let mut game = Game::new(state);
    
    // 执行游戏操作
    game.handle_move('d'); // 向右移动
    
    // 验证结果
    assert_eq!(game.state.player.position.x, 2);
    assert_eq!(game.state.player.position.y, 1);
}

单元测试

  • 在模块文件末尾添加:
#[cfg(test)]
mod tests { /* 用 super::* 访问当前模块项 */ }
  • 使用断言:assert_eq!, assert!, matches!;可用 #[should_panic] 断言崩溃。

集成测试

  • tests/ 目录下创建测试文件,针对库 API。
  • 二进制项目建议将逻辑提取到库模块以便集成测试。

注意事项

  1. 测试代码应该覆盖各种情况,包括正常情况、边界情况和错误情况。
  2. 避免在测试中引入外部依赖,如网络或文件系统,除非必要。
  3. 使用#[should_panic]时要指定预期的panic信息,以确保测试的准确性。
  4. 保持测试的独立性,避免测试之间相互依赖。
  5. 定期运行测试,确保代码更改不会破坏现有功能。
  6. 使用描述性的测试函数名称,清楚表达测试意图。
  7. 避免在测试中使用随机数,除非使用固定种子。
  8. 对于耗时较长的测试,考虑使用#[ignore]标记。

本项目中的使用

在我们的项目中,我们在各个模块文件的末尾添加了单元测试,例如在map/mod.rs中:

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

    #[test]
    fn map_border_is_wall() {
        let m = Map::new(5, 5);
        for x in 0..5 {
            assert!(matches!(m.get_tile(x, 0), Tile::Wall));
            assert!(matches!(m.get_tile(x, 4), Tile::Wall));
        }
        for y in 0..5 {
            assert!(matches!(m.get_tile(0, y), Tile::Wall));
            assert!(matches!(m.get_tile(4, y), Tile::Wall));
        }
    }
}

game/mod.rs中也有类似的测试:

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

    #[test]
    fn move_blocked_by_wall() {
        let map = Map::new(5, 5);
        let player = Player { position: Position { x: 1, y: 1 }, hp: 10 };
        let state = GameState { map, player };
        let mut game = Game::new(state);
        // 上方是墙(y=0)
        game.handle_move('w');
        assert_eq!(game.state.player.position, Position { x: 1, y: 1 });
    }
}

这些测试确保了我们的核心功能按预期工作。

测试改进建议

在地牢探险游戏中,我们可以添加更多类型的测试:

  1. 边界条件测试
#[test]
fn test_player_movement_boundaries() {
    let map = Map::new(5, 5);
    let player = Player { position: Position { x: 0, y: 0 }, hp: 10 };
    let state = GameState { map, player };
    let mut game = Game::new(state);
    
    // 测试左边界
    game.handle_move('a'); // 向左移动
    assert_eq!(game.state.player.position, Position { x: 0, y: 0 });
    
    // 测试上边界
    game.handle_move('w'); // 向上移动
    assert_eq!(game.state.player.position, Position { x: 0, y: 0 });
}
  1. 参数化测试
#[test]
fn test_all_directions_blocked_by_walls() {
    let test_cases = vec![
        ('w', Position { x: 1, y: 1 }), // 上
        ('s', Position { x: 1, y: 3 }), // 下
        ('a', Position { x: 1, y: 1 }), // 左
        ('d', Position { x: 3, y: 1 }), // 右
    ];
    
    for (direction, expected_position) in test_cases {
        let map = Map::new(5, 5);
        let player = Player { position: expected_position, hp: 10 };
        let state = GameState { map, player };
        let mut game = Game::new(state);
        
        game.handle_move(direction);
        assert_eq!(game.state.player.position, expected_position, 
                   "Failed for direction: {}", direction);
    }
}
  1. Mock测试
#[cfg(test)]
mod mock_tests {
    use super::*;
    
    // Mock随机数生成器用于可重现测试
    struct MockRng {
        values: std::collections::VecDeque<bool>,
    }
    
    impl MockRng {
        fn new(values: Vec<bool>) -> Self {
            Self {
                values: values.into(),
            }
        }
    }
    
    impl rand::RngCore for MockRng {
        fn next_u32(&mut self) -> u32 {
            if let Some(val) = self.values.pop_front() {
                if val { 1 } else { 0 }
            } else {
                0
            }
        }
        
        fn next_u64(&mut self) -> u64 { self.next_u32() as u64 }
        fn fill_bytes(&mut self, _dest: &mut [u8]) {}
        fn try_fill_bytes(&mut self, _dest: &mut [u8]) -> Result<(), rand::Error> {
            Ok(())
        }
    }
}

高级测试技术

测试覆盖率

# 安装tarpaulin
cargo install cargo-tarpaulin

# 运行覆盖率测试
cargo tarpaulin --ignore-tests

基准测试

// benches/game_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn bench_player_movement(c: &mut Criterion) {
    let map = Map::new(10, 10);
    let player = Player { position: Position { x: 1, y: 1 }, hp: 10 };
    let state = GameState { map, player };
    let mut game = Game::new(state);
    
    c.bench_function("player_movement", |b| {
        b.iter(|| {
            game.handle_move(black_box('d'));
        })
    });
}

criterion_group!(benches, bench_player_movement);
criterion_main!(benches);

属性测试

// 使用proptest进行属性测试
use proptest::prelude::*;

proptest! {
    #[test]
    fn test_player_stays_in_bounds(x in 0..100usize, y in 0..100usize) {
        let map = Map::new(10, 10);
        let player = Player { position: Position { x, y }, hp: 10 };
        let state = GameState { map, player };
        let mut game = Game::new(state);
        
        // 执行多次移动
        for _ in 0..10 {
            let directions = ['w', 'a', 's', 'd'];
            let dir = *directions.choose(&mut rand::thread_rng()).unwrap();
            game.handle_move(dir);
        }
        
        // 验证玩家仍在地图内
        assert!(game.state.player.position.x < 10);
        assert!(game.state.player.position.y < 10);
    }
}

练习:

  1. 扩展移动逻辑的测试覆盖多方向与边界。
  2. 把渲染拆分为纯函数,断言字符串输出。

概念补充

  • 测试金字塔:单元测试快而多,集成测试覆盖边界,端到端可选;优先构建可测试的纯逻辑。
  • 隔离副作用:I/O、随机、时间通过注入接口或参数(种子/时钟)以替换为可预测实现。
  • 固件与夹具:使用 #[ctor]/once_cell/lazy_static 构造共享测试数据要谨慎,避免跨测试耦合。
  • 断言与诊断:pretty_assertions 改善 diff 可读性;失败信息尽量包含关键上下文。
  • 覆盖范围:cargo tarpaulin(Linux)或基于 llvm-cov 的方案评估覆盖率,关注关键路径与错误分支。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐