这3个C#单元测试工具,到底谁才是王者?

发表于:2020-12-22 09:59

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:程伟静 郎显磊    来源:51Testing软件测试网原创

  概述
  单元测试
  单元测试(unit testing),是指对软件中的最小可测试单元(函数/模块/类)进行检查和验证。
  单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。
  单元测试从长期来看,可以提高代码质量,减少维护成本,降低重构难度。但是从短期来看,加大了工作量,对于进度紧张的项目中的开发人员来说,可能会成为不少的负担。
  单元测试应该遵循以下原则:
  可靠性、可维护性、可读性;
  尽量避免测试中的逻辑,一个单元测试应该是一系列的方法调用和断言;
  避免重复代码;
  测试隔离,低耦合,防止不同测试之间的互相影响。
  主流C#单元测试工具
  我们调研了以下开源C#单元测试工具(开源工具数据来自于GitHub):
  C#单元测试相关的开源软件中,NUnit及XUnit.NET星级排名靠前,MsTest是微软公司开发的集成在Visual Studio中的C#单元测试工具,所以本文选取了星级排名前两名的NUnit、XUnit.NET和MsTest。
  测评指标
  对C#单元测试工具进行测评主要从功能性及非功能性两部分来进行。其中,功能性测评中包括是否支持测试用例分类、排序等;非功能测评点包括社区活跃度及文档完备性等。
  测评环境
  工具简介
  MsTest
  框架介绍
  (1) 基本介绍
  MSTest是一款由微软公司开发的单元测试框架,它能够很好地被应用在Visual Studio中,并且集成在了Visual Studio单元测试框架中,操作简单,上手容易。
  从使用的角度来看,如果用户使用的是Visual Studio作为IDE,那么MSTest在对它的集成方面无疑是最方便的,无需下载,无需安装,内置在vs的测试框架模板中。在VS中使用MsTest生成测试项目和新建一个C#项目一样方便;如果用户不使用VS,那么也可以通过命令行执行.exe文件来执行单元测试,但是MsTest不提供自己单独的GUI界面。
  MsTest中核心的概念有Test Class(测试类)、Test Method测试方法、断言和初始化及清理方法。
  Test Class:通过使用[TestClass]属性装饰类来声明测试类。该属性用于标识包含测试方法的类,最佳做法规定测试类应仅包含单元测试代码。
  Test Method:通过使用[TestMethod]属性装饰单元测试方法来声明测试方法。该属性用于标识包含单元测试代码的方法,最佳实践指出,单元测试方法应仅包含单元测试代码。
  断言:断言是一段代码,当运行于测试一个条件或行为针对预期的结果。通过调用Assert类中的方法来执行。
  初始化和清理方法:初始化和清理方法用于在运行之前准备单元测试,并在执行单元测试之后进行清除。初始化方法通过用[TestInitialize]属性装饰初始化方法来声明,而清理方法通过用[TestCleanup]属性声明清理方法。
  许多时候会把VsTest和MsTest混淆,其实这两个概念之间还是有一定区别的。
  VsTest是Visual Studio测试平台(Visual Studio Test Platform)的简称,是一个开放且可扩展的测试平台,可用于运行测试,收集诊断数据和报告结果。VsTest支持运行在各种测试框架中并使用可插拔适配器模型编写的测试。根据用户的选择,所需的测试框架及其相应的适配器可以视情况以vsix或NuGet软件包的形式获取。可以使用测试平台公开的公共API来编写适配器。
  MsTest是指微软开发的单元测试框架。目前MsTest最新的版本是MsTest V2,V2的版本依赖于两个包:MSTest.TestFramework和MSTest.TestAdapter,用户在使用时可以通过Nuget下载这两个包来使用MsTest进行单元测试。MsTest V2主要是为了.net core准备的,也可以在.net framework上运行,并且较V1版本新加入了一些扩展。
  下图是VsTest的总体架构图,从图中可以看到,整个VsTest体系架构主要有四个组件:测试运行器、测试执行器、数据收集器和IDE。
  其中,测试执行器部分主要包含一个执行测试的引擎,它把发现和执行测试的责任委托给了一些可扩展的测试适配器,允许测试平台基于第三方测试框架发现/运行测试。这将通过相应框架的适配器来完成,该适配器了解如何在该框架中定义测试,并可以运行它们以向测试平台提供结果。例如,MsTest适配器了解MsTest框架中编写的测试,VsTest中就会使用MsTest适配器发现并执行它们。
  在图中还可以看到,测试运行器部分还包含一个VsTest console,其作用是使用命令行运行不同框架的测试。但是如果是采用MsTest作为测试框架,也可以使用MsTest.exe作为命令行执行的工具。
  (2) 工具特点
  支持为测试用例设置分类,执行时执行指定分类的测试方法:[TestCategory]
  支持在一个或多个测试方法执行前后进行相关的准备、清理活动:[TestInitialize]和[TestCleanup]
  支持为测试用例设置参数:[DataRow]
  提供断言方法,判定期望值和实际值是否一致
  支持使用断言等方式,对返回异常的测试用例进行异常判断:[ExpectedException]
