单元测试
什么是单元测试
如果你听说过测试驱动开发(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
方法,它有两种使用方法:
assertRaises(exception, callable, *args, **kwds)
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的官方文档,可以看到很多官方文档都带有示例代码。比如 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.x
和 d2.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()
小结
文档测试非常有用,不但可以用来测试,还可以直接作为示例代码。通过某些文档生成工具,就可以自动把包含文档测试的注释提取出来。用户看文档的时候,同时也能看到文档测试。