当项目从几十行代码膨胀到几千行时,你会面临一个残酷的现实:改了一处逻辑,不知道哪里会碎。手动测试一遍所有功能越来越耗时,而且人总会漏掉东西。这就是自动化测试登场的地方——用代码来验证代码。

Python 的标准库内置了 unittest 模块,社区则更青睐简洁的 pytest。本章从零开始,带你建立测试思维,掌握两种测试框架,最终写出可维护的测试。

为什么需要测试?

没有测试的项目就像走在钢丝上:

# 假设这是你的核心函数
def calculate_discount(price: float, member_level: str) -> float:
    """根据会员等级计算折扣价"""
    discounts = {"normal": 0.95, "silver": 0.9, "gold": 0.8}
    return price * discounts.get(member_level, 1.0)

# 改了一行代码——有人把 silver 的折扣改成了 0.85
# 没有测试的话,你可能永远不会发现这个"优化"改变了行为

测试带来的好处:

好处 说明
回归防护 修改代码后立即知道是否有东西被破坏
活的文档 测试代码展示了函数在各种情况下的预期行为
设计反馈 如果函数很难写测试,通常意味着它的设计需要改进
信心 重构时不怕改出问题,测试网络会兜住你

unittest:标准库自带

unittest 是 Python 标准库的一部分,灵感来自 Java 的 JUnit。不需要额外安装,开箱即用。

基本结构

import unittest

# 被测函数
def add(a, b):
    return a + b

class TestMathOperations(unittest.TestCase):
    """测试类必须继承 unittest.TestCase"""

    def test_add_positive(self):
        """测试方法必须以 test_ 开头"""
        self.assertEqual(add(3, 5), 8)

    def test_add_negative(self):
        self.assertEqual(add(-1, 1), 0)

    def test_add_zero(self):
        self.assertEqual(add(0, 0), 0)

if __name__ == "__main__":
    unittest.main()

运行测试:

python test_math.py
# 输出:
# ...
# ----------------------------------------------------------------------
# Ran 3 tests in 0.001s
#
# OK

setUp / tearDown

每个测试方法运行前后,setUptearDown 会被自动调用。这适合准备和清理共享资源:

import unittest
import tempfile
import os

class TestFileOps(unittest.TestCase):
    def setUp(self):
        """每个测试前执行——创建临时目录"""
        self.temp_dir = tempfile.mkdtemp()
        self.test_file = os.path.join(self.temp_dir, "test.txt")
        with open(self.test_file, "w") as f:
            f.write("Hello, World!")

    def tearDown(self):
        """每个测试后执行——清理临时文件"""
        import shutil
        shutil.rmtree(self.temp_dir, ignore_errors=True)

    def test_read_file(self):
        with open(self.test_file) as f:
            content = f.read()
        self.assertEqual(content, "Hello, World!")

    def test_file_exists(self):
        self.assertTrue(os.path.exists(self.test_file))

也可以使用类级别的 setUpClass / tearDownClass(在整个测试类运行前后执行一次):

class TestDatabase(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        """测试类加载时执行一次"""
        cls.connection = create_database_connection()

    @classmethod
    def tearDownClass(cls):
        """测试类销毁时执行一次"""
        cls.connection.close()

常用断言方法

unittest 提供了一系列断言方法,覆盖各种检查场景:

class TestAssertions(unittest.TestCase):
    def test_assertions(self):
        # 相等性
        self.assertEqual(2 + 2, 4)
        self.assertNotEqual(2 + 2, 5)

        # 布尔值
        self.assertTrue(1 < 2)
        self.assertFalse(1 > 2)

        # 空值
        self.assertIsNone(None)
        self.assertIsNotNone("hello")

        # 类型
        self.assertIsInstance(42, int)
        self.assertNotIsInstance("42", int)

        # 集合
        self.assertIn("a", "abc")
        self.assertNotIn("z", "abc")

        # 近似相等(浮点数)
        self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7)

测试异常

要验证函数是否正确地抛出异常,使用 assertRaises

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

class TestDivide(unittest.TestCase):
    def test_divide_normal(self):
        self.assertEqual(divide(10, 2), 5.0)

    def test_divide_by_zero(self):
        # 方式一:上下文管理器(推荐)
        with self.assertRaises(ValueError) as ctx:
            divide(10, 0)
        self.assertEqual(str(ctx.exception), "除数不能为零")

        # 方式二:函数式
        self.assertRaises(ValueError, divide, 10, 0)

