返回博客

35. 单元测试与文档测试

35. 单元测试与文档测试

第三十五篇:单元测试与文档测试——用测试保障代码质量

前言

没有测试的代码就是技术债。Python 提供了强大的内置测试框架 unittest 和灵活的 doctest,让开发者能够系统地验证代码正确性。本文将系统讲解 Python 测试的核心知识及最佳实践。

unittest 基础 —— 第一个测试

为什么需要单元测试?

# 假设这是一个数学工具模块(math_utils.py)
def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ValueError("除数不能为零")
    return a / b

# ❌ 每次修改后手动验证效率低且容易遗漏
result = add(1, 2)
if result != 3:
    print(f"失败!期望 3,得到 {result}")

result = divide(10, 3)
if abs(result - 3.3333) > 0.0001:
    print(f"失败!")

使用 unittest 编写测试

import unittest

def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

def multiply(a, b):
    return a * b

class TestMathFunctions(unittest.TestCase):
    
    def test_add(self):
        """测试加法函数"""
        self.assertEqual(add(1, 2), 3)
        self.assertEqual(add(-1, 1), 0)
        self.assertEqual(add(0, 0), 0)
        self.assertIsInstance(add(1, 2), int)
    
    def test_subtract(self):
        """测试减法函数"""
        self.assertEqual(subtract(5, 3), 2)
        self.assertEqual(subtract(0, 0), 0)
        self.assertEqual(subtract(-1, -1), 0)
    
    def test_multiply(self):
        """测试乘法函数"""
        self.assertEqual(multiply(3, 4), 12)
        self.assertEqual(multiply(-2, 3), -6)
        self.assertEqual(multiply(0, 100), 0)

# 运行测试
if __name__ == "__main__":
    unittest.main()

# 输出:
# ...
# Ran 3 tests in 0.001s
# OK

运行测试的多种方式

# 方式一:直接运行测试文件
python test_math.py

# 方式二:使用 unittest 模块
python -m unittest test_math.py

# 方式三:自动发现并运行所有测试
python -m unittest discover

# 方式四:显示详细输出
python -m unittest test_math.py -v

# 使用 pytest(第三方,更简洁)
pip install pytest
pytest test_math.py -v

TestCase 核心方法

常用断言方法

import unittest
import math

class TestAssertions(unittest.TestCase):
    
    def test_equal(self):
        self.assertEqual(1 + 1, 2)
        self.assertNotEqual(1 + 1, 3)
    
    def test_identity(self):
        a = [1, 2]
        b = a
        c = [1, 2]
        self.assertIs(a, b)     # a 和 b 是同一对象
        self.assertIsNot(a, c)  # a 和 c 不是同一对象
    
    def test_truthiness(self):
        self.assertTrue(True)
        self.assertFalse(False)
        self.assertTrue(bool([1]))
        self.assertFalse(bool([]))
    
    def test_is_none(self):
        value = None
        self.assertIsNone(value)
        self.assertIsNotNone(1)
    
    def test_exception(self):
        with self.assertRaises(ValueError):
            raise ValueError("测试错误")
        
        with self.assertRaises(TypeError) as cm:
            raise TypeError("类型错误")
        self.assertIn("类型错误", str(cm.exception))
    
    def test_regex(self):
        import re
        self.assertRegex("hello123", r"\d+")
        self.assertRegex("Test@example.com", r".+@.+\\..+")
        self.assertNotRegex("no email here", r"@")
    
    def test_close(self):
        """浮点数精度比较"""
        self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7)
        self.assertAlmostEqual(math.pi, 3.14159, places=5)
    
    def test_list_contains(self):
        items = ["apple", "banana", "cherry"]
        self.assertIn("banana", items)
        self.assertNotIn("date", items)
    
    def test_file_content(self):
        """检查文件大小或属性"""
        self.assertGreater(100, 50)
        self.assertLess(50, 100)
        self.assertGreaterEqual(100, 100)
        self.assertLessEqual(50, 50)

