代码改变世界

单元测试实战(二):初体验

2008-04-14 20:14 Anders Cui 阅读(...) 评论(...) 编辑 收藏

上篇文章中提到过,单元测试是程序员编写的代码,用于验证某段代码的行为是否与开发者所期望的一致,并且说明了它的重要性,现在是时候来看看在实际开发中如何去做了。

为了验证代码行为是否与我们所期望的一致,就需要使用断言(assertion)。断言就是对待测代码的结果进行检查,判断是否与期望的一致。比如下面的IsTrue方法,它检查给定的条件是否为真:如果非真,则断言失败,程序中止。

public void IsTrue(bool condition)
{
    if (!condition)
    {
        Abort();
    }
}

比如,有如下代码:

int a = 2;

IsTrue(a == 2);

如果a不等于2,那么程序会退出。不难理解,所有类似的断言都可以使用此方法。但情况却不都像a == 2这样简单,我们要断言的值可能是浮点数、字符串、集合等等很多情况,如果都使用IsTrue,那么在调用前我们都要考虑如何进行比较,而且我们的处理也不止程序中止这种方式,好像很复杂啊。

如果有一个专门处理这些断言的工具多好啊。很幸运,已经有了,我们可以选择NUnit或者MbUnit。我们这里先看看NUnit,它是.NET这边最正统的单元测试工具。NUnit提供了自己的GUI工具,我们可以通过该工具测试,但是有更好的选择——TestDriven.NET,有了它,就不需要在开发环境和测试工具间来回切换了,单元测试在VS内就可以完成。

在NUnit中,有个Assert类,它提供了多种断言方式,可以满足大部分需要了。比如针对两个整数是否相等的断言,可以使用Assert.AreEqual,我们没必要写自己的IsTrue方法了,只要调用:Assert.AreEqual(2, a)即可。

好,现在从一个简单的例子开始,看一下如何使用NUnit进行单元测试。

计划你的测试

我们考虑如下一个方法,它查找一个int数组中的元素的最大值:

static int Largest(int[] array);

给定一个数组[7, 8, 9],该方法应该返回9(这就是我们的期望值)。等一下,你有没有想到其它的测试呢?思考一分钟再往下看。

首先,元素的位置应与返回值无关,所以可想到如下测试:

l [7, 8, 9] -> 9

l [8, 9, 7] -> 9

l [9, 7, 8] -> 9

接下来,如果数组中有两个相等的最大值,该是如何?

l [7, 9, 8, 9] -> 9

还有,如果数组只有一个元素,会是怎样?

l [1] -> 1

别忘了整数包含负数呢:

l [-9, -8, -7] -> -7

还不写代码啊,我早就想到它的实现方法了:

public static int Largest(int[] array)
{
    int index, max = Int32.MaxValue;
    for (int index = 0; index < array.Length - 1; index++)
    {
        if (array[index] > max)
        {
            max = array[index];
        }
 
        return max;
    }
}

测试一个简单的方法

我们来给上面的方法编写一个测试用例(Test Case):

[TestFixture]
public class MyUtilTest
{
    [Test]
    public void LargestOf3()
    {
        Assert.AreEqual(9, MyUtil.Largest(new int[]{8, 9, 7}));
    }
}

好,激动人心的时刻到了,运行下看看。

不好,测试没通过,出现了下面的信息

TestCase 'Ch02.MyUtilTest.LargestOf3' failed: 
  Expected: 9
  But was:  2147483647
    D:\myWorks\VS2008\Consoles\UnitTestingInAction\Ch02\MyUtilTest.cs(22,0): at Ch02.MyUtilTest.LargestOf3()

结果与期望不一致,输入的是7、8、9,怎么会返回那么大的数字呢?看看代码,max变量的初始值为Int32.MaxValue,这就不对了,如果改成0的话就对了。此时测试确实是通过的。

上面我们想到了很多测试还没做呢,先考虑最大值的位置,这个跟结果应当是无关的。

再添加两个断言,代码还是写在刚才的测试用例中。

Assert.AreEqual(9, MyUtil.Largest(new int[] { 8, 9, 7 }));
Assert.AreEqual(9, MyUtil.Largest(new int[] { 9, 8, 7 }));
Assert.AreEqual(9, MyUtil.Largest(new int[] { 7, 8, 9 }));

现在该通过了,运行测试。

晕,又没通过:

TestCase 'Ch02.MyUtilTest.LargestOf3' failed: 
  Expected: 9
  But was:  8
    D:\myWorks\VS2008\Consoles\UnitTestingInAction\Ch02\MyUtilTest.cs(24,0): at Ch02.MyUtilTest.LargestOf3()

第三个断言失败,最大值是8,你的直觉是什么?是不是好像9根本没执行到呢?检查下for循环:

for (index = 0; index < array.Length - 1; index++)

通常我们会写index < array.Length,这里显然少循环了一次,这是个事故多发地带,这个错误被称为“off-by-one”错误,如果使用foreach语句就不存在这个问题了。

现在测试终于通过了,再看看重复最大值和单一元素的断言:

[Test]
public void TestDups()
{
    Assert.AreEqual(9, MyUtil.Largest(new int[] { 8, 9, 7, 9 }));
}
 
[Test]
public void TestOne()
{
    Assert.AreEqual(1, MyUtil.Largest(new int[] { 1 }));
}

至此一切正常,Yeah!等等,如果元素是负数呢?

Assert.AreEqual(-7, MyUtil.Largest(new int[] { -9, -8, -7 }));
TestCase 'Ch02.MyUtilTest.TestNegative' failed: 
  Expected: -7
  But was:  0
    D:\myWorks\VS2008\Consoles\UnitTestingInAction\Ch02\MyUtilTest.cs(42,0): at Ch02.MyUtilTest.TestNegative()

0是哪里来的?看来又是初始值的问题,0要大于负数,看来初始值必须是一个最小的整数,它就是Int32.MinValue,这样就可以了。

最后,如果array为空(长度为0)怎么办?这个时候最大值是无意义的,返回任何值都不合适,应当抛出一个异常,代码修改如下:

int index, max = Int32.MinValue;
if (array.Length == 0)
{
    throw new ArgumentException("largest: Empty array.");
}
 
for (index = 0; index < array.Length; index++)

注意,代码在设计上发生了改动,改善设计正是单元测试的好处之一。

为之编写测试用例:

[Test, ExpectedException(typeof(ArgumentException))]
public void TestEmpty()
{
    MyUtil.Largest(new int[] { });
}

注意,这个方法的特性中,除了Test,还多了个ExpectedException,与前面的用例不同,我们期望的正是异常

小结

本文通过一个简单的例子描述了单元测试的过程,从此我们也可以编写测试用例了,对其有了初步的认识。其中的过程有些繁琐,也许你会问,这么一个简单的方法值得花费这么大的力气吗?答案是肯定的,单元测试保证了程序在当前的质量,而在维护时会体现出更大的价值。

文中用到了NUnit和TestDriven.NET两个工具,其详细用法可以在园子里搜一下,在后续文章中我也不想再写相关内容了。从下一篇开始将逐步深入地讨论单元测试的实施过程。