掌握 xUnit 单元测试中的 Mock 与 Stub 实战

引言

上一章节介绍了 TDD 的三大法则,今天我们讲一下在单元测试中模拟对象的使用。

Fake

Fake - Fake 是一个通用术语,可用于描述 stubmock 对象。它是 stub 还是 mock 取决于使用它的上下文。也就是说,Fake 可以是 stubmock

Mock - Mock 对象是系统中的 fake 对象,用于确定单元测试是否通过。Mock 起初为 Fake,直到对其断言。

Stub - Stub 是系统中现有依赖项的可控制替代项。通过使用 Stub,可以在无需使用依赖项的情况下直接测试代码。

参考 单元测试最佳做法[1] 让我们使用相同的术语

区别点:

  1. Stub

  • 用于提供可控制的替代行为,通常是在测试中模拟依赖项的简单行为。

  • 主要用于提供固定的返回值或行为,以便测试代码的特定路径。

  • 不涉及对方法调用的验证,只是提供一个虚拟的实现。

Mock

  • 用于验证方法的调用和行为,以确保代码按预期工作。

  • 主要用于确认特定方法是否被调用,以及被调用时的参数和次数。

  • 可以设置期望的调用顺序、参数和返回值,并在测试结束时验证这些调用。

总结:

  • Stub 更侧重于提供一个简单的替代品,帮助测试代码路径,而不涉及行为验证。

  • Mock 则更侧重于验证代码的行为和调用,以确保代码按预期执行。

在某些情况下两者可能看起来相似,但在测试的目的和用途上还是存在一些区别。在编写单元测试时,根据测试场景和需求选择合适的 stubmock对象可以帮助提高测试的准确性和可靠性。

创建实战项目

创建一个 WebApiController 项目,和一个EFCore仓储类库作为我们后续章节的演示项目

dotNetParadise-Xunit
│
├── src
│   ├── Sample.Api
│   └── Sample.Repository

Sample.Repository 是一个简单 EFCore 的仓储模式实现,Sample.Api 对外提供 RestFulApi 接口

Sample.Repository 实现

  • 第一步 Sample.Repository类库安装 Nuget

PM> NuGet\Install-Package Microsoft.EntityFrameworkCore.InMemory -Version 8.0.3
PM> Microsoft.EntityFrameworkCore.Relational -Version 8.0.3
  • 创建实体 Staff

public class Staff
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
    public int? Age { get; set; }
    public List<string>? Addresses { get; set; }

    public DateTimeOffset? Created { get; set; }
}
  • 创建 SampleDbContext 数据库上下文

public class SampleDbContext(DbContextOptions<SampleDbContext> options) : DbContext(options)
{
    public DbSet<Staff> Staff { get; set; }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
    }
}
  • 定义仓储接口和实现

public interface IStaffRepository
{
    /// <summary>
    /// 获取 Staff 实体的 DbSet
    /// </summary>
    DbSet<Staff> dbSet { get; }

    /// <summary>
    /// 添加新的 Staff 实体
    /// </summary>
    /// <param name="staff"></param>
    Task AddStaffAsync(Staff staff, CancellationToken cancellationToken = default);

    /// <summary>
    /// 根据 Id 删除 Staff 实体
    /// </summary>
    /// <param name="id"></param>
     Task DeleteStaffAsync(int id, CancellationToken cancellationToken = default);

    /// <summary>
    /// 更新 Staff 实体
    /// </summary>
    /// <param name="staff"></param>
    Task UpdateStaffAsync(Staff staff, CancellationToken cancellationToken = default);

    /// <summary>
    /// 根据 Id 获取单个 Staff 实体
    /// </summary>
    /// <param name="id"></param>
    /// <returns></returns>
    Task<Staff?> GetStaffByIdAsync(int id, CancellationToken cancellationToken = default);

    /// <summary>
    /// 获取所有 Staff 实体
    /// </summary>
    /// <returns></returns>
    Task<List<Staff>> GetAllStaffAsync(CancellationToken cancellationToken = default);

    /// <summary>
    /// 批量更新 Staff 实体
    /// </summary>
    /// <param name="staffList"></param>
    Task BatchAddStaffAsync(List<Staff> staffList, CancellationToken cancellationToken = default);

}
  • 仓储实现

