Python学习笔记-08错误、调试与测试

该笔记记录的是学习廖雪峰Python3教程的过程,摘录了一些重点,重新编排内容,并加入了更丰富的代码示例和对学习过程中所遇到问题的理解。

本章内容安排如下:

简述

在程序运行过程中我们总会遇到各种各样的错误。有的错误是程序编写有问题造成的,比如本来应该输出整数结果输出了字符串,这种错误我们通常称之为bug,bug是必须修复的;有的错误是用户输入造成的,比如让用户输入email地址,结果得到一个空字符串,这种错误可以通过检查用户输入来做相应的处理;还有一类错误是完全无法预测的,比如写入文件的时候,磁盘满了,写不进去了,或者从网络抓取数据,网络突然断掉了。这类错误也称为异常,在程序中通常是必须处理的,否则,程序会因为各种问题终止并退出。Python内置了一套异常处理机制,可以帮助我们处理这些错误。

此外,在编写代码时,我们可能会需要跟踪程序的执行,查看变量的值是否正确,然后再进行调整或者下一步操作,这个过程称为调试。Python的pdb可以让我们以单步方式执行代码,从而方便地调试程序。

最后,编写测试也很重要。编写好测试文件,这样当我们改动了代码或者实现了新的功能时,只需再运行一遍测试,就能知道原来的功能有没有出错,程序是否依然能输出我们期望的结果了。



错误处理

错误码

在程序运行的过程中,如果发生了错误,可以返回一个事先约定的错误代码,这样,就可以知道是否有错,以及出错的原因。在操作系统提供的调用中,返回错误码非常常见。比如打开文件的函数 open(),成功时返回文件描述符(就是一个整数),出错时返回-1。

用错误码来表示是否出错十分不便,因为函数本身既可能返回正常结果又可能返回错误码,所以调用者不得不用大量的代码来判断属于哪一种情况。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
def foo():
r = some_function()
if r==(-1):
return (-1)
# do something
return r
def bar():
r = foo()
if r==(-1):
print('Error')
else:
pass

函数 foo 既可能返回正常结果又可能返回错误码,因此调用 foo 的函数 bar 就不得不先进行判断,检查返回的是正常结果还是错误码。这种情况在有多种错误码时显得更为麻烦。

还有一个很大的缺点是,使用错误码时,一旦出错,就必须把这个错误码一级一级上报,直到某个函数可以处理该错误(比如,给用户输出一个错误信息)。假如上面例子中 bar 函数无法处理错误,就必须继续返回错误码给调用 bar 的上级函数,以此类推。并且在返回的过程中,我们在每个中间函数中都要对错误码进行判断,这样写出来的程序有“半壁江山”都被处理错误的逻辑占据了,着实可怕。。。

有没有可以替代错误码又能处理错误的方案呢?有的!基本上,所有高级语言都内置了一套 try...except...finally... 的错误处理机制,Python也不例外,在下一小节中将介绍这种错误处理机制。


try…except…finally

try...except...finally... 机制的工作方式是这样的:

  • 当我们认为某段代码可能会出错时,可以用 try 来运行这段代码,如果运行出错,则这段代码会终止在错误出现的地方
  • 如果后续代码中 except 语句成功捕获到错误,程序就会执行 except 语句块内的代码处理错误。如果没有捕获到,则错误没有得到处理,程序就会停止运行;
  • 最后,无论是否出错,无论是否成功捕获到错误finally 语句块内的代码都会被执行。

try...except...finally... 机制中,我们可以不使用 finally 语句块,但 tryexcept 是一定要同时出现的except 不一定能成功捕获 try 语句块内的错误,如果捕获不成功,程序就会终止运行。

接下来看一个使用 try...except...finally... 机制处理错误的具体案例:

1
2
3
4
5
6
7
8
9
try:
print('try...')
r = 10 / 0
print('result:', r)
except ZeroDivisionError as e:
print('except:', e)
finally:
print('finally...')
print('END')

上面的代码在计算 10 / 0 时会产生一个除零错误,得到输出:

1
2
3
4
try...
except: division by zero
finally...
END

从输出可以看到,当错误发生时,后续语句 print('result:', r) 不会被执行,由于 except 语句捕获到这个 ZeroDivisionError 错误,因此 except 语句块里的代码会被执行。最后,finally 语句块里的代码也会被执行。又因为错误得到了处理,所以之后程序会继续运行后续代码,输出 END

如果把除数0改成2,则执行结果如下:

