跳转至

7. 测试篇

对应章节:第11章(测试)。
本篇掌握用测试保证代码正确性:单元测试与集成测试。


项目 14:字符串工具库

目标:写纯函数库 + 单元测试 + 集成测试。

创建项目

cargo new strtools --lib
cd strtools

说明:这是库项目(--lib),没有 main 函数,代码放在 src/lib.rs。集成测试放在 tests/ 目录(无需手动创建,cargo test 会自动发现 tests/*.rs)。

功能要求

  1. word_count、reverse、is_palindrome、longest_word
  2. 单元测试(#[cfg(test)])
  3. 集成测试(tests/ 目录)

知识点

知识点 说明
纯函数库 lib.rs 无 main
#[cfg(test)] 只在测试编译
#[test] 测试函数
assert_eq! / assert! 断言宏
tests/ 目录 集成测试
use super::* 测试访问父模块

代码

src/lib.rs:

// 纯函数:输入 &str,输出计算结果,不依赖外部状态
pub fn word_count(s: &str) -> usize {
    s.split_whitespace().count()
}

pub fn reverse(s: &str) -> String {
    // chars() 字符迭代,rev() 反转,collect 成 String
    s.chars().rev().collect()
}

pub fn is_palindrome(s: &str) -> bool {
    // 过滤出字母数字,统一小写,得到"干净"的比较基准
    let cleaned: String = s.chars()
        .filter(|c| c.is_alphanumeric())
        .map(|c| c.to_ascii_lowercase())
        .collect();
    cleaned == reverse(&cleaned)   // 正反相同即回文
}

pub fn longest_word(s: &str) -> &str {
    s.split_whitespace()
        .max_by_key(|w| w.len())   // 按长度取最大
        .unwrap_or("")             // 空输入返回空串
}

// ============ 单元测试 ============
// #[cfg(test)]:只在 cargo test 时编译,生产构建剔除
#[cfg(test)]
mod tests {
    use super::*;   // 引入父模块(lib 根)的所有函数

    #[test]
    fn word_count_works() {
        assert_eq!(word_count("hello world rust"), 3);
    }

    #[test]
    fn reverse_works() {
        assert_eq!(reverse("rust"), "tsur");
    }

    #[test]
    fn palindrome_true() {
        assert!(is_palindrome("A man, a plan, a canal: Panama"));
    }

    #[test]
    fn palindrome_false() {
        assert!(!is_palindrome("hello"));
    }

    #[test]
    fn longest_word_works() {
        assert_eq!(longest_word("the quick brown fox"), "quick");
    }

    // 边界情况也要测:空字符串
    #[test]
    fn longest_word_empty() {
        assert_eq!(longest_word(""), "");
    }
}

tests/str_tools.rs(集成测试,从外部视角测公开 API):

// 集成测试文件是独立的 crate,只能访问 pub API
use str_tools::{is_palindrome, reverse, word_count};

#[test]
fn integration_reverse() {
    assert_eq!(reverse("abc"), "cba");
}

#[test]
fn integration_word_count() {
    assert_eq!(word_count("a b c d"), 4);
}

#[test]
fn integration_palindrome() {
    assert!(is_palindrome("racecar"));
}

运行

cargo test          # 全部测试(单元 + 集成)
cargo test -- --nocapture   # 显示 println 输出
cargo test word_count       # 只跑名字含 word_count 的测试

运行后会看到 test result: ok. 9 passed 之类的汇总,包含单元测试(src 内)和集成测试(tests/)。

注意事项

  1. 单元测试写在 #[cfg(test)] mod tests:生产代码编译时这个模块被剔除,不增加二进制体积。
  2. use super::*;:测试模块要能访问库内部函数,必须引入父模块。
  3. 集成测试放 tests/:每个文件是独立的 crate,只能测 pub API——这正是"外部使用者视角"。
  4. chars().rev() 反转字符串:&str 不能直接 reverse(),需转字符迭代器反转后 collect::<String>()。

进阶:更多纯函数练习

实战中的同名项目 strtools 换了一批函数继续练测试, word_count 在这里换了语义——从"数单词个数"变成"统计每个单词出现次数":

src/lib.rs:

use std::collections::HashMap;

// 首字母大写:"hello" → "Hello"
pub fn capitalize(s: &str) -> String {
    let mut chars = s.chars();
    match chars.next() {
        Some(first) => first.to_uppercase().to_string() + chars.as_str(),
        None => String::new(),   // 空串边界
    }
}

pub fn is_palindrome(s: &str) -> bool {
    let clean: String = s
        .chars()
        .filter(|c| c.is_alphanumeric())
        .map(|c| c.to_lowercase().next().unwrap())   // to_lowercase 返回迭代器,取第一个字符
        .collect();

    clean == clean.chars().rev().collect::<String>()
}

// 与原版同名但语义不同:返回 词 → 出现次数 的词频表
pub fn word_count(s: &str) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    for word in s.split_whitespace() {
        *counts.entry(word.to_string()).or_insert(0) += 1;
    }
    counts
}

// snake_case → camelCase:"hello_world_foo" → "helloWorldFoo"
pub fn camel_case(s: &str) -> String {
    s.split('_')
        .enumerate()
        .map(|(i, part)| {
            if i == 0 {
                part.to_string()   // 首段保持小写
            } else {
                let mut chars = part.chars();
                match chars.next() {
                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
                    None => String::new(),
                }
            }
        })
        .collect()
}

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

    #[test]
    fn capitalize_first_letter() {
        assert_eq!(capitalize("hello"), "Hello");
    }

    #[test]
    fn capitalize_handles_empty() {
        assert_eq!(capitalize(""), "");
    }

    #[test]
    fn is_palindrome_basic() {
        assert!(is_palindrome("racecar"));
        assert!(is_palindrome("A man a plan a canal Panama"));
        assert!(!is_palindrome("hello"));
    }
}

要点

  1. 同名函数,两种 API:原版 word_count(&str) -> usize 数总数;进阶版 word_count(&str) -> HashMap<String, usize> 给词频。函数名传达意图的能力有限,返回类型才是契约——设计 API 时值得琢磨。
  2. chars.next() + chars.as_str() 组合:取走第一个字符后,as_str() 把迭代器剩余部分变回 &str——拆头留尾的惯用法(capitalize/camel_case 两处使用)。
  3. to_lowercase() 是迭代器:字符的大小写转换可能产生多个字符(如 İ),所以 .next().unwrap() 取结果;原版的 to_ascii_lowercase() 直接返回 char,只对 ASCII 安全。
  4. entry().or_insert(0) 词频套路:不存在则插入 0,再 += 1——HashMap 计数的标准三连。
  5. enumerate() 区分首元素:camel_case 里第一段不大写,靠索引判断。

评论