setUp 和 tearDown —— 测试前后的钩子

import unittest
import os

class TestFileOperations(unittest.TestCase):
    
    def setUp(self):
        """每个测试方法执行前运行"""
        print(f"\n[SetUp] 准备: {self._testMethodName}")
        self.test_file = "test_data.txt"
        with open(self.test_file, "w") as f:
            f.write("Hello, World!\nLine 2\nLine 3\n")
        self.temp_dir = "/tmp/test_output"
        os.makedirs(self.temp_dir, exist_ok=True)
    
    def tearDown(self):
        """每个测试方法执行后运行"""
        print(f"[TearDown] 清理: {self._testMethodName}")
        if os.path.exists(self.test_file):
            os.remove(self.test_file)
    
    def test_read_file(self):
        with open(self.test_file, "r") as f:
            content = f.read()
        self.assertIn("Hello", content)
        self.assertEqual(content.count("\n"), 3)
    
    def test_count_lines(self):
        with open(self.test_file, "r") as f:
            lines = f.readlines()
        self.assertEqual(len(lines), 3)

# 输出:
# 
# [SetUp] 准备: test_read_file
# .[TearDown] 清理: test_read_file
# 
# [SetUp] 准备: test_count_lines
# .[TearDown] 清理: test_count_lines

class-level 级别 setup/teardown

import unittest
import tempfile

class TestDatabaseConnection(unittest.TestCase):
    
    @classmethod
    def setUpClass(cls):
        """整个类的所有测试开始前只运行一次"""
        print("[SetupClass] 初始化数据库连接池...")
        cls.connection_string = "sqlite:///:memory:"
        cls.db_engine = f"Engine({cls.connection_string})"
    
    @classmethod
    def tearDownClass(cls):
        """整个类的所有测试结束后只运行一次"""
        print("[TeardownClass] 关闭数据库连接池...")
        cls.db_engine = None
    
    def setUp(self):
        """每个测试开始前运行"""
        print(f"[Setup] 获取新会话...")
        self.session_id = f"session_{id(self)}"
    
    def tearDown(self):
        """每个测试结束后运行"""
        print(f"[Teardown] 释放会话 {self.session_id}")
        self.session_id = None
    
    def test_query_1(self):
        print("执行查询 1...")
        self.assertIsNotNone(self.db_engine)
    
    def test_query_2(self):
        print("执行查询 2...")
        self.assertIsNotNone(self.db_engine)

# 输出顺序:
# [SetupClass] 初始化数据库连接池...
# [Setup] 获取新会话...   → session_A
# 执行查询 1...
# [Teardown] 释放会话 session_A
# [Setup] 获取新会话...   → session_B
# 执行查询 2...
# [Teardown] 释放会话 session_B
# [TeardownClass] 关闭数据库连接池...

实战:完整的测试案例

待测代码(calculator.py)

class Calculator:
    """简单的计算器类"""
    
    def __init__(self):
        self.history = []
    
    def add(self, a, b):
        result = a + b
        self.history.append(f"{a} + {b} = {result}")
        return result
    
    def subtract(self, a, b):
        result = a - b
        self.history.append(f"{a} - {b} = {result}")
        return result
    
    def multiply(self, a, b):
        result = a * b
        self.history.append(f"{a} * {b} = {result}")
        return result
    
    def divide(self, a, b):
        if b == 0:
            raise ZeroDivisionError("除数不能为零")
        result = a / b
        self.history.append(f"{a} / {b} = {result}")
        return result
    
    def power(self, base, exponent):
        result = base ** exponent
        self.history.append(f"{base} ^ {exponent} = {result}")
        return result
    
    def clear_history(self):
        self.history.clear()
    
    def get_last_result(self):
        if not self.history:
            raise IndexError("计算历史为空")
        return self.history[-1].split("=")[-1].strip()

