使用通用存储库、UnitOfWork、NUnit 和 Moq 进行单元测试

Unit testing with Generic repository, UnitOfWork, NUnit and Moq

Whosebug 上有很多与此主题类似的文章,但我没有找到专门针对我的情况的文章。

我正在尝试使用 Moq 和 NUnit 对实现了工作单元模式的通用存储库进行单元测试。

这是通用存储库的代码:

public class GenericRepository<TContext, TEntity> : IGenericRepository<TEntity>
    where TEntity : class 
    where TContext : DbContext
{
    protected readonly TContext Context;

    public GenericRepository(TContext context)
    {
        Context = context;
    }

    public async Task<TEntity> Create(TEntity entity)
    {
        if (entity == null)
        {
            throw new ArgumentNullException();
        }

        await Task.Run(() => Context.Set<TEntity>().Add(entity)).ConfigureAwait(false);
        await Context.SaveChangesAsync().ConfigureAwait(false);
        return entity;
    }

    public async Task<IEnumerable<TEntity>> CreateRange(IEnumerable<TEntity> entities)
    {
        if (entities == null)
        {
            throw new ArgumentNullException();
        }

        await Task.Run(() => Context.Set<TEntity>().AddRange(entities)).ConfigureAwait(false);
        await Context.SaveChangesAsync().ConfigureAwait(false);
        return entities;
    }

    public async Task<TEntity> Retrieve(object entityId)
    {
        return await Context.Set<TEntity>().FindAsync(entityId).ConfigureAwait(false);
    }

    public async Task<IEnumerable<TEntity>> Retrieve(Expression<Func<TEntity, bool>> predicate)
    {
        return await Task.Run(() => Context.Set<TEntity>().Where(predicate)).ConfigureAwait(false);
    }

    public async Task<TEntity> Update(TEntity entity)
    {
        if (entity == null)
        {
            throw new ArgumentNullException();
        }

        await Task.Run(() =>
        {
            Context.Set<TEntity>().AddOrUpdate(entity);
        }).ConfigureAwait(false);
        await Context.SaveChangesAsync().ConfigureAwait(false);
        return entity;
    }

    public async Task<IEnumerable<TEntity>> UpdateRange(IEnumerable<TEntity> entities)
    {
        var updateRange = entities as TEntity[] ?? entities.ToArray();
        if (updateRange.Any(entity => entity == null))
        {
            throw new ArgumentNullException();
        }

        foreach (var entity in updateRange)
        {
            await Task.Run(() =>
            {
                Context.Set<TEntity>().AddOrUpdate(entity);
            }).ConfigureAwait(false);
        }
        await Context.SaveChangesAsync().ConfigureAwait(false);
        return updateRange;
    }

    public async Task<TEntity> SafeDelete(TEntity entity)
    {
        if (entity == null)
        {
            throw new ArgumentNullException();
        }

        var propertySet = TrySetProperty(entity, "Is_deleted", true);
        if (!propertySet)
        {
            throw new InvalidOperationException();
        }

        await Task.Run(() =>
        {
            Context.Set<TEntity>().AddOrUpdate(entity);
        }).ConfigureAwait(false);
        await Context.SaveChangesAsync().ConfigureAwait(false);
        return entity;
    }

    public async Task<IEnumerable<TEntity>> SafeDeleteRange(IEnumerable<TEntity> entities)
    {
        var safeDeleteRange = entities as TEntity[] ?? entities.ToArray();
        if (safeDeleteRange.Any(entity => entity == null))
        {
            throw new ArgumentNullException();
        }

        var propertySet = false;
        foreach (var entity in safeDeleteRange)
        {
            propertySet = TrySetProperty(entity, "Is_deleted", true);
        }
        if (!propertySet)
        {
            throw new InvalidOperationException();
        }

        foreach (var entity in safeDeleteRange)
        {
            await Task.Run(() =>
            {
                Context.Set<TEntity>().AddOrUpdate(entity);
            }).ConfigureAwait(false);
        }
        await Context.SaveChangesAsync().ConfigureAwait(false);
        return safeDeleteRange;
    }

    public async Task<TEntity> Delete(TEntity entity)
    {
        if (entity == null)
        {
            throw new ArgumentNullException();
        }

        await Task.Run(() => Context.Set<TEntity>().Remove(entity));
        await Context.SaveChangesAsync().ConfigureAwait(false);
        return entity;
    }

    public async Task<IEnumerable<TEntity>> DeleteRange(IEnumerable<TEntity> entities)
    {
        if (entities == null)
        {
            throw new ArgumentNullException();
        }

        await Task.Run(() => Context.Set<TEntity>().RemoveRange(entities));
        await Context.SaveChangesAsync().ConfigureAwait(false);
        return entities;
    }

    private static bool TrySetProperty(object obj, string property, object value)
    {
        var prop = obj.GetType().GetProperty(property, BindingFlags.Public | BindingFlags.Instance);
        if (prop == null || !prop.CanWrite)
        {
            return false;
        }
        prop.SetValue(obj, value, null);
        return true;
    }
}