测试发现

unittest 可以自动发现项目中的所有测试文件:

# 递归发现当前目录下所有 test*.py 文件并运行
python -m unittest discover

# 指定目录和模式
python -m unittest discover -s tests -p "test_*.py"

# 运行单个测试类
python -m unittest test_math.TestMathOperations

# 运行单个测试方法
python -m unittest test_math.TestMathOperations.test_add_positive

# 详细输出
python -m unittest -v

跳过测试

import sys

class TestSkip(unittest.TestCase):
    @unittest.skip("临时跳过")
    def test_todo(self):
        pass

    @unittest.skipIf(sys.platform == "win32", "Windows 不适用")
    def test_unix_only(self):
        pass

    @unittest.skipUnless(sys.version_info >= (3, 10), "需要 Python 3.10+")
    def test_new_feature(self):
        pass

pytest:更现代的测试框架

pytest 是 Python 社区最受欢迎的测试框架。它的哲学是"测试代码应该像普通 Python 一样自然"。

安装:

pip install pytest

与 unittest 的对比

同样是测试加法,pytest 的版本简洁得多:

# test_math_pytest.py

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

# 不需要类,不需要继承,不需要断言方法
def test_add_positive():
    assert add(3, 5) == 8

def test_add_negative():
    assert add(-1, 1) == 0

def test_add_zero():
    assert add(0, 0) == 0
# 只需运行 pytest
pytest

# 或者指定文件
pytest test_math_pytest.py -v
对比项 unittest pytest
语法 需要类 + 继承 纯函数,assert 语句
依赖 内置,零安装 需要 pip install pytest
断言方式 self.assertEqual(x, y) assert x == y
失败信息 有限 详细的 diff 和上下文
插件生态 一般 极其丰富
参数化 有限 @pytest.mark.parametrize
学习曲线 较陡 平缓

详细的失败信息

pytest 最令人称道的特性之一:断言失败时给出丰富的上下文:

def get_user_name(user_id):
    users = {1: "Alice", 2: "Bob"}
    return users.get(user_id)

def test_get_user_name():
    assert get_user_name(1) == "Alice"
    assert get_user_name(3) == "Unknown"  # 会失败
FAILED test_example.py::test_get_user_name -
AssertionError: assert None == 'Unknown'
  +  where None = get_user_name(3)

你会直接看到问题的根源——get_user_name(3) 返回了 None,而不是预期的 "Unknown"

Fixture:测试前的准备工作

pytest 的 fixture 系统比 unittest 的 setUp/tearDown 更灵活:

import pytest

@pytest.fixture
def sample_data():
    """这个 fixture 为测试提供数据"""
    return {"name": "Alice", "age": 30, "items": [1, 2, 3]}

# fixture 作为参数注入
def test_sample_name(sample_data):
    assert sample_data["name"] == "Alice"

def test_sample_items(sample_data):
    assert len(sample_data["items"]) == 3
    assert sum(sample_data["items"]) == 6

Fixture 的作用域

import pytest

@pytest.fixture(scope="function")  # 默认,每个测试函数独立
def per_test():
    print("\n  每个测试前执行")
    yield
    print("  每个测试后执行")

@pytest.fixture(scope="module")    # 每个模块共享
def db_connection():
    print("\n[模块] 连接数据库")
    conn = {"connected": True}
    yield conn
    print("[模块] 关闭连接")

@pytest.fixture(scope="session")   # 整个测试会话共享
def global_config():
    return {"base_url": "http://localhost:8000"}
# 使用 yield 的 fixture:
# yield 之前的代码是 setUp(测试前执行)
# yield 之后的代码是 tearDown(测试后执行)

@pytest.fixture
def temp_file():
    import tempfile
    import os
    fd, path = tempfile.mkstemp()
    yield path
    os.close(fd)
    os.unlink(path)

def test_write_read(temp_file):
    with open(temp_file, "w") as f:
        f.write("test data")
    with open(temp_file) as f:
        assert f.read() == "test data"

autouse:自动应用的 Fixture

@pytest.fixture(autouse=True)
def time_it():
    """自动测量每个测试的执行时间"""
    import time
    start = time.perf_counter()
    yield
    elapsed = time.perf_counter() - start
    print(f"耗时: {elapsed:.4f}s")

参数化测试

