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

通过示例学习 .NET TDD (测试驱动开发) - 第 1 部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (32投票s)

2013年3月11日

CPOL

15分钟阅读

viewsIcon

187662

downloadIcon

2260

采用 TDD 方法从零开始构建一个简单的业务逻辑层。

引言

在这个迷你系列的第一部分中,我们将采用 TDD 方法从零开始开发一个简单的业务逻辑层,旨在实现以下目标:

  • 通过“红-绿-重构”提高代码质量
  • 随着开发同步增长并保持更新的文档
  • 自动化的回归测试工具

主要涉及首先创建单元测试,让它们失败,然后让它们通过,接着重构代码以提高质量,最后重新运行测试。当使用像resharper这样的工具来辅助代码重构时,从一开始就拥有测试能让你在不破坏任何东西的情况下安心工作。它还有助于在设计和开发应用程序或功能时,让思维过程更有针对性。

我们将在第 2 部分进一步开发该应用程序,添加一个 MVC4 Web 客户端,并继续我们的 TDD 之旅……

一些背景信息

测试先行测试驱动开发是一种宝贵的软件工程实践。它涵盖的内容远比本文所能尝试的要多,例如验收测试驱动开发 (ATDD) 和行为驱动开发 (BDD)。我们将专注于 TDD 的一个子集,它鼓励开发人员进行测试,并极大地帮助软件交付,而不是传统地将测试作为第二阶段或测试人员的责任。这种方法将测试提升为我们日常软件开发生命周期 (SDLC) 中的一等公民。有多少次,你打算在一个功能开发完成后再编写单元测试,但由于时间限制,最终放弃了它,转而开发应用程序的下一部分,心里却总觉得不安,认为在添加更复杂的层次之前,最好还是先有测试?遵循 TDD 方法可以消除这种情况,因为测试是初始实现时首先要考虑的事情。

开发成本

事实证明,在软件发布后修复错误的成本,远高于在每次代码提交到源代码控制之前(无论是微小还是重大的系统变更)都运行单元测试的成本。单元测试也比集成测试系统测试功能测试更便宜。虽然单元测试不能直接替代上述任何一种测试,但拥有一套庞大的单元测试集,可以让你安心地知道每天的开发都是富有成本效益的,并且代码仍然处于良好状态。

示例代码

示例代码包含一个库,当传入一个 1 到 3000 之间的整数时,它将返回一个表示罗马数字的字符串。

示例代码使用 Visual Studio 2012 编写,并刻意保持简单,以便专注于开发风格,而不是被实现细节分散注意力。它由两个 C# 类库组成。第一个是 MS Test 类库,用于存放我们的单元测试;第二个是标准类库,我们将用它来开发功能。单元测试项目的类库是分开的,以确保我们只测试业务逻辑的公共部分,而不将内部实现暴露给测试,因为内部实现很可能会随着时间而改变。

工具

目前有许多可用的单元测试框架。本文使用的是 Visual Studio 自带的 MS Test。使用 NUnit(我最喜欢的框架之一)或其他框架也同样可行,这取决于个人偏好或你所处的工作环境。使用 MS Test 有助于本文的代码在没有任何其他依赖的情况下运行。

  • Visual Studio 2012
  • MS Test(包含在 Visual Studio 2012 中)

创建库

因为我们采用 TDD 方法,这迫使我在给项目命名之前就提前思考。我知道我想要提供一个罗马数字的库,但如果我想要测试它,保持越松散的耦合,它的可测试性就越好。因此,我将解决方案命名为 'AssemblySoft.NumberSystems',以支持未来的数字系统转换。从 Visual Studio 中选择“空白解决方案”将帮助我们开始,如下所示

接下来,我们将添加单元测试项目,如下所示

最后一步,我们将创建类库来存放待测试的业务逻辑,如下所示

我们可以继续执行以下操作:

  • 删除这两个项目中为我们创建的默认类
  • 创建一个新的单元测试,如图所示,然后生成

如果你打开新的 'UnitTest1.cs' 类文件,你将看到以下内容

