使用XUnit的测试驱动开发过程

介绍

什么是测试驱动开发

测试驱动开发或(简称TDD)是强调重构代码和创建单元测试作为主要软件开发周期的一部分的软件开发过程。

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

然而,我相信软件开发应该由功能需求驱动,而不是测试,所以在本文中,我演示了一种修改的(适度的)TDD方法,它强调重构和单元测试创​​建作为主编码周期。

这是一个详细说明TDD开发周期的图表:

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

请注意,上面介绍的TDD循环的前3个步骤(包括重构)可以而且应该持续用于任何开发,即使跳过测试:

这个循环可以称为Coding with Refactoring 

这个循环也将成为未来文章中描述的原型驱动开发的一部分。

TDD优势

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

TDD缺点

有时,即使是对于微不足道的代码,也会创建太多测试,从而导致在测试创建上花费太多时间,并且在运行这些测试上花费太多计算机资源,从而导致构建速度变慢。

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

由于构建速度极慢,我看到整个项目几乎陷入停顿。 

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

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

示例代码

生成的示例代码位于TDD Cycle Sample Code下。

请注意,由于本文的目的是介绍该过程,因此您必须从空项目开始阅读本教程,并逐步完成最终生成示例代码的每个步骤。

我使用Visual Studio 2022.NET 6作为示例,但稍作修改(主要与Program.Main(...)方法相关)也可以使用旧版本的.NETVisual 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包:

为了能够在Visual Studio中调试XUnit测试,需要两个额外的nugetMicrosoft.NET.Test.SDKxunit.runner.visualstudio

获得初始(几乎是空的)解决方案的最简单方法是下载或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开发人员可能会注意到方法代码有问题——在这一点上,我们并不关心它。稍后我们将处理这些错误。

将创建的方法移动到通用项目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 StudioTEST菜单并选择Test Explorer来打开测试资源管理器:

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

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

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

现在,让我们通过将第一个期望值修改为不正确的值来创建测试失败,例如,修改为He1(而不是He):

string expectedStartStrPart = "He1"; 

重新运行测试——它旁边会有一个红色图标,右边的窗口会给出Assert方法失败的原因:

现在将expectedStartStrPart返回更改为正确的"He"值并重新运行测试以将其设置回绿色。

调试测试

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

在测试方法中放置一个断点,例如,在BreakStringIntoTwoParts(...)方法调用旁边:

然后,右键单击测试资源管理器中的测试并选择调试而不是运行

您将在Visual Studio调试器中的断点处停止。然后,您将能够以与调试主应用程序相同的方式进入方法或方法并调查或更改变量值。

使用InlineData属性概括我们的测试以使用不同的参数运行

您可能已经注意到,我们的测试只涵盖了一个非常特殊的情况,其中主字符串设置为Hello World!,分隔符ll和相应的预期返回值Heo World!

当然,为了确保我们的方法BreakStringIntoTwoParts(...)没有任何错误,我们需要测试更多的案例。

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")的分隔符参数在字母数字上位于第一个InlineData的分隔符"ll"之前,因此相应的测试以相反的顺序出现。

为了解决这个问题,我引入了另一个(未使用的)双输入参数testOrder作为我们BreakStringIntoTwoParts_Test(...)方法的第一个参数。然后,在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的顺序相同:

分隔符匹配字符串结尾的情况

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

我们添加属性行:

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

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

分隔符为空的情况

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

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

刷新测试并运行与新的InlineData测试对应的测试——它会显示为红色,表示它检测到错误。您将能够在右侧看到异常堆栈:

堆栈跟踪显示异常是由以下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——我们将分配separatorIdxstr.IndexOf(separator)endPartBeginIdxseparatorIdx + 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,因为在字符串中找不到分隔符。这会导致substring传递给str.Substring(...)方法的长度为负数,从而导致ArgumentOutOfRangeException抛出。

为了解决这个问题,我们应该仅当分隔符存在于字符串中时才分配separatorIdxendPartBeginIdx,即当str.IndexOf(separarot)不是时-1,否则将两个索引都初始化为返回完整字符串/空字符串作为结果。这是代码:

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人员的经验和数量、项目资金、截止日期和其他参数。此外,最佳值可能会在整个项目期间发生变化。

https://www.codeproject.com/Articles/5322646/Test-Driven-Development-Process-with-XUnit

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值