资深开发者实际时间分配有可能是4分调研+设计,3分编码,3分测试。且越是老鸟,测试比重越高。测试下功夫了,质量就到位了,返工次数少,调试难度低,工效KPI也就高了。本文分享Python
测试开发中的一些心得。
Python
测试开发通常以“单元测试”的形式体现。常用的单元测试框架有unittest
、nose
和pytest
。其中 pytest功能丰富、成熟度高、普及度高,且社区始终维护更新。
pytest
通过pytest-mock提供了mocker fixture
模拟外部依赖。pytest-mock
底层用的是unittest.mock
(见代码)。因此,我选用pytest
+ 原生unittest.mock
作为测试开发组合。此外,还会用tox作为单元测试驱动框架。
单元测试工具并不复杂,就不详细介绍了。本系列文章重点放在两大神器fixture
和mock
上。
单元测试工具不复杂,不代表单元测试不复杂。单元测试的复杂性更多体现在:测试用例设计、测试数据设计和准备、如何减少测试代码对外部系统的依赖,以及降低待测部分持续变化对测试代码的冲击,等“设计”层面问题。
关于fixture
单元测试传递同样的输入到目标代码,预期目标代码输出同样的结果,从而判断测试通过与否。为了达到“同样的输入”这个目标,通常需要测试执行前(后),执行同样的初始化(清理)逻辑。被抽离封装后的初始化和清理逻辑,可称为fixture
。pytest
中使用fixture十分简单,可参考帮助文档。下文只讲几个不关注就掉坑的问题。
fixture被执行几次?
在下面的代码中,foo2
和foo3
都引用了foo1
, 当pytest
执行该测试模块时,到底foo1
被执行几次?
1# -*- encoding: utf-8 -*-
2import pytest
3
4@pytest.fixture
5def foo1():
6 print("foo1")
7
8@pytest.fixture
9def foo2(foo1):
10 print("foo2")
11
12@pytest.fixture
13def foo3(foo1):
14 print("foo3")
15
16def test_foo(foo2, foo3):
17 assert True
18
19def test_foo2(foo2):
20 assert True
21
22def test_foo3(foo3):
23 assert True
试一下:
1$ pytest ./test.py ; 假设测试模块名为test.py
2...
3
4test.py::test_foo2
5foo1
6foo2
7PASSED
8
9test.py::test_foo3
10foo1
11foo3
12PASSED
13
14test.py::test_foo
15foo1
16foo2
17foo3
18PASSED
观察输出会有如下结论:
- 每个
test_*
都会执行一次foo1
。因此不用担心一个测试修改了fixture
,而污染另一个测试的问题。 - 对于同一个
test_*
,即使foo1
被引用多次,也只会执行一次。之后的引用,都会得到第一次执行结果的缓存 。所以务必小心:在不得不多次引用时,一旦某个引用改变了fixture
, 则所有引用都会改变(因为大家用的是同一个对象)。
fixture在何时执行?
毫无疑问,fixture
在每个测试前才会被执行。没有被用到的fixture
不会被执行。依旧使用test.py
中的fixture
, 但测试方法调整为:
1def test_foo1(foo1):
2 assert True
3
4def test_foo3(foo3):
5 assert True
执行时,先打印了测试方法名称,然后才打印出fixture
中的内容;同时foo2
没有引用,没有打印,说明没有执行:
1test.py::test_foo3
2foo1
3foo3
4PASSED
5
6test.py::test_foo1
7foo1
8PASSED
scope和autouse
上文提到fixture
每个测试前都执行,其实这不是全部真相。毕竟有些fixture
是重量级的,每个方法执行一次开销太大,得不偿失。fixture
有两个重要参数scope
和autouse
, 结合使用可以控制fixture
执行的时机和次数。
autouse
不言自明。设置autouse
为True
的fixture
即使没有被测试方法引用,也会执行。例如:每个测试都用到了数据库。为了避免测试相互干扰,执行每个测试前,需要清理数据库。每个方法签名上增加cleaned_db
的fixture
固然可以,但更好的方法是设置autouse=True
,一劳永逸的避免漏加cleaned_db
造成测试脏数据遗留的风险。
1# -*- encoding: utf-8 -*-
2import pytest
3
4@pytest.fixture(autouse=True)
5def foo1():
6 print("foo1")
7
8@pytest.fixture
9def foo2():
10 print("foo2")
11
12def test_foo1():
13 assert True
14
15def test_foo1_2():
16 assert True
验证一下:可以看到foo1
的fixture
没有显式引用,也被执行了;而foo2
则没有执行。
注意:即使加了
autouse=True
之后,foo1
依旧每个测试执行一次。另外,请不要误会:给fixture
增加了autouse=True
,不代表可以在任何测试方法里直接使用这个fixture
。如果要使用这个fixture
, 必须将其显式添加到待测方法的函数签名中。
1test.py::test_foo1_2
2foo1
3PASSED
4
5test.py::test_foo1
6foo1
7PASSED
使用scope
则可以调整fixture
执行的范围,如:
-scope
默认值为function
: 每个测试都执行一次
-class
: 一个测试类只执行一次
-module
: 一个.py
文件执行一次
-package
:test.py
所在包(当前包含__init__.py
目录)及其子包(当前目录包含__init__.py
的子目录)执行一次
-session
:pytest
所执行的进程中,只执行一次
1# -*- encoding: utf-8 -*-
2import pytest
3
4@pytest.fixture(autouse=True)
5def foo1():
6 print("foo1")
7
8@pytest.fixture(autouse=True, scope="module")
9def foo2():
10 print("foo2")
11
12def test_foo1():
13 assert True
14
15def test_foo1_2():
16 assert True
foo1
的scope
为function
,故两个测试触发两次执行;foo2
的scope
为module
,整个文件只执行一次。
同时注意:
fixture
会在scope
指定范围内,第一个测试运行时,才会执行。
1test.py::test_foo1_2
2foo2
3foo1
4PASSED
5
6test.py::test_foo1
7foo1
8PASSED
yield
fixture
可以用return
或者yield
返回对象给测试方法使用。如果使用yield
则可以实现tearDown
功能。例如:
1# -*- encoding: utf-8 -*-
2import pytest
3
4
5@pytest.fixture
6def foo1():
7 print("foo1 start")
8 yield "a"
9 print("foo1 end")
10
11@pytest.fixture
12def foo2(foo1):
13 print("foo2 start")
14 yield "b"
15 raise ValueError("foo2")
16
17def test_foo1(foo2):
18 print("test_foo1")
19 assert True
test_foo1
执行会调用foo2
,foo2
调用foo1
。当test_foo1
执行完毕,则执行yield "b"
之后的清理代码。即使遇到异常清理失败(ValueError
),foo1
的yield "a"
之后的清理代码依旧会被继续执行。
注意:
test_foo1
测试结果是PASSED
,不会因为fixture
清理失败而失败。
1test.py::test_foo1
2foo1 start
3foo2 start
4test_foo1
5PASSED
6foo2 end
7foo1 end
8
9test.py::test_foo1 ERROR