C#常用类库-详解Moq

C#常用类库-详解Moq

在C#企业级开发中,单元测试是保障代码质量、降低迭代风险的核心环节------一个可靠的单元测试,能够快速定位代码缺陷、验证业务逻辑正确性,同时为后续重构提供安全保障。而单元测试的关键痛点之一,就是如何处理依赖项:当待测试的类依赖于数据库、网络请求、第三方服务或未完成的模块时,直接测试会变得困难、缓慢且不稳定。

此时,Mock框架应运而生。Moq(发音为"Mock You")作为.NET生态中最流行、最易用的Mock框架之一,凭借"语法简洁、功能强大、零配置、无缝集成主流测试框架"的优势,成为绝大多数C#开发者的首选。它允许我们创建"模拟对象"(Mock Object),替代真实的依赖项,从而隔离待测试代码,实现快速、稳定、可重复的单元测试。

本文将从基础概念→核心定位→环境搭建→基础用法→进阶特性→实战案例→性能优化→避坑指南,全方位、有深度地解析Moq,结合实际开发场景(如接口模拟、依赖隔离、行为验证等),帮你彻底掌握Moq的使用技巧,让单元测试不再成为负担,真正融入日常开发流程。

一、前言:Moq的定位与核心价值

在讲解Moq之前,我们先明确三个核心问题:Moq解决了什么痛点?它与其他Mock框架的差异是什么?为什么企业级开发中必须掌握Moq?这是理解其设计理念、合理运用的基础。

1. 核心痛点:传统单元测试的困境

在没有Mock框架的情况下,编写单元测试会面临诸多难以解决的问题,尤其是在依赖项复杂的企业级场景中:

  • 依赖项难以隔离:待测试类往往依赖于其他类、接口、数据库或外部服务(如支付接口、短信服务),这些依赖项的稳定性、可用性会直接影响测试结果,导致测试"不可靠"。例如,测试一个依赖数据库的用户服务,若数据库连接失败,测试就会失败,即便用户服务的逻辑本身没有问题。

  • 测试成本高、效率低:为了测试一个简单的业务逻辑,需要搭建完整的依赖环境(如启动数据库、部署第三方服务),搭建过程繁琐、耗时,且每次测试都需要重复初始化,测试效率极低。

  • 难以覆盖异常场景:真实依赖项的异常场景(如数据库超时、网络中断、第三方服务返回错误)难以模拟,导致单元测试无法覆盖边界场景,测试覆盖率低下。

  • 耦合度高,可维护性差:测试代码与真实依赖项强耦合,当依赖项发生变化时,测试代码也需要同步修改,增加了维护成本;同时,未完成的模块无法被测试,影响开发迭代速度。

而Moq的核心价值,就是通过模拟对象解决上述痛点------它可以创建一个与真实依赖项"接口一致"但无需实际实现的模拟对象,替代真实依赖项参与测试,从而实现"隔离待测试代码、屏蔽依赖项影响、降低测试成本、提升测试覆盖率"的目标。

2. Moq的核心定位

Moq是一款基于.NET平台的开源、轻量级、类型安全的Mock框架 ,核心目标是"让单元测试更简单、更高效、更可靠"。它支持.NET Framework 4.5+、.NET Core 2.0+、.NET 5+等所有主流.NET版本,无缝集成xUnit、NUnit、MSTest等主流单元测试框架,无需复杂配置,开箱即用。

Moq的核心设计理念是"基于接口的模拟"------它通过动态代理技术,在运行时创建接口(或抽象类)的模拟对象,我们可以通过Moq提供的链式API,配置模拟对象的行为(如返回指定值、抛出异常)、验证模拟对象的调用(如是否被调用、被调用次数、传入参数是否正确)。

与其他Mock框架(如Rhino Mocks、NSubstitute)相比,Moq的核心优势可概括为5点:

  • 语法简洁直观:采用流畅的链式API设计,代码可读性高,无需记忆复杂的语法规则,上手成本极低。

  • 类型安全:基于泛型设计,编译期即可检查类型错误,避免运行时因类型不匹配导致的测试失败,比动态Mock框架(如Moq的早期版本、Rhino Mocks)更可靠。

  • 功能强大且全面:支持模拟接口、抽象类、密封类(需特殊配置),支持配置返回值、抛出异常、回调函数,支持验证调用行为、参数匹配,满足从简单到复杂的各类测试场景。

  • 零配置开箱即用:无需XML配置、无需手动创建代理类,仅需通过NuGet安装包,即可快速创建模拟对象,集成主流测试框架。

  • 生态完善,社区活跃:作为.NET生态中最流行的Mock框架,Moq的文档完善、社区活跃,遇到问题可快速找到解决方案,且持续更新维护,适配最新的.NET版本。

