Python 代码测试 unittest / pytest
整理一下 Python 代码测试的内容,包括:
- 内置的单元测试模块
unittest
- 第三方测试工具
pytest
除此之外,其实还有直接解析源码注释,获取测试用例的内置模块
doctest
。
准备
为了便于描述,下面准备两个函数和一个类作为测试目标,构成
my_module.py
1 | def my_add(a, b): |
unittest
特点:
- Python 标准库自带的标准模块,开箱即用
- 使用类似 Java 的 JUnit,必须使用测试类以及测试方法的结构
编写测试
基于 unittest 进行单元测试时,代码编写范式相对固定:
- 测试类必须继承
unittest.TestCase
类 - 以
test_
开头的方法被视作测试方法,每一个测试方法视作一个测试用例 - 测试方法不能带其它输入参数,不需要返回值(始终返回
None
)
1 | import unittest |
测试方法作为测试用例,它们之间相互独立,实际调用顺序可能采用字符串排序。
在测试方法中可以使用
assertEqual
、assertTrue
、
assertIs
、assertIn
等进行断言,例如
1
2
3
4
5
6class TestXXX(unittest.TestCase):
def test_xxx(self):
self.assertEqual(1, 1)
self.assertTrue(1 == 1)
self.assertFalse(1 != 1)
断言会抛出异常也是一种常见的情况,例如 1
2
3
4
5class TestXXX(unittest.TestCase):
def test_xxx(self):
with self.assertRaises(ValueError):
...
一个测试用例(测试方法)通过的判定标准为:
- 如果所有断言都通过,包括断言有异常的上下文会抛出异常,并且没有其它异常抛出,测试通过。
- 否则测试失败。
unittest 支持 Fixture:通过实现 setUp()
和
tearDown()
方法可以设置每一个测试方法开始前与完成后需要执行的公共指令,例如
1
2
3
4
5
6
7
8
9
10class TestXXX(unittest.TestCase):
def setUp(self):
print("call before test")
def tearDown(self):
print("call after test")
def test_xxx(self):
...
注意:如果 setUp()
方法引发异常,测试框架会认为测试已经失败,测试方法就不会被执行,但是执行清理工作的
tearDown()
方法仍然会运行。
完整代码示例
1 | import unittest |
执行测试
假设测试脚本名为 test_my_module_unittest.py
,调用
unittest
模块可以进行测试(-v
显示详细信息),例如指定(单个或多个)模块名 1
2python -m unittest test_my_module_unittest -v
python -m unittest test_my_module_unittest test_my_module2_unittest -v
注意这里
-v
不能直接放在 python 后面,否则-v
选项会被 python 处理,而不是被 unittest 模块处理。
还可以指定测试的类名,方法名 1
2python -m unittest test_my_module_unittest.TestMyDict
python -m unittest test_my_module_unittest.TestMyDict.test_setattr
注:
- 这些做法还可以进行自由组合。
- 由于先启动的是 unittest 模块,测试脚本中的
if __name__ == "__main__"
部分不会被执行。
如果测试脚本不在当前位置,还可以指定测试脚本的路径 1
python -m unittest tests/test_my_module_unittest.py
可以使用 discover
子命令进行探索式测试,尝试发现测试代码
1
python -m unittest discover
为了简化使用,在不含额外参数时,
python -m unittest
等价于python -m unittest discover
。
可以使用 -s
选项指定探索目录(默认为当前目录),可以使用
-p
选项指定测试文件的匹配规则(默认为
test*.py
)。
例如测试脚本存放在 tests/
子目录,形如
tests/test*.py
1
python -m unittest discover -s tests
如果在测试脚本的最后手动调用 unittest.main()
1
2if __name__ == "__main__":
unittest.main()
那么测试脚本也支持作为普通脚本执行 1
python test_my_module_unittest.py -v
辅助工具
可以使用第三方工具 coverge
检查测试代码的覆盖率
1
pip install coverage
运行测试并收集覆盖率 1
coverage run -m unittest discover
在命令行查看覆盖率 1
coverage report -m
输出示例 1
2
3
4
5
6Name Stmts Miss Cover Missing
-----------------------------------------------------
my_module.py 15 0 100%
test_my_module_unittest.py 20 0 100%
-----------------------------------------------------
TOTAL 35 0 100%
生成覆盖率的 HTML 报告 1
coverage html
此时会生成一个 htmlcov/
目录,打开
htmlcov/index.html
就能在浏览器里查看测试的代码覆盖率。
pytest
pytest
是一个使用更友好的第三方测试工具
1
pip install pytest
特点:
- 语法更简洁灵活,除了测试类,还支持直接编写测试函数
- 支持 参数化测试
- 插件生态丰富,支持覆盖率、性能分析、mock、异步测试等
- 对使用 unittest 的测试代码保持兼容
pytest 的基本使用相对简单,但是实际的功能非常复杂,下面只讨论一些基本用法。
编写测试
pytest 支持更灵活的测试用例写法:
- 测试函数:使用
test
开头的函数视作一个测试用例 - 测试类:使用
Test
开头的自定义类型视作测试类,包括若干使用test
开头的测试方法,每一个测试方法视作一个测试用例
测试函数例如 1
2def test_my_add():
...
测试类例如 1
2
3
4
5
6
7class TestXXX:
def test_XXX(self):
...
def test_YYY(self):
...
与 unittest
的严格要求不同,这里不要求继承任何基类,但是不允许定义测试类的
__init__
构造方法。
下面主要讨论测试函数的用法。
所有的测试基本上都由 assert
语句组成,例如
1
2def test_my_add():
assert my_add(2, 3) == 5
此外,对于异常抛出的写法为 1
2
3def test_raises():
with pytest.raises(TypeError) as e:
connect('localhost', '6379')
一个常见的需求是使用多组参数进行测试,pytest
提供了基于装饰器的参数化测试工具,例如 1
2
3
def test_passwd_length(passwd):
assert len(passwd) >= 6
如果某个测试用例需要输入参数,那么就要由 Fixture 以返回值形式提供,并且测试用例的输入参数必须与 Fixture 同名,此时会在进入之前调用对应的 Fixture,例如
1 |
|
如果需要让 Fixture
在所有测试用例进入之前自动生效,则需要加上参数autouse=True
1
2
3
def setup_env():
print("set up")
Fixture 如果含有 yield
,那么 yield
之前的部分会在测试用例之前进行,yield
之后的部分会在测试用例之后进行。
例如 1
2
3
4
5
6
7
8
def db():
print("connect db")
yield "db_connection"
print("close db")
def test_db(db):
assert db == "db_connection"
完整代码如下
1 | import pytest |
执行测试
pytest
提供了同名的可执行文件,在执行测试时,可以指定测试脚本(-v
显示详细信息)
1
pytest -v test_square.py
此外,pytest 也会采用与 unittest 类似的探索逻辑,自动发现测试脚本
1
pytest -v
也可以指定测试文件的子目录 1
pytest -v tests/
当然,把 pytest 视作一个模块进行调用执行也是可以的,例如
1
python -m pytest -v
如果在测试脚本的最后手动调用 pytest.main()
1
2if __name__ == "__main__":
pytest.main()
那么测试脚本也支持作为普通脚本执行 1
python test_my_module_pytest.py -v