为什么 pytest 取代了 unittest

Python 标准库自带 unittest,但写过的人都知道它啰嗦:要继承 TestCase 类、要用 self.assertEqual、要写一大堆样板代码。pytest 的设计哲学完全不同——写测试就像写普通函数,用 assert 语句,不需要类,不需要特殊方法名。

同样一个加法测试,unittest 要写十几行,pytest 只要三行:

# test_math.py
def test_add():
    assert add(1, 2) == 3

pytest 的超能力在于:它自动发现所有 test_*.py 文件里的 test_ 开头的函数;失败时给出详细的上下文对比(哪个变量是什么值);fixture 系统让测试前置准备可以跨文件复用;插件生态极其丰富,覆盖率、mock、参数化都有现成方案。

测试不是负担,而是你改代码时的安全网。没有测试的重构等于裸奔,pytest 让这张网编得轻松。
本文核心知识点
  • assertpytest 原生支持 Python assert,不需要 self.assertEqual。
  • fixture用装饰器定义可复用的测试前置数据和环境。
  • parametrize一个测试函数跑多组输入输出。
  • mock替换外部依赖(数据库、API),让测试只测业务逻辑。
pytest 测试示意:蓝色渐变背景上放大镜悬停于勾选网格面板,旁边测试报告图表上升,周围漂浮盾牌图标
测试就是放大镜:把代码里的每个分支都照一遍,盾牌代表测试通过后心里的踏实。

写第一个测试:断言与命名约定

安装 pytest 后,在项目里建一个 tests/ 目录,写 test_*.py 文件。pytest 的发现规则很简单:文件名以 test_ 开头,函数名以 test_ 开头,类名以 Test 开头。

# tests/test_calc.py
from myapp.calc import add, divide

def test_add_positive():
    assert add(1, 2) == 3

def test_add_negative():
    assert add(-1, -2) == -3

def test_divide_by_zero():
    import pytest
    with pytest.raises(ZeroDivisionError):
        divide(1, 0)

运行 pytest 命令,pytest 会自动找到所有测试文件并执行。输出每个测试的通过/失败状态,失败时打印详细的变量对比。

几个常用断言模式:

场景写法
相等assert a == b
包含assert x in list
布尔assert result is True
抛异常with pytest.raises(SomeError)
近似值assert abs(a - b) < 0.001

fixture:测试的前置准备复用

很多测试需要相同的前置数据——比如一个测试用的数据库连接、一个已登录的用户对象、一份测试用的 JSON。如果每个测试函数都手写一遍,代码会重复得让人发疯。fixture 就是解决这个问题的。

import pytest
from myapp.models import User

@pytest.fixture
def test_user():
    """返回一个测试用的用户对象,每个测试函数都能直接用"""
    return User(name="测试用户", email="test@example.com")

def test_user_name(test_user):
    assert test_user.name == "测试用户"

def test_user_email(test_user):
    assert "@" in test_user.email

关键在于:测试函数的参数名如果和 fixture 函数名一样,pytest 会自动调用 fixture 并把返回值传进去。你不需要手动调用,只需要声明参数。

fixture 还可以指定作用域:@pytest.fixture(scope="module") 让整个文件只创建一次;scope="session" 整个测试会话只创建一次。对于建数据库连接这种昂贵操作,用 session 级 fixture 能大幅加快测试速度。

参数化测试:一次测多组输入

同一个函数,边界值、正常值、异常值都要测。写三个 test 函数复制粘贴太蠢了——pytest 的参数化装饰器让你一个函数跑多组用例:

import pytest
from myapp.calc import add

@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

这段代码会跑四个测试用例,每组输入对应一次执行。pytest 会把每组参数的值显示在测试名里,失败时一眼就知道是哪组数据出了问题。

参数化特别适合验证业务规则:比如一个折扣计算函数,满 100 打九折、满 200 打八折、不满 100 不打折——把这些规则列成参数表,一个函数全覆盖。

mock:隔离外部依赖

你的函数可能要调数据库、发 HTTP 请求、读文件。但单元测试的目标是只测当前函数的逻辑,不测外部系统。外部系统应该被 mock 掉——假装它返回了预期结果,验证你的函数处理得对不对。

from unittest.mock import patch, MagicMock
from myapp.services import get_user_order_count

def test_get_user_order_count():
    # mock 掉数据库查询,假装返回 5 条订单
    with patch("myapp.services.db.query") as mock_query:
        mock_query.return_value = [{"id": 1}, {"id": 2}, {"id": 3}]

        result = get_user_order_count(user_id=42)

        assert result == 3
        # 验证确实调了数据库,且参数正确
        mock_query.assert_called_once_with(user_id=42)

patch 把指定路径的函数替换成 MagicMock 对象,它记录每次调用的参数,你可以预设返回值。这样测试就不依赖真实数据库,跑得快、结果稳定、不需要准备测试数据。

mock 的核心原则:mock 你拥有的边界,不 mock 你正在测的函数。数据库、外部 API、时间函数这些是边界,mock 掉;业务逻辑函数本身不要 mock,要真跑。