【Python系列专栏】第二十五篇 Python中的单元测试

本文介绍单元测试的重要性和实施方法,并通过实例演示如何编写有效的单元测试和文档测试。涵盖测试驱动开发、unittest模块使用、with语法、assertRaises方法及文档测试doctest的应用。

单元测试

什么是单元测试

如果你听说过测试驱动开发(TDD:Test-Driven Development),单元测试就不陌生。

单元测试是用来对一个模块、一个函数或者一个类来进行正确性检验的测试工作

比如我们实现了一个求绝对值的函数 abs(),则测试用例需要包含以下这些情况:

  • 输入正数,比如1、1.2、0.99,期待返回值与输入相同;
  • 输入负数,比如-1、-1.2、-0.99,期待返回值与输入相反;
  • 输入0,期待返回0;
  • 输入非数值类型,比如None、[]、{},期待抛出TypeError。

把上面的测试用例放到一个测试模块里,就得到了一个完整的单元测试。

如果单元测试通过,说明我们测试的代码能够正常工作。如果单元测试不通过,要么代码有bug,要么单元测试没有编写好,总之,需要修复代码使单元测试能够通过。

单元测试通过后有什么意义呢?如果我们对 abs() 函数代码做了修改,只需要再跑一遍单元测试,如果通过,说明我们的修改不会对 abs() 函数原有的行为造成影响,如果测试不通过,说明我们的修改与原有行为不一致,此时我们要么修改代码,要么修改测试。

这种以测试为驱动的开发模式最大的好处就是确保一个程序模块的行为符合我们设计的测试用例。在将来修改的时候,可以极大程度地保证该模块行为仍然是正确的。


编写一个单元测试

假设我们要编写一个 Dict 类,这个类的行为和 dict 一致,但是可以通过属性来访问,可以像下面这样使用:

>>> d = Dict(a=1, b=2)
>>> d['a']
1
>>> d.a
1

把类定义写在 mydict.py 文件中:

class Dict(dict):

    def __init__(self, **kw):
        super().__init__(**kw)

    def __getattr__(self, key):
        try:
            return self[key]
        except KeyError:
            raise AttributeError(r"'Dict' object has no attribute '%s'" % key)

    def __setattr__(self, key, value):
        self[key] = value

为了编写单元测试,我们需要引入Python自带的 unittest 模块,把单元测试写在 mydict_test.py 文件中:

import unittest         # 导入Python自带的单元测试模块unittest
from mydict import Dict # 导入我们要进行单元测试的模块/类/函数等等

class TestDict(unittest.TestCase):

    def test_init(self):
        d = Dict(a=1, b='test')
        self.assertEqual(d.a, 1)
        self.assertEqual(d.b, 'test')
        self.assertTrue(isinstance(d, dict))

    def test_key(self):
        d = Dict()
        d['key'] = 'value'
        self.assertEqual(d.key, 'value')

    def test_attr(self):
        d = Dict()
        d.key = 'value'
        self.assertTrue('key' in d)
        self.assertEqual(d['key'], 'value')

    def test_keyerror(self):
        d = Dict()
        with self.assertRaises(KeyError):
            value = d['empty']

    def test_attrerror(self):
        d = Dict()
        with self.assertRaises(AttributeError):
            value = d.empty

我们使用一个测试类来实现单元测试,把所有类型的测试用例都封装为该类的方法。测试类继承自 unittest 模块的 TestCase 类。注意,所有测试方法都必须以 test 开头,不以 test 开头的方法不被认为是测试方法,测试的时候不会被执行。

每一类测试样例都需要编写一个 test_xxx() 方法。由于 unittest.TestCase 提供了很多内置的条件判断方法,我们只需要调用这些方法就可以断言输出是否符合我们的期望。最常用的断言就是 assertEqual()

self.assertEqual(abs(-1), 1) # 断言函数返回的结果与1相等