3. Moq vs 其他Mock框架(核心差异对比)

为了更清晰地体现Moq的优势,我们从"易用性、功能、性能、学习成本"等核心维度,与.NET生态中其他主流Mock框架进行对比,帮助大家合理选型:

对比维度 Moq Rhino Mocks NSubstitute
易用性 极高,链式API简洁直观,零配置,上手最快 中等,语法繁琐,需学习"录制-回放"模式,上手成本高 高,语法简洁,但功能不如Moq全面
功能全面性 全面,支持接口、抽象类、密封类,参数匹配、回调、验证功能完善 全面,但部分功能语法复杂,配置繁琐 中等,核心功能齐全,但高级特性(如密封类模拟)支持不足
类型安全 极高,基于泛型设计,编译期类型检查 中等,部分功能依赖动态类型,存在运行时类型风险 高,基于泛型,但部分场景需手动转换类型
性能表现 优秀,动态代理优化完善,测试执行速度快 中等,"录制-回放"模式存在一定性能损耗 优秀,性能与Moq持平,略优于Rhino Mocks
学习成本 低,API直观,文档完善,熟悉C#基础即可快速掌握 高,需理解"约束""录制-回放"等概念,语法规则多 中低,语法简洁,但高级特性需额外学习
生态适配 完美适配所有主流测试框架,社区活跃,持续更新 适配主流测试框架,但更新缓慢,生态活跃度低 适配主流测试框架,社区活跃,但用户基数少于Moq
选型建议(结合实际场景):
  • 日常开发、企业级项目、新手入门,优先选择Moq------易用性高、功能全面、生态完善,能满足绝大多数测试场景,且学习成本低。

  • 已有Rhino Mocks项目,且团队熟悉其语法,可继续使用,但不建议新项目选型(更新缓慢,学习成本高)。

  • 简单测试场景、追求极简语法,可选择NSubstitute,但复杂场景(如密封类模拟、复杂参数匹配)仍推荐Moq。

4. 版本与安装

Moq最新稳定版本为4.20.70(本文基于此版本讲解,适配.NET 6+,向下兼容.NET Core 2.0+、.NET Framework 4.5+),核心包体积小(仅几十KB),无过多依赖,安装方式通过NuGet实现,根据测试框架类型选择对应的包。

常用安装包(按需选择):

  • 核心包(必装):Install-Package Moq(.NET CLI:dotnet add package Moq)------ 提供Moq核心功能(模拟对象创建、行为配置、调用验证等),适配所有主流.NET版本。

  • 测试框架集成包(按需):

    • xUnit集成:Install-Package Moq.Xunit(简化Moq与xUnit的集成,提供额外的断言方法)。

    • NUnit集成:Install-Package Moq.NUnit(适配NUnit测试框架,支持NUnit的断言语法)。

    • MSTest集成:无需额外安装,Moq可直接与MSTest集成。

  • 扩展包(按需):

    • Moq.AutoMock:Install-Package Moq.AutoMock(自动注入模拟对象,简化依赖注入场景的测试)。

    • Moq.Extensions.EntityFrameworkCore:Install-Package Moq.Extensions.EntityFrameworkCore(专门用于模拟EF Core的DbContext、DbSet,简化数据访问层测试)。

安装完成后,引入核心命名空间即可使用:using Moq;

注意:Moq 4.x版本与3.x版本API差异较大(如模拟对象创建方式、验证方法),本文基于最新4.x版本讲解,避免使用过时API(如旧版本的MockRepository已简化)。

二、基础用法:从零开始使用Moq(核心场景)

Moq的核心流程分为3步:创建模拟对象(Mock)→ 配置模拟对象的行为 → 验证模拟对象的调用。基础用法覆盖5个核心场景:模拟接口、配置返回值、抛出异常、验证调用、参数匹配,这5个场景几乎能满足80%的日常单元测试需求,也是掌握Moq的基础。

首先,我们定义一个待测试的业务场景(以"用户服务"为例),包含接口和实现类,用于后续所有基础用法的演示:

