超越Entity Framework BeginTransaction

10

我正在学习单元测试中的模拟,并尝试将单元测试过程集成到我的项目中。因此,我已经浏览了几个教程并重构了我的代码以支持模拟。然而,由于我尝试测试的DB方法使用了事务,所以我无法通过测试。但是当我创建一个事务时,我遇到了以下错误:

The underlying provider failed on Open.

如果没有事务,一切都正常运行。

我目前的代码如下:

[TestMethod]
public void Test1()
{
    var mockSet = GetDbMock();
    var mockContext = new Mock<DataContext>();
    mockContext.Setup(m => m.Repository).Returns(mockSet.Object);

    var service = new MyService(mockContext.Object);
    service.SaveRepository(GetRepositoryData().First());
    mockSet.Verify(m => m.Remove(It.IsAny<Repository>()), Times.Once());
    mockSet.Verify(m => m.Add(It.IsAny<Repository>()), Times.Once());
    mockContext.Verify(m => m.SaveChanges(), Times.Once());
}

// gets the DbSet mock with one existing item
private Mock<DbSet<Repository>> GetDbMock()
{
    var data = GetRepositoryData();
    var mockSet = new Mock<DbSet<Repository>>();

    mockSet.As<IQueryable<Repository>>().Setup(m => m.Provider).Returns(data.Provider);
    // skipped for brevity
    return mockSet;
}

待测试代码:

private readonly DataContext _context;
public MyService(DataContext ctx)
{
    _context = ctx;
}

public void SaveRepositories(Repository repo)
{
    using (_context)
    {
        // Here the transaction creation fails
        using (var transaction = _context.Database.BeginTransaction())
        {
            DeleteExistingEntries(repo.Id);
            AddRepositories(repo);
            _context.SaveChanges();
            transaction.Commit();
        }
    }
}

我也试图模拟交易部分:

var mockTransaction = new Mock<DbContextTransaction>();
mockContext.Setup(x => x.Database.BeginTransaction()).Returns(mockTransaction.Object);

但是这并不起作用,会失败并显示以下错误:

在非虚拟成员(在 VB 中可以重写)上进行无效的设置:conn => conn.Database.BeginTransaction()

有什么想法解决这个问题吗?

4个回答

5
作为第二个错误信息所说,Moq不能模拟非虚方法或属性,因此这种方法不起作用。我建议使用适配器模式来解决这个问题。想法是创建一个适配器(实现某些接口的包装类),与DataContext进行交互,并通过该接口执行所有数据库活动。然后,您可以模拟接口。
public interface IDataContext {
    DbSet<Repository> Repository { get; }
    DbContextTransaction BeginTransaction();
}

public class DataContextAdapter {
    private readonly DataContext _dataContext;

    public DataContextAdapter(DataContext dataContext) {
        _dataContext = dataContext;
    }

    public DbSet<Repository> Repository { get { return _dataContext.Repository; } }

    public DbContextTransaction BeginTransaction() {
        return _dataContext.Database.BeginTransaction();
    }
}

所有之前直接使用DataContext的代码现在都应该使用一个IDataContext,当程序运行时,它应该是一个DataContextAdapter,但在测试中,你可以轻松地模拟IDataContext。这样也应该会使模拟变得更简单,因为你可以设计IDataContextDataContextAdapter来隐藏一些实际DataContext的复杂性。


这个设计模式很有意义,我会继续努力理解它。虽然我仍然无法使其工作,因为我无法模拟DbContextTransaction,因为它只有内部构造函数,这似乎是Moq的一个问题。不管怎样,测试执行似乎并不真正依赖于它的工作,如果我在没有调试的情况下运行测试,它们会通过,除非我明确检查是否抛出了任何异常。这很好。感谢您的见解,除非有更好的方法出现,否则我将接受这个方案。(y) - Erki M.
你可以在那里使用同样的技巧 - 创建一个 IDbContextTransaction 接口,由 DbContextTransactionAdapter 实现,它包装了一个实际的 DbContextTransaction - 并在测试中模拟 IDbContextTransaction。这有点麻烦,但这个技巧通常会使使用库代码的代码更容易测试,并且与库的耦合度更低。 - Aasmund Eldhuset
最近让我感到惊讶的一件事是当我使用 Mock.Of<MyContext> 时,该模拟对象的 Database 属性会使用 app.config 中的连接信息创建一个具体的数据库对象。因此,任何 Database.ExecuteSql 都应该在该数据库上运行。并且从 BeginTransaction 返回了一个具体的 Transaction 对象。这是如何发生的,即模拟对象如何返回真实的对象? - Rob Kent
1
调试上述代码很明显模拟器必须调用默认构造函数,以初始化真实的DbContext。无论如何,我应该使用一个接口。 - Rob Kent
@RobKent:观察得好! - Aasmund Eldhuset

