【问题标题】:c# unit testing method that calls EF Core extension method调用EF Core扩展方法的c#单元测试方法
【发布时间】:2023-03-06 12:48:01
【问题描述】:

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

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);

}

我知道我的 Assert 仍需要更多检查。

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 我喜欢这个 linq foreach 循环的语法,因为它使代码更短。我知道有些人不喜欢它,因为它会使您的代码看起来不那么可读。
  • 它也大大降低了代码的可测试性,因此由于您“任意”选择的实现,您现在面临测试问题。我会告诉你来自 Eric Lippert 的 this article,这可能会(或不会)改变你对 foreach 的看法。
  • @Spotted 我实际上可能同意你和 Eric Lippert 的观点。但我仍然认为找出如何正确测试此方法很有趣。
  • 好的,我会为此写一个答案。

标签: c# unit-testing asynchronous mocking moq


【解决方案1】:

免责声明:我不会按照 OP 的要求提供直接解决方案,因为我相信这个问题是 XY problem。相反,我将把我的答案集中在为什么这段代码很难测试,因为是的,超过 30 行“安排”来测试 2 行代码意味着出现了非常错误的问题。

简答

这种方法不需要测试,至少在单元级别。

长答案

当前实现的问题令人担忧。

第一行:_dbContext.Settings.Where(s =&gt; s.SoftwareId == softwareId).ForEachAsync(s =&gt; s.IsDeleted = true); 包含业务逻辑(s.softwareId == softwareId, s.IsDeleted = true),还包含 EF 逻辑(_dbContext, ForEachAsync)。

第二行:_dbContext.SaveChanges(); 只包含 EF 逻辑

关键是:这种方法(混合了关注点)很难在单元级别进行测试。因此,您需要模拟和几十个“排列”代码来测试仅 2 行实现!

基于此状态,您有 2 个选项:

  1. 您删除了您的测试,因为该方法主要包含对测试无用的 EF 逻辑(有关详细信息,请参阅this great series of articles
  2. 您提取业务逻辑并编写一个真实(且简单)的单元测试

在第二种情况下,我会实现这个逻辑,这样我就可以编写这样的测试:

[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 循环。我将与我的同事讨论您的解决方案
  • 如果你想使用 ForEachAsync(),你会怎么做?
  • @Jeroen 我无论如何都不会使用它,因为在您需要调用 _dbContext.SaveChanges() 之后,这会破坏调用 ForEachAsync() 的全部目的。
【解决方案2】:

您根本不必模拟 ForEachAsyncForEachAsync 返回 Task 并且正在异步执行这是您问题的根源。

使用asyncawait keywards 解决您的问题:

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&lt;TEntity&gt;。以下是链接中的实现:

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; } 
    } 
} 

现在在Setup 中,您必须像这样使用它:

mockSet.As<IQueryable<Setting>>() 
       .Setup(m => m.Provider) 
       .Returns(new TestDbAsyncQueryProvider<Setting>(data.Provider)); 

【讨论】:

  • 你还必须让你的单元测试使用 async/await 而不是“public void”,而是 public async Task
  • @mark_h 是的,如果此方法返回 Task 而不是 void,那么您是对的。看来 OP 正在寻找“ForEachAsync”问题的解决方案……+1 来自我 :)
  • 你说的很对,这个方法必须返回一个Task。我会向 OP 指出,这样做被认为是最佳实践(尽可能避免异步无效)。如果这确实需要异步无效,那么测试也将是异步无效的。
  • 当我删除对 foreachAsync 的模拟时,我收到错误“源 IQueryable 没有实现 IAsyncEnumerable
  • @Jeroen 我编辑了我的答案来回答你的新问题
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2021-09-19
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2022-11-09
  • 1970-01-01
相关资源
最近更新 更多