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_0test_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——那些让单元测试真正「独立」起来的技术。