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

XUnit 驱动的测试开发过程

starIconstarIconstarIconstarIconstarIcon

5.00/5 (17投票s)

2022 年 1 月 16 日

CPOL

14分钟阅读

viewsIcon

25762

本文使用 XUnit 和详细示例解释了测试驱动开发。

引言

什么是测试驱动开发

测试驱动开发(简称 TDD)是一种软件开发过程,它强调在主要的软件开发周期中进行代码重构和创建单元测试。

在其最纯粹的形式中,TDD 鼓励先创建测试,然后再为被测试的功能创建实现。

然而,我认为软件开发应该由功能需求驱动,而不是由测试驱动。因此,在本文中,我将演示一种修改后的(适度的)TDD 方法,它强调重构和单元测试创建是主要编码周期中不可或缺的一部分。

下面是一张详细说明 TDD 开发周期的图表

如果你有些困惑,不用担心——在描述我们的示例时,我们将详细介绍这个开发周期。

请注意,上面 TDD 周期中的前 3 个步骤(包括重构)可以并且应该持续用于任何开发,即使跳过测试也是如此。

这个周期可以称为“带有重构的编码”。

这个周期也将是我将在未来文章中描述的“原型驱动开发”的一部分。

TDD 的优点

  1. 鼓励开发人员将可重用的功能提取到可重用的方法和类中。
  2. 单元测试与功能一起创建(而不仅仅是在开发人员有空闲时间时)。

TDD 的缺点

有时会为琐碎的代码创建过多的测试,导致在测试创建上花费过多的时间,并在运行这些测试上花费过多的计算机资源,从而使构建变慢。

构建的缓慢也可能进一步显著减缓开发速度。

我曾见过整个项目因为构建速度极慢而几乎停滞不前。

因此,应该由经验丰富的项目架构师来决定哪些功能需要测试,哪些不需要,以及哪些测试应该运行以及运行频率,并在项目开发过程中根据需要进行调整。

不同的项目可能需要不同的 TDD 指导原则,具体取决于它们的规模、重要性、资金、截止日期、开发人员和 QA 资源的数量和经验。

示例代码

生成的示例代码位于 TDD 周期示例代码 下。

请注意,由于本文的目的是展示该过程,因此您必须从空的开始项目开始,然后按照每个步骤进行,最终得到示例代码。

我使用了 Visual Studio 2022 和 .NET 6 进行演示,但通过少量修改(主要与 Program.Main(...) 方法相关),也可以使用旧版本的 .NET 和 Visual Studio。

我使用了一个流行的(也许是最流行的)XUnit 框架来创建和运行单元测试。

TDD 视频

还有一个 TDD 视频可在 TDD 开发周期视频 上观看,内容与本文相同。

为了获得最佳效果,我建议阅读本文,完成演示并观看视频。

TDD 开发周期演示

从几乎空的解决方案开始

您的初始解决方案应仅包含三个项目

  1. 主项目 MainApp
  2. 可重用项目 NP.Utilities(在Core 文件夹下)
  3. 单元测试项目 NP.Utilities.Test(在Tests 文件夹下)

MainAppProgram.cs 文件应完全为空(请记住这是 .NET 6)。

NP.Utilities 项目的StringUtils.cs 文件可以包含一个空的 public static class

public static class StringUtils { }

主项目和测试项目都应引用 NP.Utilities 可重用项目。

测试项目 NP.Utility.Test 还应引用 XUnit 和另外两个 NuGet 包

另外两个 NuGet 包,“Microsoft.NET.Test.SDK”和“xunit.runner.visualstudio”是为了能够在 Visual Studio 中调试 XUnit 测试。

获取初始(几乎为空)解决方案的最简单方法是下载或 git 克隆 TDD 周期示例代码,运行src/MainApp/MainApp.sln 解决方案,删除MainApp/Program.csNP.Utilities/StringUtils.cs 文件中的所有代码,并删除NP.Utility.Tests/Test_StringUtils.cs 文件。

您也可以尝试自己创建一个这样的解决方案(不要忘记提供项目和 nuget 包依赖项)。