另一种重要的断言就是期待抛出指定类型的Error,比如通过 d['empty'] 访问不存在的 key 时,断言会抛出 KeyError

with self.assertRaises(KeyError):
    value = d['empty']

而通过 d.empty 访问不存在的 key 时,我们期待抛出 AttributeError

with self.assertRaises(AttributeError):
    value = d.empty

当这些断言输出是否符合我们的期望时,测试用例通过,否则测试用例失败。这一小节知识说明怎样编写单元测试,具体怎么进行测试会在后续的小结中详细说明。


补充说明

这里补充一下 with 语法和 assertRaises 方法的说明。

使用with的语法

关于 with 语句的相关概念可以看看浅谈 Python 的 with 语句这篇文章。使用 with 的语法一般如下:

with ContextExpression [as alias]:
    with-body

例如:

with open(r'somefileName') as f:
    for line in f:
        print(line)
        # ...more code

跟在 with 关键字后的表达式称为上下文表达式,它必须能返回一个**上下文管理器(Context Manager)**对象。with 语句包裹起来的代码块则称为 with-语句体(with-body)。如果我们在语句体中不需要用到上下文管理器对象,就不需要为这个对象取别名(alias),也即方括号 [] 内的是可忽略的。比方说前面编写测试类的时候就不需要,而上面例子中由于我们需要使用文件对象中,所以取了别名 f

上下文管理器对象都实现了 __enter__()__exit__() 这两个特殊方法。执行 __enter__() 方法会进行运行时上下文(runtime context),执行 __exit__() 方法则会退出。我们可以直接调用这两个方法来管理运行时上下文,也可以使用 with 语句进行管理。在执行 with-语句体的代码之前,__enter__() 方法会被自动调用,而执行完 with-语句体的代码之后,__exit__() 方法会被调用来退出运行时上下文。

assertRaises方法

接下来说说 assertRaises 方法,它有两种使用方法:

  1. assertRaises(exception, callable, *args, **kwds)
  2. assertRaises(exception, msg=None)

方法1测试我们使用参数 *args**kwds 调用 callable 对象(可能是某个函数/方法)时,是否会出现 exception 异常,如果是则测试用例通过,否则测试失败。

方法2同样是测试一个异常是否出现,但当我们只传入异常时,assertRaises 方法会返回一个上下文管理器对象,所以我们可以用 with 来管理,从而实现判断运行某一段代码(放在 with-语句体中)时,是否出现某种异常的测试用例。

特别地,这些** TestCase 类提供的断言方法都支持传入一个关键字参数 msg**,我们可以使用它自定义断言失败时提示的错误信息。

没指定 msg 参数时断言失败的报错:

AssertionError: KeyError not raised

指定了 msg 参数(假设指定 msg = '1234')时断言失败的报错:

AssertionError: KeyError not raised : 1234

运行单元测试

一旦编写好单元测试,我们就可以运行单元测试,具体有两种实现方法。

第一次方法是直接在单元测试文件 mydict_test.py 的最后加上两行代码:

if __name__ == '__main__':
    unittest.main()

这样只要把 mydict_test.py 当成普通Python脚本来运行就可以了,运行时就会直接跑单元测试了:

C:\Users\Administrator\Desktop>python mydict_test.py
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK

第二种方法是在命令行通过参数 -m unittest 来运行单元测试:

C:\Users\Administrator\Desktop>python3 -m unittest mydict_test
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK

第二种方法更为推荐,因为这样可以一次批量运行多个单元测试,比方说:

C:\Users\Administrator\Desktop>python -m unittest mydict_test.py mydict_test.py
..........
----------------------------------------------------------------------
Ran 10 tests in 0.000s

OK

此外,还有很多工具可以自动来运行这些单元测试。

前面都是举单元测试运行通过的例子,接下来补充一个运行不通过的例子,看看有测试用例不通过时,运行单元测试会返回什么。比方说把 test_keyerror(self) 方法中的 value = d['empty'] 语句换为 pass,这样语句体就不会返回 KeyError 了,断言会失败。看看此时运行单元测试的结果:

