<font size="3"> **本文内容来自[MIT_6.031_sp18: Software Construction](http://web.mit.edu/6.031/www/sp18/)课程的Readings部分,采用[CC BY-SA 4.0](http://creativecommons.org/licenses/by-sa/4.0/)协议。**html
因为咱们学校(哈工大)大二软件构造课程的大部分素材取自此,也是推荐的阅读材料之一,因而打算作一些翻译工做,本身学习的同时也能帮到一些懒得看英文的朋友。另外,该课程的阅读资料中有许多练习题,可是没有标准答案,所给出的答案均为译者所写,有错误的地方还请指出。java
<br />git
<br />程序员
审校:李秋豪算法
V1.0 Tue Mar 6 01:54:34 CST 2018编程
<br />小程序
<br />api
“测试”是“验证”的一种例子。而验证的目的就是发现程序中的问题,以此提高你对程序正确性的信心。验证包括:数组
即便是最优的验证,程序也不可能达到十全十美,这里列出了通常状况下程序的剩余缺陷率residual defect rates(软件发行时存在的bug比率) ,这里的单位是每 kloc (每一千行代码):
这看起来让人沮丧,想想,若是你写了100万行的大型程序,那你极可能没检查出1000个bug!
<br />
这里有一些在工业界测试产品经常使用的方法,但是它们在软件行业没法发挥应有的做用。
**尽力测试(尝试全部的可能):**这一般是不可行的,由于大多数状况下输入空间会很是大,例如仅仅是一个浮点数乘法a*b
,其总共的取值就有2^64种可能性!
随机测试 (试一下看看行不行): 这一般难以发现bug,除非这个程序处处都是bug以致于随便一个输入都能崩溃。即便咱们修复了测试出来的bug,随机的输入也不能使咱们对程序的正确性很肯定。
基于统计方法的测试:遗憾的是,这种方法对软件不那么奏效。在物理系统里,工程师能够经过特定的方法加速实验的进程,例如在一天的时间里打开关闭一个冰箱门一千次,以此来模拟几年的正常使用,最终获得产品的”失败率“。之后的测试结果也将会集中分布在这个比率左右,工程师们就对这个比率进行进一步的研究。可是软件的行为一般是离散且不可预测的。程序可能在上一秒还彻底正常的工做,忽然就崩溃了,也可能对于大多数输入都没问题,对于一个值就崩溃了(没有预兆,更谈不上失败率,因此很难提早作好监测的准备),例如 有名的奔腾处理器除法bug ,在90亿次的除法中才可能会有一个错误。
综上,咱们必须系统并且当心的选择测试用例,这也是下面要讲的。
<br />
测试基础
阿丽亚娜5型火箭,为欧洲空间局研发的民用卫星一次性运载火箭,名称来源于神话人物阿丽雅杜妮(Ariadne)的法语拼写。1996年6月4日,在风和日丽的法属圭亚那太空发射场,阿丽亚娜5型运载火箭首航,计划运送4颗太阳风观察卫星到预约轨道。但在点火升空以后的40秒后,在4000米高空,这个价值5亿美圆的运载系统就发生了爆炸,瞬间灰飞烟灭化为乌有。
爆炸缘由因为火箭某段控制程序直接移植自阿丽亚娜4型火箭,其中一个须要接收64位数据的变量为了节省存储空间而使用了16位字节,从而在控制过程当中产生了整数溢出,致使导航系统对火箭控制失效,程序进入异常处理模块,引爆自毁。
这个故事告诉了咱们什么?
静态类型检查不会检测到此错误,由于代码有意(强转)将64位精度转换为16位精度。
<br />
测试须要一个正确的态度:当你在写一个程序的时候,你的心态必定是让这个程序正常运行,可是做为一个测试者,你应该千方百计让程序崩溃。
这是一个隐晦但重要的区别,一个优秀的测试员会“挥舞的重锤敲打代码可能有问题的地方”,而不是“当心的呵护它”。
<br />
测试开始的时间应该尽可能早,而且要频繁地测试。当你有一大堆未经验证的代码时,不要把测试工做留到最后。把测试工做留到最后只会让调试的时间更久而且调试过程更加痛苦,由于你的代码将会充斥着bug。反之,若是你在编码的过程当中就进行测试,状况就会好的多。
在测试优先编程中,测试程序先于代码完成。编写一个函数应该按以下步骤进行:
规格说明描述了这个函数的输入输出行为。它肯定了函数参数的类型和对它们的全部约束(例如sqrt
函数的参数必须是非负的)。它还定义了函数的返回值类型以及返回值和输入之间的关系。你已经在这门课中对许多问题都查看并使用过规格说明。在代码中,规格说明包括了函数签名和一些描述函数功能的注释。咱们将会在接下来的几节课里讨论更多关于规格说明的问题。
先完成测试用例的编写可以让你更好地理解规格说明。规格说明也可能存在问题——不正确、不完整、模棱两可、缺失边界状况。先尝试编写测试用例,能够在你浪费时间实现一个有问题的规格说明以前发现这些问题。
<br />
选择合适的测试用例是一个具备挑战性可是有缺的问题。咱们即但愿测试空间足够小,以便可以快速完成测试,又但愿测试用例可以验证尽量多的状况。
为了达到这个目的,咱们能够先将输入空间划分为几个子域(subdomains) ,每个子域都是一类类似的数据。如上图所示,咱们在每一个子域中选取一些数据,它们合并起来就是咱们须要的输入用例。
分区背后的原理在于同一类型的数据在程序中的行为大多相似,因此咱们能够用一小部分表明总体的行为。这个方法的优势在于强迫程序相应输入空间里的不一样地方,有效的利用了测试资源。
若是咱们要确保测试的输出可以覆盖输出空间的不一样地方,也能够将输出空间划分为几个子域(哪些输出表明程序发生了类似的行为)。大多数状况下,对输入分区就足够了
BigInteger.multiply()
如今让咱们来看一个例子。 BigInteger
是Java库中的一个类,它可以表示任意大小的整数。同时,它有一个multiply
方法,可以对两个BigInteger类型的值进行相乘操做:
/** * @param val another BigInteger * @return a BigInteger whose value is (this * val). */ public BigInteger multiply(BigInteger val)
例如,计算ab的值:
BigInteger a = ...; BigInteger b = ...; BigInteger ab = a.multiply(b);
这个例子显示即便只有一个参数,这个操做实际上有两个操做符:你调用这个方法所在的对象(上面是a
),以及你传入的参数(上面是b
)。(在Python
中,接受方法调用的对象会显式以self
被声明。在Java中你不须要声明这个对象,它隐式的被称做this
)咱们能够把 multiply
当作一个有两个参数的方法,参数的类型是 BigInteger
,而且输出的类型也是 BigInteger
即:
multiply : BigInteger × BigInteger → BigInteger
因此咱们的输入空间是二维的,用二维点阵(a,b)表示。如今咱们对其进行分区,想想乘法是怎么工做的,咱们能够将点阵初步分为如下四个区:
这里也有一些特殊的状况要单独分出来:0 1 -1
最后,做为一个认真的测试员,咱们还要想想BigInteger的乘法多是怎么运算的:它可能在输入数据绝对值较小时使用 int
或 long
,这样运算起来快一些,只有当数据很大时才会使用更费劲的存储方法(例如列表)。因此咱们也应该将对数据的大小进行分区:
Long.MAX_VALUE
,即Java原始整型的最大值,大约是2^63。如今咱们能够将上面划分的区域整合起来,获得最终划分的点阵:
因此咱们一共能够获得 7 × 7 = 49 个分区,它们彻底覆盖了a和b组成的全部输入空间。而后从这个”栅栏“里的每一个区选取各自的测试用例,例如:
max()
如今咱们看看Java库中的另外一个例子:针对整数int
的max()
函数,它属于 Math
类:
/** * @param a an argument * @param b another argument * @return the larger of a and b. */ public static int max(int a, int b)
和上面的例子同样,咱们先分析输入空间:
max : int × int → int (译者注:这里的乘号不表明乘法,而是一种封闭的二元运算关系,参见近世代数)
经过描述分析,咱们能够将其分区为:
因此能够选择如下测试用例:
bug常常会在各个分区的边界处发生,例如:
int
和 double
为何这些边界的地方常常产生bug呢?一个很重要的缘由就是程序员常常犯**“丢失一个(off-by-one mistakes)”**的错误。例如将<=
写成<
,或者将计数器用0来初始化而不是1。另一个缘由就是边界处的值可能须要用特殊的行为来处理,例如当int
类型的变量达到最大值之后,再对其加正整数反而会变成负数。
因此,咱们在分区后,测试用例不要忘了加上边界上的值,如今从新作一下上面那个例子:
max : int × int → int.
分区:
如今咱们再次选取测试用例覆盖上面的分区和边界值:
在分区后,咱们能够选择“尽力(how exhaustive we want)”的程度来测试咱们的分区,这里有两个极限状况:
multiply
中,咱们一共使用了 7 × 7 = 49 个测试用例,每个组合都用上了。对于第二个例子,就会是 3 × 5 × 5 = 75个测试用例。要注意的是,实际上有一些组合是不存在的,例如 a < b, a=0, b=0。max
中只使用了5个测试用例,可是这5个用例覆盖到了咱们的三维输入空间的全部分区。在实际测试中咱们一般在这两个极限中折中,这种折中是基于人们的经验,对代码的获取度(黑白盒测试)、以及对代码的覆盖率,这些咱们会在后面讲到。
分区
思考下面这个规格说明:
/** * Reverses the end of a string. * * 012345 012345 * For example: reverseEnd("Hello, world", 5) returns "Hellodlrow ," * <-----> <-----> * * With start == 0, reverses the entire text. * With start == text.length(), reverses nothing. * * @param text non-null String that will have its end reversed * @param start the index at which the remainder of the input is reversed, * requires 0 <= start <= text.length() * @return input text with the substring from start to the end of the string reversed */ public static String reverseEnd(String text, int start)
对于 start
参数进行测试,下面的哪个分区是合理的 ?
译者注:要特别注意的是,本文谈到的都是对程序正确性进行测试,即输入都是规格说明里面的合法值。至于那些非法的值则是对鲁棒性(robust)或者安全性的测试。
对于 text
参数进行测试,下面的哪个分区是合理的 ?
<br />
一个良好的测试程序应该测试软件的每个模块(方法或者类)。若是这种测试每次是对一个孤立的模块单独进行的,那么这就称为“单元测试”。单元测试的好处在于debug,若是你发现一个单元测试失败了,那么bug极可能就在这个单元内部,而不是软件的其余地方。
JUnit 是Java中一个被普遍只用的测试库,咱们在之后的课程中也会大量使用它。一个JUnit测试单元是以一个方法(method)写出的,其首部有一个 @Test
声明。一个测试单元一般含有对测试的模块进行的一次或屡次调用,同时会用断言检查模块的返回值,好比 assertEquals
, assertTrue
, 和 assertFalse
.i
例如,咱们对上面提到的 Math.max()
模块进行测试,JUnit就能够这样写:
@Test public void testALessThanB() { assertEquals(2, Math.max(1, 2)); } @Test public void testBothEqual() { assertEquals(9, Math.max(9, 9)); } @Test public void testAGreaterThanB() { assertEquals(-5, Math.max(-5, -6)); }
要注意的是 assertEquals
的参数顺序很重要。它的第一个应该是咱们指望的值,一般是一个咱们算好的常数,第二个参数就是咱们要进行的测试。若是你把顺序弄反了,JUnit可能会输出一些奇怪的错误报告。记住, 全部JUnit支持的断言 都要写成这个顺序:第一个是指望值,第二个是代码测试结果。
若是一个测试断言失败了,它会当即返回,JUnit也会记录下此次测试的失败。一个测试类能够有不少 @Test
方法,它们能够各自独立的进行测试,即便有一个失败了,其它的测试也会继续进行。
<br />
如今假设咱们要测试reverseEnd
这个模块:
/** * Reverses the end of a string. * * For example: * reverseEnd("Hello, world", 5) * returns "Hellodlrow ," * * With start == 0, reverses the entire text. * With start == text.length(), reverses nothing. * * @param text non-null String that will have * its end reversed * @param start the index at which the * remainder of the input is * reversed, requires 0 <= * start <= text.length() * @return input text with the substring from * start to the end of the string * reversed */ static String reverseEnd(String text, int start)
咱们应该在测试时记录下咱们的测试策略,例如咱们是如何分区的,有哪些特殊值、边界值等等:
/* * Testing strategy * * Partition the inputs as follows: * text.length(): 0, 1, > 1 * start: 0, 1, 1 < start < text.length(), * text.length() - 1, text.length() * text.length()-start: 0, 1, even > 1, odd > 1 * * Include even- and odd-length reversals because * only odd has a middle element that doesn't move. * * Exhaustive Cartesian coverage of partitions. */
另外,每个测试方法都要有一个小的注解,告诉读者这个测试方法是表明咱们测试策略中的哪一部分,例如:
// covers test.length() = 0, // start = 0 = text.length(), // text.length()-start = 0 @Test public void testEmpty() { assertEquals("", reverseEnd("", 0)); }
假设你在为 max(int a, int b)
写测试,它是属于Math.java
的. 而且你将JUnit测试放在 MathTest.java
文件中.
下面这些文字说明应该分别放在哪里?
关于 a
参数的分区策略
[ ] 写在 Math.java
开头的注释里
[x] 写在 MathTest.java
开头的注释里
[ ] 写在 max()
开头的注释里
[ ] 写在JUnit测试的注释里
属性 @Test
[ ] 在 Math
以前
[ ] 在 MathTest
以前
[ ] 在max()
以前
[x] 在 JUnit 测试以前
注释 “表明 a < b”
[ ] 写在 Math.java
开头的注释里
[ ] 写在 MathTest.java
开头的注释里
[ ] 写在 max()
开头的注释里
[x] 写在JUnit测试的注释里
注释 “@返回a和b的最大值”
[ ] 写在 Math.java
开头的注释里
[ ] 写在 MathTest.java
开头的注释里
[x] 写在 max()
开头的注释里
[ ] 写在JUnit测试的注释里
<br />
回想上面提到的:规格说明是对函数行为的描述——参数类型、返回值类型和对它们的约束条件以及参数和返回值之间的关系。
黑盒测试意味着只依据函数的规格说明来选择测试用例,而不关心函数是如何实现的。这也是到目前为止咱们的例子里一直在作的。咱们在没有看实际代码的状况下分段而且寻找multiply
和max
的边界。
白盒测试 的意思是在考虑函数的实际实现方法的前提下选择测试用例。好比说,若是函数的实现中,对不一样的输入采用不一样的算法,那么你应该根据这些不一样的区域来分类(译者注:将输入分为不一样的类,每类输入将会触发代码实现中的一种处理算法)。若是代码实现中维护一个内部缓存来记录以前获得的输入的答案,那你应该测试重复的输入。
在作白盒测试时。你必须注意:你的测试用例不须要尝试规格说明中没有明确要求的实现行为。例如,若是规格说明中说“若是输入没有格式化,那么将抛出异常”,那么你不该该特意的检查程序是否抛出NullPointerExpection
异常,由于当前的代码实现决定了程序有可能抛出这个异常。在这种状况下,规格说明容许任何异常被抛出,因此你的测试用例一样应该“宽容”地保留实现者的自由。咱们将会在这门课接下来的课时中讨论更多关于规格说明的问题。
黑盒测试 vs. 白盒测试
思考下面这个方法:
/** * Sort a list of integers in nondecreasing order. Modifies the list so that * values.get(i) <= values.get(i+1) for all 0<=i<values.length()-1 */ public static void sort(List<Integer> values) { // choose a good algorithm for the size of the list if (values.length() < 10) { radixSort(values); } else if (values.length() < 1000*1000*1000) { quickSort(values); } else { mergeSort(values); } }
下面哪个是白盒测试中产生的边界值?
values = []
(the empty list)values = [1, 2, 3]
values = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
values = [0, 0, 1, 0, 0, 0, 0]
<br />
一种判断测试的好坏的方法就是看该测试对软件的测试程度。这种测试程度也称为“覆盖率”。如下是常见的三种覆盖率:
if
或 while
等等控制操做,它们的分支都被测试过吗?其中,分支覆盖率要比声明覆盖率严格(须要更多的测试),路径覆盖率要比分支覆盖率严格。在工业界,100%的声明覆盖率一个广泛的要求,可是这有时也是不可能实现的,由于会存在一些“不可能到达的代码”(例若有一些断言)。100%的分支覆盖率是一种很高的要求,对于军工/安全关键的软件可能会有此要求 (e.g., MC/DC, modified condition/decision coverage)。不幸的是,100%的路径覆盖率是不可能的,由于这会让测试用例空间以指数速度增加。
一个标准的方法就是不断地增长测试用例直到覆盖率达到了预约的要求。在实践中,声明覆盖一般用覆盖率工具进行计数。利用这样的工具,白盒测试会变得很容易,你只须要不断地调整覆盖的地方,直到全部重要的声明都被覆盖到。
在Eclipse中有一个好用的代码覆盖率工具 EclEmma 。如上图所示,EclEmma会将被执行过的代码用绿色标出,没有被执行的代码用红色标出。对于一个分支语句,若是它的一个分支一直没有被执行,那么这个分支判断语句会被标为黄色。例如上图中,咱们发现for
循环中的if
语句每一次都是假的,咱们下一步要作的就是调整测试用例使得这个判断能够为真。
使用覆盖率工具
对于如今的Eclipse, EclEmma 已经安装了,咱们直接使用便可。
如今咱们建立一个类 Hailstone.java
public class Hailstone { public static void main(String[] args) { int n = 3; while (n != 1) { if (n % 2 == 0) { n = n / 2; } else { n = 3 * n + 1; } } } }
利用EclEmma 运行main函数, Run → Coverage As → Java Application.并改变n的初始化值,观察EclEmma 标出行颜色的变化。
当n = 3
时,n = n/2
这一行是什么颜色 ?
绿
当n = 16
时,n = 3 * n + 1
这一行是什么颜色 ?
红
当n
的初始值是多少时,行while (n != 1)
会变成黄色 ?
1
<br />
咱们已经讨论过“单元测试”——对孤立的模块进行测试。这使得debugging变得简单,当一个单元测试报错是,咱们只须要在这个单元找bug,而不是在整个程序去找。
与此相对应的,“集成测试”是对于组合起来的模块进行测试,甚至是整个程序。若是集成测试报错,咱们就只能在大的范围去找了。可是这种测试依然是必要的,由于程序常常因为模块之间的交互而产生bug。例如,一个模块的输入是另外一个模块的输出,可是设计者在设计模块的时候将输入输出类型弄错了。另外,若是咱们已经作好单元测试了,即咱们能够确性各个单元独立的正确性,咱们的搜索bug的范围也会小不少。
下面假设你在设计一个搜索引擎。其中有两个模块 getWebPage()
, extractWords()
,其中 getWebPage()
负责下载网页,extractWords()
负责将页面内容拆成一个个词汇:
/** @return the contents of the web page downloaded from url */ public static String getWebPage(URL url) {...} /** @return the words in string s, in the order they appear, * where a word is a contiguous sequence of * non-whitespace and non-punctuation characters */ public static List<String> extractWords(String s) { ... }
而这两个模块又是被另外一个模块 makeIndex()
做为网络爬虫的一部分使用的:
/** @return an index mapping a word to the set of URLs * containing that word, for all webpages in the input set */ public static Map<String, Set<URL>> makeIndex(Set<URL> urls) { ... for (URL url : urls) { String page = getWebPage(url); List<String> words = extractWords(page); ... } ... }
咱们的测试能够分为:
getWebPage()
进行单元测试,输入不一样的 URLsextractWords()
进行单元测试,输入不一样的字符串makeIndex()
进行单元测试,输入不一样的 URLs测试员有时会犯这样一个错误:extractWords()
的测试用例依赖于getWebPage()
的正确性。正如前面所提到的,单元测试应该尽量将模块孤立起来。若是咱们在对extractWords()
的测试输入进行分区后,其值中包含有 getWebPage()
的输出,那么若是getWebPage()
自己就有bug,程序的测试将变得不可信!正确的作法应该是先组建好独立的测试用例,例如一些下载好的网页,将其做为测试用例进行测试。
注意到 makeIndex()
的单元测试并不能彻底孤立,由于咱们在测试它的时候实际上也测试了它调用的模块。若是测试失败,这些bug也可能来自于它调用过的模块之中——这也是为何咱们要先单元测试 getWebPage()
和 extractWords()
,这样一来咱们就能肯定bug出如今连接这些模块的代码之中。
若是咱们要作更高于 makeIndex()
这一层的测试,咱们将它调用的模块写成桩 。例如,一个 getWebPage()
的桩不会真正去访问网页,而是返回一个预先设置好的网页(mock web page),无论参数URL是什么。一个类的桩一般被称为“模拟对象”( mock object)。在构建大型系统的时候桩是一种重要的手段,可是在本门课程中咱们不会使用它。
译者注:关于mocks、stubs、fakes这些概念,能够参考:
<br />
没有什么能比自动化更能让测试简单的东西了。**自动化测试(Automated testing)**是指自动地运行测试对象,输入对应的测试用例,并记录结果的测试。
可以进行自动化测试的代码称做测试驱动(test driver,也被称做test harness 或者 test runner)。一个测试驱动不该该在测试的时候停下来等待你的输入,而是自动调用模块输入测试用例进行测试,最后的结果应该是“测试完成,一切正常”或者“这些测试发了报错:.....”。一个好的测试架构,例如JUnit,容许你构建这样的测试驱动。
注意到自动化测试架构好比JUnit让测试变得简单,可是你仍是要本身去构建好的测试用例。“自动化生成测试用例”是一个很难的问题,目前还处于活跃的研究之中。
要特别注意的是,当你修改你的代码后,别忘了从新运行以前的自动化测试。软件工程师常常遭遇修改大型/复杂程序所带来的痛苦,不管是修改一个bug、增长一个新的功能、优化一段代码的性能,都有可能带来新的问题。不管何时,自动化测试都能保证软件最重要的底线——行为和结果是正确的,即便只是一小段测试。咱们称修改代码带来新的bug的现象为“回归”,而在修改后从新运行全部的测试称为“回归测试”。
一个好的测试应该是能发现bug的,你应该不断的充实你的测试用例。因此不管何时修改了一个bug,记得将致使bug的输入添加到你的测试用例里,并在之后的回归测试中去使用它——毕竟这个bug已经出现了,说明它多是一个很容易犯的错误。
这些思想也是“测试优先debugging”的核心,当bug出现时,马上将触发bug的输入存放到测试用例中,当你修复bug后,再次运行这些 (译者注:注意不只是触发bug的输入)测试,若是它们都经过的话,你的debug也就完成了。
在实践中,自动化测试和回归测试一般结合起来使用。由于回归测试只有自动化才可行(否则大量的测试无法实现)。反过来,若是你已经构建了自动化测试,你一般也会用它来防止回归的发生。因此**自动化回归测试(automated regression testing)**是软件工程里的一个“最佳实践”(best-practice)。
回归测试
如下哪个选项是对回归测试的最好定义 ?
[x] 当你改变代码后应该再次进行测试
[ ] 代码的每个模块都应该有可以彻底测试它的测试
[ ] 测试应该在写代码以前完成,以此来检查你写的规格说明
[ ] 当新的测试报错时,你应该从新运行以前的全部版本的代码直到找到开始引入这个bug的版本。
自动化测试
什么状况下应该从新运行全部的 JUnit 测试?
[x] 在使用 git add/commit/push以前
[x] 在优化一个函数的性能后
[ ] 在使用覆盖率工具时
[x] 在修改一个bug后
测试方法
如下哪一些方法/思想对于“测试优先编程”中未写代码以前选择测试用例是有帮助的?
<br />
在这个reading中,咱们学到了如下知识:
还记得好软件具有的三个属性吗?试着将它们和这篇reading的内容联系起来:
</font>