65.9K
CodeProject 正在变化。 阅读更多。
Home

测试驱动/示例优先开发

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.79/5 (27投票s)

2007年3月28日

BSD

7分钟阅读

viewsIcon

134459

downloadIcon

790

一个逐步的示例,说明如何通过先编写单元测试来构建算法。

引言

关于测试驱动开发,特别是关于应该先编写测试的理念,已经写了很多。这是我努力追求的理想。然而,我倾向于稍后编写单元测试。

有些人通过示例能学得更好。本文不打算深入探讨测试驱动开发的原理,而是引导读者完成一个算法的构建和测试过程:先编写测试,然后修改被测试的方法使其满足测试。

最终代码和所有单元测试都包含在随附的下载文件中。这需要 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 具有一些有助于先编写测试的功能。可以编写一个调用被测类的测试,智能标签会提示您是否要为您创建消息存根。

Screenshot - Creating method stubs

存根看起来是这样的

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";
  }
}

为了节省篇幅,新的测试和相应的更新继续进行,直到处理完120之间的数字。代码最终会看起来像这样

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似乎很容易,但一个模式即将出现。在为2122编写测试之后,代码被重构为如下内容

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";
  }
}

现在,从122的所有测试都通过了。2329可以假定工作正常,因为它使用了经过充分测试的逻辑。

测试“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 中,很容易突出显示某些代码并将其提取到一个新方法中,从而允许它通过从多个位置调用来实现重用。

Screenshot - Extract Method

现在public方法如下所示,并且所有先前成功的测试仍然是成功的。

public static string NumberToEnglish(int number)
{
  if (number == 100)
    return "one hundred";

  return TranslateOneToNinetyNine(number);
}

对于101199之间的数字,模式是“one hundred and X”,其中X199之间的翻译结果。由于编写所有这些测试会花费太长时间,因此可以只编写边缘情况和范围中间的一两个样本。这应该足以建立信心继续前进。在这种情况下,测试是针对101115155199的。

然后重写代码以支持这些测试

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

    对引言中读者期望的微小更新。

© . All rights reserved.