验证
验证的目的是发现程序运行时可能出现的问题。验证的方法包括:
- 形式化验证
- 同行代码审查(挑别人毛病可比挑自己毛病容易)
- 测试
为什么说测试是块硬骨头?
下面是几种常见的错误测试方法:
- 穷举法测试是万万不可取的。问题规模稍大一点,测试的情况会相当多,改动一个小地方后重新测试所花费的时间甚至可能软件构造的过程还多!
- 碰运气测试。这纯粹就是胡来,不会有人运气好到随便测测就能发现所有bug的。
- 随机或统计检验。程序不像是现实的机器,程序有可能在某个大范围内的输入数据表现的很好,但是在某个点就会出问题。这个点出现的比例相当小,要通过随机检验的方法查出来可不容易。除此之外,还有栈溢出、内存溢出、数字溢出等可能突然发生的问题,这些问题的发生没有概率可言。
因此,我们需要一种系统的检验方法。
测试优先编程
在测试优先编程中,我们:
- 先写该函数的规约
- 再根据规约,写对该函数的测试
- 实现
当我们的实现通过了测试时,任务就完成了。
测试优先编程的好处是可以尽量早的避免bug。如果把测试拖到最后面,我们得到的只会是一大堆未经验证的代码(说不定还是屎山),不出错还好,万一哪个地方有个隐蔽的小虫子(bug),在这一堆里找出那一个小问题可简直是难于上青天。
系统的测试方法
这种测试方法中,测试组具有以下三种性质:
- 正确性:测试要符合规约,并且测试的手段应该与方法的具体实现无关。
- 彻底性:彻底的一组测试应该能够找到实现中的bug,特别是那些常见的bug。
- 小规模:测试的情况不要太多,并且尽可能易于更新(在规约发生变化的情况下可能需要更新测试)。并且小规模的测试跑起来速度也很快。如果测试规模又小运行速度又快,那我们就可以在有限的时间里多跑几次测试。
在实现的时候,我们的目标是让程序正确运行,但是在测试的时候,我们的目的是想方设法让程序出错。毕竟在测试里出错总比在用户手里出错好。这个时候我们可不能把自己的实现当宝贝一样护着。为了彻底测试我们的实现是否正确,我们必须在测试中暴力一点。
对测试情形的划分:
整个测试的范围可以划分为一个个子域,每个子域都是由若干输入组成的一个集合。这些子域形成了划分:一系列不相交的集合,可以覆盖所有的测试范围,即每个输入都处于一个子域中,这就形成了我们的测试策略。
这种测试策略是将所有合法输入做一个划分,从每个划分中取一个有代表性的点参与测试,这样既做到了全面覆盖,又做到了尽量减小测试集的规模。
千万不要小看边界
bug非常容易在边界处出现。例如:
- 0作为正负数的边界
- 某种数据类型的最值
- 空集合类型,例如空的list
- 一个序列的首尾元素,例如String
为什么在边界处最容易问题呢?我们都知道,int类型最大为2147483647,但如果再+1呢?那就会变成负数了,这很容易出问题。除此之外,反思一下,在写循环条件等的时候,有没有纠结过是<还是<=呢?
利用多重划分
假设一个函数的功能表示为AxB->C,那么我们就要分别对A、B进行划分。如果A、B的划分少还好,那如果A、B各有7、8个划分呢?难道真的要写大几十种测试方法吗?
因此我们引入了一个叫“多重划分”的概念,即我们可以分别划分A、B
例如在某种情形中:
// partition on a:
// a = 0
// a = 1
// a is small integer > 1
// a is small integer < 0
// a is large positive integer
// a is large negative integer
// (where "small" fits in long, and "large" doesn't)
// partition on b:
// b = 0
// b = 1
// b is small integer > 1
// b is small integer < 0
// b is large positive integer
// b is large negative integer
这个时候,原本需要36个测试方法,现在只需要6个了,重新形成的划分图:
除此之外,我们还可能担心AB之间的关系会影响程序运行结果,因此还需要测试AB之间的关系,以确保我们的测试更彻底。例如AB都是正数、一正一副、至少一个为零等等的情形,我们可以生成更多的划分,但是我们需要的测试方法数不再是划分数之积,而是可以利用我们的多重划分方法,大大减少我们的工作量。
贴心小Tips
记得在测试代码中注释你的测试策略哟
黑盒测试和白盒测试
黑盒测试是根据规约选择测试情形,我们并不知道程序内部是怎么运作的。就拿刚提到的测试优先编程来说,我们先写测试再实现,就是黑盒测试。毕竟鬼知道接下来我会怎么实现这个程序。
白盒测试显然和黑盒测试相反了。你知道程序是怎么编的,然后据此生成测试。当然,戏测不是胡测,改测不是乱测,生成测试也要按照基本法,要按照我们的规约来,不能自己天马行空测试一个要求更高的规约,这没有意义。
覆盖率测试
这可以用来检查我们的测试情形对程序的测试有多彻底。测试覆盖率有三种常见的类型:
- 语句覆盖:是每一句都被某个测试情形测试过吗?
- 分支覆盖:对于if、while,他们的每个分支都测过了吗?
- 路径覆盖:每个分支可能形成的组合都测试过吗?
分支覆盖比语句覆盖的要求更高,而路径覆盖又比分支覆盖的要求更高。一般来说,语句覆盖是率是要务必做到100%的。但即使如此,代码仍可能出现一些bug,因此我们要有更高要求。如果可以的话,做到100%分支覆盖。在某些要求更严格的地方,甚至要做到100%路径覆盖。这测试情形集合的大小可就是指数级别的增长了。
单元测试和集成测试
每次只测试一个小模块就是单元测试。单元测试更容易发现问题。如果单元测试都无法通过,那就没法玩了。
与单元测试相对的是集成测试。集成测试是将各模块组合起来,甚至是对整个程序进行测试。即使单元测试都对了,集成测试也可能出问题。下面这幅图足以说明我想表达的一切。
自动化的回归测试
懒惰推动了人类科技的进步,测试也不例外。比如可爱的JUnit就给我们提供了一套好的测试驱动。当然人也不能懒到懒得吃饭,测试的情形还是要人亲自写的,不然测试工程师不就失业了吗。
当然了,如果我能写出自动的测试生成器,那连续三年的图灵奖都归我,测试工程师该全体下岗了
人类最大的谎言,就是我明明什么也没有动,为什么程序就出错了呢?每一个小的改动,看似不起眼,都可能影响最后的结果,特别是对于大型复杂工程来说。
所以,每做出任何一个改动,都要对程序重新测试。不管是写bug新功能也好,还是修复bug也好,写完都要及时进行测试,尽早发现问题,这就叫回归测试。
自动化测试和回归测试结合起来,就是我们的自动化回归测试了。
改进的测试优先编程方法
在测试优先编程中,我们:
- 先写该函数的规约
- 再根据规约,写对该函数的测试,如果这一步出了问题,那就解决问题,然后重新从1开始
- 实现,如果这一步出了问题,那么解决问题,再从1重新开始
于是乎每一步都会对前面的每一步进行验证,也达到了我们回归测试的目的。
当然,我们不可能一次性就把事情做到位,我们可以逐步求精,先提出一个粗糙的测试方案,然后发现问题,及时修正改进,在一次次的回归测试中逐渐发现越来越多的问题,把测试方案和实现改进的越来越完美,这才是我们的目标。(类似敏捷开发?)这也是利用了回归测试的优势之处。
几点建议:
- 对于庞大的规约,先完成其一部分,然后写这部分的测试和实现,然后回来继续完善
- 对于一个复杂的测试系列,先从几个关键的划分开始,然后创建一个小的测试集,然后写出对应的实现,再进一步划分完善测试
- 对于一种巧妙的实现,可以先写一个简单粗暴的实现用来测试规约和验证,然后以此为基础逐渐改进升级为巧妙的实现方法。这样在测试的时候我们就可以利用现成的验证方案了。