using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace NumberSystemConverter_UnitTests
{
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void TestMethod1()
        {
        }
    }
}

如你所见,它只是一个标准的类,但有三个明显的不同之处

  1. 有一个针对 'Microsoft.VisualStudio.TestTools.UnitTesting' 的 using 语句。
  2. 'UnitTest1' 类被一个 [TestClass] 特性所修饰。
  3. 'TestMethod1' 方法被一个 [TestMethod] 特性所修饰。

这告诉我们,一个引用程序集已经被添加到我们的测试项目中,如果我们查看一下,会发现它名为 'Microsoft.VisualStudio.QualityTools.UnitTestFramework.dll'。它进一步通过 using 命名空间使用这个引用,并为类和方法添加特性,从而让测试运行器知道哪些类应该用于测试,哪些类只是辅助类或我们自己的代码。

在 TDD 中,我们被鼓励首先用尽可能少的代码让测试失败。随着你对这个过程越来越熟悉,你可以对此做一些合理的判断。我个人的做法是,第一步创建最小的可用的代码片段,并确保测试失败。现在,你可以用一个没有方法体、只抛出 'NotImplemented' 异常的方法来处理,或者你可以想一个当消费者开始使用你的库时可能出现的有效异常来启动这个过程。

回到需求

我的粗略需求规定,我可以获取 1 到 3000 之间数字的罗马数字。所以这里我们也可以说,输入小于 1 和大于 3000 的数字应该导致库失败,但更重要的是,在这个阶段,测试应该失败,因为我们是测试先行。现在,虽然我们还没有写一行有意义的代码,但希望测试先行的思维方式已经开始产生影响了。

我们的前两个测试 - 红色 (RED)

我们将保留现有的测试方法,并在代码编辑器中添加两个新的测试,遵循相同的特性准则,结果如下

[TestMethod]
[ExpectedException(typeof(IndexOutOfRangeException))]
public void Number_Greater_Than_ThreeThousand_Throws_IndexOutOfRangeException_TestMethod()
{
    var converter = new RomanNumeralConverter();
    converter.ConvertRomanNumeral(3001);
}

[TestMethod]
[ExpectedException(typeof(IndexOutOfRangeException))]
public void Number_Less_Than_One_Throws_IndexOutOfRangeException_TestMethod()
{
    var converter = new RomanNumeralConverter();
    converter.ConvertRomanNumeral(-1);
}

所以,在添加了上面的代码之后,你可能会担心你没有一个带有 'ConvertRomanNumeral' 方法的 'RomanNumeralConverter',并且你的代码无法生成。没关系,这是预料之中的。

用最少的**(有用的)**代码让测试失败

我们可以使用 Visual Studio 为我们生成类和方法的存根(stub),暂时都放在测试项目中。通过在选择 'RomanNumeralConverter' 时使用上下文菜单,并选择“为 RomanNumeralConverter 生成类”,如下所示

对方法定义也做同样的操作。

此时,我们的测试项目中有一个名为 'RomanNumeralConverter.cs' 的新 .cs 文件,其中包含一个类和方法的定义,但没有有用的实现。测试方法现在已经满足,项目可以生成了。

新的类如下所示

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace NumberSystemConverter_UnitTests
{
    class RomanNumeralConverter
    {
        internal void ConvertRomanNumeral(int p)
        {
            throw new NotImplementedException();
        }
    }
}

注意方法体中的 'NotImplemented' 异常。回到我们的测试方法,我们为每个方法添加了另一个特性 '[ExpectedException(typeof(IndexOutOfRangeException))]',它声明我们期望得到一个 IndexOutOfRange 异常,而不是一个 NotImplementedException。如果我们现在运行测试,我们应该会进入那个重要的第一个状态:“失败”或有时被称为“红色”(来自我们文章标题图)。

使用测试资源管理器查看和运行我们的测试

现在我们的测试已经就位,让我们通过右键单击测试项目并选择“运行测试”来运行测试,如下所示

