用Python进行单元测试

2021-04-19 12:51:49 浏览数 (1)

Python编程语言,不仅仅在机器学习、数据分析等领域大放异彩,在web开发中等软件开发中,使用者也越来越多。

在软件开发中,有一种被提倡的开发范式:测试驱动开发。在这种开发范式中,编写单元测试是必不可少的。如果不实施严格的测试驱动开发,编写单元测试程序也是必要的。

对于单元测试而言,最基本的模块是pytest,在本文中会对这个模块给予简要介绍。此外,还有一个现在很流行的模块fizz buzz,本文也会向读者推荐。

为什么要自动化测试

并非所有人都理解自动化测试的必要性,有人甚至认为纯粹是个负担,他们认为自己在编写代码的时候,就已经发现了程序中的BUG,并且已经及时修复了。

这么说,也不是完全没道理。因为我们在开发的时候,就是边写代码、边执行程序。如果有问题,肯定会及时修改。特别是对有丰富开发经验的程序员,编写的代码中错误的确很少。

不过,BUG是难免的。一般情况下,我们都使用已有的框架或者库进行开发,并非从头开始写每一行代码。还有可能是维护、修改、升级原有的功能。在这些情况下,程序中出现BUG的概率就更高了。

因此,自动化测试就不可缺少了。开发者应该将自动化测试视为代码的保险策略,防止由于增加新功能致使BUG产生。

另外一个要实施自动化测试的缘由,是因为人工测试在某些时候是难以完成对程序的所有功能测试。例如,一段程序是从第三方API那里获取一些数据,如果用人工测试,无法测试出对方服务在出现问题时程序获得的异常信息。但是,如果用自动化测试,则能轻易实现。

单元测试、集成测试和功能测试

先简单罗列一下这三种测试的含义:

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

  • 集成测试(Integration tests):又称组装测试,即对程序模块采用一次性或增值方式组装起来,对系统的接口进行正确性检验的测试工作。集成测试一般在单元测试之后、系统测试之前进行。实践表明,有时模块虽然可以单独工作,但是并不能保证组装起来也可以同时工作
^{[3]}

  • 功能测试(Functional tests):功能测试就是对产品的各功能进行验证,根据功能测试用例,逐项测试,检查产品是否达到用户要求的功能
^{[4]}

如你所见,三种测试各司其职。在编写代码时,通常会用单元测试,这个更简单快捷,便于执行。所以,本文仅讨论单元测试。

用Python进行单元测试

Python中的单元测试,就是编写一个测试函数,在其中执行一小段应用程序,检验代码是否正确,如果有问题,会抛出异常。例如,函数forty_two()返回值是42,针对这个函数编写的单元测试如下:

代码语言:javascript复制
from app import forty_two

def test_forty_two():
    result = forty_two()
    assert result == 42

这个例子非常简单,在实际的开发过程中会比这复杂,assert语句也可能不止一条。

要执行这个单元测试,则需将其保存为一个Python文件,然后执行该文件,就能完成测试过程。

在Python中有两个非常流行的单元测试框架,一个是标准库中的unittest,另外一个是pytest。在本文中,将使用混合测试解决方案,这两个包对会用到:

  • 按照面向对象的编程思想,用unittest包的TestCase构建和组织单元测试。
  • 用Python中的assert语句实现断言,并辅之以pytest中的方法,增强assert语句的表达,从而能输出更多的异常信息。
  • 含有pytest的执行文件执行最后的测试,在此测试程序中,完全支持unittest包中的TestCase类。

如果你对有些东西还不太理解,不要担心,看看下面的示例就明白了。

测试示例

写一段程序,对1到100的整数进行处理:能被3整除,则输出Fizz;能被5整除,输出Buzz;能被3和5同时整除,输出FizzBuzz;其他情况则打印该数字。这个问题就是初学编程者都会遇到的名为“Fizz Buzz”的题目。

如果在网上搜一下,会找到很多相关的条目。比如,有人用下面的代码实现。

代码语言:javascript复制
for i in range(1, 101):
    if i % 15 == 0:
        print("FizzBuzz")
    elif i % 3 == 0:
        print("Fizz")
    elif i % 5 == 0:
        print("Buzz")
    else:
        print(i)

对于上面这样的代码,很难进行测试。必须进行改写,像下面的这样:

代码语言:javascript复制
def fizzbuzz(i):
    if i % 15 == 0:
        return "FizzBuzz"
    elif i % 3 == 0:
        return "Fizz"
    elif i % 5 == 0:
        return "Buzz"
    else:
        return i


def main():
    for i in range(1, 101):
        print(fizzbuzz(i))


if __name__ == '__main__':
    main()

main()函数不是必须的,但是fizzbuzz是不可或缺的。然后将上面的代码保存为fizzbuzz.py文件,那么它就可以作为一个单独的模块使用(关于模块问题,请参阅《Python大学实用教程》)。

然后创建虚拟环境(关于虚拟环境,请参阅“Python虚拟环境”一文),并在虚拟环境中安装pytest

代码语言:javascript复制
(venv) $ pip install pytest

要测试函数fizzbuzz()是否能正常工作,基本思想就是“控制变量”。比如,先向此函数提供3、6、9等这类能被3整除的数字,测试其是否返回Fizz

按照这个思路,新建一个文件,并写入如下代码。注意,这里所创建的类TestFizzBuzz继承了unittest.TestCase,并且从前面已经创建的fizzbuzz.py——将其视为一个模块,引入创建的函数fizzbuzz

代码语言:javascript复制
import unittest
from fizzbuzz import fizzbuzz


class TestFizzBuzz(unittest.TestCase):
    def test_fizz(self):
        for i in [3, 6, 9, 18]:
            print('testing', i)
            assert fizzbuzz(i) == 'Fizz'

这段程序保存为名为test_fizzbuzz.py的文件,并且与fizzbuzz.py在同一个目录里面。然后在终端输入pytest

代码语言:javascript复制
(venv) $ pytest
========================== test session starts ===========================
platform darwin -- Python 3.8.6, pytest-6.1.2, py-1.9.0, pluggy-0.13.1
rootdir: /Users/miguel/testing
collected 1 items

test_fizzbuzz.py .                                                 [100%]

=========================== 1 passed in 0.03s ============================

pytest命令会自动检测单元测试。一般来说,按照*test_[something].py[something]_test.py* 模式命名的Python文件都会被视为单元测试,另外,pytest还将在子目录中查找具有此命名模式的文件。

如果是一个大型项目,更应该有条不紊地进行单元测试,常见的一种方法把测试用的.py文件放到名为tests的目录中,从而与应用程序的代码分开。

比如,对于上面的应用程序fizzbuzz.py,如果想测试一下,遇到不能被3整除的数字的表现,就可以在test_fizzbuzz.py的列表中增加一个数字4,然后运行pytest

代码语言:javascript复制
(venv) $ pytest
========================== test session starts ===========================
platform darwin -- Python 3.8.6, pytest-6.1.2, py-1.9.0, pluggy-0.13.1
rootdir: /Users/miguel/testing
collected 1 item

test_fizzbuzz.py F                                                 [100%]

================================ FAILURES ================================
_________________________ TestFizzBuzz.test_fizz _________________________

self = <test_fizzbuzz.TestFizzBuzz testMethod=test_fizz>

    def test_fizz(self):
        for i in [3, 4, 6, 9, 18]:
            print('testing', i)
>           assert fizzbuzz(i) == 'Fizz'
E           AssertionError: assert 4 == 'Fizz'
E               where 4 = fizzbuzz(4)

test_fizzbuzz.py:9: AssertionError
-------------------------- Captured stdout call --------------------------
testing 3
testing 4
======================== short test summary info =========================
FAILED test_fizzbuzz.py::TestFizzBuzz::test_fizz - AssertionError: asse...
=========================== 1 failed in 0.13s ===========================

注意,一旦遇到不符合条件的数字,测试程序会断言,即停止。为了能准确定位到失败的位置,pytest会显示源码,并标记断言位置和实际的执行结果。此外,还自动输出测试内容。例如,上面的测试报告中显示,对34两个数字进行了测试,当测试4的时候失败。测试失败后,会回到测试的初始条件。

Fizz测试完毕,类似地,还可以继续增加对BuzzFizzBuzz的测试:

代码语言:javascript复制
import unittest
from fizzbuzz import fizzbuzz