csharp 复制代码
// 1. 定义依赖接口(用户数据访问层接口)
public interface IUserRepository
{
    // 根据ID获取用户
    User GetById(int id);
    // 新增用户
    bool Add(User user);
    // 更新用户
    bool Update(User user);
    // 删除用户
    bool Delete(int id);
    // 根据用户名查询用户(支持模糊查询)
    List<User> Search(string keyword);
}

// 2. 定义用户实体
public class User
{
    public int Id { get; set; }
    public string UserName { get; set; }
    public string Email { get; set; }
    public int Age { get; set; }
    public DateTime CreateTime { get; set; }
}

// 3. 待测试的业务服务(依赖IUserRepository)
public class UserService
{
    private readonly IUserRepository _userRepository;

    // 构造函数注入依赖(依赖注入模式,便于Mock测试)
    public UserService(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    // 业务方法1:根据ID获取用户,若不存在则返回null
    public User GetUserById(int id)
    {
        if (id <= 0)
            throw new ArgumentException("用户ID不能小于等于0");
        
        return _userRepository.GetById(id);
    }

    // 业务方法2:新增用户,若用户名已存在则返回false
    public bool AddUser(User user)
    {
        if (user == null)
            throw new ArgumentNullException(nameof(user));
        
        var existingUsers = _userRepository.Search(user.UserName);
        if (existingUsers.Any())
            return false;
        
        return _userRepository.Add(user);
    }

    // 业务方法3:批量删除用户,返回删除成功的数量
    public int BatchDelete(List<int> userIds)
    {
        if (userIds == null || !userIds.Any())
            return 0;
        
        int successCount = 0;
        foreach (var id in userIds)
        {
            if (_userRepository.Delete(id))
                successCount++;
        }
        return successCount;
    }
}

1. 核心基础:Moq核心对象与模拟对象创建

Moq的所有操作都围绕Mock<T>对象展开(T为要模拟的接口或抽象类),它是Moq的核心对象,负责创建模拟对象、配置行为、验证调用。创建模拟对象是使用Moq的第一步,语法简洁,无需复杂配置。

(1)核心对象说明
  • Mock:模拟对象的容器,T必须是接口、抽象类,或带有虚方法的非密封类(密封类需特殊配置)。通过该对象可以配置模拟对象的行为、验证调用。

  • Mock.Object:获取模拟对象的实例,该实例实现了T接口(或继承了抽象类),可以传递给待测试类的构造函数或方法,替代真实依赖项。

  • It:Moq提供的参数匹配工具类,用于配置方法参数的匹配规则(如任意参数、指定值、满足条件的参数等)。

  • Times:Moq提供的调用次数验证工具类,用于验证模拟对象的方法被调用的次数(如一次、多次、从未调用等)。

(2)创建模拟对象(基础方式)

最基础的模拟对象创建方式,适用于大多数简单场景,只需指定要模拟的接口或抽象类即可:

csharp 复制代码
using Moq;
using Xunit; // 本文使用xUnit作为测试框架,其他框架用法类似

// 测试类
public class UserServiceTests
{
    // 测试方法:创建模拟对象并注入到待测试服务
    [Fact]
    public void Test_Mock_Creation()
    {
        // 1. 创建IUserRepository的模拟对象(Mock容器)
        var mockUserRepo = new Mock<IUserRepository>();

        // 2. 获取模拟对象实例(实现了IUserRepository接口)
        IUserRepository userRepoMock = mockUserRepo.Object;

        // 3. 将模拟对象注入到待测试的UserService中
        var userService = new UserService(userRepoMock);

        // 验证:确保模拟对象注入成功,UserService不为null
        Assert.NotNull(userService);
    }
}

关键说明:

  • 创建模拟对象时,Mock<IUserRepository>中的泛型参数必须是接口或抽象类,若传入非抽象的密封类,会抛出异常(后续进阶特性会讲解如何模拟密封类)。

  • mockUserRepo.Object返回的是模拟对象的实例,该实例并非真实的IUserRepository实现,而是Moq动态生成的代理对象,所有方法调用都会被Moq拦截。

