C#单元测试面面观


标题有点标题党,但相信各位看完这篇文章一定会所收获,如果之前没有接触过单元测试或了解不深通过本文都能对单元测试有个全新认识。本文的特点是不脱离实际,所测试的代码都是常见的模式。

写完这篇文章后,我看了一些关于单元测试理论的东西,发现文章中有些好像不太合主流测试理论,由于理论和使用个人难以完美结合,只能取实用为本。

另外本文编写的单元测试都是基于已有代码进行测试,而不是TDD倡导的现有测试后有可以工作的代码,不同思想指导下写出的测试代码可能不太一样。

最近的项目中写了一个巨长的函数,调试的时候总是发现各种潜在的问题,一遍一遍的F5启动调试,键盘都快按烂了,那个函数还没跑通。想了想,弄个单元测试很有必要,说干就干。找来xUnit等把单元测试搞了起来。说来这还是第一次正八经的搞单元测试,想想有必要把这个过程记录一下,于是这篇文章就诞生了。

进行单元测试代码编写的过程中收获还真不少,比如把那个巨长的函数重构为4个功能相对独立,大小适中的函数。另外写测试可以以用户的角度使用函数,从而发现了几个之前没有想到的应该进行逻辑判断的地方并在程序代码中加入了if段。其实这些都是单元测试的好处,当然单元测试的利可能不只这些,总之越早在项目中加入单元测试越是事半功倍。

这篇文章对单元测试做了一些总结,当然最重要的是记录了Mocks工具的使用。在这次单元测试之前我对单元测试的了解停留在几个测试框架的测试方法上。拿测试运行器干的最多的事不是“测试”而是“调试”。即一般都是在一个类及函数不方便启动程序来调试时,搞一个测试类,用测试运行器的调试功能专门去Debug这个方法。这其实也只是用了测试框架(和测试运行器)很小的一部分功能。

在开始正题之前说一说单元测试工具的选择。现在xUnit.net几乎成为了准官方的选择。xUnit.net配套工具完善,上手简单初次接触单元测试是很好的选择。测试运行器选择了ResharperxUnit runner插件(Resharper也vs必不可少的插件),个人始终感觉VS自带的测试运行工具远不如Resharper的好用。Mock框架选择了大名鼎鼎的RhinoMocks,神一样的开源Mock框架。

由于我是单元测试新手,这也是第一次比较仔细的写单元测试,最大的体会就是Mock工具要比Test Framework与编写单元测试代码的用户关系更密切。本文将从最简单的测试开始争取将所有可能遇到的测试情况都写出来,如有不完整也请帮忙指出,如有错误请不吝赐教。

插播一下,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常周期性抽风,有时不得不走代理,速度龟慢。

 

1.最简单的单元测试

这里先展示一个最简单的方法及测试,目的是让没有接触过单元测试的同学有个直观印象:

被测方法是一个计算斐波那契数的纯计算方法:

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实现,也有重载支持传入IEqualityComparer
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,或传入IComparer
Assert.NotInRange() 与上面的相反
Assert.Null() 验证对象为空
Assert.NotNull() 与上面的相反
Assert.StrictEqual() 判断两个对象严格相等,使用默认的IEqualityComparer对象
Assert.NotStrictEqual() 与上面相反
Assert.IsType()/Assert.IsType() 验证对象是某个类型(不能是继承关系)

Assert.IsNotType()/

Assert.IsNotType()

与上面的相反

Assert.IsAssignableFrom()/

Assert.IsAssignableFrom()

验证某个对象是指定类型或指定类型的子类
Assert.Subset() 验证一个集合是另一个集合的子集
Assert.ProperSubset() 验证一个集合是另一个集合的真子集
Assert.ProperSuperset() 验证一个集合是另一个集合的真超集
Assert.Collection() 验证第一个参数集合中所有项都可以在第二个参数传入的Action序列中相应位置的Action上执行而不抛出异常。
Assert.All()

验证第一个参数集合中的所有项都可以传入第二个Action类型的参数而不抛出异常

。与Collection()类似,区别在于这里Action只有一个而不是序列。

Assert.PropertyChanged() 验证执行第三个参数Action使被测试INotifyPropertyChanged对象触发了PropertyChanged时间,且属性名为第二个参数传入的名称。

Assert.Throws()/Assert.Throws()

Assert.ThrowsAsync()/

Assert.ThrowsAsync()

验证测试代码抛出指定异常(不能是指定异常的子类)

如果测试代码返回Task,应该使用异步方法

Assert.ThrowsAny()

Assert.ThrowsAnyAsync()

验证测试代码抛出指定异常或指定异常的子类

如果测试代码返回Task,应该使用异步方法