参数化让你用不同的输入运行同一个测试逻辑:

import pytest

def is_even(n):
    return n % 2 == 0

@pytest.mark.parametrize("input_val, expected", [
    (2, True),
    (3, False),
    (0, True),
    (-2, True),
    (-3, False),
    (100, True),
])
def test_is_even(input_val, expected):
    assert is_even(input_val) == expected

当参数很多时,参数化测试的好处很明显:

  • 一个测试覆盖所有边界情况
  • 新增测试用例只需加一行数据
  • 失败时明确知道是哪个参数组合出错

嵌套参数化:

@pytest.mark.parametrize("a, b", [(1, 2), (3, 4)])
@pytest.mark.parametrize("op, expected", [
    ("add", lambda a, b: a + b),
    ("mul", lambda a, b: a * b),
])
def test_two_levels(a, b, op, expected):
    """组合 2×2 = 4 个测试用例"""
    assert op(a, b) == expected

使用 pytest-mock

pytest-mock 提供了更简洁的 mock 语法:

pip install pytest-mock
import requests

def fetch_user_data(user_id):
    """从外部 API 获取用户数据"""
    response = requests.get(f"https://api.example.com/users/{user_id}")
    response.raise_for_status()
    return response.json()

def test_fetch_user_data(mocker):
    """用 mock 模拟 API 调用,不依赖网络"""
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"id": 1, "name": "Alice"}
    mock_response.raise_for_status.return_value = None

    mocker.patch("requests.get", return_value=mock_response)

    result = fetch_user_data(1)
    assert result["name"] == "Alice"
    requests.get.assert_called_once_with("https://api.example.com/users/1")

monkeypatch:pytest 原生的打补丁工具

不需要额外安装,pytest 自带 monkeypatch

import os

def get_api_key():
    api_key = os.environ.get("API_KEY")
    if not api_key:
        raise RuntimeError("API_KEY 环境变量未设置")
    return api_key

def test_get_api_key(monkeypatch):
    monkeypatch.setenv("API_KEY", "test-key-123")
    assert get_api_key() == "test-key-123"

def test_get_api_key_missing(monkeypatch):
    monkeypatch.delenv("API_KEY", raising=False)
    with pytest.raises(RuntimeError, match="未设置"):
        get_api_key()

测试覆盖率

覆盖率告诉你有多少代码被测试执行到。它不能保证代码没有 bug,但未覆盖的代码肯定没被测试过。

pip install pytest-cov

# 生成覆盖率报告
pytest --cov=myproject --cov-report=html

# 终端报告
pytest --cov=myproject --cov-report=term-missing

覆盖率指标:

指标 含义
line coverage 被执行的代码行数占比
branch coverage 条件分支(if/else)的覆盖占比
function coverage 被调用的函数占比

注意:100% 覆盖率不等于没有 bug——只说明你的测试把每条代码都跑了一遍。但高覆盖率(80%+)仍然是项目健康的重要指标。

实战:测试 Calculator 类

让我们完整地测试一个计算器类:

# calculator.py
class Calculator:
    """一个简单的计算器类"""

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

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

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

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

    def power(self, a, b):
        return a ** b

    def modulo(self, a, b):
        if b == 0:
            raise ValueError("模数不能为零")
        return a % b
# test_calculator.py
import pytest
from calculator import Calculator

@pytest.fixture
def calc():
    """每个测试都使用新的 Calculator 实例"""
    return Calculator()

class TestCalculator:
    """Calculator 的测试套件"""

    def test_add(self, calc):
        assert calc.add(3, 5) == 8
        assert calc.add(-1, 1) == 0
        assert calc.add(0, 0) == 0

    def test_subtract(self, calc):
        assert calc.subtract(10, 3) == 7
        assert calc.subtract(5, 5) == 0
        assert calc.subtract(3, 10) == -7

    def test_multiply(self, calc):
        assert calc.multiply(4, 3) == 12
        assert calc.multiply(0, 5) == 0
        assert calc.multiply(-2, 3) == -6

    def test_divide(self, calc):
        assert calc.divide(10, 2) == 5.0
        assert calc.divide(7, 2) == 3.5
        with pytest.raises(ValueError, match="除数不能为零"):
            calc.divide(10, 0)

    @pytest.mark.parametrize("a, b, expected", [
        (2, 3, 8),
        (5, 0, 1),
        (2, -1, 0.5),
    ])
    def test_power(self, calc, a, b, expected):
        assert calc.power(a, b) == expected

    def test_modulo(self, calc):
        assert calc.modulo(10, 3) == 1
        assert calc.modulo(4, 2) == 0
        with pytest.raises(ValueError, match="模数不能为零"):
            calc.modulo(10, 0)

    def test_divide_by_zero_message(self, calc):
        with pytest.raises(ValueError) as exc_info:
            calc.divide(5, 0)
        assert str(exc_info.value) == "除数不能为零"