完整测试(test_calculator.py)

import unittest
from calculator import Calculator


class TestCalculator(unittest.TestCase):
    
    def setUp(self):
        self.calc = Calculator()
    
    def test_add(self):
        self.assertEqual(self.calc.add(2, 3), 5)
        self.assertEqual(self.calc.add(-1, 1), 0)
        self.assertEqual(self.calc.add(0, 0), 0)
        self.assertEqual(self.calc.add(1.5, 2.5), 4.0)
    
    def test_subtract(self):
        self.assertEqual(self.calc.subtract(5, 3), 2)
        self.assertEqual(self.calc.subtract(0, 5), -5)
        self.assertEqual(self.calc.subtract(-5, -5), 0)
    
    def test_multiply(self):
        self.assertEqual(self.calc.multiply(3, 4), 12)
        self.assertEqual(self.calc.multiply(-2, 3), -6)
        self.assertEqual(self.calc.multiply(0, 999), 0)
    
    def test_divide_by_zero(self):
        with self.assertRaises(ZeroDivisionError):
            self.calc.divide(10, 0)
    
    def test_divide(self):
        self.assertAlmostEqual(self.calc.divide(10, 3), 3.333333, places=6)
        self.assertEqual(self.calc.divide(8, 4), 2.0)
    
    def test_power(self):
        self.assertEqual(self.calc.power(2, 10), 1024)
        self.assertEqual(self.calc.power(10, 0), 1)
        self.assertEqual(self.calc.power(0, 5), 0)
    
    def test_history(self):
        self.calc.add(1, 2)
        self.calc.multiply(3, 4)
        self.assertEqual(len(self.calc.history), 2)
        self.assertEqual(self.calc.get_last_result(), "12")
    
    def test_clear_history(self):
        self.calc.add(1, 2)
        self.calc.clear_history()
        self.assertEqual(len(self.calc.history), 0)
        with self.assertRaises(IndexError):
            self.calc.get_last_result()
    
    def test_chained_operations(self):
        """链式运算测试"""
        result = self.calc.add(10, 5)
        result = self.calc.multiply(result, 2)
        result = self.calc.subtract(result, 3)
        self.assertEqual(result, 27)

doctest —— 从文档中生成测试

基本用法

def fibonacci(n):
    """
    计算第 n 个斐波那契数。

    Parameters:
        n (int): 非负整数索引

    Returns:
        int: 第 n 个斐波那契数

    Examples:
        >>> fibonacci(0)
        0
        >>> fibonacci(1)
        1
        >>> fibonacci(5)
        5
        >>> fibonacci(10)
        55
        >>> fibonacci(-1)
        Traceback (most recent call last):
            ...
        ValueError: n 必须是非负整数

    Notes:
        斐波那契数列: 0, 1, 1, 2, 3, 5, 8, 13, ...
    """
    if n < 0:
        raise ValueError("n 必须是非负整数")
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b


# 运行 doctest
if __name__ == "__main__":
    import doctest
    doctest.testmod(verbose=True)

# 输出:
# Trying: fibonacci(0)
# Expecting:
# 0
# ok
# Trying: fibonacci(1)
# Expecting:
# 1
# ok
# ...

多行输出测试

def format_table(data):
    """
    将数据格式化为表格字符串。

    Examples:
        >>> data = [("Alice", 25), ("Bob", 30)]
        >>> table = format_table(data)
        >>> "Alice" in table and "Bob" in table
        True
        >>> len(table.split("\\n")) >= 3  # 至少 3 行(标题 + 表头 + 数据)
        True

        >>> data = [("X", 1), ("Y", 2), ("Z", 3)]
        >>> table = format_table(data)
        >>> "X" in table
        True
    """
    header = "| Name | Age |"
    separator = "|------|-----|"
    rows = [f"| {name} | {age} |" for name, age in data]
    return "\n".join([header, separator] + rows)

