NSubstitute DbSet / IQueryable<T>

42
所以,EntityFramework 6比之前的版本更容易进行测试。对于像 Moq 这样的框架,互联网上有一些不错的例子,但问题在于,我更喜欢使用 NSubstitute。我已经将“non-query”示例翻译成了与 NSubstitute 配合使用的代码,但是我无法理解“query-test”。
Moq 的 items.As<IQueryable<T>>().Setup(m => m.Provider).Returns(data.Provider); 在 NSubstitute 中如何转换?我想到了类似这样的代码 ((IQueryable<T>) items).Provider.Returns(data.Provider);,但是它没有成功。我还尝试过items.AsQueryable().Provider.Returns(data.Provider);,但它也没有成功。
我得到的异常信息是:

“System.NotImplementedException:类型 'DbSet1Proxy'(继承于'DbSet1')上尚未实现成员'IQueryable.Provider'。'DbSet`1' 的测试替身必须提供已使用的方法和属性的实现。”

所以,让我援引上面链接中的代码示例。此代码示例使用 Moq 来模拟 DbContext 和 DbSet。
public void GetAllBlogs_orders_by_name()
{
  // Arrange
  var data = new List<Blog>
  {
     new Blog { Name = "BBB" },
     new Blog { Name = "ZZZ" },
     new Blog { Name = "AAA" },
  }.AsQueryable();

  var mockSet = new Mock<DbSet<Blog>>();
  mockSet.As<IQueryable<Blog>>().Setup(m => m.Provider).Returns(data.Provider);
  mockSet.As<IQueryable<Blog>>().Setup(m => m.Expression).Returns(data.Expression);
  mockSet.As<IQueryable<Blog>>().Setup(m => m.ElementType).Returns(data.ElementType);
  mockSet.As<IQueryable<Blog>>().Setup(m => m.GetEnumerator()).Returns(data.GetEnumerator());

  var mockContext = new Mock<BloggingContext>();
  mockContext.Setup(c => c.Blogs).Returns(mockSet.Object);

  // ...
}

这就是我在NSubstitute方面所做的工作

public void GetAllBlogs_orders_by_name()
{
  // Arrange
  var data = new List<Blog>
  {
     new Blog { Name = "BBB" },
     new Blog { Name = "ZZZ" },
     new Blog { Name = "AAA" },
  }.AsQueryable();

  var mockSet = Substitute.For<DbSet<Blog>>();
  // it's the next four lines I don't get to work
  ((IQueryable<Blog>) mockSet).Provider.Returns(data.Provider);
  ((IQueryable<Blog>) mockSet).Expression.Returns(data.Expression);
  ((IQueryable<Blog>) mockSet).ElementType.Returns(data.ElementType);
  ((IQueryable<Blog>) mockSet).GetEnumerator().Returns(data.GetEnumerator());

  var mockContext = Substitute.For<BloggingContext>();
  mockContext.Blogs.Returns(mockSet);

  // ...
}

那么问题来了:如何替换IQueryable的属性(比如Provider)?

1
更新:使用EntityFramework.Testing.NSubstitute包,该包提供了DbAsyncQueryProvider的实现。 - Andriy Tolstoy
在 EF Core 中,请考虑使用内存提供程序 https://learn.microsoft.com/zh-cn/ef/core/testing/ - Michael Freidgeim
7个回答

43

这是由于 NSubstitute 语法的特定原因导致的。例如:

((IQueryable<Blog>) mockSet).Provider.Returns(data.Provider);

NSubstitute调用提供程序的getter,然后指定返回值。这个getter调用不会被代理截取,因此你会得到一个异常。这是因为DbQuery类中明确实现了IQueryable.Provider属性。

你可以使用NSub显式地创建多个接口的替代品,并创建一个覆盖所有指定接口的代理。然后对接口的调用将被代替品截取。

请使用以下语法:

// Create a substitute for DbSet and IQueryable types:
var mockSet = Substitute.For<DbSet<Blog>, IQueryable<Blog>>();
    
// And then as you do:
((IQueryable<Blog>) mockSet).Provider.Returns(data.Provider);
((IQueryable<Blog>) mockSet).Expression.Returns(data.Expression);
((IQueryable<Blog>) mockSet).ElementType.Returns(data.ElementType);
((IQueryable<Blog>) mockSet).GetEnumerator().Returns(data.GetEnumerator());

谢谢!这真是有用的信息。我还不确定这是否是替换DbSet的最佳方法(因为使用IDbSet解决了问题),但它在其他情况下肯定很有帮助。 - s.meijer
我将您标记为答案,因为您的建议是支持异步操作的解决方案(Substitute.For<IDbSet<Blog>, IDbAsyncEnumerable<Blog>>())。这并不是问题的实质,但它是其扩展。再次感谢您的见解! - s.meijer
5
提示:确保您上下文中的 DbSetvirtual 的。 - Michael Haren
2
有没有一种优雅的解决方案来处理 .Include() 问题?我一直收到 source is null 错误。 - John Lieb-Bauman

19
感谢Kevin,我已经找到了我的代码翻译问题所在。
这些 单元测试代码示例 使用的是模拟的 DbSet,但是NSubstitute需要接口实现。因此,对于NSubstitute的Moqs中的new Mock<DbSet<Blog>>(),其等效项是Substitute.For<IDbSet<Blog>>()。并不总是要提供接口,所以我很困惑。但在这种特定情况下,它被证明是至关重要的。
同时,在使用接口IDbSet时,我们也不必强制转换为Queryable类型。
因此,工作正常的测试代码如下:
public void GetAllBlogs_orders_by_name()
{
  // Arrange
  var data = new List<Blog>
  {
    new Blog { Name = "BBB" },
    new Blog { Name = "ZZZ" },
    new Blog { Name = "AAA" },
  }.AsQueryable();

  var mockSet = Substitute.For<IDbSet<Blog>>();
  mockSet.Provider.Returns(data.Provider);
  mockSet.Expression.Returns(data.Expression);
  mockSet.ElementType.Returns(data.ElementType);
  mockSet.GetEnumerator().Returns(data.GetEnumerator());

  var mockContext = Substitute.For<BloggingContext>();
  mockContext.Blogs.Returns(mockSet);

  // Act and Assert ...
}

我编写了一个小的扩展方法来清理单元测试中的“安排(Arrange)”部分。

public static class ExtentionMethods
{
    public static IDbSet<T> Initialize<T>(this IDbSet<T> dbSet, IQueryable<T> data) where T : class
    {
        dbSet.Provider.Returns(data.Provider);
        dbSet.Expression.Returns(data.Expression);
        dbSet.ElementType.Returns(data.ElementType);
        dbSet.GetEnumerator().Returns(data.GetEnumerator());
        return dbSet;
    }
}

// usage like:
var mockSet = Substitute.For<IDbSet<Blog>>().Initialize(data);

不是问题,但如果您也需要能够支持异步操作:

public static IDbSet<T> Initialize<T>(this IDbSet<T> dbSet, IQueryable<T> data) where T : class
{
  dbSet.Provider.Returns(data.Provider);
  dbSet.Expression.Returns(data.Expression);
  dbSet.ElementType.Returns(data.ElementType);
  dbSet.GetEnumerator().Returns(data.GetEnumerator());

  if (dbSet is IDbAsyncEnumerable)
  {
    ((IDbAsyncEnumerable<T>) dbSet).GetAsyncEnumerator()
      .Returns(new TestDbAsyncEnumerator<T>(data.GetEnumerator()));
    dbSet.Provider.Returns(new TestDbAsyncQueryProvider<T>(data.Provider));
  }

  return dbSet;
}

// create substitution with async
var mockSet = Substitute.For<IDbSet<Blog>, IDbAsyncEnumerable<Blog>>().Initialize(data);
// create substitution without async
var mockSet = Substitute.For<IDbSet<Blog>>().Initialize(data);

1
没问题。但是TestDbAsyncEnumerator和TestDbAsyncQueryProvider是什么? - vborutenko
这是一个内存数据库查询提供程序,正如我在问题中提供的链接和这个答案所解释的那样。请查看msdn页面:http://msdn.microsoft.com/nl-nl/data/dn314429.aspx#async - s.meijer
2
我已经尝试过这个,但是出现了异常:NSubstitute.Exceptions.CouldNotSetReturnDueToTypeMismatchException: 无法返回类型为IDbSet1Proxy的值,因为BloggingContext.get_Blogs(期望类型DbSet1)的类型不匹配。该上下文是由EF TT模板生成的。 - Shevek
1
@Shevek 遇到了同样的问题,你可以通过调整 for 调用中的接口/类型来使其工作:Substitute.For<IDbSet<DataModel>, DbSet<DataModel>>(); - klyd
@Shevek 你也可以修改你的BloggingContext类:public virtual IDbSet <Blog> Blogs { get; set; } public virtual IDbSet <Post> Posts { get; set; } - Stuart Clement

8

这是我的静态通用方法,用于生成虚假的DbSet。它可能会很有用。

 public static class CustomTestUtils
{
    public static DbSet<T> FakeDbSet<T>(List<T> data) where T : class
    {
        var _data = data.AsQueryable();
        var fakeDbSet = Substitute.For<DbSet<T>, IQueryable<T>>();
        ((IQueryable<T>)fakeDbSet).Provider.Returns(_data.Provider);
        ((IQueryable<T>)fakeDbSet).Expression.Returns(_data.Expression);
        ((IQueryable<T>)fakeDbSet).ElementType.Returns(_data.ElementType);
        ((IQueryable<T>)fakeDbSet).GetEnumerator().Returns(_data.GetEnumerator());

        fakeDbSet.AsNoTracking().Returns(fakeDbSet);

        return fakeDbSet;
    }

}

1
我认为在参数"data"的定义中缺少关键字"this"。 - Panagiotis Pnevma
我在几个项目中使用过这个。我喜欢它能够从我的测试中移除繁琐的部分。 - Ashley Kilgour

3
我大约一年前写了一个包装器,围绕着你所引用的代码(EF6及以上版本中使用自己的测试替身进行测试)。这个包装器可以在GitHub DbContextMockForUnitTests上找到。这个包装器的目的是减少设置单元测试所需的重复/重复代码量,在你想要模拟DbContextDbSets的地方使用EF。大部分你在OP中的模拟EF代码可以缩减到2行代码(如果你使用DbContext.Set<T>而不是DbSet属性,只需要1行代码),然后在包装器中调用模拟代码。
要使用它,请将文件夹MockHelpers中的文件复制并包含到你的测试项目中。
下面是一个使用你之前提供的内容的示例测试,注意现在只需要2行代码来设置模拟的DbSet<T>和模拟的DbContext
public void GetAllBlogs_orders_by_name()
{
  // Arrange
  var data = new List<Blog>
  {
     new Blog { Name = "BBB" },
     new Blog { Name = "ZZZ" },
     new Blog { Name = "AAA" },
  };

  var mockContext = Substitute.For<BloggingContext>();

  // Create and assign the substituted DbSet
  var mockSet = data.GenerateMockDbSet();
  mockContext.Blogs.Returns(mockSet);

  // act
}

我们可以轻松地将此测试转换为使用异步/等待模式的代码,例如在DbSet<T>上调用.ToListAsync()

public async Task GetAllBlogs_orders_by_name()
{
  // Arrange
  var data = new List<Blog>
  {
     new Blog { Name = "BBB" },
     new Blog { Name = "ZZZ" },
     new Blog { Name = "AAA" },
  };

  var mockContext = Substitute.For<BloggingContext>();

  // Create and assign the substituted DbSet
  var mockSet = data.GenerateMockDbSetForAsync(); // only change is the ForAsync version of the method
  mockContext.Blogs.Returns(mockSet);

  // act
}

1
使用MockQueryable.NSubstitute非常容易模拟DbSet。
你可以按照以下步骤进行操作:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
using Fridge.API.Core.Entities;
using Fridge.API.Core.Interfaces.Persistence;
using Fridge.Application.Features.UserManagement.Queries.GetUser;
using Microsoft.EntityFrameworkCore;
using MockQueryable.NSubstitute;
using NSubstitute;
using NSubstitute.Core;
using NUnit.Framework;

namespace Fridge.Application.UnitTests.Features.UserManagement.Queries.GetUser;

[TestFixture]
public class GetUserQueryHandlerTests
{
    private IAppDbContext _dbContext = null!;
    private IMapper _mapper = null!;

    private GetUserQueryHandler _handler = null!;


    [SetUp]
    protected void BeforeEach()
    {
        _dbContext = Substitute.For<IAppDbContext>();
        _mapper = Substitute.For<IMapper>();

        _handler = new GetUserQueryHandler(_dbContext, _mapper);
    }

    [Test]
    public async Task Handler_WhenUserDoesNotExists_ReturnsResponseFailure()
    {
        // Arrange
        var users = Array.Empty<User>().AsQueryable();
        var usersDbSet = users.BuildMockDbSet();
        _dbContext.Users.Returns(usersDbSet);

        // Act
        var response = await _handler.Handle(new GetUserQuery(Guid.NewGuid()), CancellationToken.None);

        // Assert
        Assert.Multiple(() =>
        {
            Assert.That(response.Succeed, Is.False);
            Assert.That(response.Value, Is.Null);
            Assert.That(response.Error.Code, Is.EqualTo((int)HttpStatusCode.NotFound));
            Assert.That(response.Error.Message, Is.EqualTo("Could not found user"));
            Assert.That(response.Error.Name, Is.EqualTo(nameof(HttpStatusCode.NotFound)));
        });
    }
}

我想嘲笑一个空数组,但你可以填充它的条目并以同样的方式模拟返回数据。

0

当你使用类似于

MyDbContext.CounterpartyDbSet.AsQuariable()    // or AsNoTracking()
    .bla().bla().bla()

你可以通过简单的方式来实现:

var counterpartyList = new List<Counterparty>()
{ 
    // some items here;
}

var myDbContext = Substitute.For<IMyDbContext>();
var counterpartySet = Substitute.For<DbSet<Counterparty>>();
counterpartySet.AsQueryable()    // or AsNoTracking()
    .Returns(counterpartyList.AsQueryable());
myDbContext.CounterpartyDbSet.Returns(counterpartySet);

0

你不需要模拟 IQueryable 的所有部分。当我使用 NSubstitute 模拟 EF DbContext 时,我会这样做:

interface IContext
{
  IDbSet<Foo> Foos { get; set; }
}

var context = Substitute.For<IContext>();

context.Foos.Returns(new MockDbSet<Foo>());

通过在列表或其他容器周围实现IDbSet,就可以轻松地创建MockDbSet()。

总的来说,您应该模拟接口而不是类型,因为NSubstitute只会覆盖虚方法。


在 EF 6 中,DbSet 属性应该被设置为 virtual 来实现可测试性。我不想创建一个假的 DbSet 包装器。替换这些 4 个属性应该比创建您提出的包装器更容易和更好。所以您的建议并不是我正在寻找的答案。 - s.meijer
1
从错误信息来看,我会说由NSubstitute创建的DbSet代理不支持显式实现接口,只支持您替换的特定合同或对象。这在(https://github.com/nsubstitute/NSubstitute/issues/95)中得到了证实,并且可能是框架的限制。 - Kevin

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接