实战:测试文件 I/O

# file_ops.py
import json
from pathlib import Path

def save_json(data, filepath):
    """将数据保存为 JSON 文件"""
    with open(filepath, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)

def load_json(filepath):
    """从 JSON 文件读取数据"""
    with open(filepath, "r", encoding="utf-8") as f:
        return json.load(f)

def append_line(filepath, line):
    """向文件追加一行"""
    with open(filepath, "a", encoding="utf-8") as f:
        f.write(line + "\n")
# test_file_ops.py
import pytest
import json
from pathlib import Path
from file_ops import save_json, load_json, append_line

class TestJsonFileOps:
    def test_save_and_load_json(self, tmp_path):
        """保存后再读取,内容应一致"""
        data = {"name": "Alice", "age": 30}
        filepath = tmp_path / "data.json"

        save_json(data, filepath)

        loaded = load_json(filepath)
        assert loaded == data

    def test_save_json_overwrite(self, tmp_path):
        """保存应覆盖已有文件"""
        filepath = tmp_path / "data.json"
        save_json({"version": 1}, filepath)
        save_json({"version": 2}, filepath)

        loaded = load_json(filepath)
        assert loaded["version"] == 2

    def test_load_nonexistent_file(self, tmp_path):
        """读取不存在的文件应抛异常"""
        filepath = tmp_path / "nonexistent.json"
        with pytest.raises(FileNotFoundError):
            load_json(filepath)

class TestAppendLine:
    def test_append_new_file(self, tmp_path):
        """在新文件中追加一行"""
        filepath = tmp_path / "log.txt"
        append_line(filepath, "first line")

        content = filepath.read_text(encoding="utf-8")
        assert content == "first line\n"

    def test_append_existing_file(self, tmp_path):
        """在已有文件中追加多行"""
        filepath = tmp_path / "log.txt"
        filepath.write_text("line1\n", encoding="utf-8")

        append_line(filepath, "line2")
        append_line(filepath, "line3")

        lines = filepath.read_text(encoding="utf-8").splitlines()
        assert lines == ["line1", "line2", "line3"]

注意上面用到了 tmp_path——这是 pytest 内置的 fixture,为每个测试提供一个独立的临时目录,测试结束后自动清理。

实战:测试 API 调用

测试网络调用的关键是 不要真的发起 HTTP 请求——否则你的测试就会依赖网络、第三方服务是否在线、API key 是否有效。用 mock 来模拟网络层。

# weather_client.py
import requests
from dataclasses import dataclass

@dataclass
class Weather:
    city: str
    temperature: float
    condition: str

class WeatherClient:
    """调用外部天气 API 的客户端"""

    BASE_URL = "https://api.weather.example.com"

    def __init__(self, api_key: str):
        self.api_key = api_key
        self.session = requests.Session()
        self.session.headers.update({"Authorization": f"Bearer {api_key}"})

    def get_current(self, city: str) -> Weather:
        """获取指定城市的当前天气"""
        response = self.session.get(
            f"{self.BASE_URL}/current",
            params={"city": city}
        )
        response.raise_for_status()
        data = response.json()

        return Weather(
            city=data["location"],
            temperature=data["temp_c"],
            condition=data["condition"],
        )
# test_weather_client.py
import pytest
import requests
from weather_client import WeatherClient, Weather

