Python 单元测试

单元测试(又称为模块测试, Unit Testing)是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

如果发现代码难以构造测试,很有可能就是接口设计不够优雅,或者耦合严重,尝试从测试的角度思考能够让我们更好地设计。单元测试同时也为重构提供了保证,比如我们想优化一个函数内部实现,更换更优的数据结构和算法,只需要重新跑一下测试就可以验证新的实现是否引入了错误或bug。

单元测试有以下好处:

  • 确保代码质量
  • 改善代码设计,难以测试的代码一般是设计不够简洁的代码。
  • 保证重构不会引入新问题,以函数为单位进行重构的时候,只需要重新跑测试就基本可以保证重构没引入新问题。

单元测试的框架

关于单元测试,有很多现成的框架,其特点如下:

  • unittest,内置库,模仿PyUnit写的,简洁易用,缺点是比较繁琐。
  • nose,测试发现,发现并运行测试。
  • pytest,写起来很方便,并且很多知名开源项目在用,推荐。
  • mock,替换掉网络调用或者 rpc 请求等
    由于pytest使用起来比较方便,因此选择pytest作为python代码单元测试的工具,关于pytest的具体介绍和使用方法。

安装

1
2
3
sudo pip install -U pytest
# 确认是否安装成功
py.test --version

嵌入代码内的测试

此处参考官网示例,新建一个测试文件。

1
2
3
4
5
6
# test_sample.py的内容 
def func(x):
return x + 1

def test_answer():
assert func(3) == 5

直接在脚本目录中运行 pytest ,输出如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PS D:\Git_Repo\Work_temp> pytest.exe
============================= test session starts ==============================
platform win32 -- Python 3.11.8, pytest-8.0.2, pluggy-1.3.0
rootdir: D:\Git_Repo\Work_temp
plugins: anyio-3.7.1
collected 1 item

test_temp.py F [100%]

=================================== FAILURES ===================================
_________________________________ test_answer __________________________________

def test_answer():
> assert func(3) == 5
E assert 4 == 5
E + where 4 = func(3)

test_temp.py:6: AssertionError
=========================== short test summary info ============================
FAILED test_temp.py::test_answer - assert 4 == 5
============================== 1 failed in 0.53s ===============================
PS D:\Git_Repo\Work_temp>

pytest会在当前的目录下,寻找以 test 开头的文件(即测试文件),
找到测试文件之后,进入到测试文件中寻找test_开头的测试函数并执行。

通过上面的测试输出,我们可以看到该测试过程中,一个收集到了一个测试函数,测试结果是失败的(标记为F),并且在FAILURES部分输出了详细的错误信息,帮助我们分析测试原因,我们可以看到”assert func(3) == 5”这条语句出错了,错误的原因是func(3)=4,然后我们断言func(3) 等于 5。

拆分的测试代码

我们可以看到嵌入代码内的测试有一个很大的弊端,就是严格限制了代码文件的名称,一方面非常容易引起歧义,另一方面测试代码再实际业务层面其实是属于冗余代码的范畴,代码杂糅不利于后期的管理。
所以大部分业务逻辑下,我们其实是需要将测试代码和业务代码分离的。这时候,我们需要把我们的业务代码进行打包(代码目录创建 __init__.py)
示例如下,我们再 get_max 目录下创建 get_max/get_max.pyget_max/__init__.py

  • get_max/get_max.py

    1
    2
    3
    4
    5
    def max(a,b):
    if a > b:
    return a
    else:
    return b
  • get_max/init.py

    1
    from .get_max import max

然后创建我们的单元测试代码test_temp.py

1
2
3
4
5
6
7
8
import pytest
from .get_max import get_max

def test_max():
assert max(1,2)==2.

def test_max1():
assert max(1,2)==3

然后运行 pytest 结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
PS D:\Git_Repo\Work_temp> pytest.exe
============================= test session starts ==============================
platform win32 -- Python 3.11.8, pytest-8.0.2, pluggy-1.3.0
rootdir: D:\Git_Repo\Work_temp
plugins: anyio-3.7.1
collected 2 items

test_temp.py .F [100%]

=================================== FAILURES ===================================
__________________________________ test_max1 ___________________________________

def test_max1():
> assert max(1,2)==3
E assert 2 == 3
E + where 2 = max(1, 2)

test_temp.py:8: AssertionError
=========================== short test summary info ============================
FAILED test_temp.py::test_max1 - assert 2 == 3
========================= 1 failed, 1 passed in 0.55s ==========================

可以看到我们的两个测试用例,一个pass,一个fail,其中失败的是 test_max1。

补充说明

pytest测试样例非常简单,只需要按照下面的规则:

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

执行测试样例的方法很多种,上面第一个实例是直接执行pytest,第二个实例是传递了测试文件给pytest。其实pytest有好多种方法执行测试:

1
2
3
4
5
6
7
8
9
10
pytest               # run all tests below current dir
pytest test_mod.py # run tests in module
pytest somepath # run all tests below somepath
pytest -k stringexpr # only run tests with names that match the
# the "string expression", e.g. "MyClass and not method"
# will select TestMyClass.test_something
# but not TestMyClass.test_method_simple
pytest test_mod.py::test_func # only run tests that match the "node ID",
# e.g "test_mod.py::test_func" will select
# only test_func in test_mod.py

-------------本文结束感谢您的阅读-------------