VSCode + xUnit 编写 C# 单元测试

概述

什么是单元测试?为什么要写单元测试?如何写一个单元测试?实话实说,写单元测试是一件挺麻烦的事情,却又是你在软件开发的成长路上难以回避的一个问题。我个人在以前也并不喜欢单元测试,不过现在我的态度有所改观了。如果本文所写的内容可以让你理解单元测试的重要性,并且说服你在今后也开始尝试编写单元测试的话,那么这篇博文就是成功的。

Title


一、什么是单元测试?

Having automated tests is a great way to ensure a software application does what its authors intend it to do. There are multiple types of tests for software applications. These include integration tests, web tests, load tests, and others. Unit tests test individual software components and methods. Unit tests should only test code within the developer’s control. They should not test infrastructure concerns. Infrastructure concerns include databases, file systems, and network resources.

以上是微软在官方文档《.NET Core 和 .NET Standard 中的单元测试》中对单元测试给出的描述。其中提到,单元测试用于测试个人软件组件或方法,单元测试仅应测试开发人员控件内的代码,它们不应测试基础结构问题。 基础结构问题包括数据库、文件系统和网络资源。也就是说单元测试的被测目标必须足够的“独立”,进而测试代码也应足够简洁。

按我的理解来说,单元测试就是一项验证一个类或方法是否能够正常执行并得到正确结果的测试工作,其中的类或方法就是被测试的单元。看完这些,你可能还是不能够清晰地理解单元测试,不过不要紧,在后面的分析中,相信单元测试的形象将会在你的脑海中逐渐清晰。

二、为什么要写单元测试?

实话实说,写单元测试是一件挺麻烦的事情,我个人在以前也并不喜欢单元测试,不过现在我的态度有所改观了。如果我之后写的内容可以让你理解单元测试的重要性,并且说服你在今后也开始尝试单元测试,那么这一小节就是成功的。

首先,在实际的开发过程中施行单元测试是一项非常有意义的事情,它既可以保证我们程序的健壮性,也可以在很大程度上帮助我们写出更稳定、低耦合的代码,进而在维护期保护了应用程序迭代更新的安全性与需求变动时的可拓展性。这是因为,一个优秀完备的单元测试往往能够覆盖类和方法的各种边界条件,进而在源头处破灭了许多bug诞生的梦想,由此各个单元都能够稳定正确地执行,整个程序的健壮性便得到了保障。

考虑一个简单的例子,你正在写一个求整型数组中最大元素的方法,下面是其实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace MyAlgorithm
{
public class ArrayService
{
public static int MaxItemValue(int[] arr)
{
var maxValue = arr[0];
foreach(var item in arr)
{
if(item>maxValue) maxValue = item;
}
return maxValue;
}
}
}

通常,如果你直接着手实现的细节而开始写代码的话,你将难免忘记考虑一些边界条件,而程序在各种环境运行的过程中,情况往往丰富多变,这就导致所写出的程序往往在调试期间可以“正确运行”,可是一旦投入使用,各种灵异错误就接踵而来。上面的代码段作为一个错误的例子,相信你不难看出是有明显的问题的。在通常的输入下,它可以正常的执行,可是如果输入的数组为空呢?那就糟糕了,你绝对不想看到编译器给你抛出一堆鲜红的Errors,更不希望让用户看到错误警告。可是当你养成写单元测试的习惯时,你就会自然地为测试单元有意地设计各种看似“不合理”的输入,而一旦你的代码通过了严苛而险恶的单元测试,恭喜你,这个可靠的单元可以被你贴上”合格”的标签放心地使用了。

于此同时,单元测试帮助代码更稳定、低耦合是由于,一个与其它单元耦合度高又内部复杂的类或方法,我们几乎无法为其写出一个简明可行的单元测试,这就显示地要求开发者对当前过分复杂的“单元”进行拆分与解耦,以便为其书写单元测试,这也就促使程序变得更加模块化,更具可读性了。这对于一直想要写出更加优秀代码的你来讲简直是太棒了,不是吗?

由此可以发现,单元测试其实就是为组成我们程序的一个个小单元,可以是类或方法或是其它的组件,单独编写测试用例,以保证它们的正确性与可靠性。这既是单元测试的进一步阐述,也是你为什么要写单元测试的原因。

三、如何创建单元测试?

了解了单元测试及其必要性,下面就来看一看如何写一个单元测试吧。在不同的编辑器或集成开发环境中为代码编写单元测试的方法不尽相同,本节中将演示如何在 VSCode 中使用 xUnit 为 C# 项目编写一个简单的单元测试。这一节演示将分为四个部分:创建项目、编写一个示例类、为示例类创建单元测试类,以及在单元测试类中为示例类的一个成员函数编写单元测试代码。

