Python单元测试之基本测试框架

unittest

unittest是Python标准库中提供的一个单元测试框架,官方文档为:unittest-Unit testing framework。使用unittest,我们主要需要学习测试用例的书写,以及一些相关的测试方法、断言方法等。

首先我们准备需要被测试的代码。首先在项目根目录下提供srctests两个目录,在src目录中存放源代码,在tests中存放后续的测试代码。

这里的源代码模拟了一个计算的功能,代码如下:

1
2
3
4
5
6
7
8
9
10
11
# My Calculator
def calculate(num1: int, op: str, num2: int):
if op == '+':
return num1 + num2
if op == '-':
return num1 - num2
if op == '*':
return num1 * num2
if op == '/':
return num1 / num2

这里需要提示一下,一个较为规范的项目结构,应该在根目录下提供至少两个目录和一个入口文件。入口文件作为整个项目的入口,如果是package项目的话,则这个入口文件可能是setup.py。另外两个目录,其中一个目录存放源代码,并且所有的源代码都应该存放在这个目录下;另外一个目录名为tests,存放所有的单元测试。一般来说,这两个目录都声明为package的形式,即拥有一个__init__.py文件。

当然由于Python社区的自由,目前也没有像Java中的Maven一样,有一个统一的项目结构和项目管理标准。以上只是其中一种推荐的项目结构。

之后,我们需要建立一个测试类来继承unittest里面的TestCase类,继承这个类之后我们才是真正的使用unittest框架去写测试用例。继承之后,在类中书写测试方法,注意测试方法必须要以test_开头,同时我们存放测试类的.py文件也需要以test_开头,这样才能被unittest框架检测到。完成测试方法之后,可以选择在main方法中调用unittest.main()来运行测试用例,也可以使用命令行的方式进行运行。运行之后unittest就会自动检测测试方法并进行运行。

下面是示例测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# test_calculate
import unittest
from src.MyCalculator import calculate


class TestMyCalculator(unittest.TestCase):
def test_add_method(self):
res = calculate(1, '+', 2)
self.assertEqual(res, 3)

def test_subtract_method(self):
res = calculate(3, '-', 1)
self.assertEqual(res, 2)

def test_zero_division(self):
with self.assertRaises(ZeroDivisionError):
calculate(1, '/', 0)


if __name__ == '__main__':
unittest.main()

注意上面除零异常的测试,需要使用with语句进行包裹。我们可以运行main函数,会发现一共有3个测试案例运行了。当然也可以使用命令行的方式,在项目根目录下运行下面的命令,unittest就会检测项目中所有的测试案例并进行运行。注意测试案例的规范,即文件名和测试方法名均以test_开头。(一个较为规范的格式是测试类也以Test开头)

1
python -m unittest

可以看到,利用unittest完成单元测试还是非常简单的,只需要按照如下的步骤:

  1. 根据规范建立测试案例,包括测试源代码文件、测试类以及测试方法
  2. 在测试方法中进行逻辑测试,以及相应的assert断言
  3. 利用命令行或者main函数的方法进行测试

当然除了断言方法外,unittest中还提供了一些较为常用的函数。例如下面的方法可以帮助我们在测试的前后都进行一些相关逻辑的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
def setUp(self) -> None:  # 每条用例执行之前都会执行
print("test case {} start ...".format(self))

def tearDown(self) -> None: # 每条用例执行之后都会执行
print("test case {} end ...".format(self))

@classmethod # 注意该装饰器必须使用
def setUpClass(cls) -> None: # 整个测试用例类执行之前会执行一次
print("test class {} start ...".format(cls))

@classmethod
def tearDownClass(cls) -> None: # 整个测试类执行之后会执行一次
print("test class {} end ...".format(cls))

pytest

quick start

pytest是一个第三方Python单元测试框架,官方文档为:pytest: helps you write better programs。相比于unittest,pytest更加灵活简便,支持参数化,具有丰富的第三方插件,支持自定义扩展等。

在使用pytest之前需要进行安装,直接使用pip安装即可。

1
pip install -U pytest

之后我们可以进行测试代码的书写,被测试的代码仍然是上面的MyCalculator。pytest的测试书写更为灵活,可以是函数级别的组织,也可以是类级别的组织。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from src.MyCalculator import calculate
import pytest


def test_sum_method():
assert 2 == calculate(1, '+', 1)


class TestCalculate:
def test_multipy_method(self):
assert 8 == calculate(4, '*', 2)

def test_zero_division(self):
# 可以通过match来assert指定的错误信息格式
with pytest.raises(ZeroDivisionError, match='division by zero'):
calculate(2, '/', 0)

利用pytest进行测试案例的书写同样需要遵循一定的规则

  • 测试文件以test_开头,或者_test结尾;
  • 测试类以Test开头,并且不能带有init方法;
  • 测试函数以test_开头;
  • 断言使用基本的assert即可