这将启动 MS Test 运行器并显示测试资源管理器窗口,如下所示

测试资源管理器允许深入查看单个测试,并帮助诊断测试失败的原因。上面的截图突出了 'ConvertRomanNumeral' 方法抛出的异常。你可以多操作一下测试资源管理器并熟悉它。你也可以用不同的方式运行测试,找到最适合你的方式。

我们可以做的一件事是删除 Visual Studio 添加的那个 'TestMethod',因为它通过了我们的测试运行,而这在“红色”阶段不是我们想要的。

我们现在准备进入下一个阶段,即通过测试,或者让它变为“绿色”。

让测试通过 (绿色)

如果传入 'ConvertRomanNumeral' 方法的数字小于 1 或大于 3000,我们的两个方法应该会失败。我们的测试在这些情况下期望得到一个 IndexOutOfRangeExcpetion,所以如果提供的数字确实是 <1 || > 3000,我们就从方法中抛出这个异常。

修改后的代码如下所示

internal void ConvertRomanNumeral(int p)
{
    if (p < 1 || p > 3000)
    {
        throw  new IndexOutOfRangeException("The number supplied is out of the expected range (1 - 3000).");
    }
}

如果我们再次运行这两个测试,我们会看到测试确实如预期般通过了。(绿色)

此时需要注意的一点是,通常好的做法是将测试作为单个语句来执行,而不是在测试内部包含分支逻辑。这确实有助于确保测试仍然有效,并且每次运行时都是确定性的。在这种情况下,测试总是期望一件事发生,即一个 IndexOutOfRange 异常。

重构

重构是 TDD 生命周期中的一个重要步骤。当我们重构时,我们是在修改业务逻辑的内部实现,而不影响我们的测试所消费的公共 API。这一步可能涉及完全重写我们的实现、重命名变量、添加进一步的抽象或设计模式。但要记住的重要一点是,我们的测试库是业务库的一个客户端或消费者,就像其他客户端一样。不改变公共的外部功能或 API 将确保我们的测试仍然能够验证被测系统,无论其内部如何。事实上,这是一种很好的方式,可以确保当我们使用像 resharper 这样的工具来使我们的代码更智能时,我们能通过简单地重新运行测试来快速确定是否有任何东西被破坏了。

在设计和开发新功能时,我们第一次尝试让某些东西工作的代码,通常与我们经过审查、合理性检查并被认为是最终实现的代码看起来大相径庭。“重构”这一步鼓励了这种做法,即你可以把整理和如术语所示的“重构”过程,作为生命周期中的一个阶段。这确实有助于我们不必一开始就过于纠结于创建一套完美的代码,而是专注于 API 或公共接口的设计以及你想要测试的内容,将重构留到生命周期的这个阶段。

知道可以先产出一个粗糙的初步实现,其中可能包含存根或任何满足 API 所需的东西,这会让你感觉很舒心。当然,如果现实中你能在合理的时间内制作出一个有用的初步实现作为你的初稿,那就去做吧。这些都只是指导方针,应该根据你的情况进行调整,以提高生产力,同时创建一个可测试的系统。

做一些改变

在这一点上,我们可以开始整理工作,进一步完善实现并添加功能,所有这一切都基于一个安全的认知:我们可以在做出更改后进行测试(至少是两种场景)。

让我们对 'NumberSystemConverter' 项目执行以下操作

  • 将 'RomanNumeralConverter' 类移动到 'NumberSystemConverter' 项目
  • 将 'RomanNumeralConverter' 类的命名空间更改为 'NumberSystemConverter'
  • 为 'RomanNumeralConverter' 类添加 'public' 访问修饰符
  • 将 'ConvertRomanNumeral' 的访问修饰符设置为 'public'
  • 将 'ConvertRomanNumeral' 内部的参数名从 'p' 更改为 'number'
  • 将返回类型更改为 string,并在 if 语句块下方返回一个空字符串
  • 确保项目能够生成

NumberSystemConverter 的代码应如下所示

using System;

