2. 使用MSTest进行单元测试

编写单元测试有助于代码维护。例如,在更新代码时,想要确定更新不会破坏其他代码。创建自动单元测试可以帮助确保修改代码后,所有功能都得以保留。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

下面是规则汇总:

  • 测试项目的名称是在项目名后加上Tests,例如,对于项目UnitTestingSamples,测试项目的名称是UnitTestingSamples.Tests。
  • 测试类名与被测试的类名相同,后跟Test。例如,DeepThought的测试类时DeepThoughtTest。
  • 单元测试方法名采用描述性的名称,例如,名称AddOrUpdateBookAsync_ThrowsForNull表示,一个单元测试调用AddOrUpdateBookAsync方法,检查传递null时它是否抛出异常。

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,可以在解决方案中运行测试。

2. 使用MSTest进行单元测试_第1张图片

下图显示了一个失败的测试,列出了失败的所有细节。

2. 使用MSTest进行单元测试_第2张图片

要在命令行上运行测试,可以调用 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());
        }

测试结果:

2. 使用MSTest进行单元测试_第3张图片

注意

要测试不使用依赖注入的方法,用测试类替代在内部使用的依赖项,可以使用Fakes Framework。这只能用于Visual Studio企业版的.NET Framework项目。

 

你可能感兴趣的:(测试,C#)