C#单元测试调用EF Core扩展方法的方法

3

我一直在尝试对这个简单的方法进行单元测试:

public void DeleteAllSettingsLinkedToSoftware(Guid softwareId)
{
    _dbContext.Settings.Where(s => s.SoftwareId == softwareId).ForEachAsync(s => s.IsDeleted = true);
    _dbContext.SaveChanges();
}

然而,当ForEachAsync()方法被调用时,我很难对这个方法进行单元测试。

到目前为止,我已经使用Moq来设置dbContext,以便在执行Where()时返回正确的设置。 我的尝试:

Setup(m => m.ForEachAsync(It.IsAny<Action<Setting>>(), CancellationToken.None));

我的问题是:如何对 ForEachAsync() 方法调用进行单元测试?
我在网上看到有些人说对于某些静态方法来说,单元测试是不可能的。如果这种情况在我的情况下是真的,我很好奇有没有其他测试方法可以尽可能地测试该方法。 编辑 我的完整测试代码:
[TestMethod]
public async Task DeleteAllSettingsLinkedToSoftware_Success()
{
    //Arrange
    var settings = new List<Setting>
    {
        new Setting
        {
            SoftwareId = SoftwareId1
        },
        new Setting
        {
            SoftwareId = SoftwareId1
        },
        new Setting
        {
            SoftwareId = SoftwareId1
        },
        new Setting
        {
            SoftwareId = SoftwareId2
        }
    }.AsQueryable();

    var queryableMockDbSet = GetQueryableMockDbSet(settings.ToList());
    queryableMockDbSet.As<IQueryable<Setting>>()
        .Setup(m => m.Provider)
        .Returns(new TestDbAsyncQueryProvider<Setting>(settings.Provider));

    DbContext.Setup(m => m.Settings).Returns(queryableMockDbSet.Object);

    _settingData = new SettingData(DbContext.Object, SettingDataLoggerMock.Object);

    //Act
    var result = await _settingData.DeleteAllSettingsLinkedToSoftwareAsync(SoftwareId1);

    //Assert
    DbContext.Verify(m => m.Settings);
    DbContext.Verify(m => m.SaveChanges());
    Assert.AreEqual(4, DbContext.Object.Settings.Count());
    Assert.AreEqual(SoftwareId2, DbContext.Object.Settings.First().SoftwareId);

}

我知道我的断言还需要更多的检查。
GetQueryableMockDbSet方法:
public static Mock<DbSet<T>> GetQueryableMockDbSet<T>(List<T> sourceList) where T : class
{
    var queryable = sourceList.AsQueryable();

    var dbSet = new Mock<DbSet<T>>();
    dbSet.As<IQueryable<T>>().Setup(m => m.Provider).Returns(queryable.Provider);
    dbSet.As<IQueryable<T>>().Setup(m => m.Expression).Returns(queryable.Expression);
    dbSet.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(queryable.ElementType);
    dbSet.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(() => queryable.GetEnumerator());
    dbSet.Setup(d => d.Add(It.IsAny<T>())).Callback<T>(s => sourceList.Add(s));
    dbSet.Setup(d => d.AddRange(It.IsAny<IEnumerable<T>>())).Callback<IEnumerable<T>>(sourceList.AddRange);
    dbSet.Setup(d => d.Remove(It.IsAny<T>())).Callback<T>(s => sourceList.Remove(s));
    dbSet.Setup(d => d.RemoveRange(It.IsAny<IEnumerable<T>>())).Callback<IEnumerable<T>>(s =>
    {
        foreach (var t in s.ToList())
        {
            sourceList.Remove(t);
        }
    });

    return dbSet;
}

你为什么选择不使用普通的foreach循环来实现删除? - Spotted
@Spotted 我喜欢这个linq foreach循环的语法,因为它可以让代码更短。我知道有些人不喜欢它,因为它可能会让你的代码看起来不太易读。 - Jeroen
@Spotted,我可能真的同意你和Eric Lippert的观点。但我仍然认为找出如何正确测试这种方法很有趣。 - Jeroen
好的,我会为此写一个答案。 - Spotted
我创造的东西 - Jeroen
显示剩余2条评论
2个回答

