课程内容及目标:
- 讨论测试和调试。讨论如何测试代码,以确定是否可能有bug
- 黑盒测试
- 白盒测试
- 讨论调试的相关技术
1.我们应该在什么时候进行测试?什么时候调试bug
如果我们提前弄清楚如何设计我们的代码,那么测试和调试将会更加简单。
优秀的程序员会如何设计:
(1)将代码分解成独立的模块,从而独立地进行测试和调试
(2)写出好的文档。在进入代码前,认真想想并写下你下一步将干什么,我对输入的期望是什么?我对输出的期望是什么?
(3)会记录下可能的假设
一旦我们设计好了代码,就可以进行测试了。为了测试它,基本上我们需要做两件事:
(1)剔除任何的语法错误
(2)删除任何静态语义错误
2.测试的方法:
目标:
(1)确认错误确实存在
(2)如果能证明bug不存在是最好的,但事实上,难以做到。比起证明一件事情不存在,证明一件事情存在要容易得多。
我们没有时间去将所有的可能性都测试一遍,所以需要一种方法来完成测试。我们来分析一下:
我们想做的是找到一系列输入,它们很有可能暴露错误,但这样的测试实际上非常有效,这就是所谓的测试套件,其思想就是我们将进行分解,将输入分解成子集,为代码正确性提供等效信息。所以我们要做的是将所有可能的输入集分割成一系列的子集,并且保证每个元素都在一个子集里。一旦分解完成,将会构建一个测试集,其中至少包含每个子集中的一个元素。从每个子集中,挑选一个输入元素,然后运行测试,看是否能够成功。举个栗子:
def isBigger(x, y):
'''
功能:判断x是否大于y,假设x和y都是整型
如果x<y将返回真,否则为假
'''
输入空间:所有成对的整数
可能的分割:
——x为正,y为正
——x为负,y为负
——x为正,y为负
——x为负,y为正
——x=0,y=0
——x=0,y≠0
——x≠0,y=0
这样分解的原因:
其实还有很多分割的方法,如x是个质数而y不是质数等等,但是这种分割方法跟问题不相关。这个输入空间的分割其实被称为自然分区,即看到整数,那它自然有正数、负数和0三种分区。当然我们也可以分割成九种情况。毕竟x和y分别有正数、负数和0三种情况。
构成测试套件:
(1)通过特定分类使用启发式的方法探索路径,称之为黑盒测试:
优点:可以避免发生偏见;测试不需要实施的知识。
这里有段代码:
def sqrt(x, eps):
"""Assumes x,eps floats
x>=0
eps>0
return res such that
x-eps<=res*res<=x+eps"""
规格路径:
——x=0
——x>0
按理来说需要两种测试用例即可。但是这是不够的。有了经典测试还应该有边界测试,如下面的几个测试用例可以很好地体现这一点:
x | eps |
0.0 | 0.0001 |
25.0 | 0.0001 |
.05 | 0.0001 |
2.0 | 0.0001 |
2.0 | 1.0/2.0**64.0 |
1.0/2.0**64 | 1.0/2.0**64.0 |
2.0**64.0 | 1.0/2.0**64.0 |
1.0/2.0**64.0 | 2.0**64.0 |
2.0**64.0 | 2.0**64.0 |
(2)基于代码本身使用启发式的方法探索路径,称之为白盒测试:
这个方法要利用代码本身进行检测。一个好的白盒检测套件,也被称作穷举路径测试,其中代码片段的每一条可能路径至少都被检测了一次,但几乎不可能。如果有一个循环执行了10亿次,我们可能就不能够完成这个任务了。我们可以提供几个不同的检测方案,这和递归有异曲同工之妙。举个例子:
def abs(x):
"""Assumes x is an int
returns x if x>=0 and -x otherwise"""
if x<-1:
return -x
else:
return x
这段代码用于寻找绝对值。那么测试套件的选择:
{-2,2},这是个穷举测试路径,既检测了if,还检测了else
但是漏了-1这种情况,当x为-1时应该执行if语句而不是执行else语句。即我们依旧要检验边界情况,即:
{-2,-1,2}
这里有几条要遵守的规则:
<1>确保检查了if语句的所有分支
<2>确保都测试了每次例外
<3>如果我有一个循环,一个for循环,要检测循环之前的程序,即循环体刚好被执行了一次,或者该循环体本应执行多次,如前面所述,不可能执行所有检测,那么我们可以抽取三个案例,因为它们很可能暴露错误。
<4>对while循环也是如此
<5>如果有个递归函数,那么需要测试一下没有递归调用会发生什么。如果刚好有一个递归调用,又会发生什么。同样,有多次递归又会发生什么。即0次、1次和多次的测试。
3.开始测试:
将从名为单元测试(unit testing)的方法入手:
单元测试会选择一个模块,比如一个函数,然后检测它。用输入值检测它,这些结果应该是我们可以预期的。这就会抓住一些算法错误。
完成单元测试后,下一步就是进行集成测试(integration testing),检验作为整体的系统是否正常工作。如果有若干个互相作用的函数,这将会抓住一些所谓的交互错误。
循环以上两个步骤,直到抓到了所有可能的错误。
还有一个测试很有用,就是建立一个测试驱动。驱动就是代码,建立它们就是为了进行测试。首先将会创建一段代码,从而绑定全局变量、数据结构以及其他我需要做测试的东西,之后拿着这套检测套件——即输入序列——在每一种检测手段中都运行一遍这段代码,保存结果,并且输出报告
注意,在循环测试完毕后,还要进行回归测试,即将之前用于测试的套件在新的代码汇总进行测试。
4.调试(Debugging):
错误分为显性错误和隐性错误,永久性错误和间歇性错误。
如何调试:
①研究手头的数据。正确的测试用例和错误的测试用例都要看。
②建立一个和数据一致的假设
③设计并运行一个可重复的实验,但会包含驳回假设的可能性。
④要一直记录游览一遍实验中所做的事,这会帮助我们缩小假设的范围,即代码中什么出错了。
然后将调试看做检索。我们要做的是选出可能出现错误的范围,然后把它不断缩小。设计一些东西,帮助我们暴露计算的中间环节。一个非常好并简单的工具就是打印语句。举个例子:
下面代码用于检测是否是回文结构:
def isPal(x):
assert type(x) == list
temp = x
temp.reverse
if type == x:
return True
else:
return False
def silly(n):
for i in range(n):
result = []
elem = raw_input('Enter element:')
result.append(elem)
if isPal(result):
print('Yes')
else:
print('No')
进行测试发现,输入'ab'后依旧返回Yes,出现错误,那么使用二分查找来分离出错误的具体位置:
选择代码的中点并放置一个打印语句:
发现输入'ab'仅输出列表['b']。然后锁定错误区域在上半部分,在上半部分继续运用二分查找。发现错误改正后如下:
输入'ab'后发现输出列表为['a','b'],是对的,但返回结果仍为Yes,错误锁定在下半部分,即isPal函数。在isPal函数主要部分二分查找,插入print:
以检测temp和x是否互为回文,发现结果并不互为回文,输入'ab'后依旧为['a','b'] ['a','b']。那么问题锁定在上半部分。继续插入print:
反向之前temp和x是多少。发现依旧是['a','b']。也就是说,反向语句没有执行它的任务。修改为temp.reverse()即可。输入'ab'后,发现又出问题了,x也跟着temp反向了。意味着遇到了别名错误,这表示当对某物进行反向时,创建的另一个名称却也指向了同样的东西,这就导致另一个的反向。
这里我们不让temp指向x的值,而是指向x的副本。
5.一些提示:
①查看最常发生错误的地方,这些地方会发生典型错误
②如果你找不到错误的位置,不要问为什么代码没有做你想做的事情,而是问为什么代码在做一件它应该做的事情。关注代码在做什么,而不是尝试找出错误发生的地方。
③错误可能不在你想象的位置。或者说,排除那些错误可能出现的地方
④和别人交流
⑤不想相信说明文档
⑥确实被困住的话,就先暂时放下