class TestFizzBuzz(unittest.TestCase):
    def test_fizz(self):
        for i in [3, 6, 9, 18]:
            print('testing', i)
            assert fizzbuzz(i) == 'Fizz'

    def test_buzz(self):
        for i in [5, 10, 50]:
            print('testing', i)
            assert fizzbuzz(i) == 'Buzz'

    def test_fizzbuzz(self):
        for i in [15, 30, 75]:
            print('testing', i)
            assert fizzbuzz(i) == 'FizzBuzz'

现在再次运行pytest,将显示有三个单元测试,它们都通过了:

代码语言:javascript复制
(venv) $ pytest
========================== test session starts ===========================
platform darwin -- Python 3.8.6, pytest-6.1.2, py-1.9.0, pluggy-0.13.1
rootdir: /Users/miguel/testing
collected 3 items

test_fizzbuzz.py ...                                               [100%]

=========================== 3 passed in 0.04s ============================

测试覆盖率

对上述三项测试还满意吗?

虽然你必须根据自己的经验来判断需要多少自动化测试,以确保程序将来不会出现BUG。为此,有一个概念、或者工具:代码覆盖率,它可以帮助开发者更好地实施单元测试。

再安装一个模块:pytest-cov,运用它,能够检测到测试的代码覆盖率。

代码语言:javascript复制
(venv) $ pip install pytest-cov

执行命令pytest --cov=fizzbuzz,运行单元测试,注意命令行的参数列表中,声明了为fizzbuzz模块启用代码覆盖率跟踪:

代码语言:javascript复制
(venv) $ pytest --cov=fizzbuzz
========================== test session starts ===========================
platform darwin -- Python 3.8.6, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /Users/miguel/testing
plugins: cov-2.11.1
collected 3 items

test_fizzbuzz.py ...                                               [100%]

---------- coverage: platform darwin, python 3.8.6-final-0 -----------
Name          Stmts   Miss  Cover
---------------------------------
fizzbuzz.py      13      4    69%
---------------------------------
TOTAL            13      4    69%


=========================== 3 passed in 0.07s ============================

请注意,上面的命令行,用--cov参数设置了代码覆盖率的限制范围,即fizzbuzz模块。如果不进行此参数设置,会在最终的测试报告中输出很多内容,包括但不限于Python标准库、第三方库等内容的测试,从而呈现在眼前的是一个令人眼花缭乱的报告。

通过这个报告,我们可以知道,三个单元测试覆盖了的69%的fizzbuzz.py的代码,另有31%没有覆盖,也很有必要知道没测试到的代码是什么。方法就是增加一个命令行参数。

pytest-cov提供了多种格式的最终报告,像下面的执行那样,增加了--cov-report=term-missing,就会在最终报告中增加一列Missing,这里会显示未覆盖的代码行。

代码语言:javascript复制
(venv) $ pytest --cov=fizzbuzz --cov-report=term-missing
========================== test session starts ===========================
platform darwin -- Python 3.8.6, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /Users/miguel/testing
plugins: cov-2.11.1
collected 3 items

test_fizzbuzz.py ...                                               [100%]

---------- coverage: platform darwin, python 3.8.6-final-0 -----------
Name          Stmts   Miss  Cover   Missing
-------------------------------------------
fizzbuzz.py      13      4    69%   9, 13-14, 18
-------------------------------------------
TOTAL            13      4    69%


=========================== 3 passed in 0.07s ============================

报告显示,fizzbuzz.py中的第13行和第14行没有被单元测试所覆盖,这两行main()中的两行,它们其实与我们真正要测试的部分没有什么关系,不覆盖也是理所当然的。

此外,还有第18行,即fizzbuzz.py的最后一行,返回去看看源程序,它的作用只是执行此脚本,也不是测试对象。

但是,报告中所提到尚未覆盖的第9行,是fizzbuzz()函数中的一行。虽然我们测试的目标就是这个函数,看来还是有遗漏。不过,第9行是函数的最后一行,它在确定输入的数字不能被3或5整除后返回该数字。因此有必要添加一个单元测试,专门来检查不是FizzBuzzFizzBuzz的数字。

对照源文件fizzbuzz.py,上面的单元测试并没有对其中的if条件语句进行测试,如果要想覆盖,还需要在命令行中增加--cov-branch

代码语言:javascript复制
(venv) $ pytest --cov=fizzbuzz --cov-report=term-missing --cov-branch
========================== test session starts ===========================
platform darwin -- Python 3.8.6, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /Users/miguel/testing
plugins: cov-2.11.1
collected 3 items