图2-1 MSTest中带参及异常判断实例
  支持通过注解等方式跳过执行带有该注解的测试用例:[Ignore]
  支持设置超时时间,通过在TestMethod中的[Timeout]属性可以设置单独测试case的超时时间;也可以在.runSettings配置文件中为所有case设置全局的超时时间。
图2-2 MSTest中timeout属性实例
  NUnit
  框架介绍
  (1)基本介绍
  NUnit 是专门针对于.NET 的自动化单元测试框架,是 XUnit 家族的一个成员,最初是由Java的单元测是框架JUnit 而来,作者最终用C#对其进行重新编写,NUnit完全由C# 编写,使其更加符合C#习惯,并充分利用了.NET中反射、客户属性等特性。因此,该工具具有丰富的单元测试历史的同时,也具有适当的C#风格。
  由于其独立的历史,NUnit还具有与其他工具良好交互的特点,并且在支持多种平台,包括:.NET Core、Xamarin Mobile、Compact Framework及Silverlight等。NUnit在快速测试运行方面也享有盛誉,并且还具有一些不错的附加功能,例如允指定给定测试的多个输入等。
  NUnit采用分层体系架构,主要有三层:测试运行器层(Test Runner)、测试引擎层(Test Engine)和框架层(Framework),其中,Test Runner层主要包含各种运行程序,包括独立程序和在其它程序下运行的任务或者插件;Test Engine层则是NUnit平台的核心,它提供公共API,供希望查找,加载和运行测试并显示测试结果的应用程序使用;Framework层主要是为了兼容各个版本的NUnit程序。
  NUnit中主要有三个抽象类:TestFixtureBuilderAttribute、TestCaseBuilderAttribute和IncludeExcludeAttribute。
  TestFixtureBuilderAttribute是任何知道如何从所提供的类中构建某种测试fixture的属性的基类,testfixture是指基于用户类的任何测试。
  TestCaseBuilderAttribute是任何知道如何从给定方法构建测试用例的属性的基类。测试用例可以是简单的(没有参数)也可以是参数化的(接受参数),并且总是基于MethodInfo。
  IncludeExcludeAttribute是任何用于根据字符串属性include、exclude和Reason来决定是在当前运行中包含测试还是排除的属性的基类,抽象类是使这些属性可供派生类使用,派生类负责对它们采取操作。
  在使用方面,NUnit可以通过控制台或自己独立的GUI来运行,在使用Visual Studio作为IDE时,NUnit也提供了相应的适配器,可以更好地和Visual Studio搭配使用。
  (2)工具特点
  NUnit2中有包含GUI界面;
  支持为测试用例设置分类,执行时执行指定分类的测试方法:使用[Category]属性;
  支持在一个或多个测试方法执行前后进行相关的准备、清理活动:[SetUp]和[TearDown];
  提供断言方法,如果断言失败,则方法调用不会有值返回并报告错误。如果一个测试包含多个断言,那么在某次断言失败之后就终止,其后的任何断言都不会执行;
  支持为测试用例设置参数:[TestCase]
图2-3 NUnit带参测试方法实例
  可以指定测试用例的执行顺序:[Order]
  支持使用断言等方式,对返回异常的测试用例进行异常判断:Assert.That
