Python单元测试

Python 单元测试:别再用 print 调试了,写个测试一劳永逸

改了一行代码,然后花了半小时手动点页面验证功能对不对——这事你干过吧?

项目小的时候还能忍,项目大了就变成噩梦:改 A 测 A,改 B 测 B,改完发现 A 又被你改坏了。这种”改一处、测全部”的死循环,靠手工点永远跳不出来。

单元测试就是干这个的。把验证逻辑写成代码,每次改完跑一遍,几秒钟告诉你哪里坏了。

不写测试的代价 vs 写了测试的好处

典型场景: 你修了一个”购物车结算金额算错了”的 Bug,上线后发现”用户登录”挂了。因为修改结算逻辑的时候,不小心改到了一个公用的工具函数。

写了测试之后:

  • 修 Bug → 跑测试 → 发现登录测试红了 → 知道哪里坏了 → 修好 → 再跑测试 → 全绿 → 上线
  • 全程 5 分钟,不用手动点页面

单元测试的价值:

价值 说明
快速反馈 写完代码跑测试,秒级知道对不对
重构安全网 改完代码跑测试,全绿说明没改坏
活文档 测试就是最新的 API 使用示例
防止复发 修过的 Bug 写进测试,不会再犯

unittest vs pytest:用哪个?

Python 有两个主流测试框架:

unittest(内置) pytest(第三方)
安装 不用装 pip install pytest
风格 类 + 继承 + 特定断言 函数 + 原生 assert
代码量 较冗长 简洁
参数化 麻烦 @pytest.mark.parametrize 原生支持
生态 有限 200+ 插件

直接结论:新项目用 pytest,不用纠结。

unittest 是 Python 自带的,但 pytest 用起来更顺手,代码更少,社区更活跃。老项目如果已经用了 unittest 也可以继续用,但新项目建议直接用 pytest。

从零开始写第一个测试

1. 安装 pytest

pip install pytest

2. 写一个待测试的函数

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

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

3. 写测试文件

测试文件命名:test_*.py*_test.py。测试函数命名:以 test_ 开头。

# test_calculator.py
import pytest
from calculator import divide, add

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

def test_divide_success():
    assert divide(10, 2) == 5
    assert divide(9, 3) == 3

def test_divide_by_zero():
    with pytest.raises(ValueError) as exc:
        divide(10, 0)
    assert "除数不能为0" in str(exc.value)

4. 运行测试

# 运行所有测试
pytest

# 运行指定文件
pytest test_calculator.py

# 显示详细信息(推荐)
pytest -v

# 显示 print 输出(调试时用)
pytest -s

看到 3 passed 就表示通过了。

测试结构:AAA 模式

每个测试都应该遵循 AAA 结构:

def test_something():
    # Arrange(准备):准备好输入数据和环境
    user_data = {"name": "Alice", "age": 25}
    
    # Act(执行):调用被测试的函数
    result = process_user(user_data)
    
    # Assert(断言):验证结果对不对
    assert result["status"] == "success"

这个模式让测试清晰、一致。每个测试只测一个行为,失败了能快速定位问题。

参数化:一组数据测同一个逻辑

如果你要测 add 函数,写好几个测试用例:

def test_add_1():
    assert add(1, 1) == 2

def test_add_2():
    assert add(2, 2) == 4

def test_add_3():
    assert add(3, 3) == 6

太啰嗦了。用参数化:

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

一组数据测一次,代码少、覆盖全。

Fixture:共享测试准备逻辑

如果多个测试需要同样的准备步骤,用 fixture 复用。

import pytest

@pytest.fixture
def sample_user():
    """返回一个示例用户,多个测试可以共用"""
    return {"name": "Alice", "age": 25, "email": "alice@example.com"}

def test_user_age(sample_user):
    assert sample_user["age"] == 25

def test_user_email(sample_user):
    assert "@" in sample_user["email"]

fixture 的生命周期控制:

@pytest.fixture(scope="session")   # 整个测试会话只执行一次
def db_connection():
    conn = create_connection()
    yield conn
    conn.close()

@pytest.fixture(scope="function")  # 每个测试执行一次(默认)
def clean_data():
    return {}

scope="session" 适合数据库连接这种”建立一次、反复使用”的资源。scope="function"(默认)适合”每次测试都需要干净数据”的场景。

Mock:隔离外部依赖

你的函数可能依赖网络请求、数据库、文件系统。单元测试要测的是”你的逻辑”,不是”外部服务能不能通”。

用 Mock 模拟外部依赖:

from unittest.mock import Mock, patch
import requests

def get_user_name(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()["name"]

def test_get_user_name():
    # 模拟响应对象
    mock_response = Mock()
    mock_response.json.return_value = {"name": "Alice"}
    
    # 用 mock 替换 requests.get
    with patch("requests.get", return_value=mock_response):
        result = get_user_name(123)
    
    assert result == "Alice"

这样测试不用真的发网络请求,快、稳定、不依赖外部服务。

测试覆盖率

覆盖率告诉你:测试覆盖了多少代码。

# 安装
pip install pytest-cov

# 运行测试并查看覆盖率
pytest --cov=calculator test_calculator.py

# 生成 HTML 报告(可以打开看具体哪行没覆盖)
pytest --cov=calculator --cov-report=html tests/

覆盖率目标: 核心业务逻辑 90%+,一般应用 80%+。

注意:100% 覆盖率不代表没 Bug,但低覆盖率一定高风险。