public class StaffRepository : IStaffRepository
{
    private readonly SampleDbContext _dbContext;
    public DbSet<Staff> dbSet => _dbContext.Set<Staff>();
    public StaffRepository(SampleDbContext dbContext)
    {
        dbContext.Database.EnsureCreated();
        _dbContext = dbContext;
    }
    public async Task AddStaffAsync(Staff staff, CancellationToken cancellationToken = default)
    {
        await dbSet.AddAsync(staff, cancellationToken);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    public async Task DeleteStaffAsync(int id, CancellationToken cancellationToken = default)
    {
        //await dbSet.AsQueryable().Where(_ => _.Id == id).ExecuteDeleteAsync(cancellationToken);
        var staff = await GetStaffByIdAsync(id, cancellationToken);
        if (staff is not null)
        {
            dbSet.Remove(staff);
            await _dbContext.SaveChangesAsync(cancellationToken);
        }
    }

    public async Task UpdateStaffAsync(Staff staff, CancellationToken cancellationToken = default)
    {
        dbSet.Update(staff);
        _dbContext.Entry(staff).State = EntityState.Modified;
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    public async Task<Staff?> GetStaffByIdAsync(int id, CancellationToken cancellationToken = default)
    {
        return await dbSet.AsQueryable().Where(_ => _.Id == id).FirstOrDefaultAsync(cancellationToken);
    }

    public async Task<List<Staff>> GetAllStaffAsync(CancellationToken cancellationToken = default)
    {
        return await dbSet.ToListAsync(cancellationToken);
    }

    public async Task BatchAddStaffAsync(List<Staff> staffList, CancellationToken cancellationToken = default)
    {
        await dbSet.AddRangeAsync(staffList, cancellationToken);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }
}
  • 依赖注入

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddEFCoreInMemoryAndRepository(this IServiceCollection services)
    {
        services.AddScoped<IStaffRepository, StaffRepository>();
        services.AddDbContext<SampleDbContext>(options => options.UseInMemoryDatabase("sample").EnableSensitiveDataLogging(), ServiceLifetime.Scoped);
        return services;
    }
}

到目前为止 仓储层的简单实现已经完成了,接下来完成 WebApi

Sample.Api

Sample.Api 添加项目引用Sample.Repository

program 依赖注入

builder.Services.AddEFCoreInMemoryAndRepository();
  • 定义 Controller

[Route("api/[controller]")]
[ApiController]
public class StaffController(IStaffRepository staffRepository) : ControllerBase
{
    private readonly IStaffRepository _staffRepository = staffRepository;

    [HttpPost]
    public async Task<IResult> AddStaff([FromBody] Staff staff, CancellationToken cancellationToken = default)
    {
        await _staffRepository.AddStaffAsync(staff, cancellationToken);
        return TypedResults.NoContent();
    }

    [HttpDelete("{id}")]
    public async Task<IResult> DeleteStaff(int id, CancellationToken cancellationToken = default)
    {
        await _staffRepository.DeleteStaffAsync(id);
        return TypedResults.NoContent();
    }

    [HttpPut("{id}")]
    public async Task<Results<BadRequest<string>, NoContent, NotFound>> UpdateStaff(int id, [FromBody] Staff staff, CancellationToken cancellationToken = default)
    {
        if (id != staff.Id)
        {
            return TypedResults.BadRequest("Staff ID mismatch");
        }
        var originStaff = await _staffRepository.GetStaffByIdAsync(id, cancellationToken);
        if (originStaff is null) return TypedResults.NotFound();
        originStaff.Update(staff);
        await _staffRepository.UpdateStaffAsync(originStaff, cancellationToken);
        return TypedResults.NoContent();
    }

    [HttpGet("{id}")]
    public async Task<Results<Ok<Staff>, NotFound>> GetStaffById(int id, CancellationToken cancellationToken = default)
    {
        var staff = await _staffRepository.GetStaffByIdAsync(id, cancellationToken);
        if (staff == null)
        {
            return TypedResults.NotFound();
        }
        return TypedResults.Ok(staff);
    }


    [HttpGet]
    public async Task<IResult> GetAllStaff(CancellationToken cancellationToken = default)
    {
        var staffList = await _staffRepository.GetAllStaffAsync(cancellationToken);
        return TypedResults.Ok(staffList);
    }


