如何对 .NET Core 3.1 中使用 ExecuteSqlRawAsync 调用存储过程的函数进行单元测试?

How to unit test a function in .NET Core 3.1 that uses ExecuteSqlRawAsync to call a stored procedure?

我正在 ASP.NET Core 3.1 中编写 API,使用 EF Core 访问 SQL 服务器数据库。我在 API 中有一个函数需要调用具有多个输入参数和一个输出参数的存储过程。此功能的简化版本如下。

我正在使用 DbContext.UseInMemoryDatabase() 进行其他测试,但是内存数据库不能与存储过程一起使用。

(这个解决方案是数据库优先,而不是代码优先。如果有必要,可以更改存储过程,但如果我不必这样做会好得多。我可以更改我的 C# 函数来调用如果有帮助的话,可以使用不同的存储过程。)

如何对这个函数进行单元测试?

public class MyFoo : IFoo
{
    public ApplicationDbContext DbContext { get; }

    public MyFoo(ApplicationDbContext dbContext)
    {
        DbContext = dbContext;
    }

    public async Task<bool> GetMyStoredProcResult(string val1, string val2, string val3, string val4, string val5)
    {
        // input validation removed for brevity
        var p1 = new SqlParameter
        {
            ParameterName = "p1",
            DbType = System.Data.DbType.String,
            Direction = System.Data.ParameterDirection.Input,
            Value = val1
        };
        // p2 - p5 removed for brevity
        var resultParam = new SqlParameter
        {
            ParameterName = "Result",
            DbType = System.Data.DbType.Boolean,
            Direction = System.Data.ParameterDirection.Output
        };
        var sql = "EXEC sp_MyProcedure @p1, @p2, @p3, @p4, @p5, @Result OUTPUT";
        _ = await DbContext.Database.ExecuteSqlRawAsync(sql, p1, p2, p3, p4, p5, resultParam);
        return (bool)resultParam.Value;
    }
}

在单元测试中,您可以假设其他部分和外部调用始终执行正常。所以你可以像这样模拟调用。一种方法是实施一些策略,将过程调用移动到具有特定实现的接口中

interface IProcedureExecutor
{
    void MyProcedure();
}

class DefaultProcedureExecutor: IProcedureExecutor
{
    public void MyProcedure()
    {
        //call procedure in database
    }
}

class MockedProcedureExecutor: IProcedureExecutor
{
    public void MyProcedure()
    {
        //do some direct data operations with in memory database
    }
}

然后在您的 MyFoo 中传递 IProcedureExecutor 并在单元测试期间用模拟实例替换实例。

这只是一个例子。你也可以像这样模拟任何部分

我的最终解决方案是基于 Stas Petrov 给出的答案。我使用一个带有 class 的接口包装了对 DbContext.Database.ExecuteSqlRawAsync() 的调用,该接口被添加到 Startup.ConfigureServices().

中的 DI

我创建了以下界面 class:

public interface IStoredProcedureExecutor
{
    public Task<int> ExecuteSqlRawAsync(string sql, params object[] parameters);
}

public class StoredProcedureExecutor : IStoredProcedureExecutor
{
    public ApplicationDbContext DbContext { get; }

    public StoredProcedureExecutor(ApplicationDbContext dbContext)
    {
        DbContext = dbContext;
    }

    public Task<int> ExecuteSqlRawAsync(string sql, params object[] parameters)
    {
        return DbContext.Database.ExecuteSqlRawAsync(sql, parameters);
    }
}

在问题的代码中,我替换了这个调用:

_ = await DbContext.Database.ExecuteSqlRawAsync(sql, p1, p2, p3, p4, p5, resultParam);

有了这个:

_ = await StoredProcedureExecutor.ExecuteSqlRawAsync(sql, p1, p2, p3, p4, p5, resultParam);

然后在测试代码中,我创建了这个 class 我实例化,设置一个合适的 ReturnValue,然后插入 class 我正在测试而不是 StoredProcedureExecutor:

class TestStoredProcedureExecutor : IStoredProcedureExecutor
{
    public bool ReturnValue { get; set; }

    public Task<int> ExecuteSqlRawAsync(string sql, params object[] parameters)
    {
        foreach (var param in parameters)
        {
            var p = (SqlParameter)param;
            if (p.Direction == System.Data.ParameterDirection.Output) p.Value = ReturnValue;
        }
        return Task.FromResult(0);
    }
}

问题是 ExecuteSqlRawAsync 方法是 static

如果只有一个地方需要模拟它,那么引入一种新类型并将其添加到容器中可能有点矫枉过正。

使用旧的 “提取 + 覆盖” 模拟技巧可能就足够了。

“提取” 对这个新方法的静态调用:

internal virtual async Task<int> ExecuteSqlRawAsync(MyContext context, string sql, CancellationToken cancellationToken)
{
  return await context.Database.ExecuteSqlRawAsync(sql, cancellationToken);
}

并用新方法替换原来的调用:

//await context.Database.ExecuteSqlRawAsync(sql, p1, p2, p3, p4, p5, resultParam);
await ExecuteSqlRawAsync(context, sql, p1, p2, p3, p4, p5, resultParam);

在测试中,您现在可以“覆盖”(即模拟)该方法。

理论上,它并不完美 - 您不知道该方法是否正确实现(有时您可以编写另一个测试来检查该方法的行为,或者通过将其包含在集成测试中来接受它的工作原理)。但在很多情况下,这种方法已经足够好了。