在演示中,我们将创建一个用于数字计算的 NumberCacluation 类,并实现该类的一个整数求和函数 add(),最后为该类创建单元测试类并为其成员函数 add() 编写单元测试代码。如果你使用的是 Visual Studio,那么网上也有很多针对 VS 的单元测试编写指导,在此就不多提了。下面就让我们开始吧!

1.创建项目

  1. 在 VSCode 中打开一个文件夹,本例中新建并打开文件夹 MyProject。

  2. 打开终端,执行 dotnet new sln 来新建一个解决方案,此时你在 MyProject 文件夹中将得到一个 MyProject.sln 解决方案文件。

  3. 在 MyProject 文件夹下新建一个目录 MyMath,作为我们的数学计算类库文件夹。

  4. 在终端中进入 MyMath 目录,执行 dotnet new classlib 来对此类库文件夹进行初始化,你将在 MyMath 文件夹中得到 MyMath.csproj 与一个默认的 cs 文件 Class1.cs,在此我们将其改名为 NumberCalculation.cs,其代码结构如下:

    1
    2
    3
    4
    5
    6
    7
    8
    using System;

    namespace MyMath.Number
    {
    public class NumberCalculation
    {
    }
    }
  5. 最后,在终端中回到 MyProject 目录,执行 dotnet sln add .\MyMath\MyMath.csproj 来将 MyMath 添加到解决方案当中。至此,我们就完成了项目的创建。

2.编写示例类

打开 MyMath 目录下的 NumberCalculation.cs 文件,下面我们为其编写一个静态函数,用于求解两个整数求和计算的结果,实现代码非常简单,下面是完整的代码:

1
2
3
4
5
6
7
8
9
10
11
12
using System;

namespace MyMath.Number
{
public class NumberCalculation
{
public static int add(int a, int b)
{
return a + b;
}
}
}

3.创建单元测试

  1. 我们先在终端 MyProject 目录下新建一个文件夹:MyMath.Tests,这个文件夹中用于存放 MyMath 的相关单元测试文件。

  2. 在终端中进入 MyMath.Tests 目录,执行 dotnet new xunit 来初始化 xunit,此命令会创建将 xUnit 用作测试库的测试项目。你将得到 MyMath.csproj 和 一个默认的单元测试类 UnitTest1.cs,我们将其改名为 MyMath.Tests.Number.cs,代码结构如下(记得为其手动添加 NumberCalculation 的命名空间引用using MyMath.Number;):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    using System;
    using Xunit;

    namespace MyMath.Tests.Number
    {
    public class UnitTest1
    {
    [Fact]
    public void Test1()
    {
    }
    }
    }
  3. 在终端 MyMath.Tests 目录下执行 dotnet add reference ..\MyMath\MyMath.csproj 来将 MyMath 引入到我们的单元测试项目中来。

  4. 最后在终端中回到 MyProject 目录,执行 dotnet sln add .\MyMath.Tests\MyMath.Tests.csproj 来将 MyMath.Tests 添加到解决方案中。至此,我们完成了 MyMath 类单元测试的创建与一些初始设置。

4. 编写单元测试

打开 MyMath.Tests 目录下的 UnitTest1.cs 文件,编写如下四个测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using System;
using Xunit;
using MyMath.Number;
namespace MyMath.Tests.Number
{
public class UnitTest1
{
[Fact]
public void Test1()
{
Assert.True(NumberCalculation.add(0,2)==2, "0 + 2 Should be 2");
}
[Fact]
public void Test2()
{
Assert.True(NumberCalculation.add(1,2)==3, "1 + 2 Should be 3");
}
[Fact]
public void Test3()
{
Assert.True(NumberCalculation.add(-1,2)==1, "-1 + 2 Should be 1");
}
[Fact]
public void Test4()
{
Assert.True(NumberCalculation.add(-1,-2)==-3, "-1 + -2 Should be -3");
}
}
}

这样,我们就完成了 add() 方法的简单单元测试示例。其中 [Fact] 标记指明下面的方法为测试方法,Assert.True() 函数中的第一项参数为实际值,其预期值为 True,当实际值与预期值一致时,该测试就可以通过,否则该测试不能通过,并将在终端中将参数二作为错误信息输出显示。

最后,在终端中 MyMath.Tests 文件夹下执行 dotnet test 即可运行单元测试。显然,我们之前编写的函数是可以正确计算所给用例的加法的,因此终端输出了如下信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PS C:\...\MyProject> dotnet test
已开始生成,请等待...
Skipping running test for project C:\...\MyProject\MyMath\MyMath.csproj. To run tests with dotnet test add "<IsTestProject>true<IsTestProject>" property to project file.
完成的生成。