test_fizzbuzz.py ...                                               [100%]

---------- coverage: platform darwin, python 3.8.6-final-0 -----------
Name          Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------
fizzbuzz.py      13      4     10      2    65%   6->9, 9, 13-14, 17->18, 18
---------------------------------------------------------
TOTAL            13      4     10      2    65%


=========================== 3 passed in 0.07s ============================

现在,测试覆盖率降低到65%,在Missing列下不仅显示了第9、13、14和18行,还添加了那些只包含布尔值的条件语句所在行。

前面说过了,要再增加一个单元测试,才能测试不能被3或5整除的数字,就用这个单元测试来覆盖第9行吧:

代码语言:javascript复制
import unittest
from fizzbuzz import fizzbuzz


class TestFizzBuzz(unittest.TestCase):
    def test_fizz(self):
        for i in [3, 6, 9, 18]:
            print('testing', i)
            assert fizzbuzz(i) == 'Fizz'

    def test_buzz(self):
        for i in [5, 10, 50]:
            print('testing', i)
            assert fizzbuzz(i) == 'Buzz'

    def test_fizzbuzz(self):
        for i in [15, 30, 75]:
            print('testing', i)
            assert fizzbuzz(i) == 'FizzBuzz'

    def test_number(self):
        for i in [2, 4, 88]:
            print('testing', i)
            assert fizzbuzz(i) == i

再执行pytest命令,注意后面的命令行参数,观察输出的测试报告,是否提高了测试覆盖率。

代码语言:javascript复制
(venv) $ pytest --cov=fizzbuzz --cov-report=term-missing --cov-branch
========================== test session starts ===========================
platform darwin -- Python 3.8.6, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /Users/miguel/testing
plugins: cov-2.11.1
collected 4 items

test_fizzbuzz.py ....                                              [100%]

---------- coverage: platform darwin, python 3.8.6-final-0 -----------
Name          Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------
fizzbuzz.py      13      3     10      1    74%   13-14, 17->18, 18
---------------------------------------------------------
TOTAL            13      3     10      1    74%


=========================== 4 passed in 0.08s ============================

看起来不错,覆盖率达到了74%,尤其是覆盖了fizzbuzz()函数的所有行,它是程序的核心。

尽管如此,报告还是显示第13、14和18行没有被覆盖,此外,第17行的条件句只有部分覆盖。

先看第17、18行,根据我们经验,这两行肯定是安全的,根本不需要测试。不过,上面报告的显示,让人不爽——如果你有这个感觉,那么,直接的做法是在不需要测试的行后面增加一个注释:# pragma: no cover,单元测试会认识这个注释(如下所示的代码)。

代码语言:javascript复制
def fizzbuzz(i):
    if i % 15 == 0:
        return "FizzBuzz"
    elif i % 3 == 0:
        return "Fizz"
    elif i % 5 == 0:
        return "Buzz"
    else:
        return i


def main():
    for i in range(1, 101):
        print(fizzbuzz(i))


if __name__ == '__main__':  # pragma: no cover
    main()

注意,只需要在第17行添加注释即可,这是因为,单元测试程序会在此行中捕获该“异常”,并作用域这个代码块,所以不需要在第18行重复添加注释。

再运行一次测试:

代码语言:javascript复制
(venv) $ pytest --cov=fizzbuzz --cov-report=term-missing --cov-branch
========================== test session starts ===========================
platform darwin -- Python 3.8.6, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /Users/miguel/testing
plugins: cov-2.11.1
collected 4 items

test_fizzbuzz.py ....                                              [100%]

---------- coverage: platform darwin, python 3.8.6-final-0 -----------
Name          Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------
fizzbuzz.py      11      2      8      0    79%   13-14
---------------------------------------------------------
TOTAL            11      2      8      0    79%


=========================== 4 passed in 0.07s ============================

这份报告看起来清爽了很多。

第13和14行是否也应标记为免于覆盖呢?你决定吧。

结论

单元测试,是开发中必不可少的环节,最好不要都推给“测试员”。

参考文献

[1]. How to Write Unit Tests in Python, Part 1: Fizz Buzz

[2]. 维基百科-单元测试

[3]. 维基百科-集成测试

[4]. https://www.jianshu.com/p/7716ccc68814

0 人点赞