编写单元测试有助于代码维护。例如,在更新代码时,想要确定更新不会破坏其他代码。创建自动单元测试可以帮助确保修改代码后,所有功能都得以保留。Visual Studio 2017提供了一个健壮的单元测试框架,还可以在Visual Studio内使用其他测试框架。
1. 使用MSTest创建单元测试
下面的示例测试类库UnitTestingSamples中一个非常简单的方法。这是一个.NET标准库。当然,可以创建其他基于MSBuild的项目。类DeepThought包含TheAnswerToTheUltimateQuestionOfLifeTheUniversalAndEverything方法,该方法返回42作为结果:
public class DeepThought
{
public int TheAnswerOfTheUltimateQuestionOfLifeUniversalAndEverything() =>
42;
}
为了确保没有人改变返回错误结果的方法,创建一个单元测试。要创建单元测试,可以使用dotnet命令:
> dotnet new mstest
也可以使用Visual Studio中的Unit Test Project(.NET Core)项目模版。
开始创建第一个测试之前,最好考虑一下测试和测试项目的命名。当然,可以使用任何名称,但.NET Core团队提供了较好的命名规则,参阅:
https://github.com/aspnet/Home/wiki/Engineer_guidelines#unit-tests-ad-functional-tests
下面是规则汇总:
MSTest项目包含对NuGet包Microsoft.NET.Test.Sdk、MSTest.TestAdapter和MSTest.TestFramework的引用:
netcoreapp3.1
false
单元测试类标有TestClass特性,测试方法标有TestMethod特性。该实现方法创建DeepThought的一个实例,并调用要测试的方法TheAnswerToTheUltimateQuestionOfLifeTheUniversalAndEverything。返回值使用Assert.AreEqual与42进行比较。如果Assert.AreEqual失败,测试就失败:
[TestClass]
public class DeepThoughtTest
{
[TestMethod]
public void ResultOfTheAnswerToTheUltimateQuestionOfLifeTheUniversalAndEverything()
{
//arrange
int expected = 42;
var dt = new DeepThought();
//act
int actual = dt.TheAnswerOfTheUltimateQuestionOfLifeUniversalAndEverything();
//assert
Assert.AreEqual(expected,actual);
}
}
单元测试由3个A定义:Arrage、Act和Assert。首先,一切都安排好了,单元测试可以开始了。在安排阶段,在第一个测试中,给变量expected分配调用要测试的方法时预期的值,调用DeepThought类的一个实例。现在准备好测试功能了。在行动阶段,调用方法。在完成行动阶段后,需要验证结果是否与预期相同。这在断言阶段使用Assert类的方法来完成。
Assert类是Microsoft.VisualStudio.TestTools.UnitTesting名称空间中MSTest框架的一部分。这个类提供了一些可用于单元测试的静态方法。默认情况下,Assert.Fail方法添加到自动创建的单元测试中,提供测试还没有实现的信息。其他一些方法有:AreNotEqual验证两个对象是否不同;IsFalse和IsTrue验证布尔结果;IsNull和IsNotNull验证空结果;IsInstanceOfType和IsNotInstanceOfType验证传入的类型。
2. 运行单元测试
使用Test Explorer,可以在解决方案中运行测试。
下图显示了一个失败的测试,列出了失败的所有细节。
要在命令行上运行测试,可以调用 dotnet test:
> dotnet test
在示例应用程序中,会得到成功的结果:
Determining projects to restore...
All projects are up-to-date for restore.
You are using a preview version of .NET. See: https://aka.ms/dotnet-core-preview
UniTestingSamples -> D:\C#TrainingSamples\Test\UniTestingSamples\bin\Debug\netstandard2.0\UniTestingSamples.dll
UnitTestingSamples.MSTests -> D:\C#TrainingSamples\Test\UnitTestingSamples.MSTests\bin\Debug\netcoreapp3.1\UnitTestingSamples.MSTests.dll
Test run for D:\C#TrainingSamples\Test\UnitTestingSamples.MSTests\bin\Debug\netcoreapp3.1\UnitTestingSamples.MSTests.dll(.NETCoreApp,Version=v3.1)
Microsoft (R) Test Execution Command Line Tool Version 16.7.0
Copyright (c) Microsoft Corporation. All rights reserved.
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Test Run Successful.
Total tests: 1
Passed: 1
Total time: 0.7106 Seconds
当然,这只是一个很简单的场景,测试通常是没有这么简单的。例如,方法可以抛出异常,用其他的路径返回其他值,或者使用了不应该在单个单元中测试的代码(例如数据库访问代码或调用的服务)。接下来介绍一个比较复杂的单元测试场景。
下面的类StringSample定义了一个带字符串参数的构造函数、方法GetStringDemo和一个字段。方法GetStringDemo根据first和second参数使用不同的路径,并返回一个从这些参数得到的字符串:
public class StringSample
{
public StringSample(string init)
{
if (init is null)
{
throw new ArgumentNullException(nameof(init));
}
_init = init;
}
private string _init;
public string GetStringDemo(string first,string second)
{
if (first is null)
{
throw new ArgumentNullException(nameof(first));
}
if (string.IsNullOrEmpty(first))
{
throw new ArgumentException("empty string is not allowed",first);
}
if (second is null)
{
throw new ArgumentNullException(nameof(second));
}
if (second.Length > first.Length)
{
throw new ArgumentException(nameof(second),
"must be shorter than first");
}
int startIndex = first.IndexOf(second);
if (startIndex < 0)
{
return $"{second} not found in {first}";
}
else if (startIndex < 5)
{
string result = first.Remove(startIndex, second.Length);
return $"removed {second} from {first}: {result}";
}
else
{
return _init.ToUpperInvariant();
}
}
}
注意:
为复杂的方法编写单元测试时,有时单元测试也会变得复杂起来。这有助于调试单元测试,找出当前执行的操作。调试单元测试很简单:给单元测试代码添加断点,并从Test Explorer的上下文菜单中选择Debug Selected Tests(Debug)。
3. 使用MSTest预期异常
以null为参数调用StringSample类的构造函数和GetStringDemo方法时,可以预计会发生什么ArgumentNullException异常。在测试代码中很容易测试这一点,只需要像下面的示例那样对测试方法应用ExpectedException特性。这样,测试方法将成功地捕获到异常:
[TestClass]
public class StringSampleTest
{
[TestMethod]
[ExpectedException(typeof(ArgumentNullException))]
public void ConstructorShouldThrowOnNull()
{
var sample = new StringSample(null);
}
}
对于GetStringDemo方法抛出的异常,可以采取类似的处理。
4. 测试全部代码路径
为了测试全部代码路径,可以创建多个测试,每个测试针对一条代码路径。下面的测试示例将字符串a和b传递给GetStringDemo方法。因为第二个字符串没有包含在第一个字符串内,所以if语句的第一个路径生效。结果将被相应地检查:
[TestMethod]
public void GetStringDemoBNotInA()
{
var expected = "b not found in a";
var sample = new StringSample(string.Empty);
string actual = sample.GetStringDemo("a","b");
Assert.AreEqual(expected,actual);
}
下一个测试方法验证GetStringDemo方法的另一个路径。在这个示例中,第二个字符串包含在第一个字符串内,并且索引小于5,所以将执行else if语句的代码块:
[TestMethod]
public void GetStringDemoRemoveBCFromABCD()
{
var expected = "removed bc from abcd: ad";
var sample = new StringSample(string.Empty);
string actual = sample.GetStringDemo("abcd", "bc");
Assert.AreEqual(expected,actual);
}
其他所有代码路径都可以以类似的方式测试。为了查看单元测试覆盖了哪些代码,以及还缺少什么代码,可以启动Visual Studio 2017中的Code Coverage,使用dotnet test命令的--collect选项。在Visual Studio 2017 的Code Coverage Results窗口(Test | Windows | Code Coverage Results)中,可以看到单元测试覆盖代码的百分比(需要Visual Studio 2017+ 企业版)。
许多方法都依赖于不受应用程序本身控制的某些功能,例如调用Web服务或者访问数据库。在测试外部资源的可用性时,可能服务或数据库并不可用。更糟的是,数据库和服务可能在不同的时间返回不同的数据,这就很难与预期的数据进行比较。在单元测试中,必须排除这种情况。
下面的代码依赖于外部的某些功能。方法ChampionsByCountry()访问一个Web服务器上的XML文件,该文件以Firstname、Lastname、Wins和Country元素的形式列出了一级方程式世界冠军。这个列表按国家筛选,并使用Wins元素的值按数字顺序排序。返回的数据是一个XElement,其中包含了转换后的XML代码:
public class Formulal
{
public XElement ChampionsByCountry(string country)
{
XElement champions = XElement.Load(FlAddresses.RacersUrl);
var q = from r in champions.Elements("Racer")
where r.Element("Country").Value == country
orderby int.Parse(r.Element("Wins").Value) descending
select new XElement("Racer",
new XAttribute("Name", r.Element("Fristname").Value +
r.Element("Lastname").Value),
new XAttribute("Country", r.Element("Country").Value),
new XAttribute("Wins", r.Element("Wins").Value));
return new XElement("Racers", q.ToArray());
}
}
到XML文件的链接由FlAddresses类定义:
public class FlAddresses
{
public const string RacersUrl =
"http://www.cninnovation.com/downloads/Racers.xml";
}
应该为ChampionsByCountry方法创建一个单元测试。测试不应该依赖于服务器上的数据源。一方面,服务器可能不可用。另一方面,服务器上的数据可能随时间发生改变,返回新的冠军和其他值。正确的测试应该确保按预期方式完成筛选,并以正确的顺序返回正确筛选后的列表。
创建独立于数据源的单元测试的一种方法是使用依赖注入,重构ChampionsByCountry方法的实现代码。在这里, 创建一个返回XElement的工厂,来取代XElement.Load方法。IChampionsLoader接口是在ChampionsByCountry方法中使用的唯一外部要求。IChampionsLoader接口定义了方法LoadChampions,可以代替XElement.Load方法:
public interface IChampionsLoader
{
XElement LoadChampions();
}
类ChampionsLoader使用LoadChampions方法实现了接口IChampionsLoader,该方法由ChampionsByCountry方法预先使用:
public class ChampionsLoader : IChampionsLoader
{
public XElement LoadChampions() => XElement.Load(FlAddresses.RacersUrl);
}
现在就能修改ChampionsByCountry()方法的实现,使用接口而不是直接使用XElement.Load()方法来加载冠军。IChampionsLoader传递给类Formulal的构造函数,然后ChampionsByCountry()将使用这个加载器:
public class Formulal
{
private IChampionsLoader _loader;
public Formulal(IChampionsLoader loader) => _loader = loader;
public XElement ChampionsByCountry(string country)
{
//XElement champions = XElement.Load(FlAddresses.RacersUrl);
XElement champions = _loader.LoadChampions();
var q = from r in champions.Elements("Racer")
where r.Element("Country").Value == country
orderby int.Parse(r.Element("Wins").Value) descending
select new XElement("Racer",
new XAttribute("Name", r.Element("Fristname").Value +
r.Element("Lastname").Value),
new XAttribute("Country", r.Element("Country").Value),
new XAttribute("Wins", r.Element("Wins").Value));
return new XElement("Racers", q.ToArray());
}
}
在典型的实现代码中,会把一个ChampionsLoader实例传递给Formulal构造函数,以从服务器检索赛车手。
创建单元测试时,可以实现一个自定义方法来返回一级方程式冠军,如方法FormulalSampleData()所示:
return @"
Nino
Farina
Italy
33
5
Alberto
Ascari
Italy
32
10
Juan Manuel
Fangio
Argentina
51
24
Mike
Hawthorn
UK
45
3
Phil
Hill
USA
48
3
John
Surtees
UK
111
6
Jim
Clark
UK
72
25
Jack
Brabham
Australia
125
14
Denny
Hulme
New Zealand
112
8
Graham
Hill
UK
176
14
Jochen
Rindt
Austria
60
6
Jackie
Stewart
UK
99
27
Emerson
Fittipaldi
Brazil
143
14
James
Hunt
UK
91
10
Mario
Andretti
USA
128
12
Jody
Scheckter
South Africa
112
10
Alan
Jones
Australia
115
12
Keke
Rosberg
Finland
114
5
Niki
Lauda
Austria
170
25
Nelson
Piquet
Brazil
204
23
Ayrton
Senna
Brazil
161
41
Nigel
Mansell
UK
187
31
Alain
Prost
France
197
51
Damon
Hill
UK
114
22
Jacques
Villeneuve
Canada
165
11
Mika
Hakkinen
Finland
160
20
Michael
Schumacher
Germany
308
91
Fernando
Alonso
Spain
248
32
Kimi
Raikkonen
Finland
226
20
";
方法FormulalVerificationData返回符合预期结果的样品测试数据:
internal XElement FormulalVerificationData()
{
return XElement.Parse(@"
");
}
测试数据的加载器实现了与ChampionsLoader类相同的接口:ICompionsLoader。这个加载器仅使用样本数据,而不访问Web服务器:
public class FlTestLoader : IChampionsLoader
{
public XElement LoadChampions()
=> XElement.Parse(FormulalSampleData());
}
现在,很容易创建一个使用样本数据的单元测试:
[TestMethod]
public void ChampionsByCountryFilterFinland()
{
Formulal fl = new Formulal(new FlTestLoader());
XElement actual = fl.ChampionsByCountry("Finland");
Assert.AreEqual(FormulalVerificationData().ToString(),actual.ToString());
}
测试结果:
注意:
要测试不使用依赖注入的方法,用测试类替代在内部使用的依赖项,可以使用Fakes Framework。这只能用于Visual Studio企业版的.NET Framework项目。