新功能需求

假设您正在主解决方案的Program.cs 文件中创建新功能。

同样,假设您需要创建新功能来将字符串“Hello World!”分割成两个 string——一个在第一个“ll”字符实例之前,一个在相同字符实例之后——当然,这两个结果字符串将是“He”和“o World!”。

首先在Program.cs 文件中定义初始字符串和分隔符字符串

string str = "Hello World!";
string separator = "ll"; // startStrPart="He" endStrPart="o World!"  

另外请注意——我们在行注释中提到了开始和结束结果部分。

重要提示:为了本次演示的目的,我们假设 string.Split(...) 方法不存在,即使我们使用了一些更简单的 string 类型的方法(string.Substring(...)string.IndexOf(...))。本质上,我们重新实现了一个更简单的 Split(...) 版本,它只在分隔符的第一个实例周围进行分割,并将结果作为元组返回,而不是数组。

在最接近使用位置的地方内联创建新功能

我们首先在最简单、最直接、非可重用的方式下,在紧邻使用位置的Program.cs 文件中创建新功能。

string str = "Hello World!";
string separator = "ll"; // startStrPart="He" endStrPart="o World!"

// Get the index of the first instance of the 
// separator within the string. 
int separatorIdx = str.IndexOf(separator);

// We get the first part of the result - 
// part between index 0 and separatorIdx
string startStrPart = str.Substring(0, separatorIdx);

// We get the index after the separator end:
int endPartBeginIdx = separatorIdx + separator.Length;

// We get the second part of the result:
string endStrPart = str.Substring(endPartBeginIdx);

// We print out the first and second parts of the result
// to verify that they indeed equal to "He" and "o World!" correspondingly
Console.WriteLine($"startStrPart = '{startStrPart}'");
Console.WriteLine($"endStrPart = '{endStrPart}'");  

代码很简单,并且在注释中进行了说明。

当然,代码运行正常并打印

startStrPart = 'He'
endStrPart = 'o World!'  

正如预期的那样。

将功能包装在同一文件内的类中

在下一步,让我们稍微泛化该功能,创建一个名为 BreakStringIntoTwoParts(...) 的方法,该方法接受主字符串和分隔符,并返回一个包含结果的第一部分和第二部分的元组。然后,我们使用此方法获取结果的开始和结束部分。

在此阶段,为了简单起见,请将该方法放在同一个文件Program.cs中。

(string startStrPart, string endStrPart) BreakStringIntoTwoParts(string str, string separator)
{
    // Get the index of the first instance of the 
    // separator within the string. 
    int separatorIdx = str.IndexOf(separator);

    // We get the first part of the result - 
    // part between index 0 and separatorIdx
    string startStrPart = str.Substring(0, separatorIdx);

    // We get the index after the separator end:
    int endPartBeginIdx = separatorIdx + separator.Length;

    // We get the second part of the result:
    string endStrPart = str.Substring(endPartBeginIdx);

    return (startStrPart, endStrPart);
}

string str = "Hello World!";
string separator = "ll"; // startStrPart="He" endStrPart="o World!"

// Use the method to obtain the start and the end parts of the result:
(string startStrPart, string endStrPart) = BreakStringIntoTwoParts(str, separator);

// We print out the first and second parts of the result
// to verify that they indeed equal to "He" and "o World!" correspondingly
Console.WriteLine($"startStrPart = '{startStrPart}'");
Console.WriteLine($"endStrPart = '{endStrPart}'");  

运行该方法,当然,您将获得相同正确的字符串分割。

有经验的 .NET 开发人员可能会注意到该方法代码存在 bug——此时我们不关心它。稍后我们将处理这些 bug。

将创建的方法移动到通用项目 NP.Utilities

现在,我们将方法移到位于可重用 NP.Utilities 项目下的StringUtils.cs 文件中,并将其修改为方便的 static 扩展方法。