class TestWeatherClient:
    @pytest.fixture
    def client(self):
        return WeatherClient(api_key="test-key")

    def test_get_current_success(self, client, mocker):
        """正常获取天气"""
        mock_response = mocker.Mock()
        mock_response.raise_for_status.return_value = None
        mock_response.json.return_value = {
            "location": "Beijing",
            "temp_c": 22.5,
            "condition": "Sunny",
        }
        mocker.patch("requests.Session.get", return_value=mock_response)

        weather = client.get_current("Beijing")

        assert weather.city == "Beijing"
        assert weather.temperature == 22.5
        assert weather.condition == "Sunny"

    def test_get_current_http_error(self, client, mocker):
        """API 返回 HTTP 错误"""
        mock_response = mocker.Mock()
        mock_response.raise_for_status.side_effect = requests.HTTPError("401 Unauthorized")
        mocker.patch("requests.Session.get", return_value=mock_response)

        with pytest.raises(requests.HTTPError):
            client.get_current("Beijing")

    def test_get_current_network_error(self, client, mocker):
        """网络连接失败"""
        mocker.patch(
            "requests.Session.get",
            side_effect=requests.ConnectionError("连接超时")
        )

        with pytest.raises(requests.ConnectionError):
            client.get_current("Beijing")

测试文件组织

良好的测试结构让项目更易维护:

project/
├── src/
│   ├── calculator.py
│   ├── file_ops.py
│   └── weather_client.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py           # 共享 fixture
│   ├── test_calculator.py
│   ├── test_file_ops.py
│   └── test_weather_client.py
├── pyproject.toml
└── pytest.ini

conftest.py 是 pytest 的配置文件,其中的 fixture 可以被同目录及子目录下所有测试共享:

# tests/conftest.py
import pytest

@pytest.fixture(scope="session")
def project_root():
    """返回项目根目录"""
    from pathlib import Path
    return Path(__file__).parent.parent

@pytest.fixture
def temp_data_dir(tmp_path):
    """创建一个测试数据目录"""
    data_dir = tmp_path / "data"
    data_dir.mkdir()
    return data_dir

pytest.ini 可以配置 pytest 的行为:

[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
markers =
    slow: 慢速测试(默认跳过)
    integration: 需要外部服务的集成测试
addopts = -v --tb=short --strict-markers

测试最佳实践

1. 每个测试只测一件事

# 坏:一个测试检查多个行为
def test_user_operations(bad):
    user = create_user("Alice")
    assert user.name == "Alice"
    user.set_age(30)
    assert user.age == 30
    # 如果 set_age 失败,你也不知道前面的 create_user 有没有问题

# 好:每个测试聚焦一个场景
def test_create_user():
    user = create_user("Alice")
    assert user.name == "Alice"

def test_set_user_age():
    user = create_user("Alice")
    user.set_age(30)
    assert user.age == 30

2. 测试边界条件

def test_divide_by_zero(): ...
def test_empty_input(): ...
def test_very_large_number(): ...
def test_negative_values(): ...
def test_special_characters(): ...

3. 测试命名要有描述性

# 坏
def test_calc(): ...

# 好
def test_calculator_divide_by_zero_raises_error(): ...
def test_calculator_add_two_positive_numbers(): ...
def test_weather_client_handles_network_timeout(): ...

4. 使用 TDD(测试驱动开发)

红-绿-重构循环:

1. 先写一个会失败的测试(红)
2. 写最少的代码让它通过(绿)
3. 重构代码,保持测试通过(重构)
# 1. 先写测试(会失败)
def test_validate_email():
    assert validate_email("user@example.com") is True
    assert validate_email("invalid") is False

# 2. 写实现
def validate_email(email: str) -> bool:
    return "@" in email and "." in email.split("@")[1]

# 3. 重构——完善正则
import re

def validate_email(email: str) -> bool:
    pattern = r"^[\w\.-]+@[\w\.-]+\.\w+$"
    return bool(re.match(pattern, email))

小结

本章我们学习了 Python 测试的核心知识:

  • 测试的价值:回归防护、活的文档、更好的设计、重构信心
  • unittest:标准库内置,使用 TestCase 类,setUp/tearDown 管理生命周期,丰富的断言方法
  • pytest:更简洁的语法、assert 替代断言方法、详细的失败信息、强大的 fixture 系统、参数化测试
  • Fixturescope 控制生命周期,autouse 自动注入,yield fixture 实现清理逻辑
  • Mock:用 pytest-mockmonkeypatch 模拟外部依赖,让测试不依赖网络和环境
  • 覆盖率pytest-cov 衡量测试覆盖了多少代码
  • 实战:完整测试了 Calculator 类、文件 I/O 函数和 API 客户端

记住:测试不是写在最后的事后想法——它是代码的一部分,和生产代码一样重要。从今天开始,为你的每个新函数写测试吧。

Summary: unittest 与 pytest 测试框架、fixture、mock、覆盖率和实战。