1
2
3
4
try...
result: 5
finally...
END

由于没有错误发生,所以 except 语句块不会被执行,但是 finally 语句块只要存在,就一定会被执行。

除了上面出现的 ZeroDivisionError 错误,在实际运行中,还有可能出现各种不同类型的错误。不同类型的错误应该由不同的 except 语句块进行处理。我们可以使用多个 except 语句来捕获不同类型的错误:

1
2
3
4
5
6
7
8
9
10
11
try:
print('try...')
r = 10 / int('a')
print('result:', r)
except ValueError as e:
print('ValueError:', e)
except ZeroDivisionError as e:
print('ZeroDivisionError:', e)
finally:
print('finally...')
print('END')

因为当 int() 函数无法把参数转换为 int 类型时会抛出 ValueError 错误,我们用一个 except 来捕获和处理 ValueError,用另一个 except 来捕获并处理做除法可能产生的 ZeroDivisionError

特别地,我们还可以在 except 语句块后面加一个 else 语句块。当错误没有发生时,就会执行 else 语句内的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
try:
print('try...')
r = 10 / int('2')
print('result:', r)
except ValueError as e:
print('ValueError:', e)
except ZeroDivisionError as e:
print('ZeroDivisionError:', e)
else:
print('no error!')
finally:
print('finally...')
print('END')

我们常说,在Python中一切皆对象。其实呀,Python中的错误也是采用面向对象实现的,每一种错误都是一个类,BaseException 类是所有错误类型最顶级的父类。在使用 except 时需要注意,它不但会捕获所指定类型的错误,还把属于该类型子类的错误一并捕获。比如:

1
2
3
4
5
6
try:
foo()
except ValueError as e:
print('ValueError')
except UnicodeError as e:
print('UnicodeError')

这里的第二个 except 永远也不会捕获到 UnicodeError,因为 UnicodeErrorValueError 的子类,如果出现了 UnicodeError 就一定会被第一个 except 语句捕获。

常见的错误类型和继承关系看这里:

https://docs.python.org/3/library/exceptions.html#exception-hierarchy

在上一小节中,我们说到了使用错误码处理错误有两大缺点,一是函数既可能返回正常结果也可能返回错误码,二是一旦发生错误必须层层上报。那么使用 try...except...finally... 机制是否能克服这两个缺点呢?答案是肯定的!举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
def foo(s):
return 10 / int(s)
def bar(s):
return foo(s) * 2
def main():
try:
bar('0')
except Exception as e:
print('Error:', e)
finally:
print('finally...')

这里我们在 main 函数中调用 bar 函数,在 bar 函数中调用 foo 函数。我们使用 try 模块来运行调用代码,当 foo 函数发生错误时,我们不需要返回错误码,也不需要一级级上报,程序会自动寻找对应的 except 语句进行错误处理。也即是说,不需要在每个可能出错的地方去捕获错误,只要在合适的层次去捕获错误就可以了。这样一来,我们就能使用非常简洁的方式来处理程序运行中可能出现的错误了。


错误的调用链

如果错误没有被捕获,就会一直往上抛,最后被Python解释器捕获,打印出错误信息,然后程序终止运行。

编写一个包含如下代码的 err.py 文件:

1
2
3
4
5
6
7
8
9
10
11
# err.py:
def foo(s):
return 10 / int(s)
def bar(s):
return foo(s) * 2
def main():
bar('0')
main()

执行该文件,结果如下:

1
2
3
4
5
6
7
8
9
10
11
$ python3 err.py
Traceback (most recent call last):
File "err.py", line 11, in <module>
main()
File "err.py", line 9, in main
bar('0')
File "err.py", line 6, in bar
return foo(s) * 2
File "err.py", line 3, in foo
return 10 / int(s)
ZeroDivisionError: division by zero

出错并不可怕,可怕的是不知道哪里出错了。解读错误信息是定位错误的关键。我们从上往下可以看到整个错误的函数调用链

错误信息的第1行:

1
Traceback (most recent call last):

这句话告诉我们下面是错误的跟踪信息。

错误信息的第2~3行:

1
2
File "err.py", line 11, in <module>
main()

告诉我们调用 main() 出错了,具体是在代码文件 err.py 的第11行代码。

错误信息的第4~5行:

1
2
File "err.py", line 9, in main
bar('0')

告诉我们调用 bar('0') 出错了,具体是在代码文件 err.py 的第9行代码。

错误信息的第6~7行:

1
2
File "err.py", line 6, in bar
return foo(s) * 2

告诉我们调用 foo(s) 出错了,具体是在代码文件 err.py 的第6行代码。

错误信息的第8~9行:

1
2
File "err.py", line 3, in foo
return 10 / int(s)

告诉我们语句 return 10 / int(s) 出错了,具体是在代码文件 err.py 的第3行代码。这是错误的源头,因为下面打印了具体的错误原因:

1
ZeroDivisionError: integer division or modulo by zero

根据错误类型 ZeroDivisionError,我们可以判断 int(s) 本身并没有出错,但是 int(s)返回了0,在计算 10 / 0 时程序出错了。这和我们使用 except 来捕获错误信息时打印出的内容是一样的。


记录错误

上一小节讲到,如果不在代码中进行错误处理,Python解释器最终会捕获错误并打印出错误调用链,但同时程序也会终止运行。那么,有没有既能打印出错误调用链,帮助我们分析出错的原因和源头,同时又能让程序继续运行的方法呢?有的,Python内置的 logging 模块可以帮助我们非常容易地记录错误信息。

这里举一个简单的例子,首先编写 err_logging.py 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import logging
def foo(s):
return 10 / int(s)
def bar(s):
return foo(s) * 2
def main():
try:
bar('0')
except Exception as e:
logging.exception(e) # 使用logging模块的exception方法打印错误信息
main()
print('END')

同样是打印出错误调用链,但程序打印完错误信息后会继续运行,并正常结束:

1
2
3
4
5
6
7
8
9
10
11
$ python3 err_logging.py
ERROR:root:division by zero
Traceback (most recent call last):
File "err_logging.py", line 13, in main
bar('0')
File "err_logging.py", line 9, in bar
return foo(s) * 2
File "err_logging.py", line 6, in foo
return 10 / int(s)
ZeroDivisionError: division by zero
END

此外,我们还可以借助 logging 模块把错误信息记录到日志文件里,方便事后排查,这里不作举例了。


抛出错误

前面我们说到,在Python中错误都是通过类来实现的,捕获一个错误就是捕获到该类的一个实例。错误并不是凭空产生的,而是有意地创建并抛出的。Python的内置函数会抛出很多不同类型的错误,我们自己编写函数时也可以这样做。

举一个简单的例子,首先编写 err_raise.py 文件:

1
2
3
4
5
6
7
8
9
10
class FooError(ValueError):
pass
def foo(s):
n = int(s)
if n==0:
raise FooError('invalid value: %s' % s)
return 10 / n
foo('0')

这里我们自定义了一个错误类型 FooError,继承自 ValueError使用 raise 语句抛出一个错误的实例。执行 err_raise.py,最终可以跟踪到我们自定义的错误类型:

1
2
3
4
5
6
7
$ python3 err_raise.py
Traceback (most recent call last):
File "err_throw.py", line 11, in <module>
foo('0')
File "err_throw.py", line 8, in foo
raise FooError('invalid value: %s' % s)
__main__.FooError: invalid value: 0

只有在必要的时候才自定义错误类型。如果可以使用Python内置的错误类型(比如ValueError,TypeError等等),就应尽量使用Python内置的错误类型。

最后,我们来看另一种错误处理的方式,首先编写 err_reraise.py 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def foo(s):
n = int(s)
if n==0:
raise ValueError('invalid value: %s' % s)
return 10 / n
def bar():
try:
foo('0')
except ValueError as e:
print('ValueError!')
raise
bar()

bar() 函数中,我们明明已经捕获了错误,但是,打印一个 ValueError 之后,又把错误通过 raise 语句再次抛出去了,为什么呢?

其实这种错误处理方式并没有错,而且相当常见。有时候,捕获错误的目的只是记录一下,便于后续追踪。如果当前函数没有处理该错误的逻辑,最恰当的方式就是继续往上抛,让顶层调用者去处理。好比一个员工处理不了一个问题时,就把问题抛给他的老板,如果他的老板也处理不了,就一直往上抛,最终抛给CEO去处理。

特别地,当 raise 语句不带参数时,会把当前错误原样抛出。但既然我们可以在 except 语句块中使用 raise 语句,那就可以轻易地抛出一个别的错误,从而把一种错误类型转换成另一种错误类型。例如:

1
2
3
4
try:
10 / 0
except ZeroDivisionError:
raise ValueError('input error!')

当然,我们不能滥用这样的功能,只有在有必要进行转换时才进行合理的转换。


小结

