如何单元测试调用EF Core ForEachAsync的C#方法?
ForEachAsync遇到的问题与解决方案 首先,咱们先明确核心问题:ForEachAsync是EF Core的扩展方法,本质是静态方法,Moq这类Mock框架只能拦截实例方法的调用,所以直接Mock它是行不通的。不过没关系,有几种思路可以解决这个问题,咱们一个个说:
一、优先验证业务结果(最推荐的方案)
其实你没必要纠结ForEachAsync有没有被调用——这个方法的核心职责是把指定softwareId的所有Setting标记为IsDeleted,所以直接验证最终的实体状态才是最有意义的测试。
修改你的断言部分,替换成更精准的业务验证:
// 验证目标设置都被标记为已删除 var targetSettings = DbContext.Object.Settings.Where(s => s.SoftwareId == SoftwareId1).ToList(); Assert.IsTrue(targetSettings.All(s => s.IsDeleted)); // 验证其他设置不受影响 var unaffectedSettings = DbContext.Object.Settings.Where(s => s.SoftwareId == SoftwareId2).ToList(); Assert.IsTrue(unaffectedSettings.All(s => !s.IsDeleted));
这种方式跳过了对中间方法调用的验证,直接测试方法的实际效果,更符合单元测试的核心目标。
二、让Mock支持异步枚举(验证ForEachAsync调用)
如果你一定要验证ForEachAsync确实被执行了,需要让你的Mock DbSet实现IAsyncEnumerable<T>接口——因为ForEachAsync是基于异步枚举实现的。
步骤1:完善GetQueryableMockDbSet方法
在方法里添加对IAsyncEnumerable<T>的Mock配置:
public static Mock<DbSet<T>> GetQueryableMockDbSet<T>(List<T> sourceList) where T : class { var queryable = sourceList.AsQueryable(); var dbSet = new Mock<DbSet<T>>(); // 原有IQueryable的配置... 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()); // 添加IAsyncEnumerable的配置 dbSet.As<IAsyncEnumerable<T>>() .Setup(m => m.GetAsyncEnumerator(It.IsAny<CancellationToken>())) .Returns(new TestDbAsyncEnumerator<T>(queryable.GetEnumerator())); // 原有Add/Remove等配置... 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; }
步骤2:实现TestDbAsyncEnumerator类
这个类用来模拟异步枚举器的行为:
public class TestDbAsyncEnumerator<T> : IAsyncEnumerator<T> { private readonly IEnumerator<T> _innerEnumerator; public TestDbAsyncEnumerator(IEnumerator<T> inner) { _innerEnumerator = inner; } public ValueTask DisposeAsync() { _innerEnumerator.Dispose(); return ValueTask.CompletedTask; } public ValueTask<bool> MoveNextAsync() { return ValueTask.FromResult(_innerEnumerator.MoveNext()); } public T Current => _innerEnumerator.Current; }
步骤3:验证异步枚举的调用
现在你可以通过验证GetAsyncEnumerator是否被调用来间接确认ForEachAsync执行过:
DbContext.Verify(m => m.Settings, Times.Once); // 验证异步枚举器被创建过 queryableMockDbSet.As<IAsyncEnumerable<Setting>>() .Verify(m => m.GetAsyncEnumerator(CancellationToken.None), Times.Once);
三、重构原方法,降低测试复杂度
如果觉得Mock异步逻辑太麻烦,可以重构原方法,把标记删除的逻辑抽成单独的方法,让代码更易测试:
public void DeleteAllSettingsLinkedToSoftware(Guid softwareId) { var settingsToDelete = _dbContext.Settings.Where(s => s.SoftwareId == softwareId).ToList(); MarkSettingsAsDeleted(settingsToDelete); _dbContext.SaveChanges(); } // 这个内部方法可以单独测试,不需要依赖DbContext internal void MarkSettingsAsDeleted(IEnumerable<Setting> settings) { foreach (var setting in settings) { setting.IsDeleted = true; } }
这样你可以单独写测试用例验证MarkSettingsAsDeleted方法,而DbContext部分只需要验证Where和SaveChanges的调用即可。
四、改用EF Core内存数据库(最贴近真实场景)
放弃Moq,直接使用EF Core的内存数据库(需要安装Microsoft.EntityFrameworkCore.InMemory NuGet包),它可以模拟真实的DbContext行为,包括异步操作,不需要写复杂的Mock逻辑:
[TestMethod] public async Task DeleteAllSettingsLinkedToSoftware_Success() { // Arrange:创建内存数据库上下文 var options = new DbContextOptionsBuilder<YourDbContext>() .UseInMemoryDatabase(databaseName: "Test_DeleteSettings_" + Guid.NewGuid()) .Options; using var context = new YourDbContext(options); context.Settings.AddRange(new List<Setting> { new Setting { SoftwareId = SoftwareId1 }, new Setting { SoftwareId = SoftwareId1 }, new Setting { SoftwareId = SoftwareId2 } }); await context.SaveChangesAsync(); var settingData = new SettingData(context, SettingDataLoggerMock.Object); // Act await settingData.DeleteAllSettingsLinkedToSoftwareAsync(SoftwareId1); // Assert:直接查询内存数据库验证结果 var targetSettings = await context.Settings.Where(s => s.SoftwareId == SoftwareId1).ToListAsync(); Assert.IsTrue(targetSettings.All(s => s.IsDeleted)); var unaffectedSetting = await context.Settings.FirstOrDefaultAsync(s => s.SoftwareId == SoftwareId2); Assert.IsFalse(unaffectedSetting.IsDeleted); }
这种方法的测试更接近真实运行环境,能覆盖更多边界场景,而且代码更简洁。
内容的提问来源于stack exchange,提问作者Jeroen

