You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

.NET 6 Web API Moq单元测试如何绕过服务内部SelectFoo2方法

问题背景

我在.NET 6中编写Web API的服务层代码,实现如下:

public async Task<Foo1> GetFileByObjectId(int objectId)
{
    List<Foo2> selectedFoo2 = (await SelectFoo2(objectId)).ToList();
    if (selectedFoo2.Count == 0)
    {
        throw new HttpStatusException(HttpStatusCode.InternalServerError, 22, $"The object doesn't exist.");
    }

    if (!IsAdmin)
    {
        List<Permission> permissions = (await _permissionService.Select(username)).ToList();
        if (permissions.Count == 0 || permissions.Count() == 0)
        {
            throw new HttpStatusException(HttpStatusCode.InternalServerError, 23, $"The User {username} doesn't own the necessary rights");
        }
    }
    // 剩余业务逻辑省略
}

目前使用Moq编写单元测试,用于验证权限缺失的异常场景,测试代码如下:

public async Task TestGetFileByObjectId()
{
    // Arrange
    Mock.Get(_permissionService).Setup(mock => mock.Select(It.IsAny<int>(), It.IsAny<string>(), string.Empty)).Returns(Task.FromResult(Enumerable.Repeat(Mock.Of<Permission>(), 0)));

    var configValues = new Dictionary<string, string>
        {
            {"AppSettings:RootFolder", "..."},
        };

    var configuration = new ConfigurationBuilder()
        .AddInMemoryCollection(configValues)
        .Build();

    Foo2Service myService = new Foo2Service(..., _permissionService, ...);

    // Act

    // Assert
    HttpStatusException exceptionThrown = await Assert.ThrowsExceptionAsync<HttpStatusException>(async () => await myService.GetFileByObjectId(It.IsAny<int>()));
    Assert.IsTrue(exceptionThrown.CustomErrorCode == 23);
}

现存问题

SelectFoo2是Foo2Service自身的方法,并非外部注入的服务,执行时会请求数据库连接字符串,我不需要测试该方法的SQL查询执行逻辑,如何在单元测试中绕过SelectFoo2方法?

补充代码说明

SelectFoo2方法(不希望测试其中的查询执行逻辑)实现如下:

public async Task<IEnumerable<Foo2>> SelectFoo2(int objectId)
{
    // Query sql using RepoDB
    return await ExecuteQueryAsync<EcmObject>(sql, param);
}

基类BaseService中的ExecuteQueryAsync方法实现如下:

public async Task<IEnumerable<T>> ExecuteQueryAsync<T>(string sql, Dictionary<string, object> param)
{
    using (SqlConnection connection = new SqlConnection(_ecmCommonInfoService.ConnectionString))
    {
        connection.Open();
        IEnumerable<T> records = await connection.ExecuteQueryAsync<T>(sql, param);
        return records;
    }
}

解决方案

有两种常用实现方式,按需选择即可:

方案1(推荐,符合设计规范):抽离数据访问依赖

当前代码将业务逻辑和数据访问逻辑耦合在同一个服务类中,本身不符合单一职责原则。可以将SelectFoo2对应的数据库查询逻辑抽离到独立的仓储类中,通过接口注入:

  1. 定义仓储接口
public interface IFoo2Repository
{
    Task<IEnumerable<Foo2>> SelectFoo2(int objectId);
}
  1. 将原有SelectFoo2的SQL查询逻辑移动到IFoo2Repository的实现类中,在Foo2Service的构造函数中注入该依赖,将原代码中await SelectFoo2(objectId)的调用替换为await _foo2Repository.SelectFoo2(objectId)。
  2. 单元测试时直接MockIFoo2Repository,配置返回非空的Foo2列表即可跳过数据库访问逻辑,直接进入后续权限校验流程:
var mockFoo2Repo = new Mock<IFoo2Repository>();
// 配置返回测试用的非空数据,跳过对象不存在的校验分支
mockFoo2Repo.Setup(r => r.SelectFoo2(It.IsAny<int>()))
    .ReturnsAsync(new List<Foo2> { new Foo2() });
// 注入Mock的仓储实例创建服务
var myService = new Foo2Service(mockFoo2Repo.Object, _permissionService, /* 其他依赖 */);

这种方式实现的测试逻辑最干净,也能优化生产代码的结构,是首选方案。

方案2(无需重构生产代码):使用Moq部分模拟

如果暂时不想调整生产代码结构,可以用Moq的部分模拟功能拦截SelectFoo2的执行,注意需要先将SelectFoo2方法标记为virtual,Moq才能重写该方法的返回值:

  1. 修改服务类中SelectFoo2的定义,加virtual关键字:
public virtual async Task<IEnumerable<Foo2>> SelectFoo2(int objectId)
{
    // 原有逻辑不变
}
  1. 单元测试中不要直接new服务实例,通过Mock<Foo2Service>创建实例,配置SelectFoo2的返回值:
// 创建服务Mock,CallBase设为true表示未Setup的方法走原有逻辑
var serviceMock = new Mock<Foo2Service>(/* 传入构造函数所需的所有依赖实例 */) 
{ 
    CallBase = true 
};
// 拦截SelectFoo2,直接返回测试数据,不执行原方法的数据库访问
serviceMock.Setup(s => s.SelectFoo2(It.IsAny<int>()))
    .ReturnsAsync(new List<Foo2> { new Foo2() });
var myService = serviceMock.Object;

原有测试代码的注意点

你当前写的测试代码有两个细节问题会导致测试不通过:

  1. _permissionService.Select的Setup参数和实际调用不匹配:业务代码中调用的是_permissionService.Select(username),只有1个参数,但你Setup时写了3个参数,Moq无法匹配到这个配置,不会返回你设置的空权限列表。
  2. 调用被测方法时不能传It.IsAny<int>():It.IsAny<T>()只能用于Mock配置时的参数匹配,实际执行方法要传入具体的测试值,比如1。

内容的提问来源于stack exchange,提问作者Emanuele

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.01 04:03:25