C:\...\MyProject\MyMath.Tests\bin\Debug\netcoreapp2.2\MyMath.Tests.dll 的测试运行(.NETCoreApp,Version=v2.2)
Microsoft (R) 测试执行命令行工具版本 15.9.0
版权所有 (C) Microsoft Corporation。保留所有权利。

正在启动测试执行,请稍候...

总测试: 4。已通过: 4。失败: 0。已跳过: 0。
测试运行成功。
测试执行时间: 2.4093 秒

如果我们的 add() 方法编写有误,例如将 return a + b; 错误地修改为 return b;,再次执行 dotnet test 将得到如下信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
PS C:\...\MyProject> dotnet test
已开始生成,请等待...
Skipping running test for project C:\...\MyProject\MyMath\MyMath.csproj. To run tests with dotnet test add "<IsTestProject>true<IsTestProject>" property to project file.
完成的生成。

C:\...\MyProject\MyMath.Tests\bin\Debug\netcoreapp2.2\MyMath.Tests.dll 的测试运行(.NETCoreApp,Version=v2.2)
Microsoft (R) 测试执行命令行工具版本 15.9.0
版权所有 (C) Microsoft Corporation。保留所有权利。

正在启动测试执行,请稍候...
[xUnit.net 00:00:01.04] MyMath.Tests.Number.UnitTest1.Test4 [FAIL]
[xUnit.net 00:00:01.07] MyMath.Tests.Number.UnitTest1.Test2 [FAIL]
[xUnit.net 00:00:01.07] MyMath.Tests.Number.UnitTest1.Test3 [FAIL]
MyMath.Tests.Number.UnitTest1.Test4 个失败
错误消息:
-1 + -2 Should be -3
Expected: True
Actual: False
堆栈跟踪:
at MyMath.Tests.Number.UnitTest1.Test4() in C:\...\MyProject\MyMath.Tests\UnitTest1.cs:line 26
MyMath.Tests.Number.UnitTest1.Test2 个失败
错误消息:
1 + 2 Should be 3
Expected: True
Actual: False
堆栈跟踪:
at MyMath.Tests.Number.UnitTest1.Test2() in C:\...\MyProject\MyMath.Tests\UnitTest1.cs:line 16
MyMath.Tests.Number.UnitTest1.Test3 个失败
错误消息:
-1 + 2 Should be 1
Expected: True
Actual: False
堆栈跟踪:
at MyMath.Tests.Number.UnitTest1.Test3() in C:\...\MyProject\MyMath.Tests\UnitTest1.cs:line 21

总测试: 4。已通过: 1。失败: 3。已跳过: 0。
测试运行失败。
测试执行时间: 2.4545 秒

可以看到,终端中明确输出了未通过测试的用例及其错误信息,这将帮助你很快地定位并修复相关错误。

四、优化你的单元测试

写出一个单元测试不是一件难事,但是写出一个好的单元测试也不是一件容易的事。就上一小结 编写单元测试 中给出的测试代码就是极度糟糕的代码的典范。为了使用 xUnit 写出基本还不错的测试代码,你需要了解 xUnit 的一些其它属性。例如 [Theory] 表示执行相同的代码,但是使用不同的输入参数用来测试,并使用 [InlineData] 来指定这些参数的输入值。

由此改进后的单元测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
public class UnitTest1
{
[Theory]
[InlineData(0,2,2)]
[InlineData(1,2,3)]
[InlineData(-1,2,1)]
[InlineData(-1,-2,-3)]
public void Test1(int a, int b, int excepted)
{
Assert.True(NumberCalculation.add(a,b)==excepted, $"{a} + {b} Should be {excepted}");
}
}

同时,为了确保单元测试的正确性与轻便性,应当选取尽可能少且具有代表性的参数作为测试用例,否则过多的测试用例不仅有可能被人为地赋予错误期望造成单元测试失效(例如不小心将 [InlineData(-1,-2,-3)] 的期望输出写成了 3),也将增加每一次单元测试的时间开销。

针对 .NET Core 和 .NET Standard 单元测试的最佳做法与工程实施规范,微软在其官方文档中给出了详尽的说明,这些条款在单元测试的编写中具有很好的指导意义,可以帮助你写出更具规范与更易读的单元测试:《.NET Core 和 .NET Standard 单元测试最佳做法》

五、参考文章

如果你觉得文章内容对你有帮助,可以打赏支持一下哦!

0%