标题有点标题党,但相信各位看完这篇文章必定会所收获,若是以前没有接触过单元测试或了解不深经过本文都能对单元测试有个全新认识。本文的特色是不脱离实际,所测试的代码都是常见的模式。html
写完这篇文章后,我看了一些关于单元测试理论的东西,发现文章中有些好像不太合主流测试理论,因为理论和使用我的难以完美结合,只能取实用为本。程序员
另外本文编写的单元测试都是基于已有代码进行测试,而不是TDD倡导的现有测试后有能够工做的代码,不一样思想指导下写出的测试代码可能不太同样。正则表达式
最近的项目中写了一个巨长的函数,调试的时候老是发现各类潜在的问题,一遍一遍的F5启动调试,键盘都快按烂了,那个函数还没跑通。想了想,弄个单元测试颇有必要,说干就干。找来xUnit等把单元测试搞了起来。说来这仍是第一次正八经的搞单元测试,想一想有必要把这个过程记录一下,因而这篇文章就诞生了。算法
进行单元测试代码编写的过程当中收获还真很多,好比把那个巨长的函数重构为4个功能相对独立,大小适中的函数。另外写测试能够以用户的角度使用函数,从而发现了几个以前没有想到的应该进行逻辑判断的地方并在程序代码中加入了if段。其实这些都是单元测试的好处,固然单元测试的利可能不仅这些,总之越早在项目中加入单元测试越是事半功倍。数据库
这篇文章对单元测试作了一些总结,固然最重要的是记录了Mocks工具的使用。在此次单元测试以前我对单元测试的了解停留在几个测试框架的测试方法上。拿测试运行器干的最多的事不是“测试”而是“调试”。即通常都是在一个类及函数不方便启动程序来调试时,搞一个测试类,用测试运行器的调试功能专门去Debug这个方法。这其实也只是用了测试框架(和测试运行器)很小的一部分功能。编程
在开始正题以前说一说单元测试工具的选择。如今xUnit.net几乎成为了准官方的选择。xUnit.net配套工具完善,上手简单初次接触单元测试是很好的选择。测试运行器选择了Resharper的xUnit runner插件(Resharper也是vs必不可少的插件),我的始终感受VS自带的测试运行工具远不如Resharper的好用。Mock框架选择了大名鼎鼎的RhinoMocks,神同样的开源Mock框架。json
因为我是单元测试新手,这也是第一次比较仔细的写单元测试,最大的体会就是Mock工具要比Test Framework与编写单元测试代码的用户关系更密切。本文将从最简单的测试开始争取将全部可能遇到的测试状况都写出来,若有不完整也请帮忙指出,若有错误请不吝赐教。c#
插播一下,xUnit.net的安装很简单,打开Nuget包管理器找到xUnit.net并安装就能够了(写这篇文章是最新正式版是2.0,2.1到了RC),就是一些程序集。RhinoMocks也是同理。Resharper的xUnit Test Runner经过Resharper的Extension Manager(有这么一个菜单项)来安装,点击菜单弹出以下图的对话框:缓存
图1框架
写这段内容时,xUnit.net Test Runner排在显眼的第一位,点击ToggleButton切换到Install,点击安装就能够了,完了须要重启vs。
ps.新版的Resharper Extension Manager基于Nuget实现,我这里的联通宽带连nuget常周期性抽风,有时不得不走代理,速度龟慢。
这里先展现一个最简单的方法及测试,目的是让没有接触过单元测试的同窗有个直观印象:
被测方法是一个计算斐波那契数的纯计算方法:
public int Fibonacci(int n) { if (n == 1 || n == 2) { return 1; } int first = 1; int second = 1; for (int i = 2; i < n; i++) { var temp = second; second += first; first = temp; } return second; }
测试方法:
[Fact] public void Test_Fibonacci_N() { var act = Fibonacci(10); var expect = 55; Assert.True(act == expect); }
xUnit最简单的使用就是在测试方法上标记[Fact],若是使用Resharper Test Runner的话在vs的代码窗口中能够看到这样这样一个小圆圈,点击就能够“运行”或“调式”这个测试方法。(其它runner也相似)
图2
在测试方法所在的类声明那行前面也有一个这个的圆点,点击后能够执行类中全部测试方法。若是测试经过圆点是绿色小球标识,若是不经过会以红色标记显示。
另外也能够打开Resharper的UnitTest窗口,里面会列出项目中全部的单元测试,也能够经过这个执行单个或批量测试:
图3
咱们执行上面的测试,能够看到下面的结果:
图4
嗯 ,咱们的测试经过了。有时候咱们还会编写一些测试,测试相反的状况,或边界状况。如:
[Fact] public void Test_Fibonacci_N_Wrong() { var act = Fibonacci(11); var expect = 55; Assert.False(act == expect); }
在团队人员配置比较齐全的状况下,设计测试用例应该是测试人员的工做,程序员按照设计好的测试用例编写测试方法,对被测试方法进行全方面的测试。
除了上面用到的Assert.True/False,xUnit还提供了以下几种断言方法(以2.0版为准,表格尽可能给这些方法分类排的序,可能不太完整):
断言 | 说明 |
---|---|
Assert.Equal() | 验证两个参数是否相等,支持字符串等常见类型。同时有泛型方法可用,当比较泛型类型对象时使用默认的IEqualityComparer<T>实现,也有重载支持传入IEqualityComparer<T> |
Assert.NotEqual() | 与上面的相反 |
Assert.Same() | 验证两个对象是否同一实例,即判断引用类型对象是否同一引用 |
Assert.NotSame() | 与上面的相反 |
Assert.Contains() | 验证一个对象是否包含在序列中,验证一个字符串为另外一个字符串的一部分 |
Assert.DoesNotContain() | 与上面的相反 |
Assert.Matches() | 验证字符串匹配给定的正则表达式 |
Assert.DoesNotMatch() | 与上面的相反 |
Assert.StartsWith() | 验证字符串以指定字符串开头。能够传入参数指定字符串比较方式 |
Assert.EndsWith() | 验证字符串以指定字符串结尾 |
Assert.Empty() | 验证集合为空 |
Assert.NotEmpty() | 与上面的相反 |
Assert.Single() | 验证集合只有一个元素 |
Assert.InRange() | 验证值在一个范围以内,泛型方法,泛型类型须要实现IComparable<T>,或传入IComparer<T> |
Assert.NotInRange() | 与上面的相反 |
Assert.Null() | 验证对象为空 |
Assert.NotNull() | 与上面的相反 |
Assert.StrictEqual() | 判断两个对象严格相等,使用默认的IEqualityComparer<T>对象 |
Assert.NotStrictEqual() | 与上面相反 |
Assert.IsType()/Assert.IsType<T>() | 验证对象是某个类型(不能是继承关系) |
Assert.IsNotType()/ Assert.IsNotType<T>() |
与上面的相反 |
Assert.IsAssignableFrom()/ Assert.IsAssignableFrom<T>() |
验证某个对象是指定类型或指定类型的子类 |
Assert.Subset() | 验证一个集合是另外一个集合的子集 |
Assert.ProperSubset() | 验证一个集合是另外一个集合的真子集 |
Assert.ProperSuperset() | 验证一个集合是另外一个集合的真超集 |
Assert.Collection() | 验证第一个参数集合中全部项均可以在第二个参数传入的Action<T>序列中相应位置的Action<T>上执行而不抛出异常。 |
Assert.All() | 验证第一个参数集合中的全部项均可以传入第二个Action<T>类型的参数而不抛出异常 。与Collection()相似,区别在于这里Action<T>只有一个而不是序列。 |
Assert.PropertyChanged() | 验证执行第三个参数Action<T>使被测试INotifyPropertyChanged对象触发了PropertyChanged时间,且属性名为第二个参数传入的名称。 |
Assert.Throws()/Assert.Throws<T>() Assert.ThrowsAsync()/ Assert.ThrowsAsync<T>() |
验证测试代码抛出指定异常(不能是指定异常的子类) 若是测试代码返回Task,应该使用异步方法 |
Assert.ThrowsAny<T>() Assert.ThrowsAnyAsync<T>() |
验证测试代码抛出指定异常或指定异常的子类 若是测试代码返回Task,应该使用异步方法 |
编写单元测试的测试方法就是传说中的3个A,Arrange、Act和Assert。
Arrange用于初始化一些被测试方法须要的参数或依赖的对象。
Act方法用于调用被测方法获取返回值。
Assert用于验证测试方法是否定期望执行或者结果是否符合指望值
大部分的测试代码都应按照这3个部分来编写,上面的测试方法中只有Act和Assert2部分,对于逻辑内聚度很高的函数,这2部分就能够很好的工做。像是一些独立的算法等按上面编写测试就能够了。可是若是被测试的类或方法依赖其它对象咱们就须要编写Arrange部分来进行初始化。下一节就介绍相关内容。
在大部分和数据库打交道的项目中,尤为是使用EntityFramework等ORM的项目中,经常会有IRepository和Repository<T>这样的身影。我所比较赞同的一种对这种仓储类测试的方法是:使用真实的数据库(这个真实指的非Mock,通常来讲使用不一样于开发数据库的测试数据库便可,经过给测试方法传入测试数据库的连接字符串实现),而且相关的DbContext等都直接使用EntityFramework的真实实现而不是Mock。这样,在IRepository之上的全部代码咱们均可以IRepository的Mock来做为实现而不用去访问数据库。
若是对于实体存储到数据库可能存在的问题感到担忧,如类型是否匹配,属性是否有可空等等,咱们也能够专门给实体写一些持久化测试。为了使这个测试的代码编写起来更简单,咱们能够把上面测试好的IRepository封装成一个单独的方法供实体的持久化测试使用。
下面将给出一些示例代码:
首先是被测试的IRepository
public interface IRepository<T> where T : BaseEntity { T GetById(object id); void Insert(T entity); void Update(T entity); void Delete(T entity); IQueryable<T> Table { get; } IQueryable<T> TableNoTracking { get; } void Attach(T entity); }
这是一个项目中最多见的IRepository接口,也是最简单化的,没有异步支持,没有Unit of Work支持,但用来演示单元测试足够了。这个接口的实现代码EFRepository就不列出来的(用EntityFramework实现这个接口的代码大同小异)。下面给出针对这个接口进行的测试并分析测试中的一些细节。
public class EFRepositoryTests:IDisposable { private const string TestDatabaseConnectionName = "DefaultConnectionTest"; private readonly IDbContext _context; private readonly IRepository<User> _repository;//用具体的泛型类型进行测试,这个不影响对EFRepository测试的效果 public EFRepositoryTests() { _context = new MyObjectContext(TestDatabaseConnectionName); _repository = new EfRepository<User>(_context); } [Fact] public void Test_insert_getbyid_table_tablenotracking_delete_success() { var user = new User() { UserName = "zhangsan", CreatedOn = DateTime.Now, LastActivityDate = DateTime.Now }; _repository.Insert(user); var newUserId = user.Id; Assert.True(newUserId > 0); //声明新的Context,否则查询直接由DbContext返回而不通过数据库 using (var newContext = new MyObjectContext(TestDatabaseConnectionName)) { var repository = new EfRepository<User>(newContext); var userInDb = repository.GetById(newUserId); user.UserName.ShouldEqual(userInDb.UserName); } using (var newContext = new MyObjectContext(TestDatabaseConnectionName)) { var repository = new EfRepository<User>(newContext); var userInDb = repository.Table.Single(r => r.Id == newUserId); user.UserName.ShouldEqual(userInDb.UserName); } using (var newContext = new MyObjectContext(TestDatabaseConnectionName)) { var repository = new EfRepository<User>(newContext); var userInDb = repository.TableNoTracking.Single(r => r.Id == newUserId); user.UserName.ShouldEqual(userInDb.UserName); } _context.Entry(user).State.ShouldEqual(EntityState.Unchanged); _repository.Delete(user); using (var newContext = new MyObjectContext(TestDatabaseConnectionName)) { var repository = new EfRepository<User>(newContext); var userInDb = repository.GetById(newUserId); userInDb.ShouldBeNull(); } } [Fact] public void Test_insert_update_attach_success() { var user = new User() { UserName = "zhangsan", CreatedOn = DateTime.Now, LastActivityDate = DateTime.Now }; _repository.Insert(user); var newUserId = user.Id; Assert.True(newUserId > 0); //update using (var newContext = new MyObjectContext(TestDatabaseConnectionName)) { var repository = new EfRepository<User>(newContext); var userInDb = repository.GetById(newUserId); userInDb.UserName = "lisi"; repository.Update(userInDb); } //assert using (var newContext = new MyObjectContext(TestDatabaseConnectionName)) { var repository = new EfRepository<User>(newContext); var userInDb = repository.GetById(newUserId); userInDb.UserName.ShouldEqual("lisi"); } //update by attach&modifystate using (var newContext = new MyObjectContext(TestDatabaseConnectionName)) { var repository = new EfRepository<User>(newContext); var userForUpdate = new User() { Id = newUserId, UserName = "wangwu", CreatedOn = DateTime.Now, LastActivityDate = DateTime.Now }; repository.Attach(userForUpdate); var entry = newContext.Entry(userForUpdate); entry.State.ShouldEqual(EntityState.Unchanged);//assert entry.State = EntityState.Modified; repository.Update(userForUpdate); } //assert using (var newContext = new MyObjectContext(TestDatabaseConnectionName)) { var repository = new EfRepository<User>(newContext); var userInDb = repository.GetById(newUserId); userInDb.UserName.ShouldEqual("wangwu"); } _repository.Delete(user); } public void Dispose() { _context.Dispose(); } }
如代码所示,经过2个测试方法覆盖了对IRepository方法的测试。在测试类的成员中声明了被测试接口的对象以及这些接口所依赖的成员的对象。这个场景是测试数据仓储因此这些依赖对象使用真实类型而非Mock(后文会见到使用Mock的例子)。而后在构造函数中对这些成员进行初始化。这些部分都是测试的Arrange部分。即对于全部测试方法通用的初始化信息咱们放在测试类构造函数完成,因测试方法而异的Arrange在每一个测试方法中完成。
测试方法中用的到扩展方法能够见文章最后一小节。
对于须要清理分配资源的测试类,能够实现IDisposable接口并实现相应Dispose方法,xUnit.net将负责将构造函数中分配对象的释放。
xUnit.net每次执行测试方法时,都是实例化一个测试类的新对象,好比执行上面的测试类中的两个测试测试方法会执行测试类的构造函数两次(Dispose也会执行两次保证分配的对象被释放)。这种设置使每一个测试方法都有一个干净的上下文来执行,不一样测试方法使用同名的测试类成员不会产生冲突。
若是测试方法能够共用相同的测试类成员,或是出于提升测试执行速度考虑咱们但愿在执行类中测试方法时初始化代码只执行一次,可使用下面介绍的方法来共享同一份测试上下文(测试类的对象):
首先实现一个Fixture类用来完成须要共享的对象的初始化和释放工做:
public class DbContextFixture: IDisposable { private const string TestDatabaseConnectionName = "DefaultConnectionTest"; public readonly IDbContext Context; public DbContextFixture() { Context = new MyObjectContext(TestDatabaseConnectionName); } public void Dispose() { Context.Dispose(); } }
下面是重点,请注意怎样在测试类中使用这个Fixture:
public class EFRepositoryByFixtureTests : IClassFixture<DbContextFixture> { private readonly IDbContext _context; private readonly IRepository<User> _repository; public EFRepositoryByFixtureTests(DbContextFixture dbContextFixture) { _context = dbContextFixture.Context; _repository = new EfRepository<User>(_context); } //测试方法略... }
测试类实现了IClassFixture<>接口,而后能够经过构造函数注入得到前面的Fixture类的对象(这个注入由xUnit.net来完成)。
这样全部测试方法将共享同一个Fixture对象,即DbContext只被初始化一次。
除了在同一个类的测试方法之间共享测试上下文,也能够在多个测试类之间共享测试上下文:
public class DbContextFixture : IDisposable { private const string TestDatabaseConnectionName = "DefaultConnectionTest"; public readonly IDbContext Context; public DbContextFixture() { Context = new GalObjectContext(TestDatabaseConnectionName); } public void Dispose() { Context.Dispose(); } } [CollectionDefinition("DbContext Collection")] public class DbContextCollection : ICollectionFixture<DbContextFixture> { }
Fixture类和以前如出一辙,此次多了一个Collection结尾的类来实现一个名为ICollectionFixture<>接口的类。这个类没有代码其最主要的做用的是承载这个CollectionDefinition Attribute,这个特性的名字很是重要。
来看一下在测试类中怎么使用:
[Collection("DbContext Collection")] public class EFRepositoryCollectionTest1 { private readonly IDbContext _context; private readonly IRepository<User> _repository; public EFRepositoryCollectionTest1(DbContextFixture dbContextFixture) { _context = dbContextFixture.Context; _repository = new EfRepository<User>(_context); } //测试方法略... } [Collection("DbContext Collection")] public class EFRepositoryCollectionTest2 { private readonly IDbContext _context; private readonly IRepository<User> _repository; public EFRepositoryCollectionTest2(DbContextFixture dbContextFixture) { _context = dbContextFixture.Context; _repository = new EfRepository<User>(_context); } //测试方法略... }
在测试类上经过Collection特性标记这个测试类须要Fixture,注意Collection特性构造函数的参数与CollectionDefinition特性构造函数的参数必须彻底匹配,xUnit.net经过这个来进行关联。标记上[Collection]后就能够经过构造函数注入得到Fixture对象了,这个与以前就是相同的了。
有几个测试类就标几个[Collection],这些测试类将共享相同的Fixture对象。
若是咱们把DbContextCollection的实现改为:
[CollectionDefinition("DbContext Collection")] public class DbContextCollection : IClassFixture<DbContextFixture> { }结果是EFRepositoryCollectionTest1和EFRepositoryCollectionTest2拥有不一样的Fixture对象,但在它们类的范围内这个Fixture是共享的。
异步编程在C#和.NET中变得原来越流行,库中不少方法都增长了Async版本,有些新增长的库甚至只有Async版本的方法(以UWP为表明)。对异步方法的测试也愈来愈重要,xUnit.net从某个版本(忘了是哪一个了)起开始支持异步方法测试。须要的改动很是简单就是把返回void的测试方法改为返回Task并添加async关键字变为异步方法,这样xUnit.net就能正确的从被测试的异步方法获取值并完成测试。
好比加入以前用过的IRepository中多了一个异步方法GetByIdAsync,要对这个方法进行单元测试:
Task<T> GetByIdAsync(object id);
异步的测试方法以下:
[Fact] public async Task Test_get_async() { var userId = 1; var user = await _repository.GetByIdAsync(userId); Assert.True(user.UserName.Length>0); }
基本上咱们怎么去写异步方法就怎么去写异步测试方法。
这一小部分是文章快完成时,读了下xUnit文档补充上的,在这以前全然不知道xUnit.net还有这么个功能,看来多写博客能够帮助完善知识点中的漏洞,你们共勉。
除了经常使用的[Fact],xUnit还提供一个名为[Theory]的测试Attribute。xUnit文档很简明的解释二者的不一样:
Fact所测试的方法结果老是一致的,即它用来测试不变的条件。
Theory测试的方法对一个特定集合中的数据测试结果为真。
想不出其它例子(个人确没用过),就给出官方的例子吧。
被测方法:
//判断一个数是否为奇数 bool IsOdd(int value) { return value % 2 == 1; }
测试方法:
[Theory] [InlineData(3)] [InlineData(5)] [InlineData(6)] public void MyFirstTheory(int value) { Assert.True(IsOdd(value)); }
测试结果:
图5
对于测试数据集合中的6不是奇数,因此测试失败。
虽然只有一个测试方法,但xUnit会针对每条的InlineData传入的数据执行一次测试,这样能够很容易看出是哪一条InlineData出了问题就如图5所示。
修改测试集:
[Theory] [InlineData(3)] [InlineData(5)] [InlineData(7)] public void MyFirstTheory(int value) { Assert.True(IsOdd(value)); }
这样测试就能够顺利经过了。
图6
仍是以实际项目中常见的场景来介绍须要使用Mock的场景,如如今有一个UserService(篇幅缘由只展现部分):
public class UserService : IUserService { private readonly IRepository<User> _userRepository; public UserService(IRepository<User> userRepository) { _userRepository = userRepository; } public User GetUserById(int userId) { return _userRepository.GetById(userId); } public void Create(User user) { _userRepository.Insert(user); } public void Update(User user) { _userRepository.Update(user); } public void Delete(User user) { ... } }
要测试这个UserService难免会对IRepository产生依赖,因为在以前的测试中看到Repository已通过完善的测试,因此在测试UserService的时候可使用一个与Repository有相同接口的Stub类,如RepositoryStub,来代替EFRepository供UserService使用,这个类不进行实际的数据访问,只是按照咱们的测试指望经过硬编码的方式返回一些值。但每每大型项目中有成百上千的类须要有对应的Mock类用于单元测试,手写这些xxxMock类是一个很大的工做。因而Mock框架诞生了。
Mock框架(微软称作Fakes框架,应该就是一个东西)的做用就是灵活方便的构造出这种Mock类的实例供单元测试方法使用。
Mock,Stub这二者的区分老外们好像一直在讨论。大概就是,Stub表示虚拟的对象中存在这些Stub方法使被测试方法能够正常工做,而Mock不可是虚拟对象中须要提供的方法,还能够验证被测对象是否与Mock发生了交互。Mock多是测试不一样过的缘由,但Stub不会是。经过文中Rhino Mocks的例子能够仔细体会这两个概念的不一样。
好比咱们测试下上面代码中的GetUserById方法(虽然这个方法很简单,实际项目中没有测试的必要,但做为例子仍是很合适的。)
[Fact] public void Test_GetUser() { var userRepository = MockRepository.GenerateStub<IRepository<User>>(); userRepository.Stub(ur => ur.GetById(1)).Return(new User() { UserName = "wangwu" }); var userService = new UserService(userRepository); var userGet = userService.GetUserById(1); Assert.Equal("wangwu", userGet.UserName); }
这多是使用Mock框架最简单的例子了,GenerateStub方法生成一个”桩“对象,而后使用Stub方法添加一个”桩“方法,使用这个桩对象来构造UserService对象,很显然测试会顺利经过。
例子中Stub方法显式要求接收1做为参数(即若是咱们给GetUserById传入非1的数字测试没法经过),但被测方法实际上是能够传入任意参数的。能够经过Rhino Mock提供的强大的Arg<T>来改变一下参数约束:
userRepository.Stub(ur => ur.GetById(Arg<int>.Is.Anything)).Return(new User() { UserName = "wangwu" });
这样就能够给被测方法传入任意整数参数,更符合测试语义。Arg<T>类提供了各类各样对参数约束的函数,以及一个几乎无所不能的Matches方法,后文还有有介绍。
上面用到的只是Mock框架一部分做用,Mock框架更神奇的地方将在下一小节介绍。
前文介绍的大部份内容Assert都是用来判断被测试方法的返回值。实际项目中还有许多没有返回值的方法也须要咱们经过测试来保证其中逻辑的正确性。这些没有返回值的方法有多是将数据保存到数据库,有多是调用另外一个方法来完成相关工做。
对于将数据保存到数据库的状况以前的测试有介绍这里再也不赘述。对于调用另外一个方法(这里指调用另外一个类的方法或调用同一个类中方法的测试下一小节介绍)的状况,咱们经过Mock框架提供的Assert方法来保证另外一个类的方法确实被调用。
这里以保存用户方法为例来看一下测试如何编写:
public void Create(User user) { _userRepository.Insert(user); }
如代码,这个方法没有返回值,使用以前的Assert方法没法验证方法正确执行。因为单元测试中的userRepository是Mock框架生成的,能够借助Rhino Mocks提供的功能来验证这个方法确实被调用并传入了恰当的参数。
[Fact] public void Test_Create_User() { var userRepository = MockRepository.GenerateMock<IRepository<User>>(); userRepository.Expect(ur => ur.Insert(Arg<User>.Is.Anything)); var userService = new UserService(userRepository); userService.Create(new User() {UserName = "zhangsan"}); userRepository.VerifyAllExpectations(); }
这个测试代码和上一小节测试代码不一样之处在于使用GenerateMock和Except方法替代了GenerateStub和Stub方法,前者用于指定一个能够被验证的指望,然后者只是提供一个虚拟的桩。在代码的最后经过VerifyAllExpectations方法验证全部指望都被执行。执行测试没有意外的话测试能够正常经过。
给Expect指定的lambda表达式中的Insert方法接受Arg<User>.Is.Anything做为参数,这正符合被测试函数的要求。若是Create函数中没有调用IRepository的Insert函数,测试也会失败:
图7
这是验证函数被执行的一种方法,还有另外一种等效的方法,且后者在外观上更符合以前提到的单元测试的AAA模式:
[Fact] public void Test_Create_User() { var userRepository = MockRepository.GenerateMock<IRepository<User>>();//这种方法中,这里使用GenerateMock和GenerateStub均可以 var userService = new UserService(userRepository); userService.Create(new User() {UserName = "zhangsan"}); userRepository.AssertWasCalled(ur => ur.Insert(Arg<User>.Is.Anything)); }
如代码所见,这段测试代码没有使用Expect设置指望,而是经过AssertWasCalled来验证一个函数是否被调用。
上面大部分例子都使用了Rhino Mocks的GenerateMock<T>()和GenerateStub<T>()静态方法。Rhino Mocks还经过MockRepository对象的实例方法DynamicMock<T>()和Stub<T>()提供了相同的功能。这二者的最主要区别是,对于Except的验证,前者只能在静态方法返回的对象上分别调用VerifyAllExpectations()方法进行验证,然后者能够在MockRepository对象上调用VerifyAll()验证MockRepository中全部的Except。
实际测试中还经常会遇到一个方法调用相同类中另外一个方法的这种须要测试的状况,为了好描述,假设是C类中的A方法调用了B方法。
先说A和B都是public方法的状况,正确的测试方法应该是分别测试A,B方法,对于A的测试使用Mock框架生成一个B的Stub方法。
先看一下用来展现的待测方法:
public void Create(User user) { if (IsUserNameValid(user.UserName)) _userRepository.Insert(user); } public virtual bool IsUserNameValid(string userName) { //检查用户名是否被占用 Debug.WriteLine("IsUserNameValid called"); return true; }
在建立用户以前须要验证用户名是否可用,为此添加了一个IsUserNameValid方法。为了演示这个方法被标记为public。值得注意是这仍是一个virtual方法,由于下文咱们要用Rhino Mocks生成这个方法的一个指望,当用Rhino Mocks生成方法的指望时,若是方法不属于一个接口,则这个方法必须是virtual方法。下面是测试代码:
[Fact] public void Test_Create_User_with_innerCall() { var userRepository = MockRepository.GenerateMock<IRepository<User>>(); userRepository.Expect(ur => ur.Insert(Arg<User>.Is.Anything)); var userService = MockRepository.GeneratePartialMock<UserService>(userRepository); userService.Expect(us => us.IsUserNameValid("zhangsan")).Return(true); userService.Create(new User() { UserName = "zhangsan" }); userRepository.VerifyAllExpectations(); userService.VerifyAllExpectations(); }
最重要的部分就是经过GeneratePartialMock方法生成了一个userService的对象,而后在上面设置了IsUserNameValid方法的指望。这样UserService对象中除了IsUserNameValid对象外,其它方法都将使用真实方法,这样咱们测试的Create方法将调用真实方法而IsUserNameValid是Mock框架生成的。就完成了咱们的需求。
上面介绍了A和B都是public方法的状况,实际项目中更常见的状况是A是public方法而B是private方法,即IsUserNameValid是一个private方法:
private bool IsUserNameValid(string userName) { //检查用户名是否被占用 Debug.WriteLine("IsUserNameValid called"); return true; }
对于这种状况通常能够经过对A的测试同时验证B的执行是正确的,即把B做为A来一块儿测试,由于这时候没法单独使用Mock框架来模拟B方法。因此也要保证在测试方法中传入的参数可让A和B都正常执行。
若是private方法很是复杂,也能够对private方法单独测试。
对于private方法的测试无法像测试public方法那样实例化一个对象而后调用方法。须要借助一个工具来调用private方法,对此微软在Microsoft.VisualStudio.QualityTools.UnitTestFramework.dll提供了一个PrivateObject类能够完成这个工做。这个dll位于C:\Program Files (x86)\Microsoft Visual Studio 14.0\Common7\IDE\PublicAssemblies\(根据vs版本不一样有所不一样)下,须要手工添加引用。
如被测方法是一个private方法:
private bool IsUserNameValid(string userName) { //检查用户名是否被占用 Debug.WriteLine("IsUserNameValid called"); return true; }
测试代码能够这样写:
[Fact] public void Test_IsUserNameValid() { var userService = new UserService(null); var userServicePrivate = new PrivateObject(userService); var result = userServicePrivate.Invoke("IsUserNameValid","zhangsan"); Assert.True((bool)result); }
即便用PrivateObject把被测类包起来,而后经过Invoke方法调用private方法便可。
有了前文和Rhino Mocks的接触的基础,这一小节来看一下Rhino Mocks的一些高级功能。
Arg实现参数约束
在前文咱们已经体会到了Arg<T>的强大,Arg<T>.Is.Anything做为参数就能够指定Stub方法接受指定类型的任意参数。Arg还能够进行更多的参数限制,当被测试方法给指望方法传入的参数不符合参数约束时,验证指望会失败最终将致使测试不经过。下面的表格来自Rhino Mocks官方文档,其中列出了Arg支持的大部分约束。(博主翻译并按照最新的3.6.1版整理了下)
Arg<T>.Is | ||
Equal(object) NotEqual(object) |
参数相等或不等 | |
GreaterThan(object) GreaterThanOrEqual(object) LessThan(object) LessThanOrEqual(object) |
大于,大于等于,小于,小于等于比较 | |
Same(object) NotSame(object) |
比较引用是否相同 | |
Anything | 任意参数 | |
Null NotNull |
参数为空或不为空 | |
TypeOf | 参数为泛型参数指定的类型 | |
Arg<T>.List | ||
OneOf(IEnumerable) | 肯定参数是指定集合中的一个 | |
Equal(IEnumerable) | 参数列表与指定列表相同 | |
Count(AbstractConstraint) | 肯定参数集合有指定数量的符合约束的元素 | |
Element(int, AbstractConstraint) | 参数集合中指定位置的元素复合一个约束 | |
ContainsAll(IEnumerable) | 肯定参数集合包含全部的指定元素 | |
IsIn(object) | 肯定指定元素属于参数集合(参数须要为IEnumerable) | |
Arg<T>.Ref() | 指定ref参数 | |
Arg<T>.Out() | 指定out参数 | |
Arg.Text | ||
StartsWith(string) EndsWith(string) Contains(string) |
参数字符串以指定字符串开始或以指定字符串结束或包含指定字符串 | |
Like(string regex) | 参数匹配指定正则表达式 | |
Arg.Is() | Arg<T>.Is.Equal()等价 | |
Arg<T>.Matches() | ||
Argt<T>.Matches(Expression) | 参数匹配一个lambda表达式指定的约束 | |
Argt<T>.Matches(AbstractConstraint) | 用于不支持lambda的C#版本,之内置的约束类型指定参数约束 |
表中大部分方法和xUnit.net支持的Assert很相似。重点来看一下其中最强大的Matches方法:
userRepository.Expect(ur => ur.Insert(Arg<User>.Matches(u=> u.UserName.Length>2 && u.UserName.Length<12&& u.Birthday>DateTime.Now.AddYears(-120)&& Regex.IsMatch(u.QQ,"^\\d[5,15]#"))));
这个复杂的Matches方法参数限制了指望函数接受的参数符合一些列条件。
WhenCalled--另外一个”bug“般的存在
在Stub、Expect等方法的调用链上有一个名为WhenCalled的方法,它用来指定当桩方法或指望方法被执行时所执行的操做。这里面能够干不少不少事。好比:
userRepository.Stub(ur => ur.GetById(Arg<int>.Is.Anything)) .Return(new User() { UserName = "wangwu" }) .WhenCalled(mi => { //能够修改桩方法的参数和返回值,还能够获取方法信息 var args = mi.Arguments; var methodInfo = mi.Method; var returnVal = mi.ReturnValue; //能够设置本地变量,供下面的代码使用 getByIdCalled = true; });
能够用设置的变量来判断方法桩是否被执行:
Assert.True(getByIdCalled);
判断方法执行次数
有时候不仅须要判断指望方法是否被执行,还要判断执行的次数。Rhino Mocks的AssertWasCalled方法的重载提供了这个功能:
userRepository.AssertWasCalled(ur => ur.Insert(Arg<User>.Is.Anything),c=>c.Repeat.Once());
这样Insert方法应该只被执行1次测试才能够经过。除此还有Twice(),Never(),AtLeastOnce()及Times(int)等其它方法用来指定不一样的次数。
AssertWasCalled第二个参数的类型Action<T>中的T(即lambda表达式参数)是IMethodOptions<T>类型,除了能够经过Repeat属性的方法设置执行次数约束外还有其它方法,大部分方法能够经过其它途径进行等价设置,还有一些已通过时就再也不赘述了。
上文的例子都是在.NET Framework 4.5的程序集中进行的,对于全部使用.NET Framework的项目类型都适用,好比Winform/WPF,ASP.NET MVC等等。对于UWP这样基于Windows Runtime平台的程序因为上文使用的RhinoMocks不能用于UWP,因此须要另外寻找可用的Mock Framework。另外当前版本的用于Resharper的xUnit.net Test Runner在UWP环境不能启动用于执行测试代码的测试程序,须要使用xUnit.net用于vs的Test Runner,并且xUnit.net和Test Runner都要使用最新的的2.1 rc才能正常启动一个程序用于执行测试代码。
在UWP中测试项目是一个可执行的程序,测试代码在这里面运行。而不像传统.NET项目的测试只须要依附于一个普通的程序集。在UWP执行测试代码若是涉及到如Windows.Storage这种与设备相关的代码是须要以应用的身份去调用的。因此单元测试项目做为一个可执行项目是必要的。
找来找去可选的真很少,一个是微软自家的Microsoft Fakes,另外一个是Telerik的JustMock。前者没找到怎么用,放弃(感受微软vs里的测试工具一直不怎么好用)。后者是一个商业工具(有免费版),暂时拿来玩玩吧。由于前文把各类测试场景也都介绍的差很少了,这里就直接给出一个例子,并看一下JustMock与RhinoMocks的细节不一样。
被测代码好像是来自国外一个开源的库,实在记不清从哪“借鉴”来的了。
public async Task ClearInvalid() { var validExtension = storage.GetFileExtension(); var folder = await storage.GetFolderAsync().ConfigureAwait(false); var files = await folder.GetFilesAsync(); foreach (var file in files.Where(x => x.FileType == validExtension)) { var loadedFile = await storage.LoadAsync<CacheObject>(file.DisplayName).ConfigureAwait(false); if (loadedFile != null && !loadedFile.IsValid) await file.DeleteAsync(); } }
这里一段UWP用于清除无效缓存项,来看一下测试代码:
[Fact] public async Task Can_ClearInvalid_Success() { var fileName = "testfile"; var storage = Mock.Create<IStorageHelper>(); Mock.Arrange(()=>storage.GetFileExtension()).Returns(".json"); var file1 = Mock.CreateLike<StorageFileFake>(sf => sf.FileType == ".json" && sf.DisplayName == fileName); var file2 = Mock.CreateLike<StorageFileFake>(sf => sf.FileType == ".json" && sf.DisplayName == "fileNoInCache"); var file3 = Mock.CreateLike<StorageFileFake>(sf => sf.FileType == ".xml" && sf.DisplayName == "fileOtherType"); var folder = ApplicationData.Current.LocalFolder;//Partial Mock Mock.ArrangeLike<StorageFolder>(folder,sf=>sf.GetFilesAsync()== Task.FromResult(new List<IStorageFile>() {file1,file2,file3} as IReadOnlyList<StorageFile>).AsAsyncOperation()); Mock.ArrangeLike(storage,s => s.GetFolderAsync()==Task.FromResult(folder)); var cacheObj = Mock.CreateLike<CacheObject>(co => co.IsValid == false); Mock.Arrange(() => storage.LoadAsync<CacheObject>(Arg.AnyString)).OccursAtLeast(2); Mock.Arrange(() => storage.LoadAsync<CacheObject>(Arg.Is(fileName))).Returns(Task.FromResult(cacheObj)); Mock.Arrange(()=>file1.DeleteAsync()).MustBeCalled(); var cacheManager = new TemporaryCacheManager(storage); await cacheManager.ClearInvalid(); storage.Assert(); file1.Assert(); }
Storage类因为特殊缘由(反正那种实如今UWP中的类都同样),不能经过Mock.Create来建立,而是使用了一个真实的对象,而后经过JustMock建立Partial Mock的方式给这个Storage对象增长一些虚拟的方法。
至于其余方法,能够经过下面这个RhinoMocks和JustMock对比(按个人理解,有错请指正)的表得知用法:
RhinoMocks | JustMock |
MockRepository.GenerateStub<T>() | Mock.CreateLike<T>() |
mock.Stub() | Mock.ArrangeLike<T>() |
MockRepository.GenerateMock<T>() | Mock.Create<T>() |
mock.Except() | Mock.Arrange() |
MockRepository.GeneratePartialMock<T>() | 直接建立真实对象,并Arrange()模拟方法 |
mock.VerifyAllExpectations() | mock.Assert() |
Arg<T> | Arg |
AssertWasCalled()//其实不太同样 | MustBeCalled() |
c=>c.Repeat.XXX() | OccursAtLeast(times) |
当前这段测试代码并不能正确运行,由于2.1RC版本的xUnit runner for vs和JustMock 2015Q2好像不太兼容,总会报少System.Core缺失啥的错误。
nopCommerce项目中给单元测试准备的一系列扩展方法用起来也很方便,能够把Act和Assert合并到一行,必定程度上提升代码的可读性。
原代码是基于NUnit的,我把它们改为了支持xUnit.net的放在下面供须要的童鞋参考。
public static class TestExtensions { public static T ShouldNotBeNull<T>(this T obj) { Assert.NotNull(obj); return obj; } public static T ShouldEqual<T>(this T actual, object expected) { Assert.Equal(expected, actual); return actual; } public static void ShouldEqual(this object actual, object expected, string message) { Assert.Equal(expected, actual); } public static Exception ShouldBeThrownBy(this Type exceptionType, Action testDelegate) { return Assert.Throws(exceptionType, testDelegate); } public static void ShouldBe<T>(this object actual) { Assert.IsType<T>(actual); } public static void ShouldBeNull(this object actual) { Assert.Null(actual); } public static void ShouldBeTheSameAs(this object actual, object expected) { Assert.Same(expected, actual); } public static void ShouldBeNotBeTheSameAs(this object actual, object expected) { Assert.NotSame(expected, actual); } public static T CastTo<T>(this object source) { return (T)source; } public static void ShouldBeTrue(this bool source) { Assert.True(source); } public static void ShouldBeFalse(this bool source) { Assert.False(source); } public static void SameStringInsensitive(this string actual, string expected) { Assert.Equal(actual,expected,true); } }
其它平台.NET Core及Xamarin没搞过,不了解。就写到这吧。欢迎指正。谢谢。
转载请保留原连接