namespace NumberSystemConverter
{
    public class RomanNumeralConverter
    {
        public string ConvertRomanNumeral(int p)
        {
            if (p < 1 || p > 3000)
            {
                throw  new IndexOutOfRangeException(
                  "The number supplied is out of the expected range (1 - 3000).");
            }

            return string.Empty;
        }
    }
}

让我们对 'NumberSystemConverter_UnitTests' 项目执行以下操作

  • 从测试项目中移除 'RomanNumeralConverter' 类
  • 在测试类中添加对 'NumberSystemConverter' 项目的引用
  • 向 'UnitTest1.cs' 文件中添加一个 `using` 语句,指向 'NumberSystemConverter'
  • 将 'UnitTest1' 类重命名为 'RomanNumeralConverterUpperAndLowerBoundsUnitTests'
  • 将 .cs 文件名也同样重命名
  • 确保项目能够生成
  • 运行测试并确保结果相同(如果不同,修复在此步骤中引入的任何错误)

NumberSystemConverter_UnitTests 的代码应如下所示

using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NumberSystemConverter;

namespace NumberSystemConverter_Tests
{
    [TestClass]
    public class RomanNumeralConverterUpperAndLowerBoundsUnitTests
    {
        [TestMethod]
        [ExpectedException(typeof (IndexOutOfRangeException))]
        public void Number_Greater_Than_ThreeThousand_Throws_IndexOutOfRangeException_TestMethod()
        {
            var converter = new RomanNumeralConverter();
            converter.ConvertRomanNumeral(3001);
        }

        [TestMethod]
        [ExpectedException(typeof (IndexOutOfRangeException))]
        public void Number_Less_Than_One_Throws_IndexOutOfRangeException_TestMethod()
        {
            var converter = new RomanNumeralConverter();
            converter.ConvertRomanNumeral(-1);
        }

        [TestMethod]
        [ExpectedException(typeof (IndexOutOfRangeException))]
        public void Number_Zero_Throws_IndexOutOfRangeException_TestMethod()
        {
            var converter = new RomanNumeralConverter();
            converter.ConvertRomanNumeral(0);
        }
    }
}

经过一些小的重构后,我们仍然能够确保我们的测试像以前一样通过。

引入更多测试 (红色)

让我们为 'ConvertRomanNumeral' 方法添加更多测试,并进一步充实其实现,以处理 1 - 3000 范围内的数字。让我们对 'NumberSystemConverter_UnitTests' 项目执行以下操作

  • 在同一个文件中添加一个名为 'RomanNumeralConverterExpectedValuesUnitTests' 的新 TestClass
  • 添加方法: Number_Equal_One_Expected_Result_I_TestMethod
  • 添加方法: Number_Equal_ThreeThousand_Expected_Result_MMM_TestMethod
  • 添加方法: Number_Equal_55_Expected_Result_LV_TestMethod
  • 添加方法: Number_Equal_100_Expected_Result_C_TestMethod
  • 添加方法: Number_Equal_500_Expected_Result_D_TestMethod
  • 添加方法: Number_Equal_599_Expected_Result_DLXXXXVIIII_TestMethod
  • 添加方法: Number_Equal_2013_Expected_Result_MMXIII_TestMethod

新方法的代码如下所示

[TestClass]
public class RomanNumeralConverterExpectedValuesUnitTests
{
    [TestMethod]
    public void Number_Equal_One_Expected_Result_I_TestMethod()
    {
        var converter = new RomanNumeralConverter();
        var result = converter.ConvertRomanNumeral(1);

        Assert.AreEqual(result, "I");

    }

    [TestMethod]
    public void Number_Equal_ThreeThousand_Expected_Result_MMM_TestMethod()
    {
        var converter = new RomanNumeralConverter();
        var result = converter.ConvertRomanNumeral(3000);

        Assert.AreEqual(result, "MMM");

    }

    [TestMethod]
    public void Number_Equal_55_Expected_Result_LV_TestMethod()
    {
        var converter = new RomanNumeralConverter();
        var result = converter.ConvertRomanNumeral(55);

        Assert.AreEqual(result, "LV");
    }