编写单元测试的测试方法就是传说中的3个A,Arrange、Act和Assert。

  • Arrange用于初始化一些被测试方法需要的参数或依赖的对象。

  • Act方法用于调用被测方法获取返回值。

  • Assert用于验证测试方法是否按期望执行或者结果是否符合期望值

大部分的测试代码都应按照这3个部分来编写,上面的测试方法中只有Act和Assert2部分,对于逻辑内聚度很高的函数,这2部分就可以很好的工作。像是一些独立的算法等按上面编写测试就可以了。但是如果被测试的类或方法依赖其它对象我们就需要编写Arrange部分来进行初始化。下一节就介绍相关内容。

2.被测试类需要初始化的情况

在大部分和数据库打交道的项目中,尤其是使用EntityFramework等ORM的项目中,常常会有IRepository和Repository这样的身影。我所比较赞同的一种对这种仓储类测试的方法是:使用真实的数据库(这个真实指的非Mock,一般来说使用不同于开发数据库的测试数据库即可,通过给测试方法传入测试数据库的链接字符串实现),并且相关的DbContext等都直接使用EntityFramework的真实实现而不是Mock。这样,在IRepository之上的所有代码我们都可以IRepository的Mock来作为实现而不用去访问数据库。

如果对于实体存储到数据库可能存在的问题感到担心,如类型是否匹配,属性是否有可空等等,我们也可以专门给实体写一些持久化测试。为了使这个测试的代码编写起来更简单,我们可以把上面测试好的IRepository封装成一个单独的方法供实体的持久化测试使用。

下面将给出一些示例代码:

首先是被测试的IRepository

public interface IRepository where T : BaseEntity
{
    T GetById(object id);

    void Insert(T entity);

    void Update(T entity);

    void Delete(T entity);

    IQueryable Table { get; }

    IQueryable 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 _repository;//用具体的泛型类型进行测试,这个不影响对EFRepository测试的效果