  • 待测试类(UserService)采用构造函数注入依赖,是实现Mock测试的关键------通过注入模拟对象,隔离了真实的IUserRepository实现,确保测试只关注UserService的业务逻辑。

2. 配置模拟对象的行为(核心基础)

创建模拟对象后,默认情况下,模拟对象的所有方法都会返回对应类型的默认值(如引用类型返回null、值类型返回0、bool返回false)。我们需要通过Moq的链式API,配置模拟对象的行为,使其返回指定值、抛出异常或执行回调函数,模拟真实场景。

(1)配置方法返回指定值(最常用)

使用Setup方法配置模拟对象的方法调用,使用Returns方法指定返回值,适用于有返回值的方法(如GetByIdSearch)。

csharp 复制代码
// 测试方法:配置模拟对象返回指定值
[Fact]
public void GetUserById_WhenIdExists_ReturnsUser()
{
    // 1. 准备测试数据
    int testId = 1;
    var expectedUser = new User
    {
        Id = testId,
        UserName = "张三",
        Email = "zhangsan@example.com",
        Age = 25,
        CreateTime = DateTime.Now
    };

    // 2. 创建模拟对象并配置行为
    var mockUserRepo = new Mock<IUserRepository>();
    // 配置:当调用GetById方法,传入参数testId时,返回expectedUser
    mockUserRepo.Setup(repo => repo.GetById(testId)).Returns(expectedUser);

    // 3. 注入模拟对象,创建待测试服务
    var userService = new UserService(mockUserRepo.Object);

    // 4. 执行待测试方法
    var actualUser = userService.GetUserById(testId);

    // 5. 断言:验证返回结果与预期一致
    Assert.NotNull(actualUser);
    Assert.Equal(expectedUser.Id, actualUser.Id);
    Assert.Equal(expectedUser.UserName, actualUser.UserName);
}

进阶配置:返回动态值(根据传入参数返回不同结果)

csharp 复制代码
// 测试方法:配置模拟对象根据传入参数返回动态值
[Fact]
public void GetUserById_WhenIdDifferent_ReturnsDifferentUser()
{
    // 1. 创建模拟对象并配置行为
    var mockUserRepo = new Mock<IUserRepository>();
    // 配置:当调用GetById方法时,根据传入的id返回对应的用户(动态值)
    mockUserRepo.Setup(repo => repo.GetById(It.IsAny<int>()))
        .Returns((int id) => new User
        {
            Id = id,
            UserName = $"用户{id}",
            Email = $"user{id}@example.com",
            Age = 20 + id % 10,
            CreateTime = DateTime.Now
        });

    // 2. 注入模拟对象,创建待测试服务
    var userService = new UserService(mockUserRepo.Object);

    // 3. 执行待测试方法(传入不同id)
    var user1 = userService.GetUserById(1);
    var user2 = userService.GetUserById(2);

    // 4. 断言:验证返回结果与传入参数对应
    Assert.Equal(1, user1.Id);
    Assert.Equal("用户1", user1.UserName);
    Assert.Equal(2, user2.Id);
    Assert.Equal("用户2", user2.UserName);
}

关键说明:

  • Setup(repo => repo.GetById(testId)):指定要配置的方法(GetById)和参数(testId),只有当方法被调用且参数匹配时,才会执行后续的配置。

  • It.IsAny<int>():参数匹配器,表示"任意int类型的参数",适用于不关心具体参数值,只关心方法被调用的场景。

  • Returns((int id) => ...):动态返回值,参数列表与被配置方法的参数列表一致,可根据传入的参数动态生成返回结果,灵活性更高。

(2)配置方法抛出异常(模拟异常场景)

使用Throws方法配置模拟对象的方法调用时抛出指定异常,适用于测试待测试方法对异常的处理逻辑(如参数非法、依赖项异常)。

csharp 复制代码
// 测试方法:配置模拟对象抛出异常,验证待测试方法的异常处理
[Fact]
public void GetUserById_WhenRepositoryThrowsException_ThrowsException()
{
    // 1. 创建模拟对象并配置行为:调用GetById方法时抛出异常
    var mockUserRepo = new Mock<IUserRepository>();
    mockUserRepo.Setup(repo => repo.GetById(It.IsAny<int>()))
        .Throws(new Exception("数据库连接超时"));

    // 2. 注入模拟对象,创建待测试服务
    var userService = new UserService(mockUserRepo.Object);

    // 3. 执行待测试方法,验证是否抛出预期异常
    var exception = Assert.Throws<Exception>(() => userService.GetUserById(1));
    Assert.Equal("数据库连接超时", exception.Message);
}

// 测试方法:验证待测试方法自身的异常逻辑(参数非法)
[Fact]
public void GetUserById_WhenIdIsZero_ThrowsArgumentException()
{
    // 1. 创建模拟对象(无需配置行为,因为方法不会执行到依赖调用)
    var mockUserRepo = new Mock<IUserRepository>();
    var userService = new UserService(mockUserRepo.Object);

    // 2. 执行待测试方法,验证参数非法时的异常
    var exception = Assert.Throws<ArgumentException>(() => userService.GetUserById(0));
    Assert.Equal("用户ID不能小于等于0", exception.Message);

    // 3. 额外验证:模拟对象的GetById方法未被调用(因为参数非法,提前抛出异常)
    mockUserRepo.Verify(repo => repo.GetById(It.IsAny<int>()), Times.Never);
}

关键说明:

  • Throws(new Exception("数据库连接超时")):配置方法调用时抛出指定异常,可用于模拟依赖项的异常场景(如数据库超时、网络中断)。

  • Times.Never:验证模拟对象的方法从未被调用,适用于测试"异常分支未执行依赖调用"的场景,确保逻辑正确性。

(3)配置无返回值方法的行为(如Add、Update、Delete)

对于无返回值的方法(void方法),无法使用Returns方法,可通过Verifiable方法标记方法需要被验证,或通过Callback方法执行额外逻辑(如记录参数、修改外部变量)。

csharp 复制代码
// 测试方法:配置无返回值方法的行为,验证方法被调用
[Fact]
public void AddUser_WhenUserNameNotExists_ReturnsTrue()
{
    // 1. 准备测试数据
    var newUser = new User
    {
        UserName = "李四",
        Email = "lisi@example.com",
        Age = 28,
        CreateTime = DateTime.Now
    };

    // 2. 创建模拟对象并配置行为
    var mockUserRepo = new Mock<IUserRepository>();
    // 配置1:Search方法返回空列表(表示用户名不存在)
    mockUserRepo.Setup(repo => repo.Search(newUser.UserName)).Returns(new List<User>());
    // 配置2:Add方法无返回值,标记为可验证(后续需验证该方法被调用)
    mockUserRepo.Setup(repo => repo.Add(newUser)).Verifiable();

    // 3. 注入模拟对象,创建待测试服务
    var userService = new UserService(mockUserRepo.Object);

    // 4. 执行待测试方法
    var result = userService.AddUser(newUser);

    // 5. 断言:验证返回结果为true
    Assert.True(result);

    // 6. 验证:模拟对象的Add方法被调用了一次,且参数正确
    mockUserRepo.Verify(repo => repo.Add(newUser), Times.Once);
}

// 测试方法:使用Callback记录方法调用的参数
[Fact]
public void AddUser_WhenCalled_RecordsUserParameter()
{
    // 1. 准备测试数据
    User actualUser = null;
    var newUser = new User
    {
        UserName = "王五",
        Email = "wangwu@example.com",
        Age = 22,
        CreateTime = DateTime.Now
    };

    // 2. 创建模拟对象并配置行为:Add方法被调用时,记录传入的参数
    var mockUserRepo = new Mock<IUserRepository>();
    mockUserRepo.Setup(repo => repo.Search(newUser.UserName)).Returns(new List<User>());
    mockUserRepo.Setup(repo => repo.Add(It.IsAny<User>()))
        .Callback((User user) => actualUser = user) // 记录传入的用户参数
        .Returns(true); // 虽然Add方法返回bool,但此处为了演示,配置返回值

    // 3. 注入模拟对象,创建待测试服务
    var userService = new UserService(mockUserRepo.Object);

    // 4. 执行待测试方法
    userService.AddUser(newUser);

    // 5. 断言:验证Callback记录的参数与传入的参数一致
    Assert.NotNull(actualUser);
    Assert.Equal(newUser.UserName, actualUser.UserName);
    Assert.Equal(newUser.Email, actualUser.Email);
}

关键说明:

  • Verifiable():标记模拟对象的方法需要被验证,后续可通过mockUserRepo.VerifyAll()一次性验证所有标记为Verifiable的方法是否被正确调用。

