写在前面
本文是 .NET 单元测试系列的第三篇,介绍如何使用 Mock 隔离外部依赖,让测试只关注业务逻辑。前置知识:xUnit 进阶(第二篇)。
一、为什么要隔离
1.1 问题场景
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| // 一个依赖数据库和邮件的服务
public class OrderService
{
private readonly IOrderRepository _repo;
private readonly IEmailService _email;
public OrderService(IOrderRepository repo, IEmailService email)
{
_repo = repo;
_email = email;
}
public OrderResult CreateOrder(Order order)
{
// 业务逻辑
if (order.Items.Count == 0)
throw new ArgumentException("订单不能为空");
order.Total = order.Items.Sum(i => i.Price * i.Quantity);
order.CreatedAt = DateTime.UtcNow;
// 依赖1:保存到数据库
_repo.Save(order);
// 依赖2:发送邮件
_email.SendOrderConfirmation(order);
return new OrderResult { Success = true, OrderId = order.Id };
}
}
|
1
2
3
4
5
| 如果不隔离依赖:
- 测试需要真实数据库 → 慢、不稳定、难搭建
- 测试需要真实邮件服务 → 会发垃圾邮件
- 数据库挂了测试就挂了 → 不是业务逻辑的问题
- 无法模拟异常场景 → 数据库超时怎么测?
|
1.2 隔离的好处
1
2
3
4
| 快速 — 不需要真实的外部服务
稳定 — 不受外部环境影响
可控 — 可以模拟任何场景(成功、失败、超时)
聚焦 — 只测业务逻辑,不测依赖
|
二、Test Double 分类
2.1 四种 Test Double
1
2
3
4
| Stub(桩) — 提供预设的返回值,让测试能跑下去
Mock(模拟) — 验证交互行为(是否调用了?调了几次?参数对不对?)
Fake(伪造) — 有真实逻辑的轻量实现(如内存数据库)
Spy(间谍) — 记录调用信息,同时使用真实逻辑
|
2.2 举例说明
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| // 接口定义
public interface IOrderRepository
{
Order GetById(int id);
void Save(Order order);
}
// Stub — 只要返回数据,让测试能继续
// "调用 GetById(1) 就返回这个 Order"
// 不关心是否真的调用了
// Mock — 验证行为
// "我预期 Save 方法被调用了一次,参数是这个 order"
// 如果没调用或者参数不对 → 测试失败
// Fake — 真实但轻量的实现
public class FakeOrderRepository : IOrderRepository
{
private readonly Dictionary<int, Order> _orders = new();
public Order GetById(int id) => _orders.GetValueOrDefault(id);
public void Save(Order order) => _orders[order.Id] = order;
}
// Spy — 记录 + 真实逻辑
public class SpyEmailService : IEmailService
{
public List<string> SentEmails { get; } = new();
public void SendOrderConfirmation(Order order)
{
SentEmails.Add(order.CustomerEmail); // 记录
// 实际不发送,但做了真实逻辑
}
}
|
2.3 什么时候用什么
1
2
3
4
| 只需要返回值 → Stub
需要验证是否调用了 → Mock
需要轻量级替代实现 → Fake
需要记录调用但保留逻辑 → Spy
|
三、Moq 框架详解
Moq 是 .NET 生态中常用的 Mock 框架之一。
3.1 安装
3.2 基本 Setup(Stub 行为)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| [Fact]
public void GetUser_ExistingId_ReturnsUser()
{
// Arrange
var mockRepo = new Mock<IUserRepository>();
// Setup:当调用 GetById(1) 时返回这个 User
mockRepo.Setup(r => r.GetById(1))
.Returns(new User { Id = 1, Name = "张三" });
var service = new UserService(mockRepo.Object);
// Act
var result = service.GetUser(1);
// Assert
Assert.Equal("张三", result.Name);
}
|
3.3 参数匹配
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // 精确匹配
mockRepo.Setup(r => r.GetById(42)).Returns(user42);
// 任意参数
mockRepo.Setup(r => r.GetById(It.IsAny<int>())).Returns(user);
// 条件匹配
mockRepo.Setup(r => r.GetById(It.Is<int>(id => id > 0)))
.Returns(user);
// 范围匹配
mockRepo.Setup(r => r.GetUsers(It.IsInRange(1, 100, Range.Inclusive)))
.Returns(users);
// 正则匹配(字符串)
mockRepo.Setup(r => r.FindByName(It.IsRegex("^张")))
.Returns(users);
// 空值匹配
mockRepo.Setup(r => r.FindByName(It.IsNull<string>()))
.Throws<ArgumentException>();
|
3.4 返回值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // 固定返回值
mockRepo.Setup(r => r.GetById(1)).Returns(user);
// 动态返回值(根据参数计算)
mockRepo.Setup(r => r.GetById(It.IsAny<int>()))
.Returns<int>(id => new User { Id = id, Name = $"用户{id}" });
// 返回 Task(异步方法)
mockRepo.Setup(r => r.GetByIdAsync(1))
.ReturnsAsync(user);
// 返回 ValueTask
mockRepo.Setup(r => r.GetByIdAsync(1))
.Returns(new ValueTask<User>(user));
|
3.5 验证行为(Mock 行为)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| [Fact]
public void CreateOrder_SavesToRepo()
{
// Arrange
var mockRepo = new Mock<IOrderRepository>();
var mockEmail = new Mock<IEmailService>();
var service = new OrderService(mockRepo.Object, mockEmail.Object);
var order = new Order { Items = new List<OrderItem> { new() } };
// Act
service.CreateOrder(order);
// Assert — 验证 Save 被调用了一次
mockRepo.Verify(r => r.Save(It.IsAny<Order>()), Times.Once);
}
[Fact]
public void CreateOrder_SendsEmail()
{
var mockRepo = new Mock<IOrderRepository>();
var mockEmail = new Mock<IEmailService>();
var service = new OrderService(mockRepo.Object, mockEmail.Object);
var order = new Order
{
CustomerEmail = "test@example.com",
Items = new List<OrderItem> { new() }
};
service.CreateOrder(order);
// 验证邮件发送的参数
mockEmail.Verify(
e => e.SendOrderConfirmation(
It.Is<Order>(o => o.CustomerEmail == "test@example.com")),
Times.Once);
}
|
3.6 Verify 的 Times 选项
1
2
3
4
5
| mockRepo.Verify(r => r.Save(order), Times.Once); // 恰好一次
mockRepo.Verify(r => r.Save(order), Times.Never); // 没调用过
mockRepo.Verify(r => r.Save(order), Times.Exactly(3)); // 恰好三次
mockRepo.Verify(r => r.Save(order), Times.AtLeast(2)); // 至少两次
mockRepo.Verify(r => r.Save(order), Times.AtMost(5)); // 最多五次
|
3.7 Callback — 捕获参数和副作用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| [Fact]
public void CreateOrder_SetsCreatedAtBeforeSaving()
{
var mockRepo = new Mock<IOrderRepository>();
var mockEmail = new Mock<IEmailService>();
var service = new OrderService(mockRepo.Object, mockEmail.Object);
Order? savedOrder = null;
mockRepo.Setup(r => r.Save(It.IsAny<Order>()))
.Callback<Order>(o => savedOrder = o); // 捕获保存的 Order
var order = new Order { Items = new List<OrderItem> { new() } };
service.CreateOrder(order);
// 验证保存前设置了 CreatedAt
Assert.NotNull(savedOrder);
Assert.True(savedOrder.CreatedAt <= DateTime.UtcNow);
}
|
3.8 Throws — 模拟异常
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| [Fact]
public void CreateOrder_DatabaseDown_ThrowsException()
{
var mockRepo = new Mock<IOrderRepository>();
mockRepo.Setup(r => r.Save(It.IsAny<Order>()))
.Throws(new InvalidOperationException("数据库连接不可用"));
var mockEmail = new Mock<IEmailService>();
var service = new OrderService(mockRepo.Object, mockEmail.Object);
Assert.Throws<InvalidOperationException>(
() => service.CreateOrder(new Order { Items = new List<OrderItem> { new() } }));
}
[Fact]
public async Task GetUserAsync_Timeout_ThrowsException()
{
var mockRepo = new Mock<IUserRepository>();
mockRepo.Setup(r => r.GetByIdAsync(It.IsAny<int>()))
.ThrowsAsync(new TimeoutException("请求超时"));
var service = new UserService(mockRepo.Object);
await Assert.ThrowsAsync<TimeoutException>(
() => service.GetUserAsync(1));
}
|
3.9 属性 Mock
1
2
3
4
5
6
7
8
9
10
11
12
13
| // 自动实现所有属性(StubProperties)
var mock = new Mock<IOptions<DatabaseSettings>>();
mock.SetupGet(o => o.Value).Returns(new DatabaseSettings
{
ConnectionString = "Server=.;Database=Test"
});
// 或者
var mockOptions = new Mock<IOptions<DatabaseSettings>>();
mockOptions.Setup(o => o.Value).Returns(new DatabaseSettings
{
ConnectionString = "Test"
});
|
3.10 Loose vs Strict Mock
1
2
3
4
5
6
7
8
9
10
| // Loose(默认)— 没有 Setup 的方法返回默认值(null、0、false)
var looseMock = new Mock<IUserRepository>();
looseMock.Object.GetById(1); // 返回 null,不报错
// Strict — 没有 Setup 的方法被调用时抛异常
var strictMock = new Mock<IUserRepository>(MockBehavior.Strict);
strictMock.Object.GetById(1); // 抛出 MockException!
// Strict 适合:确保测试明确声明了所有预期行为
// Loose 适合:只关心部分方法,其他忽略
|
3.11 VerifyAll 和 VerifyNoOtherCalls
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| [Fact]
public void CreateOrder_AllInteractionsVerified()
{
var mockRepo = new Mock<IOrderRepository>(MockBehavior.Strict);
var mockEmail = new Mock<IEmailService>(MockBehavior.Strict);
var service = new OrderService(mockRepo.Object, mockEmail.Object);
// Setup 所有预期行为
mockRepo.Setup(r => r.Save(It.IsAny<Order>()));
mockEmail.Setup(e => e.SendOrderConfirmation(It.IsAny<Order>()));
service.CreateOrder(new Order { Items = new List<OrderItem> { new() } });
// 验证所有 Setup 都被调用了
mockRepo.VerifyAll();
mockEmail.VerifyAll();
// 验证没有其他未预期的调用
mockRepo.VerifyNoOtherCalls();
mockEmail.VerifyNoOtherCalls();
}
|
四、Mock 的实战示例
4.1 完整的 Service 测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
| public class OrderServiceTests
{
private readonly Mock<IOrderRepository> _mockRepo;
private readonly Mock<IEmailService> _mockEmail;
private readonly Mock<ILogger<OrderService>> _mockLogger;
private readonly OrderService _service;
public OrderServiceTests()
{
_mockRepo = new Mock<IOrderRepository>();
_mockEmail = new Mock<IEmailService>();
_mockLogger = new Mock<ILogger<OrderService>>();
_service = new OrderService(_mockRepo.Object, _mockEmail.Object, _mockLogger.Object);
}
[Fact]
public void CreateOrder_ValidOrder_SavesAndSendsEmail()
{
var order = new Order
{
CustomerEmail = "test@example.com",
Items = new List<OrderItem>
{
new() { Price = 100, Quantity = 2 }
}
};
var result = _service.CreateOrder(order);
Assert.True(result.Success);
Assert.Equal(200, order.Total); // 100 * 2
_mockRepo.Verify(r => r.Save(It.Is<Order>(
o => o.Total == 200)), Times.Once);
_mockEmail.Verify(e => e.SendOrderConfirmation(
It.IsAny<Order>()), Times.Once);
}
[Fact]
public void CreateOrder_EmptyItems_ThrowsException()
{
var order = new Order { Items = new List<OrderItem>() };
var ex = Assert.Throws<ArgumentException>(
() => _service.CreateOrder(order));
Assert.Equal("订单不能为空", ex.Message);
// 确保没有保存和发邮件
_mockRepo.Verify(r => r.Save(It.IsAny<Order>()), Times.Never);
_mockEmail.Verify(e => e.SendOrderConfirmation(It.IsAny<Order>()), Times.Never);
}
[Fact]
public void CreateOrder_EmailFails_StillSavesOrder()
{
var order = new Order
{
CustomerEmail = "test@example.com",
Items = new List<OrderItem> { new() { Price = 50, Quantity = 1 } }
};
// 邮件发送失败
_mockEmail.Setup(e => e.SendOrderConfirmation(It.IsAny<Order>()))
.Throws(new SmtpException("邮件服务器不可用"));
// 不应该影响订单保存
var result = _service.CreateOrder(order);
Assert.True(result.Success);
_mockRepo.Verify(r => r.Save(It.IsAny<Order>()), Times.Once);
}
}
|
4.2 Mock ILogger
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| // ILogger 的 Log 方法参数复杂,直接 Mock 很麻烦
// 推荐用扩展方法验证
public static class LoggerExtensions
{
public static void VerifyLog<T>(
this Mock<ILogger<T>> logger,
LogLevel level,
string message,
Times times)
{
logger.Verify(
x => x.Log(
level,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, _) => v.ToString()!.Contains(message)),
It.IsAny<Exception?>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
times);
}
}
// 使用
[Fact]
public void CreateOrder_LogsInfo()
{
_service.CreateOrder(new Order { Items = new List<OrderItem> { new() } });
_mockLogger.VerifyLog(
LogLevel.Information,
"订单已创建",
Times.Once);
}
|
五、常见 Mock 反模式
5.1 过度 Mock
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // 反模式:Mock 所有东西
[Fact]
public void Bad_OverMocking()
{
var mockMapper = new Mock<IMapper>();
var mockValidator = new Mock<IValidator<Order>>();
var mockCalculator = new Mock<IPriceCalculator>();
var mockRepo = new Mock<IOrderRepository>();
var mockEmail = new Mock<IEmailService>();
var mockLogger = new Mock<ILogger<OrderService>>();
// 全是 Mock,测试的是 Mock 之间的交互,不是业务逻辑
// 这种测试价值很低
}
// 好的做法:只 Mock 外部依赖,内部逻辑用真实对象
[Fact]
public void Good_MockOnlyExternalDeps()
{
// 内部逻辑用真实对象
var validator = new OrderValidator();
var calculator = new PriceCalculator();
// 只 Mock 外部依赖
var mockRepo = new Mock<IOrderRepository>();
var mockEmail = new Mock<IEmailService>();
var service = new OrderService(validator, calculator, mockRepo.Object, mockEmail.Object);
}
|
5.2 Mock 被测类本身
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // 反模式:Mock 被测类的方法
[Fact]
public void Bad_MockSystemUnderTest()
{
var mockService = new Mock<OrderService> { CallBase = true };
mockService.Setup(s => s.ValidateOrder(It.IsAny<Order>()))
.Returns(true); // 跳过了验证逻辑
// 你在测 Mock,不是测真正的代码
}
// 好的做法:直接实例化被测类
[Fact]
public void Good_TestRealImplementation()
{
var service = new OrderService(repo, email);
// 测试真实行为
}
|
5.3 验证实现细节
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // 反模式:验证内部实现
[Fact]
public void Bad_VerifyInternalDetails()
{
service.CreateOrder(order);
// 谁关心你先调 Save 还是先发邮件?
mockRepo.Verify(r => r.Save(It.IsAny<Order>()), Times.Once);
mockEmail.Verify(e => e.SendOrderConfirmation(It.IsAny<Order>()), Times.Once);
// 顺序验证更过分
}
// 好的做法:只验证可观察的行为(结果)
[Fact]
public void Good_VerifyObservableResult()
{
var result = service.CreateOrder(order);
Assert.True(result.Success);
Assert.NotEqual(Guid.Empty, result.OrderId);
}
|
六、NSubstitute(替代方案)
如果你觉得 Moq 的 Setup/Verify 语法太繁琐,可以试试 NSubstitute。
6.1 安装
1
| dotnet add package NSubstitute
|
6.2 对比 Moq
1
2
3
4
5
6
7
8
9
| // Moq
var mockRepo = new Mock<IUserRepository>();
mockRepo.Setup(r => r.GetById(1)).Returns(user);
mockRepo.Verify(r => r.Save(It.IsAny<User>()), Times.Once);
// NSubstitute
var repo = Substitute.For<IUserRepository>();
repo.GetById(1).Returns(user);
repo.Received(1).Save(Arg.Any<User>());
|
6.3 常用语法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // 返回值
repo.GetById(1).Returns(user);
repo.GetAll().Returns(new List<User> { user1, user2 });
// 异步返回
repo.GetByIdAsync(1).Returns(user);
// 参数匹配
repo.FindByName(Arg.Is<string>(n => n.StartsWith("张"))).Returns(users);
repo.FindByName(Arg.Any<string>()).Returns(users);
// 验证调用
repo.Received().Save(Arg.Any<User>()); // 被调用了
repo.DidNotReceive().Delete(Arg.Any<int>()); // 没被调用
repo.Received(2).Save(Arg.Any<User>()); // 被调用了2次
// 抛异常
repo.When(r => r.Save(null!)).Throw<ArgumentNullException>();
// 事件
repo.UserAdded += Raise.Event<UserEventHandler>(user);
|
6.4 选择建议
1
2
3
4
| Moq — 最主流,社区资料多,功能最全
NSubstitute — 语法更简洁,学习曲线低
两者都能完成任务,选团队熟悉的即可
本系列后续使用 Moq
|
七、小结
本文学习了 Mock 与隔离:
- 为什么需要隔离外部依赖
- Test Double 分类(Stub、Mock、Fake、Spy)
- Moq 框架详解(Setup、Verify、Callback、Throws)
- 实战示例和 Mock ILogger
- Mock 反模式(过度 Mock、Mock 被测类、验证实现细节)
- NSubstitute 简介和选择建议
下一篇将学习依赖注入与可测试性:如何设计代码让它容易测试。