整理一下 Python 代码测试的内容,包括:

  • 内置的单元测试模块 unittest
  • 第三方测试工具 pytest

除此之外,其实还有直接解析源码注释,获取测试用例的内置模块 doctest

准备

为了便于描述,下面准备两个函数和一个类作为测试目标,构成 my_module.py

my_module.py
1
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):
...

测试方法作为测试用例,它们之间相互独立,实际调用顺序可能采用字符串排序。

在测试方法中可以使用 assertEqualassertTrueassertIsassertIn 等进行断言,例如

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.py
1
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
pip install coverage

运行测试并收集覆盖率

1
coverage run -m unittest discover

在命令行查看覆盖率

1
coverage report -m

输出示例

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 报告

1
coverage html

此时会生成一个 htmlcov/ 目录,打开 htmlcov/index.html 就能在浏览器里查看测试的代码覆盖率。

pytest

pytest 是一个使用更友好的第三方测试工具

1
pip install pytest

特点:

  • 语法更简洁灵活,除了测试类,还支持直接编写测试函数
  • 支持 参数化测试
  • 插件生态丰富,支持覆盖率、性能分析、mock、异步测试等
  • 对使用 unittest 的测试代码保持兼容

pytest 的基本使用相对简单,但是实际的功能非常复杂,下面只讨论一些基本用法。

编写测试

pytest 支持更灵活的测试用例写法:

  • 测试函数:使用 test 开头的函数视作一个测试用例
  • 测试类:使用 Test 开头的自定义类型视作测试类,包括若干使用 test 开头的测试方法,每一个测试方法视作一个测试用例

测试函数例如

1
2
def test_my_add():
...

测试类例如

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.py
1
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 类似的探索逻辑,自动发现测试脚本

1
pytest -v

也可以指定测试文件的子目录

1
pytest -v tests/

当然,把 pytest 视作一个模块进行调用执行也是可以的,例如

1
python -m pytest -v

如果在测试脚本的最后手动调用 pytest.main()

1
2
if __name__ == "__main__":
pytest.main()

那么测试脚本也支持作为普通脚本执行

1
python test_my_module_pytest.py -v