图2-4 NUnit排序及异常判断方法实例
  支持通过注解等方式跳过执行带有该注解的测试用例:[Ignore("Method is ignored")]
  支持设置超时时间,[Timeout]、[MaxTime]。[MaxTime]  标记测试用例的最大执行时间,超时时报错但不取消测试;[Timeout] 标记测试用例的超时时间,超时中断测试。
  TestSuite :UNIT3之后取消该属性,因为namespace也可以实现相同的功能。
  支持对对接主流的代码覆盖率工具,执行完单元测试用例后自动生成覆盖率报告。(ncover)
  XUnit.Net
  框架介绍
  (1)基本介绍
  XUnit .NET是一个开源的的单元测试工具,由NUnit v2的原始发明者编写,支持C#,F#,VB.NET版以及其他.NET语言,由.NET基金会支持,它采用了一种非常独特、现代和灵活的单元测试方法。
  XUnit .NET强调编写具有较高的可读性,简单性的单元测试,与其它单元测试框架相比,有一些独特的地方:
  XUnit比其他.Net单元测试框架更加灵活和可扩展,它允许创建新的属性来控制测试。XUnit支持两种类型的测试,[Fact]和[Theory],[Fact]通常用来测试不需要参数的方法,并且在XUnit中,用[Skip]属性代替了[Ignore],并要求指定跳过该测试的原因;[Theory]支持数据驱动的测试,可以用[InlineData]属性实现参数的传递,并支持多次执行同一个方法,是XUnit可扩展性强的一个重要体现。
图2-5 XUnit.NET带参测试方法实例
  XUnit支持更好地进行隔离测试。与其它测试框架不同,在xUnit中,每个测试方法运行后都会进行实例化操作,执行后释放相应的空间,测试之间更加独立,可以以任何顺序执行测试,而不必担心一个测试对其他测试的影响,消除了不同测试方法之间的依赖性。
  XUnit取消了[SetUp]和[TearDown]方法,而采用构造函数进行初始化,使用IDisposable进行测试类的后处理等操作,让每个测试对其需要的内容进行初始化。
  (2) 工具特点
  支持为测试用例设置分类,执行时执行指定分类的测试方法:[Trait("Category","UI")];
  支持为测试用例设置参数:[Theory] [InilineData];
  支持使用断言等方式,对返回异常的测试用例进行异常判断:Assert.Throws.Exception,长期使用[ExpectedException]会发现各种问题。首先,它没有具体说明应该在哪一行代码中引发异常,这会导致微妙且难以跟踪的失败,这些失败会在通过测试时显示出来。其次,由于处理不在测试的常规代码流程之内,因此它没有提供机会全面检查异常本身的详细信息。Assert.Throws允许您测试一组特定的代码以引发异常,并在成功期间返回异常,以便您可以针对异常实例本身编写进一步的断言。
图2-6 XUnit.NET异常判断实例
  支持通过注解等方式跳过执行带有该注解的测试用例,[Fact(Skip="reason")]
  支持设置超时时间,[Fact(Timeout =10)]
  工具试用
  本节采用一段简易货币转换计算器代码作为被测代码来试用三款工具,其测试用例基本可以涵盖日常单元测试所需的功能。在试用过程中,同时采用了Moq框架及dotCover覆盖率扫描工具。
  被测代码——简易货币转换计算器,主要有两个接口:ICaculator和IMoneyEx,和一个ICaculator接口的实现类:Caculator。其功能是实现简单运算及货币汇率转换功能。
  ICaculator接口定义如下:
public interface ICalculator
    {
        int Add(int param1, int param2);
        int Subtract(int param1, int param2);
        int Multipy(int param1, int param2);
        double Divide(int param1, int param2);
        int ConvertUSDtoRMB(int unit);
  }
  IMoneyEx接口定义如下:
public interface IMoneyEx
    {
        int GetActualUSDValue();
}
  ICaculator接口的实现类Caculator如下:
public class Calculator : ICalculator
    {
        private IMoneyEx _feed;
        public Calculator(IMoneyEx feed)
        {
            this._feed = feed;
        }
        #region ICalculator Members
        public int Add(int param1, int param2)
        {
            return param1 + param2;
        }
        public int Subtract(int param1, int param2)
        {
            return param1 - param2;
        }
        public int Multipy(int param1, int param2)
        {
            return param1 * param2;
        }
        public double Divide(int param1, int param2)
        {
            return param1 / param2;
        }
        public int ConvertUSDtoRMB(int unit)
        {
            return unit * this._feed.GetActualUSDValue();
        }
        #endregion
}
  测试代码
  Mock代码
// 定义mock逻辑
private IMoneyEx PrvGetMockExchangeRateFeed()
        {
            Mock<IMoneyEx> mockObject = new Mock<IMoneyEx>();
            mockObject.Setup(m => m.GetActualUSDValue()).Returns(500);
            return mockObject.Object;
        }
  不同测试工具测试代码
  (1) MsTest
namespace CalculatorPkg.Tests
{
private static ICalculator calculator = null;
    [TestClass()]
    public class MsTestCal
    {  
        // 初始化
        [TestInitialize]
        public void Setup()
        {
IMoneyEx feed = this.PrvGetMockExchangeRateFeed();
            calculator = new Calculator(feed);
            Console.WriteLine("before test...");
        }

