哈工大软件构造阅读心得3-1: 测试
本文参考:MIT Reading3哈工大学长汉化
注:这个系列是本人看过阅读资料之后,对看过的内容进行的总结
验证
验证的目的就是发现程序中的问题,以此提升你对程序正确性的信心。验证包括:
-
形式推理,即通过理论推理证明程序的正确性。形式推理目前还缺乏自动化的工具,通常需要漫长的手工计算。即使是这样,一些关键性的小程序也是需要被证明的,例如操作系统的调度程序、虚拟机里的字节码解释器,或者是文件系统
-
代码审查。即让别人仔细的阅读、审校、评价你的代码,这也是发现bug的一个常用方法,我们会在下一个reading里面介绍这种方法。
-
测试。即选择合适的输入输出用例,通过运行程序检查程序的问题。
阅读小练习
测试基础
阿丽亚娜5型火箭,为欧洲空间局研发的民用卫星一次性运载火箭,名称来源于神话人物阿丽雅杜妮(Ariadne)的法语拼写。1996年6月4日,在风和日丽的法属圭亚那太空发射场,阿丽亚娜5型运载火箭首航,计划运送4颗太阳风观察卫星到预定轨道。但在点火升空之后的40秒后,在4000米高空,这个价值5亿美元的运载系统就发生了爆炸,瞬间灰飞烟灭化为乌有。
爆炸原因由于火箭某段控制程序直接移植自阿丽亚娜4型火箭,其中一个需要接收64位数据的变量为了节省存储空间而使用了16位字节,从而在控制过程中产生了整数溢出,导致导航系统对火箭控制失效,程序进入异常处理模块,引爆自毁。
这个故事告诉了我们什么?
[x] 即使是高度关键性的程序也可能有bug
[ ] 测试所有可能输入是解决这样的问题的最好办法
[x] 与很多物理工程学上的系统不同,软件的行为是离散的
[ ] 静态检查有助于发现这个bug
注:静态类型检查不会检测到此错误,因为代码有意(强转)将64位精度转换为16位精度。
测试优先编程
测试开始的时间应该尽量早,并且要频繁地测试。当你有一大堆未经验证的代码时,不要把测试工作留到最后。把测试工作留到最后只会让调试的时间更久并且调试过程更加痛苦,因为你的代码将会充斥着bug。反之,如果你在编码的过程中就进行测试,情况就会好的多。
在测试优先编程中,测试程序先于代码完成。编写一个函数应该按如下步骤进行:
-
为函数写一个规格说明。
-
为上一步的规格说明写一些测试用例。
-
编写实际代码。一旦你的代码通过了所有你写的测试用例,这个函数就算完成了。
规格说明描述了这个函数的输入输出行为。它确定了函数参数的类型和对它们的所有约束(例如sqrt函数的参数必须是非负的)。它还定义了函数的返回值类型以及返回值和输入之间的关系。在代码中,规格说明包括了函数签名和一些描述函数功能的注释。我们将会在接下来的几节课里讨论更多关于规格说明的问题。
先尝试编写测试用例,可以在你浪费时间实现一个有问题的规格说明之前发现这些问题。
通过分区的方法选择测试用例
我们可以先将输入空间划分为几个子域(subdomains)
,每一个子域都是一类相似的数据。如上图所示,我们在每个子域中选取一些数据,它们合并起来就是我们需要的输入用例。
例子1: 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 )。我们可以把 multiply
看成一个有两个参数的方法,参数的类型是 BigInteger ,并且输出的类型也是
BigInteger 即:
multiply : BigInteger × BigInteger → BigInteger
所以我们的输入空间是二维的,用二维点阵(a,b)表示。现在我们对其进行分区:
-
a和b都是正整数
-
a和b都是负整数
-
a是正整数,b是负整数
-
b是正整数,a是负整数
这里也有一些特殊的情况要单独分出来:0 1 -1
即a或b是1\0\-1
最后,作为一个认真的测试员,我们还要想一想BigInteger的乘法可能是怎么运算的:它可能在输入数据绝对值较小时使用
int 或 long
,这样运算起来快一些,只有当数据很大时才会使用更费劲的存储方法(例如列表)。所以我们也应该将对数据的大小进行分区:
-
a或b较小
-
a或b的绝对值大于Long.MAX_VALUE ,即Java原始整型的最大值,大约是2^63。
现在我们可以将上面划分的区域整合起来,得到最终划分的点阵:
-
0
-
1
-
-1
-
较小正整数
-
较小负整数
-
大正整数
-
大负整数
所以我们一共可以得到 7 × 7 = 49
个分区,它们完全覆盖了a和b组成的所有输入空间。然后从这个“栅栏”里的每个区选取各自的测试用例。
例子2: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
通过描述分析,我们可以将其分区为:
-
a < b
-
a = b
-
a > b
注意分区之间的“边界”
我们在分区后,测试用例不要忘了加上边界上的值,现在重新做一下上面那个例子:
max : int × int → int.
分区:
-
a与b的关系
-
a < b
-
a = b
-
a > b
-
a的值
-
a = 0
-
a < 0
-
a > 0
-
a = 最小的整数
-
a = 最大的整数
-
b的值
-
b = 0
-
b < 0
-
b > 0
-
b = 最小的整数
-
b = 最大的整数
覆盖分区的两个极限情况
- 完全笛卡尔乘积
即对每一个存在组合都进行测试。例如在第一个例子multiply中,我们一共使用了 7 × 7 =
49 个测试用例,每一个组合都用上了。对于第二个例子,就会是 3 × 5 × 5 =
75个测试用例。要注意的是,实际上有一些组合是不存在的,例如 a < b, a=0, b=0。
- 每一个分区都被覆盖
即每一个分区至少被覆盖一次。例如我们在第二个例子max中只使用了5个测试用例,但是这5个用例覆盖到了我们的三维输入空间的所有分区。
练习
分区
思考下面这个规格说明:
/**
* 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
*/
public static String reverseEnd(String text, int start)
对于 start 参数进行测试,下面的哪一个分区是合理的 ?
[ ] start = 0, start = 5, start = 100
[ ] start < 0, start = 0, start > 0
[x] start = 0, 0 < start < text.length(), start = text.length()
[ ] start < text.length(), start = text.length(), start > text.length()
注:要特别注意的是,本文谈到的都是对程序正确性进行测试,即输入都是规格说明里面的合法值。至于那些非法的值则是对鲁棒性(robust)或者安全性的测试。
对于 text 参数进行测试,下面的哪一个分区是合理的 ?
[ ] text 包含一些数字; text不包含字母, 但是包含一些数字; text
既不包含字母,也不包含数字
[ ] text.length() = 0; text.length() > 0
[x] text.length() = 0; text.length()-start 是奇数; text.length()-start 是偶数
注,这个选项是第二个的超集,多的地方在于奇数偶数的判断,原因在于如果一个字符串字符的个数是奇数个,那么中间的那个字符就不需要移动位置了,这可能需要特殊的行为来处理,也可能是bug产生的原因
[ ] 测试0到100个字符的所有字符串
用JUnit做自动化单元测试
一个良好的测试程序应该测试软件的每一个模块(方法或者类)。如果这种测试每次是对一个孤立的模块单独进行的,那么这就称为“单元测试”。单元测试的好处在于debug,如果你发现一个单元测试失败了,那么bug很可能就在这个单元内部,而不是软件的其他地方。
一个JUnit测试单元是以一个方法(method)写出的,其首部有一个
@Test声明。一个测试单元通常含有对测试的模块进行的一次或多次调用,同时会用断言检查模块的返回值,比如
assertEquals,assertTrue和 assertFalse。
例如,我们对上面提到的 Math.max() 模块进行测试,JUnit就可以这样写:
要注意的是assertEquals的参数顺序很重要。它的第一个应该是我们期望的值,通常是一个我们算好的常数,第二个参数就是我们要进行的测试。记住,所有JUnit支持的断言都要写成这个顺序:第一个是期望值,第二个是代码测试结果。
如果一个测试断言失败了,它会立即返回,JUnit也会记录下这次测试的失败。一个测试类可以有很多
@Test
方法,它们可以各自独立的进行测试,即使有一个失败了,其它的测试也会继续进行。
写测试策略
现在假设我们要测试reverseEnd这个模块:
我们应该在测试时记录下我们的测试策略,例如我们是如何分区的,有哪些特殊值、边界值等等:
另外,每一个测试方法都要有一个小的注解,告诉读者这个测试方法是代表我们测试策略中的哪一部分,例如:
阅读
假设你在为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测试的注释里