pytest参数化测试

发布于:2021-06-06 | 分类:devops , python/vba/cpp


pytest是一个流行的Python单元测试框架,自身功能强大,且可以集成各种插件例如代码覆盖率、测试报告。我们可能遇到的这样的应用场景,同一功能需要在大量数据集上进行测试,并且每一个测试样本对应一个测试案例。显然,每个测试案例仅是输入不同,为每一个测试样本写一个测试函数不太现实。本文介绍的pytest参数化测试即是针对这类问题的解决方案。

目标场景

N个测试样本分别放在如下文件夹test-001test-002,...,要求:

  • 为每一个测试样本生成一个测试案例
  • 默认测试所有案例
  • 如果输入参数n,则只测试前n个案例

从pytest.fixture()开始 1

顾名思义,fixture是辅助测试函数的工装夹具,其中params参数提供了参数化输入的功能,每一个参数都会被创建为一个独立的测试案例。一个简单的例子:

# test.py
import pytest

inputs = ['test-001', 'test-002', 'test-003']

@pytest.fixture(scope="module", params=inputs)
def get_input(request):
    return request.param

# 测试函数
def test(get_input):
    print(f'Testing {get_input}')

这样,使用一个测试函数即可完成三个类似案例的测试。

$ pytest -sv test.py

test.py::test[test-001] Testing test-001
PASSED
test.py::test[test-002] Testing test-002
PASSED
test.py::test[test-003] Testing test-003
PASSED

再到@pytest.mark.parametrize 2

fixture类似,pytest.mark.parametrize装饰器也提供了参数化输入的功能。fixture例子中,测试函数的参数get_input是一个fixture,本例中get_input是一个自定义的参数名称。

import pytest

inputs = ['test-001', 'test-002', 'test-003']

# 测试函数
@pytest.mark.parametrize("get_input", inputs)
def test(get_input):
    print(f'Testing {get_input}')

以上两种方式都满足了 为每一个样本创建独立测试案例 的需求,然而问题是输入参数是以硬编码的方式写死的,可扩展性很差。于是,引出了下面的参数化测试方法。

动态生成参数化测试案例 3

pytest_generate_tests(metafunc)是pytest自带的在收集测试函数时调用的钩子函数,以便按照自定义方式、动态地生成参数化方案。其中,metafunc参数可以检查请求的测试上下文,

  • metafunc.fixturenames获取所有fixture

  • metafunc.parametrize(name, params)生成自定义测试方案

prefix = 'test-00'

def pytest_generate_tests(metafunc):
    if not "get_input" in metafunc.fixturenames: return

    params = [f'{prefix}{i+1}' for i in range(3)]
    metafunc.parametrize("get_input", params)


# 测试函数
def test(get_input):
    print(f'Testing {get_input}')

这个例子不再直接给出硬编码的test-001~test-003,而是根据需要动态生成的,这为实际应用带来无限可能。但是,其中测试案例个数3还是硬编码方式写入的!为了实现终极目标,我们必须引入命令行参数。

注册命令行参数 4 5

pytest_addoption(parser)是另一个pytest钩子函数,可以让用户注册一个自定义的命令行参数,然后通过fixture或其他钩子获取用户输入。

  • parser.addoption注册命令行参数
  • fixturepytestconfig参数获取参数:

    @pytest.fixture(scope='session')
    def user_param(pytestconfig):
        return pytestconfig.getoption('--param_name')

  • 或者pytest_generate_testsmetafunc获取参数:

    metafunc.config.getoption('param_name')

注意

pytest_addoption必须写在默认的配置文件中:conftest.py。

回到本问题,创建配置文件conftext.py,注册用户输入参数n

# conftest.py
def pytest_addoption(parser):
    parser.addoption(
        "--n", 
        action="store", 
        default=None, 
        help="Specify the count of test cases.")

此时,在conftext.py所在目录下查看pytest帮助。可以发现,pytest成功读取了配置文件并注册了用户输入参数--n

$ pytest -h
...
custom options:
  --n=N                 Specify the count of test cases.
...

接下来,与之前动态生成测试方案的例子结合:

prefix = 'test-00'

def pytest_generate_tests(metafunc):
    if not "get_input" in metafunc.fixturenames: return

    n = metafunc.config.getoption("n") # 获取输入参数值
    n = int(n) if n is not None else 3
    params = [f'{prefix}{i+1}' for i in range(n)]
    metafunc.parametrize("get_input", params)


# 测试函数
def test(get_input):
    print(f'Testing {get_input}')

试验一下可知满足既定需求:

  • 不指定n时,默认运行3个测试案例;

    $ pytest -sv test.py
    
    test.py::test[test-001] Testing test-001
    PASSED
    test.py::test[test-002] Testing test-002
    PASSED
    test.py::test[test-003] Testing test-003
    PASSED

  • 如果指定n,则运行n个测试案例。

    $ pytest -sv test.py --n=2
    
    test.py::test[test-001] Testing test-001
    PASSED
    test.py::test[test-002] Testing test-002
    PASSED

从Python代码启动pytest测试

以上内容基于从命令行启动测试的前提,已经可以满足本文开头目标场景的需要。不过,还可以进一步,直接从代码启动测试。有什么作用呢?锦上添花,可以进一步封装成一个针对本文场景的专用测试工具。

pytest提供了从代码执行测试的函数pytest.main(),它接受两个参数:

  • args 参数列表,等同于命令行模式输入的各种参数
  • plugins 插件列表。

什么是pytest插件?

pytest插件是通过钩子函数实现的一些功能,例如前面提到的conftest.py即为插件类型的一种:本地插件

我们正好将conftest.py的代码整合到一起,得到完整版:

# test.py
# 测试函数
def test(get_input):
    print(f'Testing {get_input}')
# main.py
import pytest

prefix = 'test-00'

class MyPlugin:
    def pytest_addoption(self, parser):
        parser.addoption(
            "--n", 
            action="store", 
            default=None, 
            help="Specify the count of test cases.")

    def pytest_generate_tests(self, metafunc):
        if not "get_input" in metafunc.fixturenames: return    
        n = metafunc.config.getoption("n") # 获取输入参数值
        n = int(n) if n is not None else 3
        params = [f'{prefix}{i+1}' for i in range(n)]
        metafunc.parametrize("get_input", params)

if __name__=='__main__':
    pytest.main(
        args=['-sv', '--n=2', 'test.py'], 
        plugins=[MyPlugin()])

最后,python main.py启动测试即可。

注意,虽然最后例子中直接写入--n=2,实际上main.py可以借助fire等第三方库转为命令行工具,从而接受用户输入。本文略过。