Chinaunix首页 | 论坛 | 博客
  • 博客访问: 1798391
  • 博文数量: 438
  • 博客积分: 9799
  • 博客等级: 中将
  • 技术积分: 6092
  • 用 户 组: 普通用户
  • 注册时间: 2012-03-25 17:25
文章分类

全部博文(438)

文章存档

2019年(1)

2013年(8)

2012年(429)

分类: Java

2012-03-27 13:22:47

Alberto Savoia

通常,如果一段代码能优雅、简洁地完成了需要完成的功能,我们就认为这样的代码很漂亮。但测试代码的漂亮如何判定呢?Alberto认为:


1、测试因简单而漂亮。


2、测试因揭示出使代码更优雅,更可维护和更易测试的方法而漂亮。


3、测试因其深度和广度而漂亮。

这章我们来研究一下对二分查找法的测试。一个有bug的二分查找法的Java实现为:


  1. public static int buggyBinarySearch(int[] a, int target) {
  2.     int low = 0;
  3.     int high = a.length - 1;

  4.     while (low <= high) {
  5.         int mid = (low+high)/2;
  6.         int midVal = a[mid];

  7.         if (midVal < target)
  8.             low = mid + 1;
  9.         else
  10.             high = mid - 1;
  11.         else
  12.             return mid;
  13.     }
  14. }

上面代码的bug隐藏在"int mid = (low+high)/2;"这行。如果low+high的值大于Integer.MAX_VALUE(2^31-1),它会溢出成为一个负数,这样使得 mid的值也成为一个负数。一种解决方法是用减法而不是加法来求中间值:


int mid = low + (high-low)/2;


另一种解决方法就是使用无符号位移运算:
int mid = (low+high) >>> 1;

这个bug的发现,警示着我们要懂得谦逊:哪怕是最简单的一段代码,要写正确也并非易事,更别提现实世界中的大段大段的复杂代码。

在改正掉整数溢出的bug后,二分查找法看起来已经是没问题了:


  1. public static int binarySearch(int[] a, int target) {
  2.     int low = 0;
  3.     int high = a.length - 1;

  4.     while (low <= high) {
  5.         int mid = low+high >>> 1;
  6.         int midVal = a[mid];

  7.         if (midVal < target)
  8.             low = mid + 1;
  9.         else
  10.             high = mid - 1;
  11.         else
  12.             return mid;
  13.     }
  14. }

但它是否还会有其它的bug呢?通过整数溢出的bug的教训,我们并不能百分之百的确定。这时,我们就需要一个强壮的测试来建立我们信心。

Alberto决定使用JUnit来测试上面这段二分查找算法的代码,他的测试策略为:


1、从冒烟测试开始;


2、增加边界测试;


3、继续其他各种彻底的、全覆盖类型的测试;


4、最后,添加一些性能测试。