使用Python内置的 try...except...finally 机制可以十分方便地处理错误。但出错时,会分析错误信息并定位错误发生的位置才是最关键的。

我们编写模块时可以在代码中主动抛出错误,让调用者来处理相应的错误。但是,我们应当在模块的文档中写清楚可能会抛出哪些错误,以及错误产生的原因。



调试

程序运行总会有各种各样的bug,有的bug很简单,看看错误信息就知道;但有的bug很复杂,我们不但需要知道错误类型和出错的地方,还需要知道一些变量的值才能做出准确的推断。跟踪程序的执行,查看变量的值这个过程就称为调试,这一节会介绍各种调试程序的手段。

直接打印

直接打印是一种直接粗暴但十分有效的方法,简单来说就是使用 print() 把可能有问题的变量打印出来看看:

1
2
3
4
5
6
7
8
9
def foo(s):
n = int(s)
print('n = %d' % n)
return 10 / n
def main():
foo('0')
main()

执行后在输出中查找打印的变量值:

1
2
3
4
5
$ python3 err.py
n = 0
Traceback (most recent call last):
...
ZeroDivisionError: integer division or modulo by zero

这样我们就知道除零错误是因为变量 n 的值不合理而产生的了。

但是使用直接打印来调试有一个很大的缺点,在完成调试后,我们还得删掉代码里用于输出变量值的 pinrt(),如果我们要观察很多变量的值,那么代码里就会到处都是 print(),运行结果也会包含很多垃圾信息,删除的时候就会很麻烦。


断言

我们可以用断言(assert)来替代 print(),例如:

1
2
3
4
5
6
7
def foo(s):
n = int(s)
assert n != 0, 'n is zero!'
return 10 / n
def main():
foo('0')

我们看到,使用 assert 的方法是在它后面接一个表达式以及一个字符串,如果表达式不为 True,则断言失败,此时会抛出 AssertionError 错误,并输出自定义的错误信息(跟在表达式后面的字符串):

1
2
3
4
$ python3 err.py
Traceback (most recent call last):
...
AssertionError: n is zero!

程序中如果到处充斥着 assert 语句,似乎和使用 print() 相比也没有什么不同。但是,我们可以在启动Python解释器时可以用 -O 参数来关闭 assert

1
2
3
4
$ python3 -O err.py
Traceback (most recent call last):
...
ZeroDivisionError: division by zero

关闭后,可以把所有的 assert 语句当成 pass 来看,此时断言就不会发挥作用了。


logging

我们还可以把 print() 替换为 logging。和 assert 相比,使用 logging 不会抛出错误,而且不但能打印信息还能方便地保存到日志中。这里简单举个例子,首先编写 err.py 文件:

1
2
3
4
5
6
import logging
s = '0'
n = int(s)
logging.info('n = %d' % n)
print(10 / n)

logging.info() 可以输出一段文本。但运行上述代码,发现除了抛出 ZeroDivisionError 错误之外,没有任何信息。怎么回事呢?

别急,在 import logging 之后添加一行配置再试试:

1
2
import logging
logging.basicConfig(level=logging.INFO)

再次运行,此时能看到输出了:

1
2
3
4
5
6
$ python3 err.py
INFO:root:n = 0
Traceback (most recent call last):
File "err.py", line 8, in <module>
print(10 / n)
ZeroDivisionError: division by zero

这就是使用 logging 的好处了,它允许开发者指定记录信息的级别,按程度由低到高有 debug, info, waring, error 几个级别。当我们指定 level=INFO 时,logging.debug 就不起作用了。同理,指定 level=WARNING 后,debuginfo 就不起作用了。这样一来,我们可以很方便地统一控制输出哪个级别的信息,而不用担心调试完还要删除的问题了。

使用 logging 还有另一个好处就是可以通过很简单的配置,把一条语句同时输出到不同的地方,比如命令行和文件。


pdb

前面几种方式都需要插入额外的代码,有没有不需要插入代码的调试方式呢?有的!我们可以启动Python自带的pdb调试器,让程序以单步方式运行,可以随时查看运行到某一步时各个变量的值。首先编写好 err.py 文件:

1
2
3
4
# err.py
s = '0'
n = int(s)
print(10 / n)

以参数 -m pdb 来启动pdb调试环境:

1
2
3
C:\Users\Administrator\Desktop>python -m pdb err.py
> c:\users\administrator\desktop\err.py(2)<module>()
-> s = '0'