我们在项目根目录中直接通过命令行执行测试,直接输入下面的命令即可:

1
pytest

pytest可以兼容检测到unittest中的测试案例。利用命令行运行的时候,可以发现使用unittest书写的测试案例也被运行到了。不过这是因为unittest的测试案例书写规范与pytest基本一致。需要注意的是,unittest中的setUptearDown方法在利用pytest运行的时候并没有生效。

其他功能

上面是pytest的基本用法。除此之外,pytest还提供了许多额外的功能。

fixture

在一些时候,我们可能会有一堆测试方法,它们都依赖于某个数据来源。在unittest中,我们会将数据来源的处理放在setUp方法中,以此避免大量的重复代码。这种方式存在的问题是,随着测试类越来越大,实际的测试代码和setUp函数可能相隔越来越远,我们可能无法仅仅通过测试函数的内容来很快得知它的依赖。

pytest在一定程度上解决了这个问题,它利用fixture来显式地声明一个测试方法需要的依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pytest


@pytest.fixture
def example_data():
return {
'name': 'xxx',
'age': 18
}


def test_example_data(example_data):
assert example_data['name'] == 'xxx'
assert example_data['age'] == 18

这里的example_data就像使用fixture创建了一个全局变量,在IDE中点击函数参数中的example_data,也会直接跳转到fixture的定义处。事实上fixture也是能够被import的,一个好的实践是将所有的fixture都管理在一个module中,让测试函数按需导入即可。

参数化

pytest提供测试参数化的功能,简单来说就是我们可以通过参数化的方法来以多种输入情况运行结构类似的测试。而如果不使用参数化,要达到同样的效果可能需要写非常多结构类似的代码。而通过pytest提供的参数化,我们只需要书写一遍测试结构,然后提供多种测试参数。通过pytest命令行运行之后,每个参数会对应实例化出一个测试案例。

1
2
3
4
5
6
7
8
9
10
11
import pytest


@pytest.mark.parametrize("input_num", [2, 3, 4, 5, 6])
def test_greater_than_one(input_num):
assert input_num > 1


@pytest.mark.parametrize("input_num, is_odd", [(1, True), (2, False), (3, True), (4, False)])
def test_is_odd_num(input_num, is_odd):
assert (input_num % 2 != 0) == is_odd

第三方插件

pytest还具有丰富的第三方插件,这些插件为提供了丰富的功能。这里简单介绍一些第三方插件。

  • pytest-randomly:强制测试代码按照随机的顺序运行
  • pytest-cov:集成了coverage来计算代码覆盖率,直接通过pytest --cov来测试代码覆盖率
  • pytest-djano:提供Django的测试框架

coverage

通过unittest或者pytest等单元测试框架,我们可以书写各式各样的单元测试,但是如何判断我们的单元测试是否足够呢?一个非常基础的评价指标是行覆盖率,它表达的是单元测试执行到的代码行数占总行数的比例。

我们可以通过coverage这个package来帮助计算单元测试的覆盖率。coverage属于第三方库,需要额外安装。

1
pip install coverage

在之前unittest中,我们使用python -m unittest来运行测试案例,现在使用coverage来运行测试案例。

1
2
3
4
# 根据框架不同选择使用不同的测试module
# 不同module测试能够检测到的测试案例也不同
coverage run -m unittest
coverage run -m pytest

测试完成之后,会在当前目录下生成一个.coverage的文件,其中就记录了测试结果。可以通过下面的命令查看结果,命令会在shell中以表格形式展示出覆盖率结果,包括每个文件的代码总行数,miss行数,覆盖率等。

1
2
coverage report	
coverage report -m # 查看miss的是哪些行,给出line number

当然表格形式的结果看起来还是比较费劲的,coverage同样支持以html形式展示覆盖率结果。

1
coverage html

上面的命令会在当前目录下生成一个名为htmlcov的文件夹,之后我们可以通过下面的命令在本机运行一个http server,之后通过浏览器访问http://0.0.0.0:8000/,就可以以更加直观地方式查看覆盖率结果。

1
python -m http.server --directory htmlcov/

有的时候,我们的一些代码确实可能无法通过测试代码覆盖到,那么就可以通过增加注释来让coverage在计算覆盖率的时候不统计这些代码。这个特殊的注释是# pragma: no cover。如果该注释放在函数定义的那一行,则整个函数在统计覆盖率的时候都会被忽略;如果该注释放在函数中实际逻辑的某一行,则这一行在统计覆盖率的时候会被忽略。

参考文章

  1. Effective Python Testing With Pytest|Real Python
  2. pytest测试用例编写----预期的异常测试Expecting Exceptions

Python单元测试之基本测试框架
http://example.com/2023/08/02/Python单元测试之基本测试框架/
作者
EverNorif
发布于
2023年8月2日
许可协议