4
你根本不需要模拟 ForEachAsyncForEachAsync 返回 Task 并异步执行,这是你的问题来源。
使用 asyncawait 关键字来解决你的问题:
public async void DeleteAllSettingsLinkedToSoftware(Guid softwareId)
{
    await _dbContext.Settings.Where(s => s.SoftwareId == softwareId)
                             .ForEachAsync(s => s.IsDeleted = true);
    _dbContext.SaveChanges();  
}

编辑:

新的异常出现是因为提供的Provider不是IDbAsyncQueryProvider

Microsoft实现了这个接口的通用版本:TestDbAsyncQueryProvider<TEntity>。以下是来自链接的实现:

internal class TestDbAsyncQueryProvider<TEntity> : IDbAsyncQueryProvider 
{ 
    private readonly IQueryProvider _inner; 

    internal TestDbAsyncQueryProvider(IQueryProvider inner) 
    { 
        _inner = inner; 
    } 

    public IQueryable CreateQuery(Expression expression) 
    { 
        return new TestDbAsyncEnumerable<TEntity>(expression); 
    } 

    public IQueryable<TElement> CreateQuery<TElement>(Expression expression) 
    { 
        return new TestDbAsyncEnumerable<TElement>(expression); 
    } 

    public object Execute(Expression expression) 
    { 
        return _inner.Execute(expression); 
    } 

    public TResult Execute<TResult>(Expression expression) 
    { 
        return _inner.Execute<TResult>(expression); 
    } 

    public Task<object> ExecuteAsync(Expression expression, CancellationToken cancellationToken) 
    { 
        return Task.FromResult(Execute(expression)); 
    } 

    public Task<TResult> ExecuteAsync<TResult>(Expression expression, CancellationToken cancellationToken) 
    { 
        return Task.FromResult(Execute<TResult>(expression)); 
    } 
} 

internal class TestDbAsyncEnumerable<T> : EnumerableQuery<T>, IDbAsyncEnumerable<T>, IQueryable<T> 
{ 
    public TestDbAsyncEnumerable(IEnumerable<T> enumerable) 
        : base(enumerable) 
    { } 

    public TestDbAsyncEnumerable(Expression expression) 
        : base(expression) 
    { } 

    public IDbAsyncEnumerator<T> GetAsyncEnumerator() 
    { 
        return new TestDbAsyncEnumerator<T>(this.AsEnumerable().GetEnumerator()); 
    } 

    IDbAsyncEnumerator IDbAsyncEnumerable.GetAsyncEnumerator() 
    { 
        return GetAsyncEnumerator(); 
    } 

    IQueryProvider IQueryable.Provider 
    { 
        get { return new TestDbAsyncQueryProvider<T>(this); } 
    } 
} 

internal class TestDbAsyncEnumerator<T> : IDbAsyncEnumerator<T> 
{ 
    private readonly IEnumerator<T> _inner; 

    public TestDbAsyncEnumerator(IEnumerator<T> inner) 
    { 
        _inner = inner; 
    } 

    public void Dispose() 
    { 
        _inner.Dispose(); 
    } 

    public Task<bool> MoveNextAsync(CancellationToken cancellationToken) 
    { 
        return Task.FromResult(_inner.MoveNext()); 
    } 

    public T Current 
    { 
        get { return _inner.Current; } 
    } 

    object IDbAsyncEnumerator.Current 
    { 
        get { return Current; } 
    } 
} 

现在在 设置 中你需要像这样使用它:
mockSet.As<IQueryable<Setting>>() 
       .Setup(m => m.Provider) 
       .Returns(new TestDbAsyncQueryProvider<Setting>(data.Provider)); 