3

我认为你可以通过创建DatabaseFacade的派生类来轻松模拟交易:

var dbContextMock = new Mock<DbContext>();
dbContextMock
    .SetupGet(x => x.Database)
    .Returns(new MockDatabaseFacade(dbContextMock.Object));

[...]
private class MockDatabaseFacade : DatabaseFacade
{
    public MockDatabaseFacade(DbContext context) : base(context)
    {
    }

    public override Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken = default) =>
        Task.FromResult(Mock.Of<IDbContextTransaction>());

}

编辑:注意,此代码适用于EF Core。实际上使用的是7.0版本。


1

我尝试过包装器/适配器方法,但遇到了一个问题,当你去测试代码时:

using (var transaction = _myAdaptor.BeginTransaction())

您的模拟/伪造仍需要返回一些内容,以便可以执行该行transaction.Commit();。通常情况下,我会将适配器的伪造设置为从BeginTransaction()返回接口(这样我也可以伪造返回的对象),但是BeginTransaction()返回的DbContextTransaction仅实现IDisposable,因此没有接口可以让我访问DbContextTransactionRollbackCommit方法。
此外,DbContextTransaction没有公共构造函数,因此我无法只是新建一个实例来返回(即使我能够,这也不是理想的,因为我无法检查调用提交或回滚事务的情况)。
因此,最终我采取了稍微不同的方法,并创建了一个完全独立的类来管理事务:
using System;
using System.Data.Entity;

public interface IEfTransactionService
{
    IManagedEfTransaction GetManagedEfTransaction();
}

public class EfTransactionService : IEfTransactionService
{
    private readonly IFMDContext _context;

    public EfTransactionService(IFMDContext context)
    {
        _context = context;
    }

    public IManagedEfTransaction GetManagedEfTransaction()
    {
        return new ManagedEfTransaction(_context);
    }
}

public interface IManagedEfTransaction : IDisposable
{
    DbContextTransaction BeginEfTransaction();
    void CommitEfTransaction();
    void RollbackEfTransaction();
}

public class ManagedEfTransaction : IManagedEfTransaction
{
    private readonly IDataContext  _context;
    private DbContextTransaction _transaction;

    public ManagedEfTransaction(IDataContext  context)
    {
        _context = context;
    }

    /// <summary>
    /// Not returning the transaction here because we want to avoid any
    /// external references to it stopping it from being disposed by
    /// the using statement
    /// </summary>
    public void BeginEfTransaction()
    {
        _transaction = _context.Database.BeginTransaction();
    }

    public void CommitEfTransaction()
    {
        if (_transaction == null) throw new Exception("No transaction");

        _transaction.Commit();
        _transaction = null;
    }

    public void RollbackEfTransaction()
    {
        if (_transaction == null) throw new Exception("No transaction");

        _transaction.Rollback();
        _transaction = null;
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (disposing)
        {
            // free managed resources
            if (_transaction != null)
            {
                _transaction.Dispose();
                _transaction = null;
            }
        }
    }
}

然后我将该服务类注入到需要使用事务的任何类中。例如,使用原始问题中的代码:

private readonly DataContext _context;
private readonly IEfTransactionManager _transactionManager;

public MyService(DataContext ctx, IEfTransactionManager transactionManager)
{
    _context = ctx;
    _transactionManager = transactionManager;
}

public void SaveRepositories(Repository repo)
{
    using (_context)
    {
        // Here the transaction creation fails
        using (var managedEfTransaction = _transactionManager.GetManagedEfTransaction())
        {
            try
            {
                managedEfTransaction.BeginEfTransaction();

                DeleteExistingEntries(repo.Id);
                AddRepositories(repo);
                _context.SaveChanges();

                managedEfTransaction.CommitEfTransaction();
            }
            catch (Exception)
            {
                managedEfTransaction.RollbackEfTransaction();
                throw;
            }
        }
    }
}

1

您可以在这里找到一个相当不错的解决方案。

简而言之,您需要为DbContextTransaction创建代理类,并使用它来代替原始类。这样,您就可以模拟您的代理并使用BeginTransaction()测试您的方法。

附:在我上面提供的文章中,作者忘记了在dbContext类中放置的BeginTransaction()方法中添加virtual关键字:

// <summary>
/// When we call begin transaction. Our proxy creates new Database.BeginTransaction and gives DbContextTransaction's control to proxy.
/// We do this for unit test.
/// </summary>
/// <returns>Proxy which controls DbContextTransaction(Ef transaction class)</returns>
public virtual IDbContextTransactionProxy BeginTransaction()
{
   return new DbContextTransactionProxy(this);
}

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