namespace NP.Utilities
{
    public static class StringUtils
    {
        public static (string startStrPart, string endStrPart) 
               BreakStringIntoTwoParts(this string str, string separator)
        {
            // get the index of the first instance of the 
            // separator within the string. 
            int separatorIdx = str.IndexOf(separator);

            // we get the first part of the result - 
            // part between index 0 and separatorIdx
            string startStrPart = str.Substring(0, separatorIdx);

            // we get the index after the separator end:
            int endPartBeginIdx = separatorIdx + separator.Length;

            // we get the second part of the result:
            string endStrPart = str.Substring(endPartBeginIdx);

            return (startStrPart, endStrPart);
        }
    }
}

我们还在Program.cs 文件的顶部添加了 using NP.Utilities; 行,并将方法调用修改为

(string startStrPart, string endStrPart) = str.BreakStringIntoTwoParts(separator);  

——因为该方法现在是一个扩展方法。

重新运行应用程序——您应该获得完全相同的结果。

创建单个单元测试以使用相同的参数测试该方法

现在,我们终于要创建一个单元测试来测试扩展方法了(您是不是很兴奋)。

NP.Utility.Tests 项目下,创建一个名为 Test_StringUtils 的新类。使该类成为 publicstatic(测试 string 方法不需要状态)。

在顶部添加以下 using 语句

using NP.Utilities;
using Xunit;  

以引用我们的可重用 NP.Utilities 项目和 XUnit

添加一个 public static 方法 BreakStringIntoTwoParts_Test() 来测试我们的 BreakStringIntoTwoParts(...) 方法,并用 [Fact] XUnit 属性标记它。

