测试驱动/示例优先开发






4.79/5 (27投票s)
一个逐步的示例,说明如何通过先编写单元测试来构建算法。
引言
关于测试驱动开发,特别是关于应该先编写测试的理念,已经写了很多。这是我努力追求的理想。然而,我倾向于稍后编写单元测试。
有些人通过示例能学得更好。本文不打算深入探讨测试驱动开发的原理,而是引导读者完成一个算法的构建和测试过程:先编写测试,然后修改被测试的方法使其满足测试。
最终代码和所有单元测试都包含在随附的下载文件中。这需要 NUnit 和 Visual Studio 2005。本示例中 NUnit 的使用纯属偶然,同样也可以使用 Visual Studio Team System 中的单元测试框架或任何其他单元测试框架。本示例展示了如何构建一套测试,而不是如何使用单元测试框架。假定读者已了解单元测试框架的基本使用方法。
样本问题
我曾经看到一个演示,展示了如何预先创建简单的单元测试。该方法接受一个正整数并将其转换为罗马数字。所以,我将做类似的事情。我将把一个整数转换为英文单词。这的规则可能会因语言而异,所以如果英语不是您唯一的语言,您可能会想尝试用另一种语言重复这个练习。
所以,如果整数是1
,结果将是“one
”。如果整数是23
,结果将是“twenty three
”等等。另外请注意,我将使用英式或英联邦英语。所以,对于101
,单词结果是“one hundred and one
”。在美式英语中,则为“one hundred one
”。
演练
该算法还将通过重构技术进行改进。敏捷开发方法论,特别是 eXtreme Programming,建议您做最简单的事情来使事物正常工作。所以,基于这个前提,我将分步处理解决方案。首先,让它返回“one
”,然后根据输入返回“one
”或“two
”,依此类推。一旦达到21
,就会明显看到可以进行一些重构,依此类推。最终解决方案将仅适用于 32 位整数。
入门
Visual Studio 2005 具有一些有助于先编写测试的功能。可以编写一个调用被测类的测试,智能标签会提示您是否要为您创建消息存根。
存根看起来是这样的
public static string NumberToEnglish(int p)
{
throw new Exception("The method or operation is not implemented.");
}
如果测试完成,看起来是这样的
[Test]
public void NumberToEnglishShouldReturnOne()
{
string actual = English.NumberToEnglish(1);
Assert.AreEqual("one", actual, "Expected the result to be \"one\"");
}
测试应该会失败,因为存根抛出异常,而不是执行测试期望的操作。
NUnit 报告错误如下
"NumbersInWords.Test.EnglishTest.NumberToEnglishShouldReturnOne :
System.Exception :
The method or operation is not implemented.".
接下来的任务是确保代码满足单元测试的要求。敏捷开发方法论,如 XP,建议只做最简单的更改来满足当前需求。在这种情况下,被测试的方法将被更改为如下内容
public static string NumberToEnglish(int number)
{
return "one";
}
此时,重新运行单元测试,它们都通过了。
测试“two”
由于整体要求是将任何整数转换为单词,下一个测试应该测试 2 可以被翻译。测试看起来是这样的
[Test]
public void NumberToEnglishShouldReturnTwo()
{
string actual = English.NumberToEnglish(2);
Assert.AreEqual("two", actual, "Expected the result to be \"two\"");
}
然而,由于此时被测试的方法无论输入如何都返回“one”,因此测试失败
NumbersInWords.Test.EnglishTest.NumberToEnglishShouldReturnTwo :
Expected the result to be "two"
String lengths are both 3.
Strings differ at index 0.
expected: <"two">
but was: <"one">
------------^
同样,遵循最简单的更改原则,代码更新为如下内容
public static string NumberToEnglish(int number)
{
if (number == 1)
return "one";
else
return "two";
}
测试现在通过了。
测试“three”到“twenty”
现在可以编写第三个测试。它测试输入为3
和期望返回“three
”。很自然,此时测试失败。代码再次更新,现在看起来是这样的
public static string NumberToEnglish(int number)
{
switch (number)
{
case 1:
return "one";
case 2:
return "two";
default:
return "three";
}
}
为了节省篇幅,新的测试和相应的更新继续进行,直到处理完1
到20
之间的数字。代码最终会看起来像这样
public static string NumberToEnglish(int number)
{
switch (number)
{
case 1:
return "one";
case 2:
return "two";
case 3:
return "three";
case 4:
return "four";
case 5:
return "five";
case 6:
return "six";
case 7:
return "seven";
case 8:
return "eight";
case 9:
return "nine";
case 10:
return "ten";
case 11:
return "eleven";
case 12:
return "twelve";
case 13:
return "thirteen";
case 14:
return "fourteen";
case 15:
return "fifteen";
case 16:
return "sixteen";
case 17:
return "seventeen";
case 18:
return "eighteen";
case 19:
return "nineteen";
default:
return "twenty";
}
}
测试“twenty one”到“twenty nine”
此时,执行21
似乎很容易,但一个模式即将出现。在为21
和22
编写测试之后,代码被重构为如下内容
public static string NumberToEnglish(int number)
{
if (number < 20)
return TranslateOneToNineteen(number);
if (number == 20)
return "twenty";
return string.Concat("twenty ", TranslateOneToNineteen(number - 20));
}
private static string TranslateOneToNineteen(int number)
{
switch (number)
{
case 1:
return "one";
case 2:
return "two";
case 3:
return "three";
case 4:
return "four";
case 5:
return "five";
case 6:
return "six";
case 7:
return "seven";
case 8:
return "eight";
case 9:
return "nine";
case 10:
return "ten";
case 11:
return "eleven";
case 12:
return "twelve";
case 13:
return "thirteen";
case 14:
return "fourteen";
case 15:
return "fifteen";
case 16:
return "sixteen";
case 17:
return "seventeen";
case 18:
return "eighteen";
default:
return "nineteen";
}
}
现在,从1
到22
的所有测试都通过了。23
到29
可以假定工作正常,因为它使用了经过充分测试的逻辑。
测试“thirty”到“thirty nine”
30
是另一个故事。测试将像这样失败
NumbersInWords.Test.EnglishTest.NumberToEnglishShouldReturnThirty :
Expected the result to be "thirty"
String lengths differ. Expected length=6, but was length=10.
Strings differ at index 1.
expected: <"thirty">
but was: <"twenty ten">
-------------^
通过遵循“做最简单可行的事情”的原则,public
方法更改为
public static string NumberToEnglish(int number)
{
if (number < 20)
return TranslateOneToNineteen(number);
if (number == 20)
return "twenty";
if (number <= 29)
return string.Concat("twenty ", TranslateOneToNineteen(number - 20));
return "thirty";
}
自然,31
的测试将失败
NumbersInWords.Test.EnglishTest.NumberToEnglishShouldReturnThirtyOne :
Expected the result to be "thirty one"
String lengths differ. Expected length=10, but was length=6.
Strings differ at index 6.
expected: <"thirty one">
but was: <"thirty">
------------------^
所以代码再次被更改。这次是
public static string NumberToEnglish(int number)
{
if (number < 20)
return TranslateOneToNineteen(number);
if (number == 20)
return "twenty";
if (number <= 29)
return string.Concat("twenty ", TranslateOneToNineteen(number - 20));
if (number == 30)
return "thirty";
return string.Concat("thirty ", TranslateOneToNineteen(number - 30));
}
测试“forty”到“ninety nine”
40
的测试将失败
NumbersInWords.Test.EnglishTest.NumberToEnglishShouldReturnForty :
Expected the result to be "forty"
String lengths differ. Expected length=5, but was length=10.
Strings differ at index 0.
expected: <"forty">
but was: <"thirty ten">
------------^
必要的代码更改开始显现模式。当然,这个模式本来可以很容易地预测到,但由于这个代码是按照“只做最简单的更改”的规则构建的,所以模式必须先出现才能采取行动。
模式会一直重复,直到达到99
。此时,public
方法看起来是这样的
public static string NumberToEnglish(int number)
{
if (number < 20)
return TranslateOneToNineteen(number);
int units = number % 10;
int tens = number / 10;
string result = "";
switch (tens)
{
case 2:
result = "twenty";
break;
case 3:
result = "thirty";
break;
case 4:
result = "forty";
break;
case 5:
result = "fifty";
break;
case 6:
result = "sixty";
break;
case 7:
result = "seventy";
break;
case 8:
result = "eighty";
break;
default:
result = "ninety";
break;
}
if (units != 0)
result = string.Concat(result, " ", TranslateOneToNineteen(units));
return result;
}
测试“one hundred”
100
的测试将失败。失败消息是
NumbersInWords.Test.EnglishTest.NumberToEnglishShouldReturnOneHundred :
Expected the result to be "one hundred"
String lengths differ. Expected length=11, but was length=6.
Strings differ at index 0.
expected: <"one hundred">
but was: <"ninety">
------------^
对public
方法的快速更改允许测试通过
public static string NumberToEnglish(int number)
{
if (number == 100)
return "one hundred";
if (number < 20)
return TranslateOneToNineteen(number);
// Remainder omitted for brevity
}
测试“one hundred and one”到“one hundred and ninety nine”
101
怎么样?那个测试失败如下
NumbersInWords.Test.EnglishTest.NumberToEnglishShouldReturnOneHundredAndOne :
Expected the result to be "one hundred and one"
String lengths differ. Expected length=19, but was length=10.
Strings differ at index 0.
expected: <"one hundred and one">
but was: <"ninety one">
------------^
此时,应该很容易看出,之前完成的一些工作可以被重用,只需少量重构。首先,将public
方法的大部分主体重构到一个名为TranslateOneToNinetyNine
的类中。然后,重新测试以确保重构过程没有引入任何新问题。
在 Visual Studio 2005 中,很容易突出显示某些代码并将其提取到一个新方法中,从而允许它通过从多个位置调用来实现重用。
现在public
方法如下所示,并且所有先前成功的测试仍然是成功的。
public static string NumberToEnglish(int number)
{
if (number == 100)
return "one hundred";
return TranslateOneToNinetyNine(number);
}
对于101
到199
之间的数字,模式是“one hundred and X
”,其中X
是1
到99
之间的翻译结果。由于编写所有这些测试会花费太长时间,因此可以只编写边缘情况和范围中间的一两个样本。这应该足以建立信心继续前进。在这种情况下,测试是针对101
、115
、155
和199
的。
然后重写代码以支持这些测试
public static string NumberToEnglish(int number)
{
if (number < 100)
return TranslateOneToNinetyNine(number);
if (number == 100)
return "one hundred";
string result = string.Concat("one hundred and ",
TranslateOneToNinetyNine(number - 100));
return result;
}
测试“two hundred”
从这一点开始,需要做什么的模式应该很明显。因此,中间步骤的细节被跳过,直到所有正整数都可以被写出来。但是,如果您想阅读这些中间步骤,您可以在我的网站上阅读本文的完整未删节版。
整数(Int32
)的限制意味着本节达到了 2147483647 的上限。除非使用Int64
,否则无法继续到万亿级别。
最后阶段
到目前为止,所有正整数都已成功从整数转换为单词字符串。此时,通过代码重用,将代码重构为处理负数和零应该是一个相当简单的事情。
零足够简单。单元测试被放置到位
[Test]
public void NumberToEnglishShouldReturnZero()
{
string actual = English.NumberToEnglish(0);
Assert.AreEqual("zero", actual, "Expected the result to be \"zero\"");
}
它立即失败,因为没有代码支持它。public
方法被更改为在开始时进行快速检查
public static string NumberToEnglish(int number)
{
if (number == 0)
return "zero";
if (number < 1000000000)
return TranslateOneToNineHundredAndNintyNineMillion...(number);
int billions = number / 1000000000;
string result = string.Concat(TranslateOneToNineteen(billions), " billion");
int remainder = number % 1000000000;
if (remainder == 0)
return result;
if (remainder < 100)
return string.Concat(result, " and ", TranslateOneToNinetyNine(remainder));
return string.Concat(result, " ",
TranslateOneToNineHundredAndNintyNineMillion...(remainder));
}
现在测试通过了。接下来是允许负数。
[Test]
public void NumberToEnglishShouldReturnNegativeOne()
{
string actual = English.NumberToEnglish(-1);
Assert.AreEqual("negative one", actual, "Expected the result to be
\"negative one\"");
}
这失败了,所以代码被重构为这样
public static string NumberToEnglish(int number)
{
if (number == 0)
return "zero";
string result = "";
if (number < 0)
result = "negative ";
int absNumber = Math.Abs(number);
return string.Concat(result,
TranslateOneToTwoBillion...SixHundredAndFortySeven(absNumber));
// Method call above contracted for brevity
}
然后测试通过了。但是最后的边缘情况呢?int.MinValue
?测试已经编写,但它失败了。原因是Math.Abs(int.MinValue)
是不可能的。所以,因为这是一个一次性的案例,最简单的解决方案是在public
方法中放入一个特殊情况
// Special case for int.MinValue.
if (number == int.MinValue)
return "negative two billion one hundred and forty seven million " +
"four hundred and eighty three thousand six hundred and forty eight";
结论
本文演示了如何分小步进行单元测试和构建代码。测试不断证明开发人员走在正确的道路上。随着代码的构建,不断重新运行现有测试证明任何新增强功能都不会破坏现有代码。如果出于任何原因,以后发现代码中存在错误,可以轻松创建一个测试来执行该错误代码并进行修复。
完整的最终代码以及一套 NUnit 测试可在相关下载中找到。
历史
- 文章版本 1.0。
注意:这是本文的删节版。欲阅读全文,请访问此处。
- 版本1.01
对引言中读者期望的微小更新。