4. 参数化测试框架入门:pytest参数化与unittest参数化

说到参数化测试,我刚开始带团队的时候,发现很多同学还在用最笨的办法——复制粘贴测试用例,改几个参数就当成新用例。你想想看,一个登录功能要测10组数据,就得写10个几乎一模一样的函数。维护起来简直是噩梦。

参数化测试说白了就是:同一个测试逻辑,用不同的数据跑多遍。今天我们就来聊聊Python里最主流的两种实现方式——pytest的@pytest.mark.parametrize和unittest的ddt库。

核心观点:参数化不是炫技,而是为了让你少写代码、少犯错、多覆盖。我见过太多项目因为手工复制用例导致漏测,参数化是解决这个问题的第一把钥匙。

4.1 为什么需要参数化?

先看一个反面教材。假设我们要测试一个加法函数:

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

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

def test_add_zero():
    assert add(0, 0) == 0

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

这才4组数据,已经写了4个函数。如果是20组呢?100组呢?而且你发现没有——测试逻辑完全一样,只是输入和期望输出不同。这就是参数化要解决的问题。

我个人习惯把参数化理解成「数据驱动」的雏形。测试代码只写一次,数据单独管理。这样改数据不用改代码,改代码也不会影响数据。

4.2 pytest参数化:@pytest.mark.parametrize

pytest的参数化是我用得最多的方式。语法非常简洁,一行装饰器就能搞定。

4.2.1 基本用法

import pytest

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

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

你看,一个函数覆盖了5组数据。跑起来pytest会自动生成5条测试记录,哪条失败一目了然。

小技巧:参数名用逗号分隔的字符串,pytest会自动解包。我个人习惯把参数名和测试数据分开写,这样可读性更好。

4.2.2 组合参数化

有时候我们需要测试多个参数的笛卡尔积。比如测试一个登录接口,要测不同的用户名和密码组合:

@pytest.mark.parametrize("username", ["admin", "user", "guest"])
@pytest.mark.parametrize("password", ["123456", "password", ""])
def test_login(username, password):
    # 测试逻辑
    pass

这样会生成3×3=9条用例。嗯,这里要注意:多个@parametrize叠加时,是笛卡尔积,不是一一对应。如果你想要一一对应,得用zip或者把数据放在一个列表里。

4.2.3 标记单个参数化用例

我在项目中遇到过一种情况:某组数据预期会抛异常,其他组正常。这时候可以用pytest.param加标记:

@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    pytest.param("a", 2, None, marks=pytest.mark.xfail),
    (0, 0, 0),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

这样第二组数据会被标记为「预期失败」,不会影响整体测试结果。

4.3 unittest参数化:ddt库

如果你还在用unittest框架,别担心,也有解决方案。ddt(Data-Driven Tests)就是专门为unittest设计的参数化库。

4.3.1 安装与基本用法

pip install ddt

用法也很直接:

import unittest
from ddt import ddt, data

@ddt
class TestAdd(unittest.TestCase):

    @data((1, 2, 3), (-1, -2, -3), (0, 0, 0))
    def test_add(self, case):
        a, b, expected = case
        self.assertEqual(add(a, b), expected)

注意看,类上要加@ddt装饰器,方法上用@data。ddt会自动把每组数据拆成独立的测试用例。

避坑指南:我曾经在项目里直接用@data传字典,结果发现ddt会把字典当成一个整体参数,而不是解包成多个参数。如果你需要命名参数,用@unpack装饰器。

4.3.2 使用@unpack解包

@ddt
class TestAdd(unittest.TestCase):

    @data((1, 2, 3), (-1, -2, -3), (0, 0, 0))
    @unpack
    def test_add(self, a, b, expected):
        self.assertEqual(add(a, b), expected)

加上@unpack后,ddt会自动把元组解包成独立的参数。这样代码更清晰,也更容易定位问题。

4.3.3 从外部文件加载数据

ddt支持从JSON或YAML文件加载数据。我个人比较喜欢这种方式,因为测试数据和代码彻底分离了。

import json
from ddt import ddt, file_data

@ddt
class TestAdd(unittest.TestCase):

    @file_data("test_data.json")
    def test_add(self, a, b, expected):
        self.assertEqual(add(a, b), expected)

对应的test_data.json文件:

[
    {"a": 1, "b": 2, "expected": 3},
    {"a": -1, "b": -2, "expected": -3},
    {"a": 0, "b": 0, "expected": 0}
]

4.4 pytest vs ddt:怎么选?

我经常被问到这个问题。直接给结论:

对比维度 pytest参数化 ddt
语法简洁度 ★★★★★ 装饰器一行搞定 ★★★☆☆ 需要@ddt+@data+@unpack
数据来源 支持列表、元组、pytest.param 支持列表、元组、JSON/YAML文件
组合参数 支持笛卡尔积(多个装饰器叠加) 不支持原生组合,需手动构造
报告可读性 ★★★★★ 自动生成有意义的用例名 ★★★☆☆ 默认显示参数值,可自定义
框架兼容 仅pytest 仅unittest

说白了,如果你能选框架,无脑上pytest。如果项目强制用unittest,ddt是唯一靠谱的选择。

4.5 参数化测试的核心流程

为了让你更直观地理解参数化测试在整个测试流程中的位置,我画了一张图:

参数化测试核心流程 测试数据 列表 / JSON / YAML 参数化框架 pytest / ddt 测试逻辑 断言 / 校验 自动生成 N 条独立测试用例 每条用例独立报告结果 数据与逻辑分离 → 维护成本降低 → 测试覆盖率提升

这张图想表达的核心思想是:数据、框架、逻辑三者分离。你只需要维护数据和逻辑,框架自动帮你生成多条用例并汇总结果。

4.6 实战建议

最后给几个我在项目中总结出来的建议:

  • 数据量大的时候,别写在代码里。用外部文件(JSON/YAML/Excel)管理,方便非技术人员修改。
  • 参数化不是越多越好。我见过有人把100组数据塞进一个测试函数,结果跑一次要10分钟。合理分组,按功能模块拆分。
  • 命名要清晰。pytest会自动生成用例名,但如果你用ddt,建议自定义__repr__或者加@data的label参数,方便定位失败用例。
  • 边界值要单独拎出来。参数化适合覆盖常规场景,但边界值、异常值我建议单独写测试函数,方便加特殊标记和注释。

我的习惯:每个参数化测试函数的数据量控制在5-15组。超过15组就拆文件,或者按场景拆成多个测试函数。这样既保证了覆盖率,又不会让单次运行时间过长。

好了,参数化测试的基础就聊到这里。记住一句话:写一次测试逻辑,跑多组数据,这才是高效测试该有的样子。