  • Callback((User user) => actualUser = user):回调函数,当模拟对象的Add方法被调用时,执行该函数,可用于记录参数、修改外部变量、执行额外逻辑(如日志输出)。

3. 验证模拟对象的调用(核心基础)

配置模拟对象的行为并执行待测试方法后,还需要验证模拟对象的方法是否被正确调用(如调用次数、传入参数、调用顺序),这是单元测试的核心环节------确保待测试方法按照预期调用了依赖项的方法。

Moq提供了Verify方法用于验证调用,配合TimesIt工具类,可实现灵活的验证逻辑。

(1)验证方法被调用的次数
csharp 复制代码
// 测试方法:验证批量删除时,Delete方法被调用的次数
[Fact]
public void BatchDelete_WhenUserIdsValid_ReturnsSuccessCount()
{
    // 1. 准备测试数据
    var userIds = new List<int> { 1, 2, 3 };
    int expectedSuccessCount = 2;

    // 2. 创建模拟对象并配置行为:Delete方法根据id返回不同结果(1和2成功,3失败)
    var mockUserRepo = new Mock<IUserRepository>();
    mockUserRepo.Setup(repo => repo.Delete(1)).Returns(true);
    mockUserRepo.Setup(repo => repo.Delete(2)).Returns(true);
    mockUserRepo.Setup(repo => repo.Delete(3)).Returns(false);

    // 3. 注入模拟对象,创建待测试服务
    var userService = new UserService(mockUserRepo.Object);

    // 4. 执行待测试方法
    var actualSuccessCount = userService.BatchDelete(userIds);

    // 5. 断言:验证返回的成功数量与预期一致
    Assert.Equal(expectedSuccessCount, actualSuccessCount);

    // 6. 验证调用次数:
    // - Delete(1)被调用1次
    mockUserRepo.Verify(repo => repo.Delete(1), Times.Once);
    // - Delete(2)被调用1次
    mockUserRepo.Verify(repo => repo.Delete(2), Times.Once);
    // - Delete(3)被调用1次
    mockUserRepo.Verify(repo => repo.Delete(3), Times.Once);
    // - Delete方法总共被调用3次(任意参数)
    mockUserRepo.Verify(repo => repo.Delete(It.IsAny<int>()), Times.Exactly(3));
    // - Delete方法至少被调用2次
    mockUserRepo.Verify(repo => repo.Delete(It.IsAny<int>()), Times.AtLeast(2));
    // - Delete(4)从未被调用
    mockUserRepo.Verify(repo => repo.Delete(4), Times.Never);
}
(2)验证方法传入的参数

使用It工具类的参数匹配器,验证方法被调用时传入的参数是否符合预期(如参数值、参数类型、参数满足的条件)。

csharp 复制代码
// 测试方法:验证Add方法传入的参数是否符合预期
[Fact]
public void AddUser_WhenCalled_VerifyParameter()
{
    // 1. 准备测试数据
    var newUser = new User
    {
        UserName = "赵六",
        Email = "zhaoliu@example.com",
        Age = 30,
        CreateTime = DateTime.Now
    };

    // 2. 创建模拟对象并配置行为
    var mockUserRepo = new Mock<IUserRepository>();
    mockUserRepo.Setup(repo => repo.Search(newUser.UserName)).Returns(new List<User>());
    mockUserRepo.Setup(repo => repo.Add(It.IsAny<User>())).Returns(true);

    // 3. 注入模拟对象,创建待测试服务
    var userService = new UserService(mockUserRepo.Object);

    // 4. 执行待测试方法
    userService.AddUser(newUser);

    // 5. 验证参数:
    // - 传入的User对象不为null
    mockUserRepo.Verify(repo => repo.Add(It.IsNotNull<User>()), Times.Once);
    // - 传入的User对象的UserName是"赵六"
    mockUserRepo.Verify(repo => repo.Add(It.Is<User>(u => u.UserName == "赵六")), Times.Once);
    // - 传入的User对象的Age大于25
    mockUserRepo.Verify(repo => repo.Add(It.Is<User>(u => u.Age > 25)), Times.Once);
    // - 传入的User对象的Email包含"example.com"
    mockUserRepo.Verify(repo => repo.Add(It.Is<User>(u => u.Email.Contains("example.com"))), Times.Once);
}
(3)验证方法调用顺序(进阶)

当待测试方法多次调用依赖项的不同方法时,可通过MockSequence验证方法的调用顺序是否符合预期。

csharp 复制代码
// 测试方法:验证AddUser方法中,Search和Add的调用顺序
[Fact]
public void AddUser_WhenCalled_VerifyCallOrder()
{
    // 1. 准备测试数据
    var newUser = new User
    {
        UserName = "孙七",
        Email = "sunqi@example.com",
        Age = 26,
        CreateTime = DateTime.Now
    };

    // 2. 创建模拟对象和调用序列
    var mockUserRepo = new Mock<IUserRepository>();
    var sequence = new MockSequence(); // 用于定义调用顺序

    // 3. 配置行为,并指定调用顺序
    mockUserRepo.InSequence(sequence).Setup(repo => repo.Search(newUser.UserName)).Returns(new List<User>());
    mockUserRepo.InSequence(sequence).Setup(repo => repo.Add(newUser)).Returns(true);

    // 4. 注入模拟对象,创建待测试服务
    var userService = new UserService(mockUserRepo.Object);

    // 5. 执行待测试方法
    userService.AddUser(newUser);

    // 6. 验证:Search方法先被调用,Add方法后被调用(顺序正确)
    mockUserRepo.Verify(repo => repo.Search(newUser.UserName), Times.Once);
    mockUserRepo.Verify(repo => repo.Add(newUser), Times.Once);
}

关键说明:

  • Times类的常用方法:Once(调用1次)、Never(从未调用)、Exactly(n)(调用n次)、AtLeast(n)(至少调用n次)、AtMost(n)(最多调用n次)。

  • It类的常用参数匹配器:IsAny<T>()(任意T类型参数)、IsNotNull<T>()(非null的T类型参数)、Is<T>(predicate)(满足条件的T类型参数)、Equal(value)(等于指定值的参数)。

  • MockSequence:用于定义方法的调用顺序,只有按照配置的顺序调用方法,验证才会通过,适用于对调用顺序有严格要求的场景。

4. 基础场景综合实战

结合前面的基础用法,我们编写一个综合测试案例,测试UserService的AddUser方法,覆盖"用户名存在""用户名不存在""参数为null"三个场景,完整演示Moq的基础用法:

csharp 复制代码
// 综合测试:AddUser方法的多场景测试
public class UserService_AddUser_Tests
{
    // 场景1:用户名不存在,新增成功
    [Fact]
    public void AddUser_WhenUserNameNotExists_ReturnsTrueAndCallsAdd()
    {
        // 准备数据
        var newUser = new User { UserName = "周八", Email = "zhouba@example.com", Age = 24 };
        var mockUserRepo = new Mock<IUserRepository>();

        // 配置行为
        mockUserRepo.Setup(repo => repo.Search(newUser.UserName)).Returns(new List<User>());
        mockUserRepo.Setup(repo => repo.Add(newUser)).Returns(true);

        // 执行测试
        var userService = new UserService(mockUserRepo.Object);
        var result = userService.AddUser(newUser);

        // 断言与验证
        Assert.True(result);
        mockUserRepo.Verify(repo => repo.Search(newUser.UserName), Times.Once);
        mockUserRepo.Verify(repo => repo.Add(newUser), Times.Once);
    }

    // 场景2:用户名已存在,新增失败
    [Fact]
    public void AddUser_WhenUserNameExists_ReturnsFalseAndDoesNotCallAdd()
    {
        // 准备数据
        var newUser = new User { UserName = "周八", Email = "zhouba@example.com", Age = 24 };
        var existingUsers = new List<User> { new User { UserName = "周八" } };
        var mockUserRepo = new Mock<IUserRepository>();

        // 配置行为
        mockUserRepo.Setup(repo => repo.Search(newUser.UserName)).Returns(existingUsers);

        // 执行测试
        var userService = new UserService(mockUserRepo.Object);
        var result = userService.AddUser(newUser);

        // 断言与验证
        Assert.False(result);
        mockUserRepo.Verify(repo => repo.Search(newUser.UserName), Times.Once);
        mockUserRepo.Verify(repo => repo.Add(It.IsAny<User>()), Times.Never); // Add方法未被调用
    }