    public EFRepositoryTests()
    {
        _context = new MyObjectContext(TestDatabaseConnectionName);
        _repository = new EfRepository(_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(newContext);
            var userInDb = repository.GetById(newUserId);
            user.UserName.ShouldEqual(userInDb.UserName);
        }

        using (var newContext = new MyObjectContext(TestDatabaseConnectionName))
        {
            var repository = new EfRepository(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(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(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(newContext);
            var userInDb = repository.GetById(newUserId);
            userInDb.UserName = "lisi";
            repository.Update(userInDb);
        }

        //assert
        using (var newContext = new MyObjectContext(TestDatabaseConnectionName))
        {
            var repository = new EfRepository(newContext);
            var userInDb = repository.GetById(newUserId);
            userInDb.UserName.ShouldEqual("lisi");
        }

        //update by attach&modifystate
        using (var newContext = new MyObjectContext(TestDatabaseConnectionName))
        {
            var repository = new EfRepository(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(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也会执行两次保证分配的对象被释放)。这种设置使每个测试方法都有一个干净的上下文来执行,不同测试方法使用同名的测试类成员不会产生冲突。

3.避免重复初始化

如果测试方法可以共用相同的测试类成员,或是出于提高测试执行速度考虑我们希望在执行类中测试方法时初始化代码只执行一次,可以使用下面介绍的方法来共享同一份测试上下文(测试类的对象):

首先实现一个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
{
    private readonly IDbContext _context;
    private readonly IRepository _repository;

    public EFRepositoryByFixtureTests(DbContextFixture dbContextFixture)
    {
        _context = dbContextFixture.Context;
        _repository = new EfRepository(_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
{
}

Fixture类和之前一模一样,这次多了一个Collection结尾的类来实现一个名为ICollectionFixture<>接口的类。这个类没有代码其最主要的作用的是承载这个CollectionDefinition Attribute,这个特性的名字非常重要。

来看一下在测试类中怎么使用:

[Collection("DbContext Collection")]
public class EFRepositoryCollectionTest1
{
    private readonly IDbContext _context;
    private readonly IRepository _repository;

    public EFRepositoryCollectionTest1(DbContextFixture dbContextFixture)
    {
        _context = dbContextFixture.Context;
        _repository = new EfRepository(_context);
    }

    //测试方法略...
}

[Collection("DbContext Collection")]
public class EFRepositoryCollectionTest2
{
    private readonly IDbContext _context;
    private readonly IRepository _repository;

    public EFRepositoryCollectionTest2(DbContextFixture dbContextFixture)
    {
        _context = dbContextFixture.Context;
        _repository = new EfRepository(_context);
    }

    //测试方法略...
}

在测试类上通过Collection特性标记这个测试类需要Fixture,注意Collection特性构造函数的参数与CollectionDefinition特性构造函数的参数必须完全匹配,xUnit.net通过这个来进行关联。标记上[Collection]后就可以通过构造函数注入获得Fixture对象了,这个与之前就是相同的了。

有几个测试类就标几个[Collection],这些测试类将共享相同的Fixture对象。

如果我们把DbContextCollection的实现改成:

[CollectionDefinition("DbContext Collection")]
public class DbContextCollection : IClassFixture
{
}

结果是EFRepositoryCollectionTest1和EFRepositoryCollectionTest2拥有不同的Fixture对象,但在它们类的范围内这个Fixture是共享的。

4.异步方法测试支持

异步编程在C#和.NET中变得原来越流行,库中很多方法都增加了Async版本,有些新增加的库甚至只有Async版本的方法(以UWP为代表)。对异步方法的测试也越来越重要,xUnit.net从某个版本(忘了是哪个了)起开始支持异步方法测试。需要的改动非常简单就是把返回void的测试方法改成返回Task并添加async关键字变为异步方法,这样xUnit.net就能正确的从被测试的异步方法获取值并完成测试。

比如加入之前用过的IRepository中多了一个异步方法GetByIdAsync,要对这个方法进行单元测试:

Task 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);
}

基本上我们怎么去写异步方法就怎么去写异步测试方法。

5.给测试方法传入系列参数

这一小部分是文章快完成时,读了下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

6.Mock初次登场

还是以实际项目中常见的场景来介绍需要使用Mock的场景,如现在有一个UserService(篇幅原因只展示部分):

public class UserService : IUserService
{
    private readonly IRepository _userRepository;

    public UserService(IRepository 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>();
    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来改变一下参数约束:

userRepository.Stub(ur => ur.GetById(Arg.Is.Anything)).Return(new User() { UserName = "wangwu" });

这样就可以给被测方法传入任意整数参数,更符合测试语义。Arg类提供了各种各样对参数约束的函数,以及一个几乎无所不能的Matches方法,后文还有有介绍。

上面用到的只是Mock框架一部分作用,Mock框架更神奇的地方将在下一小节介绍。

7.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>();
    userRepository.Expect(ur => ur.Insert(Arg.Is.Anything));
    var userService = new UserService(userRepository);
    userService.Create(new User() {UserName = "zhangsan"});
    userRepository.VerifyAllExpectations();
}

这个测试代码和上一小节测试代码不同之处在于使用GenerateMock和Except方法替代了GenerateStub和Stub方法,前者用于指定一个可以被验证的期望,而后者只是提供一个虚拟的桩。在代码的最后通过VerifyAllExpectations方法验证所有期望都被执行。执行测试没有意外的话测试可以正常通过。

给Expect指定的lambda表达式中的Insert方法接受Arg.Is.Anything作为参数,这正符合被测试函数的要求。如果Create函数中没有调用IRepository的Insert函数,测试也会失败:

图7

这是验证函数被执行的一种方法,还有另一种等效的方法,且后者在外观上更符合之前提到的单元测试的AAA模式:

[Fact]
public void Test_Create_User()
{
    var userRepository = MockRepository.GenerateMock>();//这种方法中,这里使用GenerateMock和GenerateStub都可以
    var userService = new UserService(userRepository);
    userService.Create(new User() {UserName = "zhangsan"});
    userRepository.AssertWasCalled(ur => ur.Insert(Arg.Is.Anything));
}

如代码所见,这段测试代码没有使用Expect设置期望,而是通过AssertWasCalled来验证一个函数是否被调用。

上面大部分例子都使用了Rhino Mocks的GenerateMock()和GenerateStub()静态方法。Rhino Mocks还通过MockRepository对象的实例方法DynamicMock()和Stub()提供了相同的功能。这两者的最主要区别是,对于Except的验证,前者只能在静态方法返回的对象上分别调用VerifyAllExpectations()方法进行验证,而后者可以在MockRepository对象上调用VerifyAll()验证MockRepository中所有的Except。

8.测试类内部方法调用

实际测试中还常常会遇到一个方法调用相同类中另一个方法的这种需要测试的情况,为了好描述,假设是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>();
    userRepository.Expect(ur => ur.Insert(Arg.Is.Anything));
    var userService = MockRepository.GeneratePartialMock(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方法即可。

9.Rhino Mocks的高级功能

有了前文和Rhino Mocks的接触的基础,这一小节来看一下Rhino Mocks的一些高级功能。

Arg实现参数约束

在前文我们已经体会到了Arg的强大,Arg.Is.Anything作为参数就可以指定Stub方法接受指定类型的任意参数。Arg还可以进行更多的参数限制,当被测试方法给期望方法传入的参数不符合参数约束时,验证期望会失败最终将导致测试不通过。下面的表格来自Rhino Mocks官方文档,其中列出了Arg支持的大部分约束。(博主翻译并按照最新的3.6.1版整理了下)

Arg.Is  
 

Equal(object)

NotEqual(object)

参数相等或不等
 

GreaterThan(object)

GreaterThanOrEqual(object)

LessThan(object)

LessThanOrEqual(object)

大于,大于等于,小于,小于等于比较
 

Same(object)

NotSame(object)

比较引用是否相同
  Anything 任意参数
 

Null

NotNull

参数为空或不为空
  TypeOf 参数为泛型参数指定的类型
Arg.List  
  OneOf(IEnumerable) 确定参数是指定集合中的一个
  Equal(IEnumerable) 参数列表与指定列表相同
  Count(AbstractConstraint) 确定参数集合有指定数量的符合约束的元素
  Element(int, AbstractConstraint) 参数集合中指定位置的元素复合一个约束
  ContainsAll(IEnumerable) 确定参数集合包含所有的指定元素
  IsIn(object) 确定指定元素属于参数集合(参数需要为IEnumerable)
Arg.Ref() 指定ref参数
Arg.Out() 指定out参数
Arg.Text  
 

StartsWith(string)

EndsWith(string)

Contains(string)

参数字符串以指定字符串开始或以指定字符串结束或包含指定字符串
  Like(string regex) 参数匹配指定正则表达式
Arg.Is() Arg.Is.Equal()等价
Arg.Matches()  
  Argt.Matches(Expression) 参数匹配一个lambda表达式指定的约束
  Argt.Matches(AbstractConstraint) 用于不支持lambda的C#版本,以内置的约束类型指定参数约束

表中大部分方法和xUnit.net支持的Assert很类似。重点来看一下其中最强大的Matches方法:

userRepository.Expect(ur => ur.Insert(Arg.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.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.Is.Anything),c=>c.Repeat.Once());

这样Insert方法应该只被执行1次测试才可以通过。除此还有Twice(),Never(),AtLeastOnce()及Times(int)等其它方法用来指定不同的次数。

AssertWasCalled第二个参数的类型Action中的T(即lambda表达式参数)是IMethodOptions类型,除了可以通过Repeat属性的方法设置执行次数约束外还有其它方法,大部分方法可以通过其它途径进行等价设置,还有一些已经过时就不再赘述了。

10.UWP中的单元测试

上文的例子都是在.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(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();
    Mock.Arrange(()=>storage.GetFileExtension()).Returns(".json");
    var file1 = Mock.CreateLike(sf => sf.FileType == ".json" && sf.DisplayName == fileName);
    var file2 = Mock.CreateLike(sf => sf.FileType == ".json" && sf.DisplayName == "fileNoInCache");
    var file3 = Mock.CreateLike(sf => sf.FileType == ".xml" && sf.DisplayName == "fileOtherType");
    
    var folder = ApplicationData.Current.LocalFolder;//Partial Mock
    Mock.ArrangeLike(folder,sf=>sf.GetFilesAsync()==
        Task.FromResult(new List() {file1,file2,file3} as IReadOnlyList).AsAsyncOperation());
    Mock.ArrangeLike(storage,s => s.GetFolderAsync()==Task.FromResult(folder));

    var cacheObj = Mock.CreateLike(co => co.IsValid == false);
    Mock.Arrange(() => storage.LoadAsync(Arg.AnyString)).OccursAtLeast(2);
    Mock.Arrange(() => storage.LoadAsync(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() Mock.CreateLike()
mock.Stub() Mock.ArrangeLike()
MockRepository.GenerateMock() Mock.Create()
mock.Except() Mock.Arrange()
MockRepository.GeneratePartialMock() 直接创建真实对象,并Arrange()模拟方法
mock.VerifyAllExpectations() mock.Assert()
Arg Arg
AssertWasCalled()//其实不太一样 MustBeCalled()
c=>c.Repeat.XXX() OccursAtLeast(times)

当前这段测试代码并不能正确运行,因为2.1RC版本的xUnit runner for vs和JustMock 2015Q2好像不太兼容,总会报少System.Core缺失啥的错误。

11.通过扩展方法进行Assert

nopCommerce项目中给单元测试准备的一系列扩展方法用起来也很方便,可以把Act和Assert合并到一行,一定程度上提高代码的可读性。

原代码是基于NUnit的,我把它们改成了支持xUnit.net的放在下面供需要的童鞋参考。

public static class TestExtensions
{
    public static T ShouldNotBeNull(this T obj)
    {
        Assert.NotNull(obj);
        return obj;
    }

    public static T ShouldEqual(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(this object actual)
    {
        Assert.IsType(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(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没搞过,不了解。就写到这吧。欢迎指正。谢谢。

转载请保留原链接