.NET 单元测试(三):Mock 与隔离

写在前面

本文是 .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 安装

1
dotnet add package Moq

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 SQLException("连接超时"));

    var mockEmail = new Mock<IEmailService>();
    var service = new OrderService(mockRepo.Object, mockEmail.Object);

    Assert.Throws<SQLException>(
        () => 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 简介和选择建议

下一篇将学习依赖注入与可测试性:如何设计代码让它容易测试。