4、unittest参数化测试:subTest上下文管理器、第三方库parameterized的使用、数据驱动测试实践
写单元测试时,最烦人的事情之一就是「重复」。
同一个测试逻辑,换个输入数据就得复制粘贴一遍代码。我早期写测试时就这么干过——一个函数测了十几种边界情况,代码长得像裹脚布。后来维护起来,改一个逻辑要改十几个地方,那叫一个酸爽。
今天我们就聊聊怎么解决这个问题。说白了,就是「参数化测试」。
4.1 为什么需要参数化测试?
先看个反面教材。假设我们要测试一个判断闰年的函数:
def is_leap_year(year):
return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0)
如果不用参数化,测试代码会写成这样:
import unittest
class TestLeapYear(unittest.TestCase):
def test_2000(self):
self.assertTrue(is_leap_year(2000))
def test_2004(self):
self.assertTrue(is_leap_year(2004))
def test_1900(self):
self.assertFalse(is_leap_year(1900))
def test_2023(self):
self.assertFalse(is_leap_year(2023))
你想想看,每个测试方法除了年份和预期结果不同,其他一模一样。这要是测几十个用例,代码得膨胀成什么样?
核心痛点:重复代码 = 维护噩梦。改一个断言逻辑,所有方法都得改。
4.2 subTest:内置的轻量方案
Python 3.4 开始,unittest 自带了一个上下文管理器叫 subTest。我个人习惯用它处理「用例数量不多,但想保持代码整洁」的场景。
用法很简单:
import unittest
class TestLeapYear(unittest.TestCase):
def test_multiple_years(self):
test_cases = [
(2000, True),
(2004, True),
(1900, False),
(2023, False),
(2100, False),
(2400, True),
]
for year, expected in test_cases:
with self.subTest(year=year):
self.assertEqual(is_leap_year(year), expected)
这里的关键是 with self.subTest(year=year)。它做了什么?
- 每个子测试独立运行,失败不会影响其他子测试
- 失败时会明确告诉你哪个参数出了问题
- 测试报告里会显示每个子测试的详细信息
运行结果大概长这样:
FAIL: test_multiple_years (__main__.TestLeapYear) (year=1900)
----------------------------------------------------------------------
AssertionError: True != False
嗯,这里要注意:subTest 的参数名(比如 year=year)会显示在失败信息里,所以命名要有意义。
我的小技巧:subTest 适合用例数在 10-20 个以内的场景。再多的话,报告会变得冗长,而且没法单独运行某个子测试。
4.3 parameterized:专业的事交给专业的库
当测试用例数量上来了,subTest 就有点力不从心了。这时候我推荐用第三方库 parameterized。
先安装:
pip install parameterized
然后看代码:
from parameterized import parameterized
import unittest
class TestLeapYear(unittest.TestCase):
@parameterized.expand([
(2000, True),
(2004, True),
(1900, False),
(2023, False),
(2100, False),
(2400, True),
])
def test_leap_year(self, year, expected):
self.assertEqual(is_leap_year(year), expected)
看到区别了吗?每个元组都会生成一个独立的测试方法。测试报告里会显示成:
test_leap_year_0 (__main__.TestLeapYear) ... ok
test_leap_year_1 (__main__.TestLeapYear) ... ok
test_leap_year_2 (__main__.TestLeapYear) ... FAIL
每个用例都是独立的测试方法,可以单独运行、单独报错。这在 CI 流水线上特别有用——你能一眼看出哪个输入数据出了问题。
我曾经踩过的坑:测试方法名不能重复。如果用了 @parameterized.expand,生成的测试方法名默认是 test_leap_year_0、test_leap_year_1 这样。如果你在同一个类里有两个参数化方法,索引会冲突。解决办法是给每个方法加不同的 name_func 参数。
4.4 数据驱动测试实践
参数化测试只是数据驱动测试的一部分。真正的「数据驱动」是把测试数据和测试逻辑彻底分离。
我在项目中遇到过这样一个场景:要测试一个用户注册接口,需要验证几十种输入组合。如果全写在代码里,维护起来简直要命。
我的做法是这样的:
4.4.1 用外部数据文件
把测试数据放在 JSON 或 YAML 文件里:
// test_data.json
[
{"username": "alice", "password": "123456", "expected": 200},
{"username": "", "password": "123456", "expected": 400},
{"username": "alice", "password": "", "expected": 400},
{"username": "a" * 51, "password": "123456", "expected": 400},
{"username": "alice", "password": "123", "expected": 400}
]
然后在测试代码里加载:
import json
from parameterized import parameterized
class TestUserRegister(unittest.TestCase):
@parameterized.expand(
[(d["username"], d["password"], d["expected"]) for d in json.load(open("test_data.json"))]
)
def test_register(self, username, password, expected):
response = register_user(username, password)
self.assertEqual(response.status_code, expected)
这样改数据只需要改 JSON 文件,不用动测试代码。产品经理改需求时,你只需要说:「把新的测试用例加到 JSON 里就行。」
4.4.2 用 CSV 管理大量数据
如果数据量特别大(比如几百上千条),CSV 更合适:
# test_data.csv
username,password,expected
alice,123456,200
,123456,400
alice,,400
alice123,123456,200
import csv
from parameterized import parameterized
def load_test_data():
with open("test_data.csv") as f:
reader = csv.DictReader(f)
return [(row["username"], row["password"], int(row["expected"])) for row in reader]
class TestUserRegister(unittest.TestCase):
@parameterized.expand(load_test_data())
def test_register(self, username, password, expected):
response = register_user(username, password)
self.assertEqual(response.status_code, expected)
数据驱动测试的核心思想:测试逻辑只写一次,测试数据从外部注入。这样测试代码变得极其稳定,而测试覆盖率的提升只需要加数据就行。
4.5 实战对比:subTest vs parameterized
我整理了一个对比表,方便你选型:
| 特性 | subTest | parameterized |
|---|---|---|
| 是否需要安装 | 不需要(内置) | 需要 pip install |
| 用例独立性 | 弱(共用一个测试方法) | 强(每个用例独立方法) |
| 单独运行某个用例 | 不支持 | 支持 |
| 失败报告清晰度 | 一般 | 清晰 |
| 适合场景 | 少量用例(<20) | 大量用例、CI 环境 |
| 数据外部化 | 需要手动实现 | 天然支持 |
我的建议是:小项目用 subTest 就够了,别为了用而用。但如果你在写一个长期维护的项目,或者测试用例超过 20 个,直接上 parameterized。省心。
4.6 避坑指南
最后分享几个我踩过的坑:
- 参数顺序别搞混:
@parameterized.expand里的元组顺序要和测试方法的参数顺序一致。我犯过把 expected 和 input 写反的错,排查了半天。 - 数据文件路径问题:用相对路径时,确保测试运行时当前目录正确。我建议用
os.path.dirname(__file__)来定位数据文件。 - 不要过度参数化:如果一个测试方法需要 5 个以上参数,说明测试逻辑可能太复杂了。考虑拆分成多个测试方法。
- subTest 的嵌套陷阱:subTest 可以嵌套,但嵌套层数太多时,失败信息会变得很难读。我一般最多嵌套两层。
好了,参数化测试就聊到这里。说白了,它就是让你少写重复代码,多关注测试数据本身。下一章我们聊聊 mock 和 patch——那些让单元测试真正「独立」起来的技术。