    [TestMethod]
    public void Number_Equal_100_Expected_Result_C_TestMethod()
    {
        var converter = new RomanNumeralConverter();
        var result = converter.ConvertRomanNumeral(100);

        Assert.AreEqual(result, "C");
    }

    [TestMethod]
    public void Number_Equal_500_Expected_Result_D_TestMethod()
    {
        var converter = new RomanNumeralConverter();
        var result = converter.ConvertRomanNumeral(500);

        Assert.AreEqual(result, "D");
    }

    [TestMethod]
    public void Number_Equal_599_Expected_Result_DLXXXXVIIII_TestMethod()
    {
        var converter = new RomanNumeralConverter();
        var result = converter.ConvertRomanNumeral(599);

        Assert.AreEqual(result, "DLXXXXVIIII");
    }

    [TestMethod]
    public void Number_Equal_2013_Expected_Result_MMXIII_TestMethod()
    {
        var converter = new RomanNumeralConverter();
        var result = converter.ConvertRomanNumeral(2013);

        Assert.AreEqual(result, "MMXIII");
    }
}

关于这些方法,有几点值得注意。它们的名称描述了它们的用途。这使得理解测试的目的是什么变得很容易。其次,通过为特定类型的测试添加类,我们能够将边界检查测试与那些针对特定数值的测试隔离开来。这在以后你需要回来查找可能受新修复或功能影响的测试时,也能极大地提供帮助。

你会注意到,在本节的大多数测试中,都大量使用了 'Assert' 关键字来比较结果。这是一种常见的做法,但需要说明的是,这在确定和比较结果的选项方面只是冰山一角。每个框架都有大量的不同选项,有些处理集合、字符串、布尔值和其他类型。不用说,这是一个学习的旅程,每次你坚持并加深对这种开发新功能方式的理解时,你测试一个值的最佳方法的武器库也会随之增加。

我们现在应该能够运行我们的新测试,并且确实看到它们失败了,因为我们的 'ConvertRomanNumeral' 方法在除了两种情况外,都会返回一个空字符串。这可以在下面看到

嗯,这正是我们所期望的,我们也可以看到在测试的预期结果中,返回值确实是一个空字符串。

你会像之前一样注意到,每个测试都试图评估一个单一的语句,在这种情况下,使用一个专门的 Assert。

让测试通过 (第二次) (绿色)

我们将为 'ConvertRomanNumeral' 方法添加一些实现,并提供一些支持数据,以帮助新测试通过。

我们要做的第一件事是添加一些支持类型,如下所示

#region Supporting Types

/// <summary>
/// Roman Numerals
/// </summary>
/// <remarks>
/// There are seven symbols that can be used to write any roman numeral
/// </remarks>
enum RomanNumeralsType
{
    M = 1000,
    D = 500,
    C = 100,
    L = 50,
    X = 10,
    V = 5,
    I = 1
}

internal class RomanNumeralPair
{
    public int NumericValue { get; set; }
    public string RomanNumeralRepresentation { get; set; }
}

#endregion

由于罗马数字仅由七个不同的符号组成,这应该就足够了。

下一步是使用这些数据创建一个内存中的数字/罗马数字对列表,以便我们稍后可以在我们的 'ConvertRomanNumeral' 方法中使用它。如下所示

private readonly List<RomanNumeralPair> _romanNumeralList;