还有一个单元测试class:

[TestFixture]
public class GenericRepositoryUnitTests
{
    private Mock<IDomainRepository> _repo;
    private Mock<IUnitOfWork> _unitOfWork;
    private Mock<IUnitOfWorkFactory<DmsDbContext>> _unitOfWorkFactory;
    private Mock<DmsDbContext> _mockContext;
    private Mock<DbSet<Domain>> _mockSet;
    private List<Domain> _domains;
    private User _user;

    [SetUp]
    public void SetUp()
    {
        var userId = Guid.NewGuid();
        _user = new User
        {
            UsersId = userId,
            Username = "Test",
            Password = "Test",
            First_name = "Test",
            Last_name = "Test",
            Img = "Test",
            Permissions = "Test",
            Is_deleted = false
        };
        _domains = new List<Domain>
        {
            new Domain
            {
                DomainId = Guid.NewGuid(),
                Name = "Test",
                Url = "Test",
                Is_deleted = false,
                Users = new List<User>
                {
                    _user,
                    new User
                    {
                        UsersId = userId,
                        Username = "Test",
                        Password = "Test",
                        First_name = "Test",
                        Last_name = "Test",
                        Img = "Test",
                        Permissions = "Test",
                        Is_deleted = false
                    },
                    new User
                    {
                        UsersId = Guid.NewGuid(),
                        Username = "Test",
                        Password = "Test",
                        First_name = "Test",
                        Last_name = "Test",
                        Img = "Test",
                        Permissions = "Test",
                        Is_deleted = false
                    }
                }
            },
            new Domain
            {
                DomainId = Guid.NewGuid(),
                Name = "Test",
                Url = "Test",
                Is_deleted = false,
                Users = new List<User>
                {
                    _user,
                    new User
                    {
                        UsersId = userId,
                        Username = "Test",
                        Password = "Test",
                        First_name = "Test",
                        Last_name = "Test",
                        Img = "Test",
                        Permissions = "Test",
                        Is_deleted = false
                    },
                    new User
                    {
                        UsersId = Guid.NewGuid(),
                        Username = "Test",
                        Password = "Test",
                        First_name = "Test",
                        Last_name = "Test",
                        Img = "Test",
                        Permissions = "Test",
                        Is_deleted = false
                    }
                }
            },
            new Domain
            {
                DomainId = Guid.NewGuid(),
                Name = "Test",
                Url = "Test",
                Is_deleted = false,
                Users = new List<User>
                {
                    _user,
                    new User
                    {
                        UsersId = userId,
                        Username = "Test",
                        Password = "Test",
                        First_name = "Test",
                        Last_name = "Test",
                        Img = "Test",
                        Permissions = "Test",
                        Is_deleted = false
                    },
                    new User
                    {
                        UsersId = Guid.NewGuid(),
                        Username = "Test",
                        Password = "Test",
                        First_name = "Test",
                        Last_name = "Test",
                        Img = "Test",
                        Permissions = "Test",
                        Is_deleted = false
                    }
                }
            }
        };
        
        _mockSet = new Mock<DbSet<Domain>>();
        _mockSet.Setup(m => m.Add(It.IsAny<Domain>())).Returns(new Domain());

        _mockContext = new Mock<DmsDbContext>();
        _mockContext.Setup(m => m.Set<Domain>()).Returns(_mockSet.Object);

        _repo = new Mock<IDomainRepository>{CallBase = true};
        _repo.Setup(r => r.Context).Returns(_mockContext.Object);
        _repo.Setup(r => r.Create(It.IsAny<Domain>())).Returns(Task.FromResult(new Domain()));
        _repo.Setup(r => r.CreateRange(It.IsAny<IEnumerable<Domain>>()))
            .Returns(Task.FromResult(new List<Domain>().AsEnumerable()));
        _repo.Setup(r => r.Retrieve(It.IsAny<Guid>())).Returns(Task.FromResult(_domains.AsEnumerable().First()));
        _repo.Setup(r => r.Retrieve(pre => pre.Is_deleted == false))
            .Returns(Task.FromResult(_domains.AsEnumerable()));
        _repo.Setup(r => r.Update(It.IsAny<Domain>())).Returns(Task.FromResult(new Domain()));
        _repo.Setup(r => r.Delete(It.IsAny<Domain>())).Returns(Task.FromResult(new Domain()));
        _repo.Setup(r => r.DeleteRange(It.IsAny<IEnumerable<Domain>>()))
            .Returns(Task.FromResult(new List<Domain>().AsEnumerable()));

        _unitOfWork = new Mock<IUnitOfWork>();
        _unitOfWork.Setup(u => u.Domains).Returns(_repo.Object);

        _unitOfWorkFactory = new Mock<IUnitOfWorkFactory<DmsDbContext>>();
        _unitOfWorkFactory.Setup(u => u.Create(It.IsAny<DmsDbContext>())).Returns(_unitOfWork.Object);
    }