此时输出有两行,第一行表示下一步执行的代码属于哪一个代码文件的哪一行(这里是 err.py 的第2行);第二行则是下一步执行的代码。我们可以输入命令 l 来查看这行代码的上下文:

1
2
3
4
5
6
(Pdb) l
1 # err.py
2 -> s = '0'
3 n = int(s)
4 print(10 / n)
[EOF]

输入命令 n 可以单步执行该行代码:

1
2
3
4
5
6
(Pdb) n
> c:\users\administrator\desktop\err.py(3)<module>()
-> n = int(s)
(Pdb) n
> c:\users\administrator\desktop\err.py(4)<module>()
-> print(10 / n)

执行后pdb会自动指向下一行代码。特别地,任何时候我们都可以通过输入命令 p 变量名 来查看一个变量的值:

1
2
3
4
(Pdb) p s
'0'
(Pdb) p n
0

继续执行:

1
2
3
4
(Pdb) n
ZeroDivisionError: division by zero
> c:\users\administrator\desktop\err.py(4)<module>()
-> print(10 / n)

此时执行到了出错代码,pdb会报错并停止在这一行,我们可以看到它指向的地方没有发生变化。

输入命令 q 可以结束调试,退出pdb:

1
2
3
(Pdb) q
C:\Users\Administrator\Desktop>

这种通过pdb在命令行调试的方法在理论上是万能的,但实在是太麻烦了,打个比方,如果代码文件中有1000行代码,要运行到第999行就得敲999次命令 n,这样还不如执行在代码文件插入代码呢。


pdb.set_trace

有没有可以直接调到我们需要检查的地方再进行单步调试的调试方式呢?有的!同样是基于pdb,这次我们在代码文件中 import pdb,然后,在可能出错的地方插入一句 pdb.set_trace(),这就设置了一个断点:

1
2
3
4
5
6
7
# err.py
import pdb
s = '0'
n = int(s)
pdb.set_trace() # 运行到这里会自动暂停
print(10 / n)

直接运行代码,这时程序会自动pdb.set_trace() 暂停并进入pdb调试环境,可以用命令 p 查看变量,用命令 n 单步执行,或者用命令 c 继续运行(如果有下一个断点就会停在下一个断点,否则直接运行到程序结束):

1
2
3
4
5
6
7
8
9
10
11
12
C:\Users\Administrator\Desktop>python err.py
> c:\users\administrator\desktop\err.py(7)<module>()
-> print(10 / n)
(Pdb) p n
0
(Pdb) c
Traceback (most recent call last):
File "err.py", line 7, in <module>
print(10 / n)
ZeroDivisionError: division by zero
C:\Users\Administrator\Desktop>

这种方式的效率要比直接启动pdb进行单步调试更高。


IDE

除了上面介绍到的方式之外,使用IDE附带的调试功能也是很常见的。通常IDE会自带一些快捷键,允许我们方便地设置断点、单步执行、查看变量值等等。PyCharm是一个不错的选择。


小结

写程序最痛苦的事情莫过于调试,程序往往会以你意想不到的流程来运行,你期待执行的语句其实根本没有执行,这时候,就需要调试了。

虽然用IDE调试起来比较方便,但是最后你会发现,logging 才是终极武器。



单元测试

什么是单元测试

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

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

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

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

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

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

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

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


编写一个单元测试

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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 文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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()

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

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

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

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

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

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


补充说明

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

使用with的语法

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

1
2
with ContextExpression [as alias]:
with-body

例如:

1
2
3
4
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 参数时断言失败的报错:

1
AssertionError: KeyError not raised

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

1
AssertionError: KeyError not raised : 1234

运行单元测试

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

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

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

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

1
2
3
4
5
6
C:\Users\Administrator\Desktop>python mydict_test.py
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s
OK

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

1
2
3
4
5
6
C:\Users\Administrator\Desktop>python3 -m unittest mydict_test
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s
OK

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

1
2
3
4
5
6
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 了,断言会失败。看看此时运行单元测试的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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() 方法中编写关闭数据库,这样我们就不必在每个测试方法中重复编写相同的代码了,也即把功能封装起来:

1
2
3
4
5
6
7
8
9
class TestDict(unittest.TestCase):
...
def setUp(self):
print('setUp')
def tearDown(self):
print('tearDown')

再次运行单元测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 模块的官方文档就带了很多示例代码,例如:

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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) 编写文档测试并执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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()

小结

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



坚持技术分享,您的支持将鼓励我继续创作!