public static class Test_StringUtils
{
    [Fact] // Fact attribute makes it an XUnit test
    public static void BreakStringIntoTwoParts_Test()
    {
        string str = "Hello World!";
        string separator = "ll";
        string expectedStartStrPart = "He"; // expected first part
        string expectedEndStrPart = "o World!"; // expected end part

        // Break string into two parts
        (string startStrPart, string endStrPart) = str.BreakStringIntoTwoParts(separator);

        // Error out if the expected parts do not match the corresponding real part
        Assert.Equal(expectedStartStrPart, startStrPart);
        Assert.Equal(expectedEndStrPart, endStrPart);
    }

XUnit 框架的最后两个 Assert.Equal(...) 方法被调用,以便在任何预期值与相应获得的值不匹配时出错。

现在,您可以从主Program.cs 文件中删除 Console.WriteLine(...) 调用了。无论如何,几周后,没有人会记得这些打印原本是做什么的。

要运行测试,请通过 Visual Studio 的“TEST”菜单选择“Test Explorer”来打开测试资源管理器。

测试资源管理器窗口将弹出

单击运行图标(从左数第二个)以刷新并运行所有测试。

之后,展开我们的 BreakStringIntoTwoParts_Test——它旁边应该有一个绿色的图标,表示测试已成功运行。

现在,让我们通过修改第一个预期值,使其不正确(例如,“He1”(而不是“He”))来创建一个测试失败。

string expectedStartStrPart = "He1";  

重新运行测试——它将有一个红色的图标,右侧的窗口将显示 Assert 方法失败的原因。

现在,将 expectedStartStrPart 改回正确的值 "He" 并重新运行测试,使其重新变绿。

调试测试

现在我将展示如何调试创建的测试。

在测试方法中设置一个断点,例如,在调用 BreakStringIntoTwoParts(...) 方法的行附近。

然后,在测试资源管理器中右键单击测试,选择“Debug”(调试)而不是“Run”(运行)。

您将在 Visual Studio 调试器中的断点处停止。然后,您可以像调试主应用程序一样,逐行进入或跳过方法,检查或更改变量值。

使用 InlineData 属性将我们的测试泛化为使用不同的参数运行

您可能已经注意到,我们的测试仅涵盖了一个非常具体的情况,即主字符串设置为“Hello World!”,分隔符为“ll”,预期的返回值分别为“He”和“o World!”。

当然,为了确保我们的 BreakStringIntoTwoParts(...) 方法没有任何 bug,我们需要测试更多的情况。

XUnit 允许我们泛化测试方法,从而使我们能够测试许多不同的测试用例。

为此,首先将我们测试方法的 [Fact] 属性更改为 [Theory]

[Theory] // // Theory attribute makes it an XUnit test with 
            // possible various combinations of input arguments
public static void BreakStringIntoTwoParts_Test(...)
{
   ...
}

然后,更改测试中定义的硬编码参数

string str = "Hello World!";
string separator = "ll";
string expectedStartStrPart = "He"; // expected first part
string expectedEndStrPart = "o World!"; // expected end part

到方法参数。

[Theory] // Theory attribute makes it an XUnit test with possible 
         // various combinations of input arguments
public static void BreakStringIntoTwoParts_Test
(
    string str, 
    string? separator,
    string? expectedStartStrPart, 
    string? expectedEndStrPart
)
{
    ...
}

如您所见,我们允许将分隔符和两个预期值作为 null 传递。

最后,在 [Theory] 属性之后,在测试方法的顶部,添加 [InlineData(...)] 属性,并将 4 个输入参数值传递给它,就像我们要将它们传递给测试方法一样。

对于第一个 [InlineData(...)] 属性,我们将传递与之前在方法本身中硬编码的参数相同的值。

[Theory] // Theory attribute makes it an XUnit test with possible various combinations 
         // of input arguments
[InlineData("Hello World!", "ll", "He", "o World!")]
public static void BreakStringIntoTwoParts_Test
(
    string str, 
    string? separator,
    string? expectedStartStrPart, 
    string? expectedEndStrPart
)
{
    // Break string into two parts
    (string startStrPart, string endStrPart) = str.BreakStringIntoTwoParts(separator);

    // Error out if the expected parts do not match the corresponding real part
    Assert.Equal(expectedStartStrPart, startStrPart);
    Assert.Equal(expectedEndStrPart, endStrPart);
}  

刷新测试(通过运行所有测试)以获取具有新签名的测试。测试将成功运行,右侧的窗格将显示传递给它的参数。

使用 InlineData 属性创建更多测试

分隔符匹配字符串开头的场景

假设我们想测试分隔符匹配字符串开头的情况。让我们再添加一个 InlineData(...) 属性,传递相同的字符串,分隔符为“Hel”,第一个和最后一个预期结果部分应为空字符串和“lo World!

[Theory] // Theory attribute makes it an XUnit test with possible 
         // various combinations of input arguments
[InlineData("Hello World!", "ll", "He", "o World!")]
[InlineData("Hello World!", "Hel", "", "lo World!")]
public static void BreakStringIntoTwoParts_Test
(
    string str, 
    string? separator,
    string? expectedStartStrPart, 
    string? expectedEndStrPart
)
{
   ...
}  

请注意,我们的新测试对应于第二个内联数据参数。

[InlineData("Hello World!", "Hel", "", "lo World!")]  

在测试资源管理器中重新运行所有测试以刷新它们。新测试将显示为未运行(蓝色图标)。

单击对应于新 InlineData 的测试并运行它——它应该成功并变为绿色。

请注意,一个令人不安的事实是,InlineData 属性的顺序与测试资源管理器中相应测试的顺序不匹配。

测试资源管理器中的测试是根据其参数值按字母顺序排序的——由于第二个 InlineData(“Hel”)的分隔符参数在字母上 precedes 第一个 InlineData 的分隔符“ll”,因此相应的测试按相反的顺序出现。

为了解决这个问题,我为 BreakStringIntoTwoParts_Test(...) 方法引入了另一个(未使用的)双输入参数 testOrder 作为第一个参数。然后,在 InlineData(...) 属性中,我根据 InlineData 的顺序分配参数。

[Theory] // Theory attribute makes it an XUnit test with possible various combinations 
         // of input arguments
[InlineData(1, "Hello World!", "ll", "He", "o World!")]
[InlineData(2, "Hello World!", "Hel", "", "lo World!")]
public static void BreakStringIntoTwoParts_Test
(
    double testOrder,
    string str, 
    string? separator,
    string? expectedStartStrPart, 
    string? expectedEndStrPart
)
{

}

这使得测试(刷新后)在测试资源管理器中按照第一个参数 testOrder 的顺序出现,该顺序与 InlineData 的顺序相同。

分隔符匹配字符串结尾的场景

接下来,我们可以添加一个内联数据来测试我们的方法在分隔符匹配字符串结尾时也能正常工作,例如,如果分隔符是“d!”,我们期望结果元组的第一部分是“Hello Worl”,第二部分是空字符串。

我们添加属性行

[InlineData(3, "Hello World!", "d!", "Hello Worl", "")]  

然后,刷新并运行相应的测试,看看测试是否成功。

分隔符为 null 的场景

现在让我们添加一个带有 null 分隔符的 InlineData。结果的第一部分应该是整个字符串,第二部分为空。

[InlineData(4, "Hello World!", null, "Hello World!", "")]

刷新测试并运行对应新 InlineData 的测试——它将显示红色,表示检测到 bug。您可以在右侧看到异常堆栈。

堆栈跟踪显示,异常是由 BreakStringIntoTwoParts(...) 方法实现中的以下行抛出的

// get the index of the first instance of the 
// separator within the string. 
int separatorIdx = str.IndexOf(separator);  

string.IndexOf(...) 方法不允许 null 参数,因此当 separatornull 时,应将其作为特殊情况进行特殊处理。

请注意,即使堆栈跟踪没有提供足够的信息,您也可以随时通过调试器在失败点检查变量值。

考虑到下一个测试用例——当分隔符既不是 null,也不是字符串的一部分时——我们将 separatorIdxendPartBeginIdx 初始化为整个字符串的大小,然后仅当 separator 不是 null 时——我们将 separatorIdx 赋值为 str.IndexOf(separator),并将 endPartBeginIdx 赋值为 separatorIdx + separator.Length

public static (string startStrPart, string endStrPart) 
       BreakStringIntoTwoParts(this string str, string separator)
{
    // initialize the indexes 
    // to return first part as full string 
    // and second part as empty string
    int separatorIdx = str.Length;
    int endPartBeginIdx = str.Length;

    // assign the separatorIdx and endPartBeginIdx
    // only if the separator is not null 
    // in order to avoid an exception thrown
    // by str.IndexOf(separator)
    if (separator != null)
    {
        // get the index of the first instance of the 
        // separator within the string. 
        separatorIdx = str.IndexOf(separator);

        // we get the index after the separator end:
        endPartBeginIdx = separatorIdx + separator.Length;
    }

    // we get the first part of the result - 
    // part between index 0 and separatorIdx
    string startStrPart = str.Substring(0, separatorIdx);

    // we get the second part of the result:
    string endStrPart = str.Substring(endPartBeginIdx);

    return (startStrPart, endStrPart);
}  

重新运行最后一个测试——它应该成功运行并变绿。重新运行所有测试,因为我们修改了被测试的方法——它们现在都应该是绿色的。

分隔符在字符串中不存在的场景

下一个测试用例是分隔符不是 null,但不存在于字符串中,例如,让我们选择 separator = "1234"。预期的结果部分应该是整个字符串和空字符串。

[InlineData(5, "Hello World!", "1234", "Hello World!", "")]  

刷新测试并运行对应新 InlineData 的测试。测试将失败。

指向以下行作为抛出异常的点。

// we get the first part of the result - 
// part between index 0 and separatorIdx
string startStrPart = str.Substring(0, separatorIdx); 

您也可以调试以查看问题的原因——即 separator 不是 null,因此 separatorIdx 被赋值为 str.IndexOf(separator),它返回 -1,因为在字符串中找不到分隔符。这导致传递给 str.Substring(...) 方法的 substring 长度为负,从而抛出 ArgumentOutOfRangeException

为了修复这个问题,我们应该仅当分隔符存在于字符串中时(即 str.IndexOf(separarot) 不为 -1)才分配 separatorIdxendPartBeginIdx,否则将两者都保留为初始值,以返回整个字符串/空字符串作为结果。这是代码

public static (string startStrPart, string endStrPart) 
       BreakStringIntoTwoParts(this string str, string separator)
{
    // initialize the indexes 
    // to return first part as full string 
    // and second part as empty string
    int separatorIdx = str.Length;
    int endPartBeginIdx = str.Length;

    // assign the separatorIdx and endPartBeginIdx
    // only if the separator is not null 
    // in order to avoid an exception thrown 
    // by str.IndexOf(separator)
    if (separator != null)
    {
        int realSeparatorIdx = str.IndexOf(separator);

        // only assign indexes if realSeparatorIdx is not
        // -1, i.e., if separator is found within str.
        if (realSeparatorIdx != -1)
        {
            // get the index of the first instance of the 
            // separator within the string. 
            separatorIdx = str.IndexOf(separator);

            // we get the index after the separator end:
            endPartBeginIdx = separatorIdx + separator.Length;
        }
    }

    // we get the first part of the result - 
    // part between index 0 and separatorIdx
    string startStrPart = str.Substring(0, separatorIdx);

    // we get the second part of the result:
    string endStrPart = str.Substring(endPartBeginIdx);

    return (startStrPart, endStrPart);
}  

重新运行所有测试(因为我们更改了被测试的方法)。所有测试现在都应该成功。

分隔符在字符串中重复多次的场景

最后,我们将分隔符设置为一个在字符串中找到多次的子字符串。正确的处理是根据分隔符在字符串中的第一个实例返回两个部分。

将分隔符设置为“l”(“Hello World!”字符串中重复 3 次的字符)。正确的结果部分应为“He”/“lo World!”。

[InlineData(6, "Hello World!", "l", "He", "lo World!")]  

新测试应该立即成功。

最终测试应该如下所示。

public static class Test_StringUtils
{
    [Theory] // Theory attribute makes it an XUnit test with 
             // possible various combinations of input arguments
    [InlineData(1, "Hello World!", "ll", "He", "o World!")]
    [InlineData(2, "Hello World!", "Hel", "", "lo World!")]
    [InlineData(3, "Hello World!", "d!", "Hello Worl", "")]
    [InlineData(4, "Hello World!", null, "Hello World!", "")]
    [InlineData(5, "Hello World!", "1234", "Hello World!", "")]
    [InlineData(6, "Hello World!", "l", "He", "lo World!")]
    public static void BreakStringIntoTwoParts_Test
    (
        double testOrder,
        string str, 
        string? separator,
        string? expectedStartStrPart, 
        string? expectedEndStrPart
    )
    {
        // break string into two parts
        (string startStrPart, string endStrPart) = str.BreakStringIntoTwoParts(separator);

        // error out if the expected parts do not match the corresponding real part
        Assert.Equal(expectedStartStrPart, startStrPart);
        Assert.Equal(expectedEndStrPart, endStrPart);
    }
}  

结论

我们提供了一个完整的测试驱动开发周期示例,仅省略了将创建的测试添加到自动化测试的最后一步。

我们展示了如何开始一个新功能,将其重构为一个通用方法,然后如何为该方法创建多个单元测试,并通过这些单元测试来完善该方法。

单元测试的一个巨大优点是它们允许测试和调试任何基本功能,而无需为此创建特殊的控制台应用程序——每个单元测试本质上都是一个用于测试和调试特定应用程序功能的实验室。

TDD 还提供了从使用角度审视编码的优点,鼓励重构,并在开发过程中持续提供自动化测试。

一个巨大的潜在缺点是减慢开发和构建速度。

因此,每个架构师和团队都应该自己决定创建多少单元测试以及运行它们的频率。这样的决定应该能够优化架构、代码质量、可靠性和编码速度,并且应该是许多变量的函数,包括开发人员和 QA 人员的经验和数量、项目资金、截止日期和其他参数。此外,最佳值可能会在项目持续期间发生变化。

历史

  • 2022 年 1 月 16 日:初始版本
© . All rights reserved.