    [Test]
    public async Task Create_NewDomainObject_AddToDatabase()
    {
        // Arrange
        var domain = new Domain
        {
            DomainId = Guid.NewGuid(),
            Name = "Test",
            Url = "Test",
            Is_deleted = false,
            Users = new List<User> { _user }
        };

        // Act
        using var unitOfWork = _unitOfWorkFactory.Object.Create(_mockContext.Object);
        await unitOfWork.Domains.Create(domain);

        // Assert
        _repo.Verify(m => m.Context.Set<Domain>().Add(It.IsAny<Domain>()), Times.Once());
    }

    [Test]
    public void Retrieve_TestDomainObjectById_ReturnsValue()
    {
        //Arrange
        var domainId = Guid.NewGuid();

        using var unitOfWork = _unitOfWorkFactory.Object.Create(_mockContext.Object);
        // Act
        var domain = unitOfWork.Domains.Retrieve(domainId);

        // Assert
        Assert.NotNull(domain);
    }

我无法使这些测试正常工作,也不知道出了什么问题。

有人可以向我解释一下如何使用上面提到的一组工具正确测试通用存储库吗?

提前致谢:)

扩展 Hayden 的评论:您正在测试错误的东西。使用存储库模式(尽管我建议 远离 通用存储库模式)是关于将业务逻辑与其对域的依赖性隔离开来,以便您可以单独对业务逻辑进行单元测试。

作为一个简单的例子:如果我有一个控制器或服务有一个方法来执行更新,从域中检索一组实体,执行一些验证,应用一些更改,并保存这些实体:

如果我直接使用 DbContext:

public void SomeAction(ViewModel viewModel)
{
    using (var context = new AppDbContext())
    {
        var someEntity = context.Entities.Include(x=> x.Children).Include(x=> x.Category).Single(x => x.Id == viewModel.Id);

        if(!x.Category.IsLocked)
        {
            // copy values from view model to entity...

            context.SaveChanges();
        }
    }
}

这里的问题是测试 SomeAction 我可以定义一个 ViewModel,但是让它实例化一个 DbContext 意味着我对数据源有很强的依赖性,该数据源将以可预测的方式进行测试。我可能希望测试涵盖以下情况下的行为:未找到实体、实体具有锁定类别、实体具有解锁类别等。

因此我们引入了存储库模式来抽象存储库和包含的工作单元。该方法更多地朝着类似的方向变化:

public void SomeAction(ViewModel viewModel)
{
    using (var unitOfWork = UnitOfWorkFactory.Create())
    {
        var someEntity = ActionRepository.GetEntityById(viewModel.Id)
            .Include(x=> x.Children).Include(x=> x.Category)
            .Single();

        if(!x.Category.IsLocked)
        {
            // copy values from view model to entity...

            unitOfWork.SaveChanges();
        }
    }
}

UnitOfWorkFactory 和 ActionRepository 是我的控制器的注入依赖项。 ActionRepository 公开了我需要与域交互的方法。在我的示例中,我使用 return IQueryable<TEntity> 的方法,因为它提供了非常薄的抽象来替代。我不测试存储库或工作单元的作用,我模拟工作单元和存储库,以便我可以编写测试来断言我的控制器操作的作用。我的测试模拟可以模拟 returning 没有匹配的实体,或者具有锁定类别的实体,或者针对特定测试场景的完全有效的实体。他们甚至可以断言工作单元上的 SaveChanges 是否应该并且确实被调用。虽然可以说这是在深入测试你的方法的具体实现,这会使你的测试更加脆弱。最终,您的目标是为特定场景的特定输出编写测试,模拟使您能够轻松设置这些场景。