        //带参测试方法,并对测试方法分类
        [TestMethod()]
        [DataRow(1,1)]
        [TestCategory("cal")]
        public void TC1_Add(int a,int b)
        {
            int c = a + b;
            Assert.AreEqual(c, calculator.Add(a,b));
        }

        // 忽略测试用例
        [TestMethod()]
        [Ignore]
        [TestCategory("cal")]
        public void TC1_Divide9By3()
        {
            double actualResult = calculator.Divide(9, 3);
            int expectedResult = 3;
            Assert.AreEqual(expectedResult, actualResult);
        }

        // 异常判断
        [TestMethod()]
        [ExpectedException(typeof(DivideByZeroException))]
        [TestCategory("cal")]
        public void TC2_DivideByZero()
        {
            double actualResult = calculator.Divide(9, 0);
        }

        // 使用mock类
        [TestMethod()]
        [TestCategory("convert")]
        public void TC3_ConvertUSDtoRMBTest()
        {
            int actualResult = calculator.ConvertUSDtoRMB(1);
            int expectedResult = 500;
            Assert.AreEqual(expectedResult, actualResult);
        }

// 后处理
        [TestCleanup]
        public void Cleanup()
        {
            Console.WriteLine("after test...");
        }


    }
}
  DotCover结果显示:
  (2) Nunit
namespace CalculatorPkg.Tests
{
    // 添加TestFixture标识类是测试类
    [TestFixture]
    public class NUnitTest
    {
        // 初始化
        [SetUp]
        public void Setup()
        {
IMoneyEx feed = this.PrvGetMockExchangeRateFeed();
            calculator = new Calculator(feed);
            Console.WriteLine("before test...");
        }

        // 测试用例分类,跳过测试方法
        [Test(Description = "Add 1 with 1. Expected result is 2.")]
        [Category("add")]
        [Ignore("Method is ignored")]
        public void TC0_Add1With1()
        {
            int actualResult = calculator.Add(1,1);
            int expectedResult = 2;
            Assert.AreEqual(expectedResult, actualResult);
        }

        // 带参测试方法
        [Test()]
        [Category("sub")]
        [TestCase(0,1)]
        [TestCase(1,1)]
        [TestCase(2, 1)]
        public void TC0_SUB(int a,int b)
        {
            int actualResult = calculator.Subtract(a,b);
            int expectedResult = a-b;
            Assert.AreEqual(expectedResult, actualResult);
        }

        // 测试divide方法,以及测试用例排序
        [Test(Description = "Divide 9 by 3. Expected result is 3.")]
        [Category("divide")]
        [Order(2)]
        public void TC1_Divide9By3()
        {
            Console.WriteLine("TC1_Divide9By3:" + DateTime.Now.TimeOfDay.ToString());
            double actualResult = calculator.Divide(9, 3);
            int expectedResult = 3;
            Assert.AreEqual(expectedResult, actualResult);
        }

        // 异常判断
        [Test(Description = "Divide any number by zero. Should throw an System.DivideByZeroException exception.")]
        [Category("divide")]
        [Order(3)]
        public void TC2_DivideByZero()
        {
            Console.WriteLine("TC2_DivideByZero:" + DateTime.Now.TimeOfDay.ToString());
            Assert.That(() => calculator.Divide(9, 0), Throws.TypeOf<DivideByZeroException>());
        }

        // mock实例
        [Test(Description = "Convert 1 USD to RMB. Expected result is 500.")]
        [Category("convert")]
        [Order(1)]
        public void TC3_ConvertUSDtoRMBTest()
        {
            Console.WriteLine("TC3_ConvertUSDtoRMBTest执行时间:" + DateTime.Now.TimeOfDay.ToString());
            int actualResult = calculator.ConvertUSDtoRMB(1);
            int expectedResult = 500;
            Assert.AreEqual(expectedResult, actualResult);
        }

        // 后处理
        [TearDown]
        public void Teardown()
        {
            Console.WriteLine("after test...");
        }
    }
}
  DotCover结果显示:
  执行顺序结果:
图 NUnit测试用例执行顺序验证
  即执行情况符合规定的顺序。
  (3) XUnit.NET