1
你还需要让你的单元测试使用async/await,而不是"public void",它应该是public async Task。 - mark_h
1
@mark_h 是的,如果这个方法返回一个Task而不是void,那么你是正确的。看起来OP正在寻找“ForEachAsync”问题的解决方案...我给你点赞 :) - Old Fox
1
你说得很对,这个方法必须返回一个Task。我要指出给OP的建议是尽可能避免使用async void(异步无返回值),因为这是最佳实践。如果确实需要使用async void,则测试也应该是async void。 - mark_h
当我移除foreachAsync的模拟时,会出现错误:“源IQueryable未实现IAsyncEnumerable <Setting>”。 - Jeroen
1
@Jeroen 我编辑了我的答案来回答你的新问题。 - Old Fox
显示剩余5条评论

2
免责声明:我不会提供直接的解决方案,因为请求者要求如此,因为我认为这个问题是一个XY问题。相反,我将把重点放在为什么这段代码很难测试上,因为是的,超过30行"安排"来测试2行代码意味着出了大问题。
简短的回答:
这个方法不需要被测试,至少不需要在单元级别测试。
长篇回答:
当前实现的问题是关注点混合。
第一行:_dbContext.Settings.Where(s => s.SoftwareId == softwareId).ForEachAsync(s => s.IsDeleted = true);包含业务逻辑(s.softwareId == softwareId,s.IsDeleted = true),但也包含EF逻辑(_dbContext,ForEachAsync)。
第二行:_dbContext.SaveChanges();只包含EF逻辑。
问题在于:这样的方法(混合关注点)很难在单元级别进行测试。因此,你需要模拟和几十行的"安排"代码才能测试仅有的2行实现!
基于这个观察结果,你有两个选择:
您删除了测试,因为该方法主要包含EF逻辑,这些逻辑对于测试是无用的(有关更多详细信息,请参见这一系列精彩文章)。
您提取业务逻辑并编写一个真正的(简单)单元测试。
在第二种情况下,我将实现此逻辑,以便能够编写以下测试:
[Test]
public void ItShouldMarkCorrespondingSettingsAsDeleted()
{
    var setting1 = new Setting(guid1);
    var setting2 = new Setting(guid2);
    var settings = new Settings(new[] { setting1, setting2 });

    settings.DeleteAllSettingsLinkedToSoftware(guid1);

    Assert.That(setting1.IsDeleted, Is.True);
    Assert.That(setting1.IsDeleted, Is.False);
}

易于编写,易于阅读。

现在的实施怎么样?

public interface ISettings
{
    void DeleteAllSettingsLinkedToSoftware(Guid softwareId);
}

public sealed class Settings : ISettings
{
    private readonly IEnumerable<Setting> _settings;
    public Settings(IEnumerable<Setting> settings) => _settings = settings;
    public override void DeleteAllSettingsLinkedToSoftware(Guid softwareGuid)
    {
        foreach(var setting in _settings.Where(s => s.SoftwareId == softwareId))
        {
            setting.IsDeleted = true;
        }
    }
}

public sealed class EFSettings : ISettings
{
    private readonly ISettings _source;
    private readonly DBContext _dbContext;
    public EFSettings(DBContext dbContext)
    {
        _dbContext = dbContext;
        _source = new Settings(_dbContext.Settings);
    }
    public override void DeleteAllSettingsLinkedToSoftware(Guid softwareGuid)
    {
        _source.DeleteAllSettingsLinkedToSoftware(softwareGuid);
        _dbContext.SaveChanges();
    }
}

使用这样的解决方案,每个关注点都被分离出来,从而可以实现以下目标:
  • 摆脱模拟对象
  • 真正地对业务逻辑代码进行单元测试
  • 提高可维护性和可读性

谢谢反馈。目前我已经将该方法转换为使用普通的foreach循环。我会与我的同事讨论你的解决方案。 - Jeroen
如果您想要使用ForEachAsync(),您会如何处理这个问题? - Jeroen
@Jeroen 我不会使用它,因为紧接着你需要调用 _dbContext.SaveChanges(),这就打败了调用 ForEachAsync() 的初衷。 - Spotted

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