首先进行冒烟测试(smoke test)。这种测试用来确保代码在最基本的使用方式下能够正常工作。这是最基本的 测试,如果一个实现连冒烟测试都无法通过,就无需在进一步的测试了。通常我们应该在编写代码前就编写冒烟测试,这种开发方式称为测试驱动开发(test- driven development)。下面是二分查找的冒烟测试


  1. import static org.junit.Assert.*;
  2. import org.junit.Test;

  3. publid class BinarySearchSmokeTest {
  4.     @Test
  5.     public void smokeTestsForBinarySearch() {
  6.         int[] arrayWith42 = new int[] {1, 4, 42, 55, 67, 87, 100, 245};
  7.         assertEquals(2, Util.binarySearch(arrayWith42, 42);
  8.         assertEquals(-1, Util.binarySearch(arrayWith42, 43);
  9.     }
  10. }

可以看到,这个测试真的很基本。通常情况下,我们可以写多个冒烟测试,并把它们组成一个测试组(suite),并在每次提交代码前运行它们。我们可能会有几千个冒烟测试,所以使每个冒烟测试都尽可能地小就是很重要的事了。

接下来,就可以进行边界测试(boundary test)了。这种测试用来处理极端或偏门的情况。
首先能想到的边界情况就是数组的大小,如果它是空的或只有一个元素会发生什么?下面的代码对这极端情况进行测试:


  1. int[] testArray;

  2. @Test
  3. public void searchEmptyArray() {
  4.     testArray = new int[] {};
  5.     assertEquals(-1, Util.binarySearch(testArray, 42));
  6. }

  7. @Test
  8. public void searchArrayOfSizeOne() {
  9.     testArray = new int[] {42};
  10.     assertEquals(0, Util.binarySearch(testArray, 42));
  11.     assertEquals(-1, Util.binarySearch(testArray, 43));
  12. }

我们还要考虑数组很大的数组,大到high+low可以发生溢出,这种情况下需要一个长度超过10亿的数组,会导致测试不容易进行。所以我们深入分析下, 确定我们现在需要确保的,其实只是中间值被正确的计算。为了使测试更容易进行,我们需要修改二分查找法的实现代码(的某行):
int mid = calculateMidpoint(low, high);
其中calculateMidpoint的实现为:


  1. static int calculateMidpint(int low, int high) {
  2.     return (low + high) >>> 1;
  3. }

这样我们就可以把测试精确定位到这个新增的函数里了。


不要为修改实现代码感到不安,因为这也应证了测试可以改进实现代码。通过上面的新增函数,程序可读性提高了。而由于编译器的优化,这个函数会被内联(inline),所以性能并不会有什么影响。
下面是对calculateMidpoint的测试:


  1. @Test
  2. public void calculateMidpointWithBoundaryValues() {
  3.     assertEquals(0, calculateMidpoint(0, 1));
  4.     assertEquals(1, calculateMidpoint(0, 2));
  5.     assertEquals(1200000000, calculateMidpoint(1100000000, 130000000));
  6.     assertEquals(Integer.MAX_VALUE-2, calculateMidpoint(Integer.MAX_VALUE-2, Integer.MAX_VALUE-1));
  7.     assertEquals(Integer.MAX_VALUE-1, calculateMidpoint(Integer.MAX_VALUE-1, Integer.MAX_VALUE));
  8. }

接下来要考虑的边界情况是元素为负数或0的情况:


  1. @Test
  2. public void testBoundaryCasesForItemsLocation() {
  3.     testArray = new int[] {-324, -3, -1, 0, 42, 99, 101};
  4.     assertEquals(0, Util.binarySearch(testArray, -324)); // first position
  5.     assertEquals(3, Util.binarySearch(testArray, 0)); // middle position
  6.     assertEquals(6, Util.binarySearch(testArray, 101)); // last position
  7. }

另一种边界情况是最大和最小整数值:


  1. public void testForMinAndMaxInteger() {
  2.     testArray = new int[] {Integer.MIN_VALUE, -324, -3, -1, 0, 42, 99, 101, Integer.MAX_VALUE};
  3.     assertEquals(0, Util.binarySearch(testArray, Integer.MIN_VALUE));
  4.     assertEquals(8, Util.binarySearch(testArray, Integer.MAX_VALUE));
  5. }

现在所有能想到的边界情况都被测试过了,我们对实现代码的已经非常有信心了。但是我们至今为止的测试过于具体,并没有对所有的输入进行一个全面的覆盖。这时我们需要随机测试,它包括:
1、一种能产生各种不同特征的、大数据量的输入集合的方法;
2、一组能通用于各种的输入集合的断言(assertion)。

下面先来实现第一种需求:


  1. public int[] generateRandomSortedArray(int maxArraySize, int maxValue) {
  2.     int arraySize = 1 + rand.nextInt(maxArraySize);
  3.     int[] randomArray = new int[arraySize];
  4.     for (int i = 0; i < arraySize; i++)
  5.         randomArray[i] = rand.nextInt(maxValue);
  6.     Array.sort(randomArray);
  7.     return randomArray;
  8. }

接下来要实现第二种需求,我们需要找到所有输入和(正确的)输出之间的共性。经过分析,我们可以得到下面的推论:


1、如果binarySearch(testArray, target)返回-1,那么testArray不包含元素target;


2、如果binarySearch(testArray, target)返回n(n>=0),那么testArray包含元素target,且在数组中的位置是n。


根据上面的推论,我们就可以写测试代码了:


  1. public class BinarySearchTestTheories {

  2. Random rand;

  3. @Before
  4. public void initialize() {
  5.     rand = new Random();
  6. }

  7. @Test
  8. public void testTheories() {
  9.     int maxArraySize = 1000;
  10.     int maxValue = 1000;
  11.     int experiments = 1000;
  12.     int[] testArray;
  13.     int target;
  14.     int returnValue;

  15.     while (experiments-- > 0) {
  16.         testArray = generateRandomSortedArray(maxArraySize, maxValue);
  17.         if (rand.nextBoolean())
  18.             target = testArray[rand.nextInt(testArray.length)];
  19.         else
  20.             target = rand.nextInt();
  21.         returnValue = Util.binarySearch(testArray, target);
  22.         assertTheory1(testArray, target, returnValue);
  23.         assertTheory2(testArray, target, returnValue);
  24.     }
  25. }

  26. public void assertTheory1(int[] testArray, int target, int returnValue) {
  27.     if (returnVAlue == -1)
  28.         assertFalse(arrayContainsTarget(testArray, target));
  29. }

  30. public void assertTheory2(int[] testArray, int target, int returnValue) {
  31.     if (returnValue >= 0)
  32.         assertEquals(target, testArray[returnValue]);
  33. }

  34. public boolean arrayContainsTarget(int[] testArray, int target) {
  35.     for (int i = 0; i < testArray.length; i++)
  36.         if (testArray[i] == target)
  37.             return true;
  38.     return false;
  39. }

  40. }

上面我们根据返回值来推导输入的情况,但这其实只是测试了一半。我们在测试时需要进行逆向思维,在验证充分条件的时候,还要验证必要条件,即对于给定的输入,它的返回值也必定是我们的期望值。比如,下面修改的(被破坏的)二分法的代码:


  1. public static int binarySearch(int[] a, int target) {
  2.     int low = 0;
  3.     int high = a.length - 1;

  4.     while (low <= high) {
  5.         int mid = (low+high)/2;
  6.         int midVal = a[mid];

  7.         if (midVal < target)
  8.             low = mid + 1;
  9.         else
  10.             high = mid - 1;
  11.         else
  12.             return mid;
  13.     }
  14.     if (target <= 424242)
  15.         return -1;
  16.     else
  17.         return -42;
  18. }

可以看到,在没找到的情况下,如果要找的目标元素不大于424242,那会正常返回-1;但如果元素值超过了424242,它会返回一个我们不期望的值。 很明显这个修改后的实现代码可以通过上面的随机测试,但是它却是错误的。为此,我们要填补另一半的测试,所以我们给出下面的另两个推论:


3、如果testArray不包含target,它就必须返回-1;


4、如果testArray在位置n上包含target,那么binarySearch(testArray, target)必须返回n。


下面是证明上两个推论的代码:


  1. public void assertTheory3(int[] testArray, int target, int returnValue) {
  2.     if (!arrayContainsTarget(testArray, target)
  3.         assertEquals(-1, returnValue);
  4. }

  5. public void assertTheory4(int[] testArray, int target, int returnValue) {
  6.     assertEquals(getTargetPosition(testArray, target), returnValue);
  7. }

  8. public int getTargetPosition(int[] testArray, int target) {
  9.     for (int i = 0; i < testArray.length; i++)
  10.         if (testArray[i] == target)
  11.             return i;
  12.     return -1;
  13. }

  14. public boolean arrayContainsTarget(int[] testArray, int target) {
  15.     return getTargetPosition(testArray, target) >= 0;
  16. }

在测试时会发现,推理4有时候会被违反:如果数组里有重复元素如{2, 11, 36, 66, 104, 108, 108, 108, 122, 155, 159, 161, 191},target=108,期望值为5但实际的得到的值是6。这是我们测试代码的问题,为此,我们修改需要修改推理4使它将重复值纳入考量。

通过前面的测试,我们已经很确信我们的代码是正确的了。但是,它的性能如何呢?为此,我们需要进行性能测试。基于二分法的特性,我们可以得到:
推理5:如果testArray的长度为n,那么二分法的比较次数必须小于或等于1+log_2(n)。
下面是对应推理5的代码:


  1. public static binarySearchComparisonCount(int[] a, target) {
  2.     int low = 0;
  3.     int high = a.length - 1;

  4.     while (low <= high) {
  5.         comparisonCount++;

  6.         int mid = (low+high)/2;
  7.         int midVal = a[mid];

  8.         if (midVal < target)
  9.             low = mid + 1;
  10.         else
  11.             high = mid - 1;
  12.         else
  13.             return comparisonCount;
  14.     }
  15.     return comparisonCount;
  16. }

  17. public void assertTheory5(int[] testArray, int target) {
  18.     int numberOfComparisons = Util.binarySearchComparisonCount(testArray, target);
  19.     assertTrue(numberOfComparisons <= 1 + log2(testArray, length));
  20. }

Alberto总结到
1、有些测试因为简单和高效而漂亮;


2、有些测试因为可以改善变测代码的质量而漂亮;


3、有些测试因为它们的覆盖面和彻底性而漂亮。


:Agitar Software的CTO。著有《The Way of Testivus》。

阅读(847) | 评论(0) | 转发(0) |
给主人留下些什么吧!~~