整理一下 Python 单元测试的内容,包括:
- 内置的单元测试模块
unittest
- 第三方测试工具
pytest
除此之外,其实还有直接解析源码注释,获取测试用例的内置模块 doctest。
准备
为了便于描述,下面准备两个函数和一个类作为测试目标,构成 my_module.py
my_module.py1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| def my_add(a, b): return a + b
def my_divide(a, b): return a / b
class MyDict(dict): def __init__(self, **kw): super().__init__(**kw)
def __getattr__(self, key): try: return self[key] except KeyError: raise AttributeError(r"'MyDict' object has no attribute '%s'" % key)
def __setattr__(self, key, value): self[key] = value
|
unittest
特点:
- Python 标准库自带的标准模块,开箱即用
- 使用类似 Java 的 JUnit,必须使用测试类以及测试方法的结构
编写测试
基于 unittest 进行单元测试时,代码编写范式相对固定:
- 测试类必须继承
unittest.TestCase 类
- 以
test_ 开头的方法被视作测试方法,每一个测试方法视作一个测试用例
- 测试方法不能带其它输入参数,不需要返回值(始终返回
None)
1 2 3 4 5 6 7 8 9 10
| import unittest
class TestXXX(unittest.TestCase):
def test_xxx(self): ...
def test_yyy(self): ...
|
测试方法作为测试用例,它们之间相互独立,实际调用顺序可能采用字符串排序。
在测试方法中可以使用 assertEqual、assertTrue、 assertIs、assertIn 等进行断言,例如
1 2 3 4 5 6
| class TestXXX(unittest.TestCase):
def test_xxx(self): self.assertEqual(1, 1) self.assertTrue(1 == 1) self.assertFalse(1 != 1)
|
断言会抛出异常也是一种常见的情况,例如
1 2 3 4 5
| class TestXXX(unittest.TestCase):
def test_xxx(self): with self.assertRaises(ValueError): ...
|
一个测试用例(测试方法)通过的判定标准为:
- 如果所有断言都通过,包括断言有异常的上下文会抛出异常,并且没有其它异常抛出,测试通过。
- 否则测试失败。
unittest 支持 Fixture:通过实现 setUp() 和 tearDown() 方法可以设置每一个测试方法开始前与完成后需要执行的公共指令,例如
1 2 3 4 5 6 7 8 9 10
| class TestXXX(unittest.TestCase):
def setUp(self): print("call before test")
def tearDown(self): print("call after test")
def test_xxx(self): ...
|
注意:如果 setUp() 方法引发异常,测试框架会认为测试已经失败,测试方法就不会被执行,但是执行清理工作的 tearDown() 方法仍然会运行。
完整代码示例
test_my_module_unittest.py1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| import unittest from my_module import my_add, my_divide, MyDict
class TestMyFunctions(unittest.TestCase): def setUp(self): print("call before test")
def setDown(self): print("call after test")
def test_my_add(self): self.assertEqual(my_add(2, 3), 5) self.assertEqual(my_add("a", "b"), "ab")
def test_my_divide(self): self.assertEqual(my_divide(6, 2), 3.0) with self.assertRaises(ZeroDivisionError): my_divide(1, 0)
class TestMyDict(unittest.TestCase): def test_init(self): d = MyDict(a=1, b="test") self.assertEqual(d.a, 1) self.assertEqual(d["b"], "test")
def test_setattr(self): d = MyDict() d.c = 99 self.assertEqual(d["c"], 99)
def test_getattr(self): d = MyDict() with self.assertRaises(AttributeError): _ = d.not_exist
if __name__ == "__main__": unittest.main()
|
执行测试
假设测试脚本名为 test_my_module_unittest.py,调用 unittest 模块可以进行测试(-v 显示详细信息),例如指定(单个或多个)模块名
1 2
| python -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 2
| python -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 2
| if __name__ == "__main__": unittest.main()
|
那么测试脚本也支持作为普通脚本执行
1
| python test_my_module_unittest.py -v
|
辅助工具
可以使用第三方工具 coverge 检查测试代码的覆盖率
运行测试并收集覆盖率
1
| coverage run -m unittest discover
|
在命令行查看覆盖率
输出示例
1 2 3 4 5 6
| Name Stmts Miss Cover Missing ----------------------------------------------------- my_module.py 15 0 100% test_my_module_unittest.py 20 0 100% ----------------------------------------------------- TOTAL 35 0 100%
|
生成覆盖率的 HTML 报告
此时会生成一个 htmlcov/ 目录,打开 htmlcov/index.html 就能在浏览器里查看测试的代码覆盖率。
pytest
pytest 是一个使用更友好的第三方测试工具
特点:
- 语法更简洁灵活,除了测试类,还支持直接编写测试函数
- 支持 参数化测试
- 插件生态丰富,支持覆盖率、性能分析、mock、异步测试等
- 对使用 unittest 的测试代码保持兼容
pytest 的基本使用相对简单,但是实际的功能非常复杂,下面只讨论一些基本用法。
编写测试
pytest 支持更灵活的测试用例写法:
- 测试函数:使用
test 开头的函数视作一个测试用例
- 测试类:使用
Test 开头的自定义类型视作测试类,包括若干使用 test 开头的测试方法,每一个测试方法视作一个测试用例
测试函数例如
测试类例如
1 2 3 4 5 6 7
| class TestXXX:
def test_XXX(self): ...
def test_YYY(self): ...
|
与 unittest 的严格要求不同,这里不要求继承任何基类,但是不允许定义测试类的 __init__ 构造方法。
下面主要讨论测试函数的用法。
所有的测试基本上都由 assert 语句组成,例如
1 2
| def test_my_add(): assert my_add(2, 3) == 5
|
此外,对于异常抛出的写法为
1 2 3
| def test_raises(): with pytest.raises(TypeError) as e: connect('localhost', '6379')
|
一个常见的需求是使用多组参数进行测试,pytest 提供了基于装饰器的参数化测试工具,例如
1 2 3
| @pytest.mark.parametrize("passwd", ["123456", "abcdefdfs", "as52345fasdf4"]) def test_passwd_length(passwd): assert len(passwd) >= 6
|
如果某个测试用例需要输入参数,那么就要由 Fixture 以返回值形式提供,并且测试用例的输入参数必须与 Fixture 同名,此时会在进入之前调用对应的 Fixture,例如
1 2 3 4 5 6 7
| @pytest.fixture def mydict(): return MyDict(a=1, b="test")
def test_mydict_init(mydict): assert mydict.a == 1 assert mydict["b"] == "test"
|
如果需要让 Fixture 在所有测试用例进入之前自动生效,则需要加上参数autouse=True
1 2 3
| @pytest.fixture(autouse=True) def setup_env(): print("set up")
|
Fixture 如果含有 yield,那么 yield 之前的部分会在测试用例之前进行,yield 之后的部分会在测试用例之后进行。
例如
1 2 3 4 5 6 7 8
| @pytest.fixture() def db(): print("connect db") yield "db_connection" print("close db")
def test_db(db): assert db == "db_connection"
|
完整代码如下
test_my_module_pytest.py1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| import pytest from my_module import my_add, my_divide, MyDict
def test_my_add(): assert my_add(2, 3) == 5 assert my_add("a", "b") == "ab"
def test_my_divide(): assert my_divide(6, 2) == 3.0 with pytest.raises(ZeroDivisionError): my_divide(1, 0)
def test_mydict_setattr(): d = MyDict() d.c = 99 assert d["c"] == 99
def test_mydict_getattr(): d = MyDict() with pytest.raises(AttributeError): _ = d.not_exist
@pytest.mark.parametrize( "a,b,expected", [ (1, 2, 3), (0, 0, 0), ("foo", "bar", "foobar"), ], ) def test_my_add_param(a, b, expected): assert my_add(a, b) == expected
@pytest.fixture def mydict(): return MyDict(a=1, b="test")
def test_mydict_init(mydict): assert mydict.a == 1 assert mydict["b"] == "test"
class TestXXX:
def test_XXX(self): assert my_add(2, 3) == 5
@pytest.fixture() def db(): print("connect db") yield "db_connection" print("close db")
def test_db(db): assert db == "db_connection"
if __name__ == "__main__": pytest.main()
|
执行测试
pytest 提供了同名的可执行文件,在执行测试时,可以指定测试脚本(-v显示详细信息)
1
| pytest -v test_square.py
|
此外,pytest 也会采用与 unittest 类似的探索逻辑,自动发现测试脚本
也可以指定测试文件的子目录
当然,把 pytest 视作一个模块进行调用执行也是可以的,例如
如果在测试脚本的最后手动调用 pytest.main()
1 2
| if __name__ == "__main__": pytest.main()
|
那么测试脚本也支持作为普通脚本执行
1
| python test_my_module_pytest.py -v
|