doctest 的运行方式

# 方式一:作为模块运行
python -m doctest fibonacci.py

# 方式二:命令行指定文件名
python -m doctest -v fibonacci.py

# 方式三:在代码中内嵌
import doctest

def run_tests():
    doctest.testmod()
    print("All tests passed!")

测试分类与项目组织

目录结构

my_project/
├── src/
│   ├── __init__.py
│   ├── calculator.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── test_calculator.py
│   ├── test_utils.py
│   └── conftest.py       # pytest fixtures(如果使用 pytest)
├── README.md
└── requirements.txt

测试夹具 Fixtures(unittest 版本)

import unittest
import json
import tempfile
import os

def load_json_config(filepath):
    """加载 JSON 配置文件"""
    with open(filepath, "r", encoding="utf-8") as f:
        return json.load(f)

def create_temp_json(data):
    """创建临时 JSON 文件"""
    fd, path = tempfile.mkstemp(suffix=".json")
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False)
    return path

class TestJSONConfig(unittest.TestCase):
    
    def setUp(self):
        """每个测试前创建不同的配置"""
        self.config_paths = {}
    
    def tearDown(self):
        """每个测试后清理临时文件"""
        for path in self.config_paths.values():
            if os.path.exists(path):
                os.remove(path)
    
    def test_load_valid_config(self):
        path = create_temp_json({"debug": True, "port": 8080})
        config = load_json_config(path)
        self.assertTrue(config["debug"])
        self.assertEqual(config["port"], 8080)
        self.config_paths["valid"] = path
    
    def test_load_missing_key(self):
        path = create_temp_json({"version": "1.0"})
        config = load_json_config(path)
        self.assertEqual(config["version"], "1.0")
        self.assertNotIn("debug", config)
        self.config_paths["minimal"] = path
    
    def test_nested_config(self):
        path = create_temp_json({
            "database": {
                "host": "localhost",
                "port": 5432,
                "name": "mydb"
            }
        })
        config = load_json_config(path)
        self.assertEqual(config["database"]["host"], "localhost")
        self.assertEqual(config["database"]["port"], 5432)
        self.config_paths["nested"] = path

参数化测试

手动参数化

import unittest

def is_palindrome(s):
    """判断是否为回文字符串"""
    cleaned = s.lower().replace(" ", "")
    return cleaned == cleaned[::-1]

class TestPalindrome(unittest.TestCase):
    
    def test_palindromes(self):
        """多个回文测试用例"""
        test_cases = [
            ("racecar", True),
            ("madam", True),
            ("level", True),
            ("A man a plan a canal Panama", True),
            ("hello", False),
            ("world", False),
            ("", True),
            ("a", True),
        ]
        for input_str, expected in test_cases:
            with self.subTest(input=input_str, expected=expected):
                self.assertEqual(is_palindrome(input_str), expected)

# subTest 会在某个用例失败时继续执行后续用例
# 而不是立即中断

使用 unittest 参数化装饰器

import unittest

def fizz_buzz(n):
    """FizzBuzz 问题"""
    if n % 15 == 0:
        return "FizzBuzz"
    elif n % 3 == 0:
        return "Fizz"
    elif n % 5 == 0:
        return "Buzz"
    else:
        return str(n)

class TestFizzBuzz(unittest.TestCase):
    
    def test_fizzbuzz_cases(self):
        cases = [
            (3, "Fizz"),
            (5, "Buzz"),
            (15, "FizzBuzz"),
            (7, "7"),
            (9, "Fizz"),
            (25, "Buzz"),
            (30, "FizzBuzz"),
            (1, "1"),
            (100, "Buzz"),
            (99, "Fizz"),
        ]
        for number, expected in cases:
            with self.subTest(n=number, expected=expected):
                self.assertEqual(fizz_buzz(number), expected)

mock —— 模拟外部依赖

为什么要 mock?