namespace CalculatorPkg.Tests
{
   public class XUnitCal
    {
        // 测试用例分类,跳过测试方法
        [Fact(Skip = "Method is ignored")]
        [Trait("Category", "add")]
        public void TC0_Add1With1()
        {
            IMoneyEx feed = this.PrvGetMockExchangeRateFeed();
            ICalculator calculator = new Calculator(feed);
            int actualResult = calculator.Add(1, 1);
            int expectedResult = 2;
            Assert.Equal(expectedResult, actualResult);
        }

        // 带参测试方法
        [Theory()]
        [Trait("Category", "multi")]
        [InlineData(0, 1)]
        [InlineData(1, 1)]
        public void TC0_multi(int a, int b)
        {
            IMoneyEx feed = this.PrvGetMockExchangeRateFeed();
            ICalculator calculator = new Calculator(feed);
            int actualResult = calculator.Multipy(a, b);
            int expectedResult = a * b;
            Assert.Equal(expectedResult, actualResult);
        }

        [Fact()]
        [Trait("Category", "divide")]
        public void TC1_Divide9By3()
        {
            IMoneyEx feed = this.PrvGetMockExchangeRateFeed();
            ICalculator calculator = new Calculator(feed);
            double actualResult = calculator.Divide(9, 3);
            int expectedResult = 3;
            Assert.Equal(expectedResult,actualResult);
        }

        // 异常判断
        [Fact()]
        [Trait("Category", "divide")]
        public void TC2_DivideByZero()
        {
            IMoneyEx feed = this.PrvGetMockExchangeRateFeed();
            ICalculator calculator = new Calculator(feed);
            //double actualResult = calculator.Divide(9, 0);
            Assert.Throws<DivideByZeroException>(() => calculator.Divide(9, 0));
        }

        // mock实例
        [Fact()]
        [Trait("Category", "convert")]
        public void TC3_ConvertUSDtoRMBTest()
        {
            IMoneyEx feed = this.PrvGetMockExchangeRateFeed();
            ICalculator calculator = new Calculator(feed);
            int actualResult = calculator.ConvertUSDtoRMB(1);
            int expectedResult = 500;
            Assert.Equal(expectedResult, actualResult);
        }
   }
}
  DotCover结果显示:
  工具对比
  本次调研重点研究的三款C#单元测试工具(MsTest、NUnit、XUnit.NET)来说,使用区别度并不是很大,具体如下:
  MsTest作为内置的visual studio测试工具来讲,操作简单,易于使用;另外,如果已经使用visual studio作为编译器,不用做任何的安装即可使用,也是其较为明显的优点之一。但是其也存在在带参测试时不能同时支持异常判断,以及无法对测试用例排序等缺点。
  NUnit作为比较成熟的C#单元测试工具,好处包括可以按名称空间进行测试分组,添加测试用例注释(使用相同的参数多次运行相同的测试)及对测试用例排序等功能,并且它与Opencover和Report Generator进行覆盖分析的效果很好。主要的缺点是它没有像MSTest那样集成,但是通过Nuget现在在visual studio中使用NUnit已经成为一件比较容易的事情。NUnit还有一个不同于其它测试工具的特点是NUnit2中有自己的GUI,可以不通过VS来看单元测试结果,但是如上所说,GUI只在NUnit2中提供,而现在普遍使用的都是NUnit3。
  XUnit.NET作为NUnit的进阶简化版,是一种和NUnit及其相似的简单现代的单元测试框架。XUnit.NET不同于其它测试工具的特点主要有两个:一是取消了单元测试框架中的前后处理方法,为每个测试方法都创建测试类的新实例,即提高了测试用例之间的隔离性;二是用断言替代了属性的方式来捕捉异常,不采用Attribute的方式来捕捉异常有两方面的好处:在代码中直接断言(Assert)能捕捉到更多种类的异常;遵守Arrange-Act-Assert (or "3A") 模式:即测试命名上“范围-作用-断言”规范。但是正是由于其简化的特性,其缺点也显而易见,即一些高阶的测试可能无法用XUnit完成,并且和NUnit一样,XUnit与VS的集成没有MsTest那么自然。
  总结
  本次调研重点测评了三款C#开源单元测试工具,对其特性及基本使用进行了介绍。其实在开源社区蓬勃发展的今天,众多开源工具之间的区别也通过很多次迭代渐渐在缩小,如本次重点调研的三款工具虽各有优势和劣势,但是差异已经不再那么明显,大家在使用时结合自身的业务背景来选择合适的工具即可。

      版权声明:本文出自51Testing会员投稿,51Testing软件测试网及相关内容提供者拥有内容的全部版权,未经明确的书面许可,任何人或单位不得对本网站内容复制、转载或进行镜像,否则将追究法律责任。
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号