C#单元测试(2)

Table of Contents

1 前言

这篇文章对单元测试做了一些总结,当然最重要的是记录了Mocks工具的使用。 在这次单元测试之前我对单元测试的了解停留在几个测试框架的测试方法上。

拿测试运行器干的最多的事不是“测试”而是“调试”。即一般都是在一个类及函数不方便启动程序来调试时,搞一个测试类,用测试运行器的调试功能专门去Debug这个方法。这其实也只是用了测试框架(和测试运行器)很小的一部分功能。

在开始正题之前说一说单元测试工具的选择。现在xUnit.net几乎成为了准官方的选择。xUnit.net配套工具完善,上手简单初次接触单元测试是很好的选择。

测试运行器选择了Resharper的xUnit runner插件(Resharper也是vs必不可少的插件),个人始终感觉VS自带的测试运行工具远不如Resharper的好用。

Mock框架选择了大名鼎鼎的RhinoMocks,神一样的开源Mock框架。

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

xUnit.net的安装

xUnit.net的安装很简单,打开Nuget包管理器找到xUnit.net并安装就可以了(写这篇文章是最新正式版是2.0,2.1到了RC),就是一些程序集。RhinoMocks也是同理。

Resharper的xUnit Test Runner通过Resharper的Extension Manager(有这么一个菜单项)来安装,点击菜单弹出如下图的对话框:

unit-test9.png

2 最简单的单元测试

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

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

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也类似)

unit-test10.png

在测试方法所在的类声明那行前面也有一个这个的圆点,点击后可以执行类中所有测试方法。如果测试通过圆点是绿色小球标识,如果不通过会以红色标记显示。

另外也可以打开Resharper的UnitTest窗口,里面会列出项目中所有的单元测试,也可以通过这个执行单个或批量测试:

unit-test11.png

我们执行上面的测试,可以看到下面的结果:

unit-test12.png

嗯 ,我们的测试通过了。有时候我们还会编写一些测试,测试相反的情况,或边界情况。如:

[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.StartWith() 验证字符串以指定字符串开头。可以传入参数指定字符串的比较方式
Assert.EndWith() 验证字符串以指定字符结尾
Assert.Empty() 验证集合为空
Assert.NotEmpty() 与上面的相反
Assert.Single() 验证集合只有一个元素
Assert.InRange() 验证值在一个范围之内,泛型方法,泛型类型需要实现Comparable<T>,或传入IComparer<T>
Assert.Null() 验证对象为空
Assert.NotNull() 与上面的相反
Assert.StrictEqual() 判断两个对象严格相等,即使用EqualityComparer<T>对象
Assert.NotStrictEqual() 与上面的相反
Assert.IsType() /Assert.IsType<T>() 验证对象是某个类型(不能是继承关系)
Assert.IsNotType() /Assert.IsNotType<T>() 与上面相反
Assert.IsAssignableFrom()/Assert.IsAssignableFrom<T>() 验证某个对象是指定类型或指定类型的子类
Assert.Subset() 验证一个集合是另一个集合的子集
Assert.ProperSubset 验证一个集合是另一个集合的真子集
Assert.ProperSuperSubset 验证一个集合是另一个集合的真超集
Assert.Collection() 验证第一个参数集合中所有项都可以在第二个参数传入的Action<T>序列中相应位置的Action<T>上执行而不抛出异常
Assert.All() 验证第一个参数集合中所有项都可以传入第二个Action<T>类型的参数而不抛出异常。与Collection类似,区别在于这里的Action<T>只有一个而不是一个序列。
Assert.Throws()/Assert.Throws<T>() Assert.ThrowsAsync()/Assert.ThrowsAsync<T>() 验证测试代码抛出指定异常(不能是指定异常的子类) 如果测试代码返回Task,应该使用异步方法
Assert.ThrowsAny()<T>()/Assert.ThrowsAnyAsync<T>() 验证测试代码抛出指定异常或指定异常的子类 如果测试代码返回Task,应该使用异步方法
Assert.PropertyChanged() 验证执行第三个参数Action<T>使被测Notify Property Changed对象触发了Property Changed时间,且属性名为第二个参数传入的名称。

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

  • Arrange用于初始化一些被测试方法需要的参数或依赖的对象。
  • Act方法用于调用被测方法获取返回值。
  • Assert用于验证测试方法是否按期望执行或者结果是否符合期望值

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

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

在大部分和数据库打交道的项目中,尤其是使用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也会执行两次保证分配的对象被释放)。

这种设置使每个测试方法都有一个干净的上下文来执行,不同测试方法使用同名的测试类成员不会产生冲突。

4 避免重复初始化

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

首先实现一个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是共享的。

5 异步方法测试支持

异步编程在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);
}

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

6 给测试方法传入系列参数

除了常用的[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));
}

测试结果:

unit-test13.png

对于测试数据集合中的6不是奇数,所以测试失败。

虽然只有一个测试方法,但xUnit会针对每条的InlineData传入的数据执行一次测试,这样可以很容易看出是哪一条InlineData出了问题

修改测试集:

[Theory]
[InlineData(3)]
[InlineData(5)]
[InlineData(7)]
public void MyFirstTheory(int value)
{
    Assert.True(IsOdd(value));
}

这样测试就可以顺利通过了。

unit-test14.png

7 Mock初次登场

还是以实际项目中常见的场景来介绍需要使用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框架更神奇的地方将在下一小节介绍。

8 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函数,测试也会失败:

unit-test15.png

这是验证函数被执行的一种方法,还有另一种等效的方法,且后者在外观上更符合之前提到的单元测试的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。

9 测试类内部方法调用

实际测试中还常常会遇到一个方法调用相同类中另一个方法的这种需要测试的情况,为了好描述,假设是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方法即可。

10 Rhino Mocks的高级功能

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

Arg实现参数约束

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

unit-test16.png

unit-test17.png

表中大部分方法和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属性的方法设置执行次数约束外还有其它方法,大部分方法可以通过其它途径进行等价设置,还有一些已经过时就不再赘述了。

11 通过扩展方法进行Assert

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

12 原文链接