    // 场景3:传入参数为null,抛出异常
    [Fact]
    public void AddUser_WhenUserIsNull_ThrowsArgumentNullException()
    {
        // 准备数据
        var mockUserRepo = new Mock<IUserRepository>();
        var userService = new UserService(mockUserRepo.Object);

        // 执行测试并断言异常
        var exception = Assert.Throws<ArgumentNullException>(() => userService.AddUser(null));
        Assert.Equal("user", exception.ParamName);

        // 验证:依赖方法未被调用
        mockUserRepo.Verify(repo => repo.Search(It.IsAny<string>()), Times.Never);
        mockUserRepo.Verify(repo => repo.Add(It.IsAny<User>()), Times.Never);
    }
}

三、核心特性:Moq进阶用法(深度重点)

基础用法能满足日常简单测试场景,而Moq的进阶特性则能适配复杂企业级测试场景(如模拟抽象类、密封类、泛型接口、依赖注入、静态方法等),这也是它区别于其他Mock框架的核心优势,更是企业级单元测试必备的技能。

1. 模拟抽象类与非密封类(含虚方法)

Moq不仅可以模拟接口,还可以模拟抽象类和带有虚方法的非密封类------对于抽象类,Moq会实现其所有抽象方法;对于非密封类,Moq只能模拟其虚方法(非虚方法无法被Moq拦截,无法配置行为)。

csharp 复制代码
// 1. 定义抽象类(用户数据访问层抽象类)
public abstract class AbstractUserRepository
{
    // 抽象方法
    public abstract User GetById(int id);
    // 虚方法
    public virtual bool Add(User user)
    {
        // 默认实现(可被Moq重写)
        return true;
    }
    // 非虚方法(无法被Moq模拟)
    public bool Delete(int id)
    {
        return false;
    }
}

// 测试方法:模拟抽象类
[Fact]
public void Test_Mock_AbstractClass()
{
    // 1. 创建抽象类的模拟对象
    var mockAbstractRepo = new Mock<AbstractUserRepository>();

    // 2. 配置抽象方法的行为
    mockAbstractRepo.Setup(repo => repo.GetById(1)).Returns(new User { Id = 1, UserName = "抽象类测试" });

    // 3. 配置虚方法的行为(重写默认实现)
    mockAbstractRepo.Setup(repo => repo.Add(It.IsAny<User>())).Returns(false);

    // 4. 验证抽象方法
    var user = mockAbstractRepo.Object.GetById(1);
    Assert.NotNull(user);
    Assert.Equal("抽象类测试", user.UserName);

    // 5. 验证虚方法
    var addResult = mockAbstractRepo.Object.Add(new User());
    Assert.False(addResult);

    // 6. 非虚方法:无法配置行为,只能调用默认实现
    var deleteResult = mockAbstractRepo.Object.Delete(1);
    Assert.False(deleteResult);
}

// 测试方法:模拟带有虚方法的非密封类
[Fact]
public void Test_Mock_ConcreteClass_WithVirtualMethod()
{
    // 1. 定义带有虚方法的非密封类
    public class ConcreteUserRepository
    {
        public virtual User GetById(int id)
        {
            return null; // 默认返回null
        }

        public bool Add(User user) // 非虚方法,无法模拟
        {
            return true;
        }
    }

    // 2. 创建非密封类的模拟对象
    var mockConcreteRepo = new Mock<ConcreteUserRepository>();

    // 3. 配置虚方法的行为
    mockConcreteRepo.Setup(repo => repo.GetById(1)).Returns(new User { Id = 1 });

    // 4. 验证虚方法
    var user = mockConcreteRepo.Object.GetById(1);
    Assert.NotNull(user);

    // 5. 非虚方法:无法配置行为,调用默认实现
    var addResult = mockConcreteRepo.Object.Add(new User());
    Assert.True(addResult);
}

关键说明:

  • 模拟抽象类时,必须配置所有抽象方法的行为(否则调用抽象方法会抛出异常);虚方法可选择性配置,未配置则调用默认实现。

  • 非密封类的非虚方法无法被Moq拦截,因此无法配置行为,只能调用其默认实现------若需模拟非虚方法,需使用其他工具(如TypeMock),但TypeMock是商业软件,且Mo

相关推荐
留院极客离心圆2 小时前
C++ 进阶笔记:栈内存 vs 堆内存
开发语言·c++
留院极客离心圆2 小时前
C++ 进阶笔记:宏
开发语言·c++·笔记
無限進步D2 小时前
关于高校C语言课程的学习方法
c语言·开发语言·学习方法·入门
星空露珠2 小时前
迷你世界UGC3.0脚本Wiki生物模块管理接口 Monster
开发语言·数据结构·算法·游戏·lua
星空露珠2 小时前
迷你世界UGC3.0脚本Wiki世界模块管理接口 World
开发语言·数据库·算法·游戏·lua
这是个栗子2 小时前
前端开发中的常用工具函数(四)
开发语言·javascript·ecmascript·find
格林威2 小时前
工业相机彩色图像采集:为什么我的图是绿色的?附海康/Basler/堡盟相机设置
开发语言·人工智能·数码相机·opencv·计算机视觉·c#·工业相机
阿贵---2 小时前
C++中的装饰器模式
开发语言·c++·算法
加密狗复制模拟2 小时前
软件加密狗中时间限制机制的破解
开发语言·网络·安全·php·软件工程·个人开发