    [HttpPost("BatchAdd")]
    public async Task<IResult> BatchAddStaff([FromBody] List<Staff> staffList, CancellationToken cancellationToken = default)
    {
        await _staffRepository.BatchAddStaffAsync(staffList, cancellationToken);
        return TypedResults.NoContent();
    }

}

F5 项目跑一下

d873d175c587f967b419b2d4cd9a93e4.png

到这儿我们的项目已经创建完成了本系列后面的章节基本上都会以这个项目为基础展开拓展

控制器的单元测试

[单元测试[2]涉及通过基础结构和依赖项单独测试应用的一部分。单元测试控制器逻辑时,仅测试单个操作的内容,不测试其依赖项或框架自身的行为。

本章节主要以控制器的单元测试来带大家了解一下StupMoq的核心区别。

创建一个新的测试项目,然后添加Sample.Api的项目引用

381cb6a902a86738dbc070ee9e051f2b.png

Stub 实战

Stub 是系统中现有依赖项的可控制替代项。通过使用 Stub,可以在测试代码时不需要使用真实依赖项。通常情况下,存根最初被视为 Fake

下面对 StaffController 利用 Stub 进行单元测试,

  • 创建一个 Stub 实现 IStaffRepository 接口,以模拟对数据库或其他数据源的访问操作。

  • 在单元测试中使用这个 Stub 替代 IStaffRepository 的实际实现,以便在不依赖真实数据源的情况下测试 StaffController 中的方法。

我们在dotNetParadise.FakeTest测试项目上新建一个IStaffRepository的实现,名字可以叫StubStaffRepository

public class StubStaffRepository : IStaffRepository
{
    public DbSet<Staff> dbSet => default!;

    public async Task AddStaffAsync(Staff staff, CancellationToken cancellationToken)
    {
        // 模拟添加员工操作
        await Task.CompletedTask;
    }

    public async Task DeleteStaffAsync(int id)
    {
        // 模拟删除员工操作
        await Task.CompletedTask;
    }

    public async Task UpdateStaffAsync(Staff staff, CancellationToken cancellationToken)
    {
        // 模拟更新员工操作
        await Task.CompletedTask;
    }

    public async Task<Staff?> GetStaffByIdAsync(int id, CancellationToken cancellationToken)
    {
        // 模拟根据 ID 获取员工操作
        return await Task.FromResult(new Staff { Id = id, Name = "Mock Staff" });
    }

    public async Task<List<Staff>> GetAllStaffAsync(CancellationToken cancellationToken)
    {
        // 模拟获取所有员工操作
        return await Task.FromResult(new List<Staff> { new Staff { Id = 1, Name = "Mock Staff 1" }, new Staff { Id = 2, Name = "Mock Staff 2" } });
    }

    public async Task BatchAddStaffAsync(List<Staff> staffList, CancellationToken cancellationToken)
    {
        // 模拟批量添加员工操作
        await Task.CompletedTask;
    }

    public async Task DeleteStaffAsync(int id, CancellationToken cancellationToken = default)
    {
        await Task.CompletedTask;
    }
}

我们新创建了一个仓储的实现来替换StaffRepository作为新的依赖

下一步在单元测试项目测试我们的Controller方法

public class TestStubStaffController
{

    [Fact]
    public async Task AddStaff_WhenCalled_ReturnNoContent()
    {
        //Arrange
        var staffController = new StaffController(new StubStaffRepository());
        var staff = new Staff()
        {
            Age = 10,
            Name = "Test",
            Email = "Test@163.com",
            Created = DateTimeOffset.Now,
        };
        //Act
        var result = await staffController.AddStaff(staff);

        //Assert
        Assert.IsType<Results<NoContent, ProblemHttpResult>>(result);
    }

    [Fact]
    public async Task GetStaffById_WhenCalled_ReturnOK()
    {
        //Arrange
        var staffController = new StaffController(new StubStaffRepository());
        var id = 1;
        //Act
        var result = await staffController.GetStaffById(id);

        //Assert
        Assert.IsType<Results<Ok<Staff>, NotFound>>(result);
        var okResult = (Ok<Staff>)result.Result;
        Assert.Equal(id, okResult.Value?.Id);
    }

      //先暂时省略后面测试方法....

}

caa0db3dac7c523584175a073afbefa6.png

Stub 来替代真实的依赖项,以便更好地控制测试环境和测试结果

Mock

在测试过程中,尤其是TDD的开发过程中,测试用例有限开发在这个时候,我们总是要去模拟对象的创建,这些对象可能是某个接口的实现也可能是具体的某个对象,这时候就必须去写接口的实现,这时候模拟对象Mock的用处就体现出来了,在社区中也有很多模拟对象的库如Moq,FakeItEasy等。

Moq 是一个简单、直观且强大的.NET 模拟库,用于在单元测试中模拟对象和行为。通过 Moq,您可以轻松地设置依赖项的行为,并验证代码的调用。

我们用上面的实例来演示一下Moq的核心用法

第一步 Nuget 包安装Moq

PM> NuGet\Install-Package Moq -Version 4.20.70

您可以使用 Moq 中的 Setup 方法来设置模拟对象(Mock 对象)中可重写方法的行为,结合 Returns(用于返回一个值)或 Throws(用于抛出异常)等方法来定义其行为。这样可以模拟对特定方法的调用,使其在测试中返回预期的值或抛出特定的异常。

创建TestMockStaffController测试类,接下来我们用Moq实现一下上面的例子

public class TestMockStaffController
{
    private readonly ITestOutputHelper _testOutputHelper;
    public TestMockStaffController(ITestOutputHelper testOutputHelper)
    {
        _testOutputHelper = testOutputHelper;
    }
    [Fact]
    public async Task AddStaff_WhenCalled_ReturnNoContent()
    {
        //Arrange
        var mock = new Mock<IStaffRepository>();

        mock.Setup(_ => _.AddStaffAsync(It.IsAny<Staff>(), default));
        var staffController = new StaffController(mock.Object);
        var staff = new Staff()
        {
            Age = 10,
            Name = "Test",
            Email = "Test@163.com",
            Created = DateTimeOffset.Now,
        };
        //Act
        var result = await staffController.AddStaff(staff);

        //Assert
        Assert.IsType<Results<NoContent, ProblemHttpResult>>(result);
    }

    [Fact]
    public async Task GetStaffById_WhenCalled_ReturnOK()
    {
        //Arrange
        var mock = new Mock<IStaffRepository>();
        var id = 1;
        mock.Setup(_ => _.GetStaffByIdAsync(It.IsAny<int>(), default)).ReturnsAsync(() => new Staff()
        {
            Id = id,
            Name = "张三",
            Age = 18,
            Email = "zhangsan@163.com",
            Created = DateTimeOffset.Now
        });

        var staffController = new StaffController(mock.Object);

        //Act
        var result = await staffController.GetStaffById(id);

        //Assert
        Assert.IsType<Results<Ok<Staff>, NotFound>>(result);
        var okResult = (Ok<Staff>)result.Result;
        Assert.Equal(id, okResult.Value?.Id);
        _testOutputHelper.WriteLine(okResult.Value?.Name);

    }

    //先暂时省略后面测试方法....
}

看一下运行测试

e9b7f15e4fac0a25948a2bd5c916e90d.png

Moq 核心功能讲解

通过我们上面这个简单的 Demo 简单的了解了一下 Moq 的使用,接下来我们对 Moq 和核心功能深入了解一下

通过安装的Nuget包可以看到, Moq依赖了Castle.Core这个包,Moq正是利用了 Castle 来实现动态代理模拟对象的功能。

基本概念

  • Mock 对象:通过 Moq 创建的模拟对象,用于模拟外部依赖项的行为。

    //创建Mock对象
    var mock = new Mock<IStaffRepository>();
  • Setup:用于设置 Mock 对象的行为和返回值,以指定当调用特定方法时应该返回什么结果。

    //指定调用AddStaffAsync方法的参数行为
      mock.Setup(_ => _.AddStaffAsync(It.IsAny<Staff>(), default));

异步方法

从我们上面的单元测试中看到我们使用了一个异步方法,使用返回值ReturnsAsync表示的

mock.Setup(_ => _.GetStaffByIdAsync(It.IsAny<int>(), default))
       .ReturnsAsync(() => new Staff()
        {
            Id = id,
            Name = "张三",
            Age = 18,
            Email = "zhangsan@163.com",
            Created = DateTimeOffset.Now
        });

Moq有三种方式去设置异步方法的返回值分别是:

  1. 使用 .Result 属性(Moq 4.16 及以上版本):

  • 在 Moq 4.16 及以上版本中,您可以直接通过 mock.Setup 返回任务的 .Result 属性来设置异步方法的返回值。这种方法几乎适用于所有设置和验证表达式。

  • 示例:
    mock.Setup(foo => foo.DoSomethingAsync().Result).Returns(true);

使用 ReturnsAsync(较早版本):

  • 在较早版本的 Moq 中,您可以使用类似 ReturnsAsyncThrowsAsync 等辅助方法来设置异步方法的返回值。

  • 示例:
    mock.Setup(foo => foo.DoSomethingAsync()).ReturnsAsync(true);

使用 Lambda 表达式

  • 您还可以使用 Lambda 表达式来返回异步方法的结果。不过这种方式会触发有关异步 Lambda 同步执行的编译警告。

  • 示例:
    mock.Setup(foo => foo.DoSomethingAsync()).Returns(async () => 42);

参数匹配

在我们单元测试实例中用到了参数匹配,mock.Setup(_ => _.GetStaffByIdAsync(It.IsAny<int>(), default)).,对就是这个It.IsAny<int>(),此处的用意是匹配任意输入的 int类型的入参,接下来我们一起看下参数匹配的一些常用示例。

  • 任意值匹配It.IsAny<T>()

    mock.Setup(_ => _.GetStaffByIdAsync(It.IsAny<int>(), default))

  • ref 参数的任意值匹配:对于 ref 参数,可以使用 It.Ref.IsAny 进行匹配(需要 Moq 4.8 或更高版本)。

    //Arrange
         var mock = new Mock<IFoo>();
         // ref arguments
         var instance = new Bar();
         // Only matches if the ref argument to the invocation is the same instance
         mock.Setup(foo => foo.Submit(ref instance)).Returns(true);

  • 匹配满足条件的值:使用 It.Is<T>(predicate) 可以匹配满足条件的值,其中 predicate 是一个函数。

    //匹配满足条件的值
      mock.Setup(foo => foo.Add(It.Is<int>(i => i % 2 == 0))).Returns(true);
     //It.Is 断言
     var result = mock.Object.Add(3);
     Assert.False(result);

  • 匹配范围:使用 It.IsInRange<T> 可以匹配指定范围内的值

    mock.Setup(foo => foo.Add(It.IsInRange<int>(0, 10, Moq.Range.Inclusive))).Returns(true);
    var inRangeResult = mock.Object.Add(3);
    Assert.True(inRangeResult);

  • 匹配正则表达式:使用 It.IsRegex 可以匹配符合指定正则表达式的值

    {
      mock.Setup(x => x.DoSomethingStringy(It.IsRegex("[a-d]+", RegexOptions.IgnoreCase))).Returns("foo");
      var result = mock.Object.DoSomethingStringy("a");
      Assert.Equal("foo", result);
    }

属性值

  • 设置属性的返回值
    通过 **Setup**后的 Returns函数 设置Mock的返回值

    {
      mock.Setup(foo => foo.Name).Returns("bar");
      Assert.Equal("bar",mock.Object.Name);
     }

  • SetupSet 设置属性的设置行为,期望特定值被设置. 主要是通过设置预期行为,对属性值做一些验证或者回调等操作

    //SetupUp
       mock = new Mock<IFoo>();
       // Arrange
       mock.SetupSet(foo => foo.Name = "foo").Verifiable();
       //Act
       mock.Object.Name = "foo";
       mock.Verify();

如果值设置为mock.Object.Name = "foo1";, 单元测试就会抛出异常

OutPut:

dotNetParadise.FakeTest.TestControllers.TestMockStaffController.Test_Moq_Demo
 源: TestMockStaffController.cs 行 70
 持续时间: 8.7 秒

消息: 
Moq.MockException : Mock<IFoo:2>:
This mock failed verification due to the following:

IFoo foo => foo.Name = "foo":
This setup was not matched.

堆栈跟踪: 
Mock.Verify(Func`2 predicate, HashSet`1 verifiedMocks) 行 309
Mock.Verify() 行 251
TestMockStaffController.Test_Moq_Demo() 行 111
--- End of stack trace from previous location ---

  • VerifySet 直接验证属性的设置操作

//VerifySet直接验证属性的设置操作
       {
           // Arrange
           mock = new Mock<IFoo>();
           //Act
           mock.Object.Name = "foo";
           //Asset
           mock.VerifySet(person => person.Name = "foo");
       }

  • SetupProperty使用 SetupProperty 可以为 Mock 对象的属性设置行为,包括 getset 的行为。

{
    // Arrange
     mock = new Mock<IFoo>();
      // start "tracking" sets/gets to this property
     mock.SetupProperty(f => f.Name);
      // alternatively, provide a default value for the stubbed property
     mock.SetupProperty(f => f.Name, "foo");
      //Now you can do:
     IFoo foo = mock.Object;
     // Initial value was stored
     //Asset
     Assert.Equal("foo", foo.Name);
 }

Moq 中,您可以使用 SetupAllProperties 方法来一次性存根(StubMock 对象的所有属性。这意味着所有属性都会开始跟踪其值,并可以提供默认值。以下是一个示例演示如何使用 SetupAllProperties 方法:

// 存根(Stub)Mock 对象的所有属性
mock.SetupAllProperties();

通过使用 SetupProperty 方法,可以更灵活地设置 Mock 对象的属性行为和默认值,以满足单元测试中的需求

处理事件(Events

Moq 4.13 及以后的版本中,你可以通过配置事件的 addremove 访问器来模拟事件的行为。这允许你指定当事件处理器被添加或移除时应该发生的逻辑。这通常用于验证事件是否被正确添加或移除,或者模拟事件触发时的行为。

  • SetupAdd 用于设置 Mock对象的事件的 add 访问器,即用于模拟事件订阅的行为

  • SetupRemove 用于设置 Mock对象的事件的remove 访问器,以模拟事件处理程序的移除行为

创建要被测试的类:

public class HasEvent
{
    public virtual event Action Event;

    public void RaiseEvent() => this.Event?.Invoke();
}
{
            var handled = false;
            var mock = new Mock<HasEvent>();
            //设置订阅行为
            mock.SetupAdd(m => m.Event += It.IsAny<Action>()).CallBase();
            // 订阅事件并设置事件处理逻辑
            Action eventHandler = () => handled = true;
            mock.Object.Event += eventHandler;
            mock.Object.RaiseEvent();
            Assert.True(handled);

            // 重置标志为 false
            handled = false;
            //  移除事件处理程序
            mock.SetupRemove(h => h.Event -= It.IsAny<Action>()).CallBase();
            // 移除事件处理程序
            mock.Object.Event -= eventHandler;
            // 再次触发事件
            mock.Object.RaiseEvent();

            // Assert -  验证事件是否被正确处理
            Assert.False(handled); // 第一次应该为 true,第二次应该为 false

        }

这段代码是一个针对 HasEvent 类的测试示例,使用 Moq 来设置事件的订阅和移除行为,并验证事件处理程序的添加和移除是否按预期工作。让我简单解释一下这段代码的流程:

  1. 创建一个 Mock 对象 mock,模拟 HasEvent 类。

  2. 使用 SetupAdd 方法设置事件的订阅行为,并使用 CallBase 方法调用基类的实现。

  3. 订阅事件并设置事件处理逻辑,将事件处理程序 eventHandler 添加到事件中。

  4. 调用 RaiseEvent 方法触发事件,并通过断言验证事件处理程序是否被正确处理。

  5. handled 标志重置为 false

  6. 使用 SetupRemove 方法设置事件的移除行为,并使用 CallBase 方法调用基类的实现。

  7. 移除事件处理程序 eventHandler

  8. 再次触发事件,并通过断言验证事件处理程序是否被正确移除。

通过这个测试示例,可以验证事件处理程序的添加和移除操作是否正常工作

  • RaiseRaise 方法用于手动触发 Mock 对象上的事件,模拟事件的触发过程

{
            // Arrange
            var handled = false;
            var mock = new Mock<HasEvent>();
            //设置订阅行为
            mock.Object.Event += () => handled = true;
            //act
            mock.Raise(m => m.Event += null);
            // Assert - 验证事件是否被正确处理
            Assert.True(handled);
        }

这个示例使用Raise方法手动触发 Mock 对象上的事件 Event,并验证事件处理程序的执行情况。通过设置事件的订阅行为,触发事件,以及断言验证事件处理程序的执行结果,测试了事件处理程序的逻辑是否按预期执行。这个过程帮助我们确认事件处理程序在事件触发时能够正确执行.

Callbacks

Callback方法用于在设置 Mock 对象的成员时指定回调操作。当特定操作被调用时,可以在 Callback 方法中执行自定义的逻辑

//Arrange
    var mock = new Mock<IFoo>();
    var calls = 0;
    var callArgs = new List<string>();

    mock.Setup(foo => foo.DoSomething("ping"))
        .Callback(() => calls++)
       .Returns(true);

    // Act
    mock.Object.DoSomething("ping");

    // Assert
    Assert.Equal(1, calls); // 验证 DoSomething 方法被调用一次

在调用 DoSomething 方法是,回调操作自动被触发参数++


  • CallBack 捕获参数

//CallBack 捕获参数
 {
     //Arrange
     mock = new Mock<IFoo>();
     mock.Setup(foo => foo.DoSomething(It.IsAny<string>()))
         .Callback<string>(s => callArgs.Add(s))
         .Returns(true);
     //Act
     mock.Object.DoSomething("a");
     //Asset
     // 验证参数是否被添加到 callArgs 列表中
     Assert.Contains("a", callArgs);
 }

使用 MoqCallback 方法可以捕获方法调用时的参数,允许我们在测试中访问和处理这些参数。通过在 Setup 方法中指定 Callback 操作,我们可以捕获方法调用时传入的参数,并在回调中执行自定义逻辑,例如将参数添加到列表中。这种方法可以帮助我们验证方法在不同参数下的行为,以及检查方法是否被正确调用和传递参数。总的来说,Callback 方法为我们提供了一种灵活的方式来处理方法调用时的参数,帮助我们编写更全面的单元测试。


  • SetupPropertySetupProperty 方法可用于设置 Mock 对象的属性,并为其提供 gettersetter

{
            //Arrange
            mock = new Mock<IFoo>();
            mock.SetupProperty(foo => foo.Name);
            mock.Setup(foo => foo.DoSomething(It.IsAny<string>()))
                .Callback((string s) => mock.Object.Name = s)
                .Returns(true);
            //Act
            mock.Object.DoSomething("a");
            // Assert
            Assert.Equal("a", mock.Object.Name);
        }

SetupProperty 方法的作用包括:

  1. 设置属性的初始值:通过 SetupProperty 方法,我们可以设置 Mock 对象属性的初始值,使其在测试中具有特定的初始状态。

  2. 模拟属性的 getter 和 setterSetupProperty 方法允许我们为属性设置 gettersetter,使我们能够访问和修改属性的值。

  3. 捕获属性的设置操作:在设置 Mock 对象的属性时,可以使用 Callback 方法捕获设置操作,以执行自定义逻辑或记录属性的设置情况。

  4. 验证属性的行为:通过设置属性和相应的行为,可以验证属性的行为是否符合预期,以确保代码的正确性和可靠性

Verification

Moq 中,Verification 是指验证 Mock 对象上的方法是否被正确调用,以及调用时是否传入了预期的参数。通过 Verification,我们可以确保 Mock 对象的方法按预期进行了调用,从而验证代码的行为是否符合预期。

{
            //Arrange
            var mock = new Mock<IFoo>();
            //Act
            mock.Object.Add(1);
            // Assert
            mock.Verify(foo => foo.Add(1));
        }

  • 验证方法被调用的行为

  • 未被调用,或者调用至少一次

{
       var mock = new Mock<IFoo>();
       mock.Verify(foo => foo.DoSomething("ping"), Times.Never());
   }
mock.Verify(foo => foo.DoSomething("ping"), Times.AtLeastOnce());

Verify指定 Times.AtLeastOnce() 验证方法至少被调用了一次。


  • VerifySet验证是否是按续期设置,上面有讲过。

  • VerifyGet用于验证属性的 getter 方法至少被访问指定次数,或者没有被访问.

{
        var mock = new Mock<IFoo>();
         mock.VerifyGet(foo => foo.Name);
    }

  • VerifyAdd,VerifyRemove

VerifyAddVerifyRemove 方法来验证事件的订阅和移除

// Verify event accessors (requires Moq 4.13 or later):
mock.VerifyAdd(foo => foo.FooEvent += It.IsAny<EventHandler>());
mock.VerifyRemove(foo => foo.FooEvent -= It.IsAny<EventHandler>());
  • VerifyNoOtherCalls

VerifyNoOtherCalls 方法的作用是在使用 Moq 进行方法调用验证时,确保除了已经通过 Verify方法验证过的方法调用外,没有其他未验证的方法被执行

mock.VerifyNoOtherCalls();

Customizing Mock Behavior

  • MockBehavior.Strict使用 Strict 模式创建的 Mock 对象时,如果发生了未设置期望的方法调用,包括未设置对方法的期望行为(如返回值、抛出异常等),则在该未设置期望的方法调用时会抛出 MockException 异常。这意味着在 Strict模式下,Mock 对象会严格要求所有的方法调用都必须有对应的期望设置,否则会触发异常。

[Fact]
    public void TestStrictMockBehavior_WithUnsetExpectation()
    {
        // Arrange
        var mock = new Mock<IFoo>(MockBehavior.Strict);
        //mock.Setup(_ => _.Add(It.IsAny<int>())).Returns(true);
        // Act & Assert
        Assert.Throws<MockException>(() => mock.Object.Add(3));
    }

如果mock.Setup这一行注释了,即未设置期望值,则会抛出异常


  • CallBase在上面的示例中我们也能看到CallBase的使用
    Moq 中,通过设置 CallBase = true,可以创建一个部分模拟对象(Partial Mock),这样在没有设置期望的成员时,会调用基类的实现。这在需要模拟部分行为并保留基类实现的场景中很有用,特别适用于模拟 System.Web 中的 Web/Html 控件。

public interface IUser
{
    string GetName();
}

public class UserBase : IUser
{
    public virtual string GetName()
    {
        return "BaseName";
    }

    string IUser.GetName() => "Name";
}

测试

[Fact]
    public void TestPartialMockWithCallBase()
    {
        // Arrange
       var mock = new Mock<UserBase> { CallBase = true };
        mock.As<IUser>().Setup(foo => foo.GetName()).Returns("MockName");
        // Act
        string result = mock.Object.GetName();//

        // Assert
        Assert.Equal("BaseName", result);

        //Act
        var valueOfSetupMethod = ((IUser)mock.Object).GetName();
        //Assert
        Assert.Equal("MockName", valueOfSetupMethod);
    }
  • 第一个Act:调用模拟对象的 GetName() 方法,此时基类的实现被调用,返回值为 "BaseName"

  • 第二个Act://通过强制类型转换将模拟对象转换为 IUser 接口类型,调用接口方法 GetName(),返回值为 "MockName"


  • DefaultValue.Mock创建一个自动递归模拟对象,该模拟对象在没有期望的成员上返回新的模拟对象

[Fact]
   public void TestRecursiveMock()
   {
       // Arrange
       var mock = new Mock<IFoo> { DefaultValue = DefaultValue.Mock };

       // Act
       Bar value = mock.Object.Bar;
       var barMock = Mock.Get(value);
       barMock.Setup(b => b.Submit()).Returns(true);

       // Assert
       Assert.True(mock.Object.Bar.Submit());
   }

在这个示例中,IFoo 接口具有一个虚拟属性 BarBar 类有一个虚拟方法Submit。通过设置 DefaultValue.Mock,我们创建了一个自动递归模拟对象 mock,在访问 Bar属性时会返回一个新的模拟对象。然后,我们对返回的 Bar 模拟对象设置了期望行为,并验证了其提交方法的返回值。这样,您可以方便地管理和设置递归模拟对象的期望行为。

  • **MockRepository**通过使用 MockRepository,可以更加方便地集中管理和验证所有模拟对象,同时确保它们的设置和验证是一致的

    [Fact]
      public void TestRepositoryMock()
      {
          // Create a MockRepository with MockBehavior.Strict and DefaultValue.Mock
          var repository = new MockRepository(MockBehavior.Strict) { DefaultValue = DefaultValue.Mock };
    
          // Create a mock using the repository settings
          var fooMock = repository.Create<IFoo>();
    
          // Create a mock overriding the repository settings with MockBehavior.Loose
          var barMock = repository.Create<Bar>(MockBehavior.Loose);
    
          // Verify all verifiable expectations on all mocks created through the repository
          repository.Verify();
    
          // Additional setup and assertions can be done on fooMock and barMock as needed
          // For example:
          barMock.Setup(b => b.Submit()).Returns(true);
          Assert.True(barMock.Object.Submit());
      }

    我们首先创建了一个 MockRepository,并设置了MockBehavior.StrictDefaultValue.Mock。然后通过 repository.Create<T>() 方法在 MockRepository 设置下创建了一个 IFoo接口的模拟对象 fooMock,以及一个使用MockBehavior.LooseBar 类的模拟对象 barMock。最后,我们调用 repository.Verify() 来验证通过 MockRepository 创建的所有模拟对象上的所有可验证期望。

Miscellaneous

Reset

可以使用 Reset()方法来重置模拟对象,清除所有的设置、默认返回值、注册的事件处理程序以及所有记录的调用。这在测试场景中特别有用,可以确保每个测试用例在独立的环境下运行,避免测试之间的相互影响

mock.Reset();
SetupSequence

可以使用 SetupSequence 方法来设置一个成员在连续调用时返回不同的值或抛出异常。这在需要模拟一个成员在多次调用时具有不同行为的场景中非常有用

[Fact]
    public void TestSetupSequence()
    {
        // Arrange
        var mock = new Mock<IFoo>();
        mock.SetupSequence(f => f.GetCount())
            .Returns(3)
            .Returns(2)
            .Returns(1)
            .Returns(0)
            .Throws(new InvalidOperationException());

        // Act & Assert
        Assert.Equal(3, mock.Object.GetCount());
        Assert.Equal(2, mock.Object.GetCount());
        Assert.Equal(1, mock.Object.GetCount());
        Assert.Equal(0, mock.Object.GetCount());

        Assert.Throws<InvalidOperationException>(() => mock.Object.GetCount());
    }

LINQ to Mocks

LINQ to MocksMoq 提供的一种声明性规范查询方式,使得您可以通过 LINQ 风格的语法来指定模拟对象的行为。通过 LINQ to Mocks,您可以从模拟对象的宇宙中获取符合特定规范的模拟对象,从而更加直观地设置模拟对象的行为

var services = Mock.Of<IServiceProvider>(sp =>
    sp.GetService(typeof(IRepository)) == Mock.Of<IRepository>(r => r.IsAuthenticated == true) &&
    sp.GetService(typeof(IAuthentication)) == Mock.Of<IAuthentication>(a => a.AuthenticationType == "OAuth"));

// Multiple setups on a single mock and its recursive mocks
ControllerContext context = Mock.Of<ControllerContext>(ctx =>
     ctx.HttpContext.User.Identity.Name == "kzu" &&
     ctx.HttpContext.Request.IsAuthenticated == true &&
     ctx.HttpContext.Request.Url == new Uri("http://moq.github.io/moq4/") &&
     ctx.HttpContext.Response.ContentType == "application/xml");

// Setting up multiple chained mocks:
var context = Mock.Of<ControllerContext>(ctx =>
     ctx.HttpContext.Request.Url == new Uri("http://moqthis.me") &&
     ctx.HttpContext.Response.ContentType == "application/xml" &&
     // Chained mock specification
     ctx.HttpContext.GetSection("server") == Mock.Of<ServerSection>(config =>
         config.Server.ServerUrl == new Uri("http://moqthis.com/api")));

最后

这篇总结详细介绍了在单元测试中模拟对象的使用,包括 FakeMockStub 的概念及区别。针对 Moq的核心功能进行了深入讲解,包括参数匹配、事件处理、回调操作、属性值设置、验证方法调用等内容。此外,还介绍了一些高级功能如自定义模拟对象行为、重置模拟对象、设置序列返回值、以及LINQ to Mocks 的使用方式,后续章节开始我们的单元测试实战啦。

  • Moq GitHub[3]

  • Moq wiki[4]

  • 本文完整源代码[5]

参考资料

[1]

单元测试最佳做法: https://learn.microsoft.com/zh-cn/dotnet/core/testing/unit-testing-best-practices

[2]

[单元测试: https://learn.microsoft.com/zh-cn/dotnet/articles/core/testing/unit-testing-with-dotnet-test

[3]

Moq GitHub: https://github.com/devlooped/moq

[4]

Moq wiki: https://github.com/devlooped/moq/wiki/Quickstart

[5]

本文完整源代码: https://github.com/Dong-Ruipeng/dotNetParadise-xUnit

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值