引言
上一章节介绍了 TDD 的三大法则,今天我们讲一下在单元测试中模拟对象的使用。
Fake
Fake
–Fake
是一个通用术语,可用于描述stub
或mock
对象。 它是stub
还是mock
取决于使用它的上下文。 也就是说,Fake
可以是stub
或mock
Mock
–Mock
对象是系统中的fake
对象,用于确定单元测试是否通过。Mock
起初为Fake
,直到对其断言。
Stub
–Stub
是系统中现有依赖项的可控制替代项。 通过使用Stub
,可以在无需使用依赖项的情况下直接测试代码。
参考 单元测试最佳做法 让我们使用相同的术语
区别点:
-
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> NuGetInstall-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? Addresses { get; set; }
public DateTimeOffset? Created { get; set; }
}
- 创建
SampleDbContext
数据库上下文
public class SampleDbContext(DbContextOptions options) : DbContext(options)
{
public DbSet Staff { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
}
}
- 定义仓储接口和实现
public interface IStaffRepository
{
///
/// 获取 Staff 实体的 DbSet
///
DbSet dbSet { get; }
///
/// 添加新的 Staff 实体
///
///
Task AddStaffAsync(Staff staff, CancellationToken cancellationToken = default);
///
/// 根据 Id 删除 Staff 实体
///
///
Task DeleteStaffAsync(int id, CancellationToken cancellationToken = default);
///
/// 更新 Staff 实体
///
///
Task UpdateStaffAsync(Staff staff, CancellationToken cancellationToken = default);
///
/// 根据 Id 获取单个 Staff 实体
///
///
///
Task GetStaffByIdAsync(int id, CancellationToken cancellationToken = default);
///
/// 获取所有 Staff 实体
///
///
Task> GetAllStaffAsync(CancellationToken cancellationToken = default);
///
/// 批量更新 Staff 实体
///
///
Task BatchAddStaffAsync(List staffList, CancellationToken cancellationToken = default);
}
- 仓储实现
public class StaffRepository : IStaffRepository
{
private readonly SampleDbContext _dbContext;
public DbSet dbSet => _dbContext.Set();
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 GetStaffByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await dbSet.AsQueryable().Where(_ => _.Id == id).FirstOrDefaultAsync(cancellationToken);
}
public async Task> GetAllStaffAsync(CancellationToken cancellationToken = default)
{
return await dbSet.ToListAsync(cancellationToken);
}
public async Task BatchAddStaffAsync(List 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();
services.AddDbContext(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 AddStaff([FromBody] Staff staff, CancellationToken cancellationToken = default)
{
await _staffRepository.AddStaffAsync(staff, cancellationToken);
return TypedResults.NoContent();
}
[HttpDelete("{id}")]
public async Task DeleteStaff(int id, CancellationToken cancellationToken = default)
{
await _staffRepository.DeleteStaffAsync(id);
return TypedResults.NoContent();
}
[HttpPut("{id}")]
public async Task, 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, NotFound>> GetStaffById(int id, CancellationToken cancellationToken = default)
{
var staff = await _staffRepository.GetStaffByIdAsync(id, cancellationToken);
if (staff == null)
{
return TypedResults.NotFound();
}
return Ty服务器托管pedResults.Ok(staff);
}
[HttpGet]
public async Task GetAllStaff(CancellationToken cancellationToken = default)
{
var staffList = await _staffRepository.GetAllStaffAsync(cancellationToken);
return TypedResults.Ok(staffList);
}
[HttpPost("BatchAdd")]
public async Task BatchAddStaff([FromBody] List staffList, CancellationToken cancellationToken = default)
{
await _staffRepository.BatchAddStaffAsync(staffList, cancellationToken);
return TypedResults.NoContent();
}
}
F5
项目跑一下
到这儿我们的项目已经创建完成了本系列后面的章节基本上都会以这个项目为基础展开拓展
控制器的单元测试
[单元测试涉及通过基础结构和依赖项单独测试应用的一部分。 单元测试控制器逻辑时,仅测试单个操作的内容,不测试其依赖项或框架自身的行为。
本章节主要以控制器的单元测试来带大家了解一下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 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 GetStaffByIdAsync(int id, CancellationToken cancellationToken)
{
// 模拟根据 ID 获取员工操作
return await Task.FromResult(new Staff { Id = id, Name = "Mock Staff" });
}
public async Task> GetAllStaffAsync(CancellationToken cancellationToken)
{
// 模拟获取所有员工操作
return await Task.FromResult(new List { new Staff { Id = 1, Name = "Mock Staff 1" }, new Staff { Id = 2, Name = "Mock Staff 2" } });
}
public async Task BatchAddStaffAsync(List 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>(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, NotFound>>(result);
var okResult = (Ok)result.Result;
Assert.Equal(id, okResult.Value?.Id);
}
//先暂时省略后面测试方法....
}
用
Stub
来替代真实的依赖项,以便更好地控制测试环境和测试结果
Mock
在测试过程中,尤其是
TDD
的开发过程中,测试用例有限开发在这个时候,我们总是要去模拟对象的创建,这些对象可能是某个接口的实现也可能是具体的某个对象,这时候就必须去写接口的实现,这时候模拟对象Mock
的用处就体现出来了,在社区中也有很多模拟对象的库如Moq
,FakeItEasy
等。
Moq
是一个简单、直观且强大的.NET
模拟库,用于在单元测试中模拟对象和行为。通过Moq
,您可以轻松地设置依赖项的行为,并验证代码的调用。
我们用上面的实例来演示一下Moq
的核心用法
第一步 Nuget
包安装Moq
PM> NuGetInstall-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();
mock.Setup(_ => _.AddStaffAsync(It.IsAny(), 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>(result);
}
[Fact]
public async Task GetStaffById_WhenCalled_ReturnOK()
{
//Arrange
var mock = new Mock();
var id = 1;
mock.Setup(_ => _.GetStaffByIdAsync(It.IsAny(), 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, NotFound>>(result);
var okResult = (Ok)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();
-
Setup
:用于设置Mock
对象的行为和返回值,以指定当调用特定方法时应该返回什么结果。//指定调用AddStaffAsync方法的参数行为 mock.Setup(_ => _.AddStaffAsync(It.IsAny(), default));
异步方法
从我们上面的单元测试中看到我们使用了一个异步方法,使用返回值ReturnsAsync
表示的
mock.Setup(_ => _.GetStaffByIdAsync(It.IsAny(), 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);
- 在 Moq 4.16 及以上版本中,您可以直接通过
-
使用 ReturnsAsync(较早版本):
- 在较早版本的 Moq 中,您可以使用类似
ReturnsAsync
、ThrowsAsync
等辅助方法来设置异步方法的返回值。 - 示例:
mock.Setup(foo => foo.DoSomethingAsync()).ReturnsAsync(true);
- 在较早版本的 Moq 中,您可以使用类似
-
使用 Lambda 表达式:
- 您还可以使用 Lambda 表达式来返回异步方法的结果。不过这种方式会触发有关异步 Lambda 同步执行的编译警告。
- 示例:
mock.Setup(foo => foo.DoSomethingAsync()).Returns(async () => 42);
参数匹配
在我们单元测试实例中用到了参数匹配,mock.Setup(_ => _.GetStaffByIdAsync(It.IsAny(), default)).
,对就是这个It.IsAny()
,此处的用意是匹配任意输入的 int
类型的入参,接下来我们一起看下参数匹配的一些常用示例。
-
任意值匹配
It.IsAny()
mock.Setup(_ => _.GetStaffByIdAsync(It.IsAny(), default))
-
ref 参数的任意值匹配:
对于 ref 参数,可以使用 It.Ref.IsAny 进行匹配(需要 Moq 4.8 或更高版本)。//Arrange var mock = new Mock(); // ref argumen服务器托管ts 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(predicate)
可以匹配满足条件的值,其中predicate
是一个函数。//匹配满足条件的值 mock.Setup(foo => foo.Add(It.Is(i => i % 2 == 0))).Returns(true); //It.Is 断言 var result = mock.Object.Add(3); Assert.False(result);
-
匹配范围:
使用It.IsInRange
可以匹配指定范围内的值mock.Setup(foo => foo.Add(It.IsInRange(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(); // 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
&
&
&&&&&&&&&&&&
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net
机房租用,北京机房租用,IDC机房托管, http://www.fwqtg.net