C:\Users\Administrator\Desktop>python -m unittest mydict_test.py
....F
======================================================================
FAIL: test_keyerror (mydict_test.TestDict)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\Users\Administrator\Desktop\mydict_test.py", line 27, in test_keyerror
    pass
AssertionError: KeyError not raised

----------------------------------------------------------------------
Ran 5 tests in 0.000s

FAILED (failures=1)

可以看到这里汇报了失败的源头是 test_keyerror 这个测试方法,原因是这个方法的 pass 语句没有引起 KeyError,使得断言失败。最后还汇报了运行了5个测试、总共运行的时间、单元测试失败、失败的测试数为1。

setUp与tearDown

在测试类中除了定义 test_xxx() 这样的测试方法,我们还可以编写两个特殊的 setUp()tearDown() 方法。这两个方法分别在每次调用一个测试方法的前后被执行

那么这两个方法有什么实际意义呢?假设测试时需要启动一个数据库,如果我们在 setUp() 方法中编写连接数据库的代码,在 tearDown() 方法中编写关闭数据库,这样我们就不必在每个测试方法中重复编写相同的代码了,也即把功能封装起来:

class TestDict(unittest.TestCase):

    ...

    def setUp(self):
        print('setUp')

    def tearDown(self):
        print('tearDown')

再次运行单元测试:

C:\Users\Administrator\Desktop>python -m unittest mydict_test.py
setUp
tearDown
.setUp
tearDown
.setUp
tearDown
.setUp
tearDown
.setUp
tearDown
.
----------------------------------------------------------------------
Ran 5 tests in 0.016s

OK

这里看到多出了一些句号 .,它们是每个测试方法通过之后会打印的。


小结

  • 单元测试可以有效地测试某个程序模块的行为,是未来重构代码的信心保证。

  • 单元测试的测试用例要覆盖常用的输入组合、边界条件和异常

  • 单元测试代码要非常简单,如果测试代码太复杂,那么测试代码本身就可能有bug。

  • 单元测试通过了并不意味着程序就没有bug了,但是不通过程序肯定有bug。

  • 除了Python自带的 unittest 模块,不妨再了解一下 NosePyTest 这两个第三方模块。



文档测试

使用文档测试

如果你经常阅读Python的官方文档,可以看到很多官方文档都带有示例代码。比如 re 模块的官方文档就带了很多示例代码,例如:

>>> import re
>>> m = re.search('(?<=abc)def', 'abcdef')
>>> m.group(0)
'def'

可以把这些示例代码在Python的交互式环境下输入并执行,结果与文档中的示例代码显示的一致。

这些代码与其他说明可以写在注释中,然后,由一些工具来自动生成文档。既然这些代码本身就可以复制出来直接运行,那么,可不可以自动执行写在注释中的代码呢?

答案是肯定的,Python内置的 “文档测试”(doctest)模块 可以提取出注释中的代码并执行测试。

当我们编写注释时,如果写上这样的注释:

def abs(n):
    '''
    Function to get absolute value of number.

    Example:

    >>> abs(1)
    1
    >>> abs(-1)
    1
    >>> abs(0)
    0
    '''
    return n if n >= 0 else (-n)

无疑更明确地向函数的调用者说明了该函数的期望输入和输出。

doctest 严格按照Python交互式命令行的输入和输出来判断测试结果是否正确。只有测试异常的时候,可以用 ... 来代替发生异常时Traceback的部分(毕竟实在是太长了…)。

不妨用文档测试 doctest 来重新实现上一节中为 Dict 类编写的单元测试,编写 mydict.py 文件:

