C#中内部细节关联度高的类如何正确进行单元测试?
问题解答
你暂称为“容器模式”的实现,本质是简化版工作单元(Unit of Work)模式,核心职责就是跟踪内存中待持久化的实体,批量提交到仓储完成持久化后清空跟踪状态,是很常见的设计思路。
现有测试方案的问题
单元测试的核心准则是只验证类型的公开可观测行为,不耦合内部实现细节。你目前用internal构造函数注入外部列表、直接断言列表状态的写法,本质是把“内部用List<Entity>存储实体”这个实现细节和测试强绑定了——后续如果要把内部存储改成ConcurrentQueue、分块数组等其他结构,哪怕对外Add、Save的行为完全没有变化,现有测试也会直接失败,维护成本很高。
你完全不需要暴露内部状态、也不需要反射访问私有字段,仅通过两个公开方法就能覆盖全部逻辑,完全符合单元测试规范。
正确的测试设计思路
你不需要单独“孤立测试Add方法”,Add方法本身没有对外返回值,它的可观测行为只有一个:添加的实体会在后续调用SaveAndClearAsync时被传递给仓储。同理SaveAndClearAsync的所有逻辑分支,都可以通过mock仓储、校验方法返回值、校验仓储的被调用情况来验证,不需要感知内部列表的存在。
你需要覆盖的核心测试场景如下:
- 未添加任何实体时调用
SaveAndClearAsync:不触发仓储保存逻辑,直接返回true - 调用Add添加实体后调用
SaveAndClearAsync:仓储仅被调用一次,入参包含所有添加的实体,方法返回true - 仓储保存抛出异常时:方法返回
false,已添加的实体不会被清空,下次调用保存时会再次传给仓储 - 保存成功后再次调用
SaveAndClearAsync:之前已保存的实体不会重复传给仓储(即内部状态已清空)
测试代码示例
首先可以删掉你写的internal测试专用构造函数,仅保留生产环境用的公有构造函数即可,测试代码如下:
// 1. 验证Add的实体确实会被持久化 [Fact] public async Task Add_EntityAdded_PassedToRepoOnSave() { // Arrange var repoMock = new Mock<IRepository>(); var container = new Container(repoMock.Object); var testEntity = new Entity { Name = "md52" }; // Act container.Add(testEntity); var saveResult = await container.SaveAndClearAsync(); // Assert Assert.True(saveResult); repoMock.Verify( r => r.SaveAsync(It.Is<List<Entity>>(list => list.Count == 1 && list[0].Name == "md52")), Times.Once ); } // 2. 验证空列表不触发仓储调用 [Fact] public async Task SaveAndClearAsync_NoEntities_SkipsRepoCallAndReturnsTrue() { // Arrange 用Strict模式,只要调用了未Setup的SaveAsync就会直接抛错 var repoMock = new Mock<IRepository>(MockBehavior.Strict); var container = new Container(repoMock.Object); // Act var result = await container.SaveAndClearAsync(); // Assert Assert.True(result); } // 3. 验证保存失败时不清空实体 [Fact] public async Task SaveAndClearAsync_RepoThrows_ReturnsFalseAndKeepsEntities() { // Arrange var repoMock = new Mock<IRepository>(); repoMock.Setup(r => r.SaveAsync(It.IsAny<List<Entity>>())) .ThrowsAsync(new Exception("DB connection failed")); var container = new Container(repoMock.Object); container.Add(new Entity { Name = "test_entity" }); // Act var firstSaveResult = await container.SaveAndClearAsync(); await container.SaveAndClearAsync(); // 第二次调用验证实体未丢失 // Assert Assert.False(firstSaveResult); repoMock.Verify( r => r.SaveAsync(It.Is<List<Entity>>(list => list.Count == 1 && list[0].Name == "test_entity")), Times.Exactly(2) ); } // 4. 验证保存成功后清空实体 [Fact] public async Task SaveAndClearAsync_SaveSucceeds_ClearsEntitiesAfterCompletion() { // Arrange var repoMock = new Mock<IRepository>(); var container = new Container(repoMock.Object); container.Add(new Entity { Name = "test_entity" }); // Act var firstSaveResult = await container.SaveAndClearAsync(); await container.SaveAndClearAsync(); // 第二次调用验证列表已空 // Assert Assert.True(firstSaveResult); repoMock.Verify( r => r.SaveAsync(It.Is<List<Entity>>(list => list.Count == 1)), Times.Once ); repoMock.Verify( r => r.SaveAsync(It.Is<List<Entity>>(list => list.Count == 0)), Times.Once ); }
另外提两处你代码里的笔误:
- 接口名
IRepostory拼写错误,正确应为IRepository - 测试代码中初始化实体的代码
new Entity { Name "md52" }缺少赋值等号,正确写法是new Entity { Name = "md52" }
内容的提问来源于stack exchange,提问作者Alexander Kozachenko
相关产品推荐
相关产品推荐