import unittest
from unittest.mock import Mock, patch, MagicMock
import requests

def fetch_weather(city):
    """从天气 API 获取天气数据"""
    url = f"https://api.weather.com/v1/{city}"
    response = requests.get(url)
    response.raise_for_status()
    data = response.json()
    return data["temperature"]

# ❶ 真实调用会受网络影响
# ❷ 速度很慢
# ❸ 可能产生费用
# ❹ 不可控因素太多

使用 Mock 替代真实请求

class TestFetchWeather(unittest.TestCase):
    
    def test_fetch_weather_success(self):
        """测试成功获取天气数据"""
        with patch("requests.get") as mock_get:
            # 设置 mock 返回值
            mock_response = MagicMock()
            mock_response.status_code = 200
            mock_response.json.return_value = {"temperature": 25}
            mock_get.return_value = mock_response
            
            temperature = fetch_weather("北京")
            
            # 验证
            self.assertEqual(temperature, 25)
            mock_get.assert_called_once_with(
                "https://api.weather.com/v1/北京"
            )
    
    def test_fetch_weather_api_error(self):
        """测试 API 返回错误"""
        with patch("requests.get") as mock_get:
            mock_response = MagicMock()
            mock_response.status_code = 404
            mock_response.raise_for_status.side_effect = requests.HTTPError()
            mock_get.return_value = mock_response
            
            with self.assertRaises(requests.HTTPError):
                fetch_weather("unknown_city")
    
    def test_fetch_weather_timeout(self):
        """测试网络超时"""
        with patch("requests.get") as mock_get:
            mock_get.side_effect = requests.Timeout()
            
            with self.assertRaises(requests.Timeout):
                fetch_weather("上海")

Mock 数据库调用

import unittest
from unittest.mock import MagicMock, call

def process_orders(order_ids, db_connection):
    """处理订单列表"""
    results = []
    for order_id in order_ids:
        order = db_connection.query("SELECT * FROM orders WHERE id = ?", order_id)
        if order and order["status"] == "pending":
            db_connection.execute(
                "UPDATE orders SET status = 'processed' WHERE id = ?", order_id
            )
            results.append(order_id)
    return results


class TestProcessOrders(unittest.TestCase):
    
    def test_process_orders(self):
        # 创建 mock 数据库
        mock_db = MagicMock()
        mock_db.query.return_value = {"status": "pending", "total": 100}
        
        order_ids = [1, 2, 3]
        results = process_orders(order_ids, mock_db)
        
        self.assertEqual(results, [1, 2, 3])
        
        # 验证数据库操作是否正确
        self.assertEqual(mock_db.query.call_count, 3)
        self.assertEqual(mock_db.execute.call_count, 3)
        
        # 验证具体的调用参数
        mock_db.query.assert_any_call(
            "SELECT * FROM orders WHERE id = ?", 1
        )

测试覆盖率

# 安装 coverage
pip install coverage

# 运行测试并生成覆盖率报告
coverage run -m unittest discover tests/
coverage report
coverage html

# 输出示例:
# Name                   Stmts   Miss  Cover
# ------------------------------------------
# src/calculator.py         32      0   100%
# src/utils.py              25      3    88%
# ------------------------------------------
# TOTAL                      57      3    95%

总结

本文系统讲解了 Python 测试的核心知识:

  1. unittest 基础 — TestCase、setUp/tearDown、五种运行方式
  2. 断言方法 — assertEqual/assertIs/assertRaises/assertAlmostEqual 等
  3. doctest — 从文档注释自动生成测试用例
  4. 参数化测试 — subTest 和测试用例集合
  5. Mock — 替代外部依赖(HTTP 请求、数据库调用)
  6. 覆盖率 — coverage 工具量化测试质量
  7. 最佳实践 — 命名规范、独立测试、快速反馈

下一篇文章将进入 IO 编程章节。


本文是 Python 教程系列的第三十五篇。