引言
上一章节介绍了 TDD 的三大法则,今天我们讲一下在单元测试中模拟对象的使用。
Fake
Fake
-Fake
是一个通用术语,可用于描述stub
或mock
对象。它是stub
还是mock
取决于使用它的上下文。也就是说,Fake
可以是stub
或mock
Mock
-Mock
对象是系统中的fake
对象,用于确定单元测试是否通过。Mock
起初为Fake
,直到对其断言。
Stub
-Stub
是系统中现有依赖项的可控制替代项。通过使用Stub
,可以在无需使用依赖项的情况下直接测试代码。
参考 单元测试最佳做法[1] 让我们使用相同的术语
区别点:
Stub:
用于提供可控制的替代行为,通常是在测试中模拟依赖项的简单行为。
主要用于提供固定的返回值或行为,以便测试代码的特定路径。
不涉及对方法调用的验证,只是提供一个虚拟的实现。
Mock:
用于验证方法的调用和行为,以确保代码按预期工作。
主要用于确认特定方法是否被调用,以及被调用时的参数和次数。
可以设置期望的调用顺序、参数和返回值,并在测试结束时验证这些调用。
总结:
Stub 更侧重于提供一个简单的替代品,帮助测试代码路径,而不涉及行为验证。
Mock 则更侧重于验证代码的行为和调用,以确保代码按预期执行。
在某些情况下两者可能看起来相似,但在测试的目的和用途上还是存在一些区别。在编写单元测试时,根据测试场景和需求选择合适的
stub
或mock
对象可以帮助提高测试的准确性和可靠性。
创建实战项目
创建一个 WebApi
的 Controller
项目,和一个EFCore
仓储类库作为我们后续章节的演示项目
dotNetParadise-Xunit
│
├── src
│ ├── Sample.Api
│ └── Sample.Repository
Sample.Repository
是一个简单 EFCore
的仓储模式实现,Sample.Api
对外提供 RestFul
的 Api
接口
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
项目跑一下
到这儿我们的项目已经创建完成了本系列后面的章节基本上都会以这个项目为基础展开拓展
控制器的单元测试
[单元测试[2]涉及通过基础结构和依赖项单独测试应用的一部分。单元测试控制器逻辑时,仅测试单个操作的内容,不测试其依赖项或框架自身的行为。
本章节主要以控制器的单元测试来带大家了解一下Stup
和Moq
的核心区别。
创建一个新的测试项目,然后添加Sample.Api
的项目引用
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);
}
//先暂时省略后面测试方法....
}
用
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);
}
//先暂时省略后面测试方法....
}
看一下运行测试
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
有三种方式去设置异步方法的返回值分别是:
使用 .Result 属性(Moq 4.16 及以上版本):
在 Moq 4.16 及以上版本中,您可以直接通过
mock.Setup
返回任务的.Result
属性来设置异步方法的返回值。这种方法几乎适用于所有设置和验证表达式。示例:
mock.Setup(foo => foo.DoSomethingAsync().Result).Returns(true);
使用 ReturnsAsync(较早版本):
在较早版本的 Moq 中,您可以使用类似
ReturnsAsync
、ThrowsAsync
等辅助方法来设置异步方法的返回值。示例:
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
对象的属性设置行为,包括get
和set
的行为。
{
// 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
方法来一次性存根(Stub
)Mock
对象的所有属性。这意味着所有属性都会开始跟踪其值,并可以提供默认值。以下是一个示例演示如何使用 SetupAllProperties
方法:
// 存根(Stub)Mock 对象的所有属性
mock.SetupAllProperties();
通过使用 SetupProperty
方法,可以更灵活地设置 Mock 对象的属性行为和默认值,以满足单元测试中的需求
处理事件(Events
)
在 Moq
4.13 及以后的版本中,你可以通过配置事件的 add
和 remove
访问器来模拟事件的行为。这允许你指定当事件处理器被添加或移除时应该发生的逻辑。这通常用于验证事件是否被正确添加或移除,或者模拟事件触发时的行为。
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 来设置事件的订阅和移除行为,并验证事件处理程序的添加和移除是否按预期工作。让我简单解释一下这段代码的流程:
创建一个 Mock 对象
mock
,模拟HasEvent
类。使用
SetupAdd
方法设置事件的订阅行为,并使用CallBase
方法调用基类的实现。订阅事件并设置事件处理逻辑,将事件处理程序
eventHandler
添加到事件中。调用
RaiseEvent
方法触发事件,并通过断言验证事件处理程序是否被正确处理。将
handled
标志重置为false
。使用
SetupRemove
方法设置事件的移除行为,并使用CallBase
方法调用基类的实现。移除事件处理程序
eventHandler
。再次触发事件,并通过断言验证事件处理程序是否被正确移除。
通过这个测试示例,可以验证事件处理程序的添加和移除操作是否正常工作
Raise
Raise
方法用于手动触发 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);
}
使用
Moq
的Callback
方法可以捕获方法调用时的参数,允许我们在测试中访问和处理这些参数。通过在Setup
方法中指定Callback
操作,我们可以捕获方法调用时传入的参数,并在回调中执行自定义逻辑,例如将参数添加到列表中。这种方法可以帮助我们验证方法在不同参数下的行为,以及检查方法是否被正确调用和传递参数。总的来说,Callback
方法为我们提供了一种灵活的方式来处理方法调用时的参数,帮助我们编写更全面的单元测试。
SetupProperty
SetupProperty
方法可用于设置Mock
对象的属性,并为其提供getter
和setter
。
{
//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
方法的作用包括:
设置属性的初始值:通过
SetupProperty
方法,我们可以设置Mock
对象属性的初始值,使其在测试中具有特定的初始状态。模拟属性的 getter 和 setter:
SetupProperty
方法允许我们为属性设置getter
和setter
,使我们能够访问和修改属性的值。捕获属性的设置操作:在设置
Mock
对象的属性时,可以使用Callback
方法捕获设置操作,以执行自定义逻辑或记录属性的设置情况。验证属性的行为:通过设置属性和相应的行为,可以验证属性的行为是否符合预期,以确保代码的正确性和可靠性
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
VerifyAdd
和 VerifyRemove
方法来验证事件的订阅和移除
// 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
接口具有一个虚拟属性 Bar
,Bar
类有一个虚拟方法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.Strict
和DefaultValue.Mock
。然后通过repository.Create<T>()
方法在MockRepository
设置下创建了一个IFoo
接口的模拟对象fooMock
,以及一个使用MockBehavior.Loose
的Bar
类的模拟对象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 Mocks
是 Moq
提供的一种声明性规范查询方式,使得您可以通过 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")));
最后
这篇总结详细介绍了在单元测试中模拟对象的使用,包括 Fake
、Mock
和 Stub
的概念及区别。针对 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