# mydict2.py
class Dict(dict):
    '''
    Simple dict but also support access as x.y style.
    # 以下为文档注释中的代码部分
    >>> d1 = Dict()
    >>> d1['x'] = 100
    >>> d1.x
    100
    >>> d1.y = 200
    >>> d1['y']
    200
    >>> d2 = Dict(a=1, b=2, c='3')
    >>> d2.c
    '3'
    >>> d2['empty'] # 注意这里我们使用省略号...来替换了Traceback的细节
    Traceback (most recent call last):
        ...
    KeyError: 'empty'
    >>> d2.empty
    Traceback (most recent call last):
        ...
    AttributeError: 'Dict' object has no attribute 'empty'
    '''
    # 以下为该类的方法
    def __init__(self, **kw):
        super(Dict, self).__init__(**kw)

    def __getattr__(self, key):
        try:
            return self[key]
        except KeyError:
            raise AttributeError(r"'Dict' object has no attribute '%s'" % key)

    def __setattr__(self, key, value):
        self[key] = value

if __name__=='__main__':
    import doctest
    doctest.testmod() # 使用doctest模块的testmod函数来进行文档测试

注意前面我们说的是注释,但这个注释并非使用 # 号标识的那种注释,而是文档注释,也即文档字符串。按PEP257的定义:

A docstring is a string literal that occurs as the first statement in a module, function, class, or method definition. Such a docstring becomes the doc special attribute of that object.

所以这里 Dict 类的第一个字符串就是 Dict 类的文档注释,我们把用于文档测试的示例代码按照Python交互式命令行的输入和输出的标准来书写即可(只有测试异常时可以用 ... 替换掉Traceback的部分)。

运行 mydict.py

C:\Users\Administrator\Desktop>python mydict.py

文档测试通过时,程序不会有任何输出。接下来我们试试把 __getattr__() 方法注释掉(这样就不能通过把字典的key作为属性来访问了),此时再运行 mydict.py

C:\Users\Administrator\Desktop>python mydict.py
**********************************************************************
File "mydict.py", line 7, in __main__.Dict
Failed example:
    d1.x
Exception raised:
    Traceback (most recent call last):
      File "F:\Anaconda3\lib\doctest.py", line 1320, in __run
        compileflags, 1), test.globs)
      File "<doctest __main__.Dict[2]>", line 1, in <module>
        d1.x
    AttributeError: 'Dict' object has no attribute 'x'
**********************************************************************
File "mydict.py", line 13, in __main__.Dict
Failed example:
    d2.c
Exception raised:
    Traceback (most recent call last):
      File "F:\Anaconda3\lib\doctest.py", line 1320, in __run
        compileflags, 1), test.globs)
      File "<doctest __main__.Dict[6]>", line 1, in <module>
        d2.c
    AttributeError: 'Dict' object has no attribute 'c'
**********************************************************************
1 items had failures:
   2 of   9 in __main__.Dict
***Test Failed*** 2 failures.

可以看到因为没有实现把key作为属性访问的功能,此时文档注释中的两个example(即 d1.xd2.c 这两行输入)出错了,而文档注释中总共包含9对输入输出example。

注意到,我们只在 if __name__=='__main__': 代码块内写了执行文档测试的逻辑,也即只有在命令行中直接运行(python mydict.py)时会进行文档测试。而使用者使用这个类,在别的模块中导入该类(from mydict import Dict)时,文档测试是不会被执行的。因此,我们不必担心文档测试会在非测试环境下被执行,编写文档测试并不会影响到使用者使用该模块。


练习

对函数 fact(n) 编写文档测试并执行:

def fact(n):
    '''
    >>> fact(1)
    1
    >>> fact(5)
    120
    >>> fact(0)
    Traceback (most recent call last):
        ...
    ValueError
    '''
    if n < 1:
        raise ValueError()
    if n == 1:
        return 1
    return n * fact(n - 1)

if __name__ == '__main__':
    import doctest
    doctest.testmod()

小结

文档测试非常有用,不但可以用来测试,还可以直接作为示例代码。通过某些文档生成工具,就可以自动把包含文档测试的注释提取出来。用户看文档的时候,同时也能看到文档测试。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Mrrunsen

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值