public RomanNumeralConverter()
{
_romanNumeralList = new List<RomanNumeralPair>()
{
    new RomanNumeralPair()
        {
//... 1000
            NumericValue = Convert.ToInt32(RomanNumeralsType.M),
            RomanNumeralRepresentation = RomanNumeralsType.M.ToString()
        },
    new RomanNumeralPair()
        {
//... 500
            NumericValue = Convert.ToInt32(RomanNumeralsType.D),
            RomanNumeralRepresentation = RomanNumeralsType.D.ToString()
        },
    new RomanNumeralPair()
        {
//... 100
            NumericValue = Convert.ToInt32(RomanNumeralsType.C),
            RomanNumeralRepresentation = RomanNumeralsType.C.ToString()
        },
    new RomanNumeralPair()
        {
//... 50
            NumericValue = Convert.ToInt32(RomanNumeralsType.L),
            RomanNumeralRepresentation = RomanNumeralsType.L.ToString()
        },
    new RomanNumeralPair()
        {
//... 10
            NumericValue = Convert.ToInt32(RomanNumeralsType.X),
            RomanNumeralRepresentation = RomanNumeralsType.X.ToString()
        },
    new RomanNumeralPair()
        {
//... 5
            NumericValue = Convert.ToInt32(RomanNumeralsType.V),
            RomanNumeralRepresentation = RomanNumeralsType.V.ToString()
        },
    new RomanNumeralPair()
        {
//... 1
            NumericValue = Convert.ToInt32(RomanNumeralsType.I),
            RomanNumeralRepresentation = RomanNumeralsType.I.ToString()
        }

};

现在我们有了一个查找表,是时候尝试使用它了(新添加的代码用粗体表示),如下所示

public string ConvertRomanNumeral(int number)
{
    if (number < 1 || number > 3000)
    {
        throw new IndexOutOfRangeException("The number provided is outside the expected range ( 1 - 3000)");
    }

    var builder = new StringBuilder();
    
    //iterate through the list, starting with the highest value
    foreach (var currentPair in _romanNumeralList)
    {
        while (number >= currentPair.NumericValue)
        {//...number is greater than or equal to the current value so store the roman numeral and decrement it's value 
            builder.Append(currentPair.RomanNumeralRepresentation);
            number -= currentPair.NumericValue;
        }
    }

    return builder.ToString();
}

在绿色阶段添加了一些新的实现之后,我们现在可以进行下一轮测试了。

调试测试

此时值得一提的是,有时你写的代码你确信会通过测试,但结果却没有,通常是因为你忽略了某些东西,至少在我的情况下是这样 眨眼 | <img src=。所以,这时应该像平时一样,打开调试器。在我们的例子中,这非常合适,因为在没有某种客户端或没有明确附加调试器的情况下,调试两个类库通常更困难。幸运的是,在这种情况下,由于使用了已经集成到 Visual Studio 中的 MS Test 框架,调试一个测试就像在测试资源管理器中右键单击特定测试,或者在 Visual Studio 代码编辑器中的测试方法内右键单击并选择“调试测试”一样简单。这会自动在上下文中附加调试器,你可以在其中正常地单步调试。

最后一次实现和测试运行的结果如下所示

重构 (第二轮)

在这个阶段,我继续进行了一些整理工作,将一些逻辑移入了一个查找表,添加了一些注释,并进行了一轮重构。我建议使用 resharper 或其他代码质量工具,进行修改,再次运行测试,确保你满意,然后就可以提交当天的代码了。

结论

我们其实只触及了 TDD 故事的皮毛,但希望你们中的一些人会觉得它有用,特别是那些不熟悉“红-绿-重构”的人。将创建单元测试作为开发新功能的第一步有很多好处。一旦测试就位,你就拥有了一套实时文档,它不仅展示了需求,还在你每天的开发中帮助强制执行这些需求,充当了一个自动化的回归测试工具。起初,这可能看起来比仅仅专注于实现细节要多做一些工作,但随着你采用这类方法,它会在你思考问题的方式上助你一臂之力,并且也为你对代码库的每一次新更改提供了一个安全保障。

下一篇文章将进一步探讨这种方法,研究客户端测试的挑战,以及将类似技术应用于现有的遗留代码(棕地项目)。

我们探讨的领域

  • 红-绿-重构 - 失败、通过、修改
  • 结构良好的代码 - 这个过程鼓励更解耦的设计
  • 自文档化 - 从规范开始,然后强制执行它
  • 自动化回归测试工具 - 如果有人破坏了什么,测试会发出警报

修订历史

  • 2013年3月11日 - 带代码的初版。
© . All rights reserved.