错误、调试和测试
在程序运行过程中,总会遇到各种各样的错误。
有的错误是程序编写有问题造成的,比如本来应该输出整数结果输出了字符串,这种错误我们通常称之为bug,bug是必须修复的。
有的错误是用户输入造成的,比如让用户输入email地址,结果得到一个空字符串,这种错误可以通过检查用户输入来做相应的处理。
还有一类错误是完全无法在程序运行过程中预测的,比如写入文件的时候,磁盘满了,写不进去了,或者从网络抓取数据,网络突然断掉了。这类错误也称为异常,在程序中通常是必须处理的,否则,程序会因为各种问题终止并退出。
Python内置了一套异常处理机制,来帮助我们进行错误处理。
此外,我们也需要跟踪程序的执行,查看变量的值是否正确,这个过程称为调试。Python的pdb可以让我们以单步方式执行代码。
最后,编写测试也很重要。有了良好的测试,就可以在程序修改后反复运行,确保程序输出符合我们编写的测试。
错误处理
在程序运行过程中,如果发生了错误,可以预先约定返回一个错误代码,这样就知道是否有错,以及错误的原因了。在操作系统提供的调用中,返回错误代码非常常见。比如打开文件的函数open()
,成功时返回文件描述符,出错时返回-1。
用错误码来表示是否出错十分不便,因为函数本身该返回的正常结果和错误码混杂在一起,造成调用者必须用大量的代码来判断是否出错:
一旦出错,还要一级一级的上报,直到某个函数可以处理该错误。所以高级语言通常内置了一套try...except...finally...
的错误处理机制,Python也不例外。
try
让我们用一个例子来看看try
的机制:
当我们认为某段代码可能会出错的时候,就可以用try
来运行这段代码,如果执行出错,则后续代码不会继续执行,而是直接跳转至错误处理代码,即except
语句块,执行完except
后,如果有finally
语句块,则执行finally
语句块,至此,执行完毕!
上面代码在计算10/0时会产生一个除法运算错误:
从输出可以看出,当错误发生的时候,后续语句print('result:',r)
不会被执行,except
由于捕获到了ZeroDivisionError
,因此被执行。最后,finally
语句被执行。然后程序按照流程走下去。
如果把除数0改为2,则执行结果如下:
由于没有错误发生,所以except
语句块不会被执行,但是finally
如果有,则一定会被执行 。
而且错误应该有很多种类,如果发生了不同类型的错误,应该由不同类型的except
语句块处理。
int()
函数可能会抛出ValueError
错误,所以我们用一个except
捕获ValueError
,用另一个except
捕获ZeroDivisionError
。
此外,如果没有错误发生,可以在except
后面语句块加一个else,当没有错误发生时,会自动执行else语句。
Python 的错误其实也是class,所有的错误类型都继承自BaseException
,所以在使用except
时需要注意的是,它不但捕获该类型的错误,还把其他子类而已捕获了,比如:
第二个except
永远也捕获不到UnicodeError
,因为UnicodeError
是ValueError
的子类,如果有也被第一个except
捕获了。
Python所有的错误都是从BaseException
类派生的,常见的错误类型和继承关系如下:
https://docs.python.org/3/library/exceptions.html#exception-hierarchy
使try...except
捕获错误还有一个巨大的好处就是可以跨越多层调用,比如函数main()
调用foo()
,foo()
调用bar()
,结果bar()
出错了,这时只要main()
捕获到了,就可以处理:
也就是说,不需要再每个可能出错的地方去捕获错误,只要在合适的层次去捕获错误就可以了。这样一来,就大大减少了写try,,,except,,,finally
的麻烦。
调用堆栈
如果错误没有被捕获,它就会一直往上抛,最后被Python解释器捕获,打印一个错误信息,然后程序退出,来看看err.py
:
|
|
执行结果如下:
解读错误信息是定位错误的关键。我们从上往下可以看到整个错误的调用函数链:
错误信息第一行:
Traceback (most recent call last):
告诉我们这是错误的跟踪信息。
第二行和第三行:
File “err.py”, line 13, in
main()
调用main()
出错了。在 代码文件err.py
的第13行代码,但是原因在第11行:
File “err.py”, line 11, in main
bar(‘0’)
调用bar(0)
出错了,在代码文件的第11行,但是原因在第8行:
File “err.py”, line 8, in bar
return foo(s)*2
原因是retrun foo(s)*2
这个语句出错了,但这不是最终原因:
File “err.py”, line 5, in foo
return 10/int(s)
原因是return 10/int(s)
这个语句出错了,这个是错误的源头,因为下面打印了:
ZeroDivisionError: division by zero
根据错误类型ZeroDivisionError
,我们判断int(s)
本身并没有错,但是int(s)
返回0,在计算10/0时出错。
记录错误
如果不捕获错误,自然可以让Python解释器来打印出错误堆栈,但程序也被结束了。既然我们能捕获错误,就可以把错误的堆栈打印出来,然后分析错误原因,同时让程序继续执行下去。
Python内置的logging
模块可以非常容易的记录错误信息:
同样是出错,但程序打印完错误信息会继续执行并正常退出:
通过配置,logging
还可以把错误记录到日志文件里,方便事后排查。
抛出错误
因为错误是class,捕获一个错误就是捕获该class的一个实例。因此错误不是凭空产生的,而是有意识的创建并抛出的。python的内置函数会抛出很多类型的错误,我们自己编写的函数也可以抛出错误。
如果要抛出错误,首先要根据需要,定义一个错误的class,选择好继承关系,然后,用raise
语句抛出一个错误的实例:
执行,可以最后追踪到我们定义的错误:
只有在必要的时候才定义我们自己的错误类型。如果可以选择Python已有的内置的错误类型,尽量使用Python内置的错误类型。
最后,我们来看另一种错误处理的方式:
在bar()
函数中,我们明明已经捕获了错误,打印了一个ValueError!
后,又把错误通过raise
语句抛出去了。
捕获错误目的是记录,便于后续追踪。但是由于当前函数不知道应该怎么处理该错误,最恰当的做法就是继续上抛,让顶层调用者去处理。
raise
语句如果不带参数,就会把当前错误原样抛出。此外,在except
中raise
一个Error,还可以把一种类型的错误转化为另一种类型:
只要合理的转换逻辑都可以接受。
调试
程序能一次性写完并正常运行的概率很小,会有各种各样的bug需要修正,因此需要一整套调试程序的手段来修复。
第一种方法简单直接,就是用print()
把可能有问题的变量打印出来:
执行后在输出中查找打印的变量值:
用print()
最大的坏处是以后还要删掉他,运行结果中会包含很多垃圾信息。
断言
凡是用print()
来辅助查看的地方,都可以用断言(assert)来替代:
assert
的意思是,表达式n!=0
应该是真,否则,根据程序运行的逻辑,后面的代码肯定会出错。如果断言失败,assert
语句本身就会抛出AssertionError
:
程序中如果到处都是assert
,和print()
相比没有好多少。不过启动Python解释器时可以用-0
参数来关闭assert
。关闭之后,可以把其当做pass来看。
logging
把print()
替换为logging
是第三种方法,和assert
相比,logging不会抛出错误,而是可以输出到文件:
logging.info()
就可以输出一段文本。运行,发现除了ZeroDivisionError
,没有任何信息。
在import logging
之后加上一行配置:
输出为:
这就是logging
的好处,它允许你指定记录信息的级别,有debug
,info
,warning
,error
等几个级别,当我们指定level=INFO
时,logging.debug
就不起错用了。这样一来,你可以放心的输出不同级别的信息,也不用删除,最后统一控制输出那个级别的信息。
logging
的另一个好处是通过简单的配置,一条语句可以同时输出到不同的地方,比如console和文件。
pdb
第四种方式是启动Python的调试器pdb,让程序以单步方式运行,可以随时查看运行状态:
运行结果:
以参数-m pdb
启动后,pdb定位到下一步要执行的代码-> s='0'
.输入命令l来查看代码:
输入命令n可以单步执行代码:
|
|
任何时候都可以输入命令p 变量名
来查看变量:
|
|
输入命令q结束调制:
pdb.set_trace()
这个方法也是用pdb,但是不需要单步执行,我们只需要import pdb
,然后,可能出错的地方放一个pdb.set_trace()
,就可以设置一个断点:
|
|
运行代码,程序会在pdb.set_trace()
暂停并进入pdb调试环境,可以用命令p
查看变量,或者用命令c继续运行:
IDE
可以用IDE调试,如PyCharm:enter link description here
或者Eclipse加上pydev。
IDE调试很方便,但是logging更适合python调试。
单元测试
单元测试是用来对一个模块、一个函数或者一个类来进行正确性检验的测试工作。比如对函数abs()
,我们可以编写出以下几个测试用例:
1.输入正数,1、2.3、0.34,期待返回值和输入值相同
2.输入负数,-1、-1.2、-0.33,期待返回值与输入相反
3.输入0,期待返回0
4.输入非数值类型,比如None
、[]
、{}
,期待抛出TypeError
把上面的测试用例放到一个测试模块里,就是一个完整的单元测试。
如果单元测试通过,说明我们测试的这个函数能够正常工作,如果单元测试不通过,要么函数有bug,要么测试条件输入不正确,需要修复。
单元测试通过后的意义在于如果我们对abs()
函数代码做了修改,只需要跑一遍单元测试,如果通过,说明我们的修改不会对abs()
函数原有的行为造成影响,如果不通过,说明我们的修改和原有行为不一致,要么修改代码,要么修改测试。
这种以测试为驱动的开发模式最大的好处就是确保一个程序模块的行为符合我们设计的测试用例。在将来修改的时候,可以极大程度的保证该模块行为仍然是正确的。
我们来编写一个Dict
类,这个类的行为和dict
一致,但是可以通过属性来访问,用起来如下:
为了编写单元测试,我们需要引入Python自带的unittest
模块,编写mydict_test.py
:
编写单元测试时,我们需要编写一个测试类,从unittest.TestCase
继承。
以test
开头的方法就是测试方法,不以test
开头的方法不被认为是测试方法,测试的时候不会被执行。对每一类测试都需要编写一个test_xxx()
方法。由于unittest.TestCase
提供了很多内置的条件判断,我们只需要调用这些方法就可以断言输出是否是我们所期望的。最常用的断言就是assertEqual()
:
另一种重要的断言就是期待抛出指定类型Error,比如通过d['empty']
访问不存在的key时,断言会抛出KeyError
:
而通过d.empty
访问不存在key时,我们期待抛出AttributeError
:
运行单元测试
一旦编写好单元测试,我们就可以运行单元测试。最简单的方式是在mydict_test.py
的最后加上两行代码:
或者在命令行通过参数-m unittest
直接运行单元测试。
setUp与tearDown
可以在单元测试中编写两个特殊的setup()
和tearDown()
有什么用呢,这时,就可以在setUp()
方法中连接数据库,在tearDown()
方法中关闭数据库,这样不必再每个测试方法中重复相同的代码:
文档测试
很多文档都有实例代码,比如re模块:
可以把这些示例代码在Python的交互环境下输入并执行,结果与文档中的实例代码显示一致。
这些代码与其他说明可以写在注释中,然后由一些工具来自动生成文档。也可以自动执行写在注释中的代码:
无疑问更明确的告诉函数的调用者该函数的期望输入和输出。并且,Python内置的文档测试(doctest)模块可以直接提取注释中的diamante并执行测试。
doctest严格按照Python交互命令行的输入和输出来判断测试结果是否正确。只有测试异常的时候,可以用...
表示中间一大段烦人的输入。
用doctest来测试上次编写的Dict类:
运行结果: