使用 EF Core InMemoryDatabase 和 Moq 模拟进行单元测试

Unit testing using EF Core InMemoryDatabase along with Moq mocks

我正在使用 EF Core 6。0.x + NUnit + Moq。下面的例子是高度匿名的,真实的场景其实是有道理的。

我有一个 DbContext:

public class MyDbContext : DbContext
{        
    public virtual DbSet<Foo> Foos { get; set; }

    public MyDbContext(DbContextOptions<MyDbContext> options) : base(options) { }
    
    public virtual void PreSaveActions()
        => throw new NotImplementedException(); //Here I've got something that must be done pre-save.

    public override int SaveChanges()
    {
        PreSaveActions();
        return base.SaveChanges();
    }
}

我有一个类似的方法:

public class SafeRemover
{
    private readonly IDbContextFactory<MyDbContext> _contextFactory;    

    public SafeRemover(IDbContextFactory<MyDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    public int SafeRemove(string name, int barId)
    {
        using var context = _contextFactory.CreateDbContext();
        var itemToRemove = context.Foos.SingleOrDefault(foo => foo.BarId == barId && foo.Name == name);

        if (itemToRemove != null)
            context.Foos.Remove(itemToRemove);
        
        return context.SaveChanges();
    }
}

DependencyInjection 注册:

Host = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder()
       .ConfigureServices((_, services) => 
       {
           services
               .AddDbContextFactory<MyDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("MyDbContext")))
               .AddSingleton<SafeRemover>();
       }).Build();

我想对该方法从数据库上下文中的给定集合中删除或不删除某些实体进行单元测试。

尝试 1 InMemoryDatabase + 模拟:

private static readonly object[] _safeRemoveSource = 
{
    new TestCaseData("Foo1", 1, new List<Foo>()).SetName("SafeRemove_FooExists_FooRemoved"),
    new TestCaseData("Foo2", 1, new List<Foo> { new Foo { Name = "Foo1", BarId = 1 } }).SetName("SafeRemove_FooWithDifferentNameExists_FooNotRemoved"),
    new TestCaseData("Foo1", 2, new List<Foo> { new Foo { Name = "Foo1", BarId = 1 } }).SetName("SafeRemove_FooWithDifferentBarIdExists_FooNotRemoved"),
}    

[TestCaseSource(nameof(_safeRemoveSource))]
public void SafeRemoveTest(string name, int barId, IList<Foo> expectedFoos)
{
    var options = new DbContextOptionsBuilder<MyDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;  

    var contextMock = new Mock<MyDbContext>(options) {CallBase = true};
    contextMock.Setup(context => context.PreSaveActions());

    var contextFactoryMock = new Mock<IDbContextFactory<MyDbContext>>();
    contextFactoryMock.Setup(factory => factory.CreateDbContext()).Returns(contextMock.Object);

    var safeRemover = new SafeRemover(contextFactoryMock.Object);
    
    safeRemover.SafeRemove(name, barId);
    var actualFoos = contextMock.Object.Foos.ToList();

    Assert.AreEqual(expectedFoos.Count(), actualFoos.Count());
    for (var i = 0; i < expectedFoos.Count(); i++)
        Assert.That(expectedFoos[i].Name.Equals(actualFoos[i].Name) && expectedFoos[i].BarId == actualFoos[i].BarId);
}

当我使用 InMemoryDatabase 时,我无法在调用 saveRemover.SafeRemove(name, barId) 后检查 contextMock.Object.Foos.ToList() 的值,因为 contextMock.Object 已经被释放:

System.ObjectDisposedException: Cannot access a disposed object. A common cause of this error is disposing a context that was resolved from dependency injection and then later trying to use the same context instance elsewhere in your application. This may occur is you are calling Dispose() on the context, or wrapping the context in a using statement. If you are using dependency injection, you should let the dependency injection container take care of disposing context instances. Object name: 'Context'.

尝试 2 模拟 dbset 和回调:

private static readonly object[] _safeRemoveSource = 
{
    new TestCaseData("Foo1", 1, new List<Foo>()).SetName("SafeRemove_FooExists_FooRemoved"),
    new TestCaseData("Foo2", 1, new List<Foo> { new Foo { Name = "Foo1", BarId = 1 } }).SetName("SafeRemove_FooWithDifferentNameExists_FooNotRemoved"),
    new TestCaseData("Foo1", 2, new List<Foo> { new Foo { Name = "Foo1", BarId = 1 } }).SetName("SafeRemove_FooWithDifferentBarIdExists_FooNotRemoved"),
}    

[TestCaseSource(nameof(_safeRemoveSource))]
public void SafeRemoveTest(string name, int barId, IList<Foo> expectedFoos)
{
    var actualFoos = new List<Foo> { new Foo { Name = "Foo1", BarId = 1 } };
    var options = new DbContextOptionsBuilder<MyDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;  

    var foosMock = new Mock<DbSet<Foo>> {CallBase = true};
    foosMock.Setup(set => set.Remove(It.IsAny<Foo>())).Callback<Foo>(foo => actualFoos.Remove(foo));

    var contextMock = new Mock<MyDbContext>(options) {CallBase = true};
    contextMock.Setup(context => context.PreSaveActions());
    contextMock.Setup(context => context.Foos).Returns(foosMock.Object);

    var contextFactoryMock = new Mock<IDbContextFactory<MyDbContext>>();
    contextFactoryMock.Setup(factory => factory.CreateDbContext()).Returns(contextMock.Object);

    var safeRemover = new SafeRemover(contextFactoryMock.Object);
    
    safeRemover.SafeRemove(name, barId);

    Assert.AreEqual(expectedFoos.Count(), actualFoos.Count());
    for (var i = 0; i < expectedFoos.Count(); i++)
        Assert.That(expectedFoos[i].Name.Equals(actualFoos[i].Name) && expectedFoos[i].BarId == actualFoos[i].BarId);
}

一切似乎都按预期工作,而不是一件事......我在上面的模拟配置中的 SingleOrDefault 方法上得到 NotSupportedException:

System.NotSupportedException : Specified method is not supported.

尝试 3(成功)

我回到尝试 1 并删除了 using 子句。现在它似乎按预期工作,但我这样做安全吗? DI container 会聪明到完全留给他吗? 我在文档中找不到任何相关信息,而且我不擅长分析以全面检查它。

@编辑:

我使用 InMemoryDatabase 派生 TestMyDbContext class。仍然没有成功。我在以下行中得到 ObjectDisposedExceptionvar actualFoos = context.Foos.ToList();

TestMyDbContext:

public class TestMyDbContext : MyDbContext
{
    public override void PreSaveActions() {}
}

单元测试:

private static readonly object[] _safeRemoveSource = 
{
    new TestCaseData("Foo1", 1, new List<Foo>()).SetName("SafeRemove_FooExists_FooRemoved"),
    new TestCaseData("Foo2", 1, new List<Foo> { new Foo { Name = "Foo1", BarId = 1 } }).SetName("SafeRemove_FooWithDifferentNameExists_FooNotRemoved"),
    new TestCaseData("Foo1", 2, new List<Foo> { new Foo { Name = "Foo1", BarId = 1 } }).SetName("SafeRemove_FooWithDifferentBarIdExists_FooNotRemoved"),
}    

[TestCaseSource(nameof(_safeRemoveSource))]
public void SafeRemoveTest(string name, int barId, IList<Foo> expectedFoos)
{
    var initialFoos = new List<Foo> { new Foo { Name = "Foo1", BarId = 1 } };
    var options = new DbContextOptionsBuilder<MyDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;  

    using var context = new TestMyDbContext(options);
    context.Foos.AddRange(actualFoos);
    context.SaveChanges();

    var contextFactoryMock = new Mock<IDbContextFactory<MyDbContext>>();
    contextFactoryMock.Setup(factory => factory.CreateDbContext()).Returns(context);

    var safeRemover = new SafeRemover(contextFactoryMock.Object);
    
    safeRemover.SafeRemove(name, barId);
    var actualFoos = context.Foos.ToList();

    Assert.AreEqual(expectedFoos.Count(), actualFoos.Count());
    for (var i = 0; i < expectedFoos.Count(); i++)
        Assert.That(expectedFoos[i].Name.Equals(actualFoos[i].Name) && expectedFoos[i].BarId == actualFoos[i].BarId);
}

我在这个帖子下得到了一位 NUnit 合作者的回答: https://github.com/nunit/nunit/issues/4090

长话短说 - 确保每次需要时都创建新的 DbContext 实例,并重复使用“选项”来指出它们应该在哪个 InMemory 数据库上运行:

private static readonly object[] _safeRemoveSource = 
{
    new TestCaseData("Foo1", 1, new List<Foo>()).SetName("SafeRemove_FooExists_FooRemoved"),
    new TestCaseData("Foo2", 1, new List<Foo> { new Foo { Name = "Foo1", BarId = 1 } }).SetName("SafeRemove_FooWithDifferentNameExists_FooNotRemoved"),
    new TestCaseData("Foo1", 2, new List<Foo> { new Foo { Name = "Foo1", BarId = 1 } }).SetName("SafeRemove_FooWithDifferentBarIdExists_FooNotRemoved"),
}    

[TestCaseSource(nameof(_safeRemoveSource))]
public void SafeRemoveTest(string name, int barId, IList<Foo> expectedFoos)
{
    var initialFoos = new List<Foo> { new Foo { Name = "Foo1", BarId = 1 } };
    var options = new DbContextOptionsBuilder<MyDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;  

    using var context = new TestMyDbContext(options);
    context.Foos.AddRange(actualFoos);
    context.SaveChanges();

    var contextFactoryMock = new Mock<IDbContextFactory<MyDbContext>>();
    contextFactoryMock.Setup(factory => factory.CreateDbContext()).Returns(new TestMyDbContext(options));

    var safeRemover = new SafeRemover(contextFactoryMock.Object);

    safeRemover.SafeRemove(name, barId);
    var actualFoos = new TestMyDbContext(options).Foos.ToList();

    Assert.AreEqual(expectedFoos.Count(), actualFoos.Count());
    for (var i = 0; i < expectedFoos.Count(); i++)
        Assert.That(expectedFoos[i].Name.Equals(actualFoos[i].Name) && expectedFoos[i].BarId == actualFoos[i].BarId);
}