单元测试中无setter属性的实体状态正确初始化方法
问题描述
在单元测试前置阶段设置实体正确状态时遇到可复现问题:Student实体包含类型为IReadOnlyCollection、仅定义getter的Courses属性,实体定义代码如下:
public class Course : ICourse { public int Id { get; set; } public string Name { get; set; } public ICollection<Student> Students { get; set; } } public class Student : IStudent { public int Id { get; set; } public string Name { get; set; } public IReadOnlyCollection<Course> Courses { get; } }
业务逻辑规则:删除Student实体前,会先从Repository对实体执行预加载(eager loaded),该学生关联的所有Course也会通过Include方法预加载到Courses属性中。StudentService执行Student删除操作前,必须先检查该学生是否关联任意Course,服务代码如下:
public class StudentService : IStudentService { public async Task<bool> DeleteStudent(int id) { Student student = await myRepository.GetStudent(id); if (!student.Courses.Any()) { await myRepository.DeleteStudent(id); return true; } throw new Exception("Student has associated courses, cannot delete."); } }
测试痛点:编写单元测试时,由于Courses属性没有setter,无法将Student实例设置为正确的测试状态。如果将该属性的setter改为public,会违反设计逻辑——不允许开发人员直接向Student添加Courses,仅允许将Student添加到Course的关联集合中,既破坏封装,也不符合单元测试最佳实践。原有测试代码如下:
public class StudentServiceTest { [Fact] public void DeleteStudent_WithAssociatedCourses_ShouldThrowException() { // Arrange Student student = new Student { Id = 1, Name = "Test Name", // 此处无法直接给无setter的Courses赋值 Courses = new List<Course> { new Course { Id = 1 } } }; Mock<IMyRepository> mockRepo = new Mock<IMyRepository>(); mockRepo.Setup(repos => repos.GetStudent(It.Is<int>(i => i == 1))) .ReturnsAsync(student) .Verifiable(); mockRepo.Setup(repos => repos.DeleteStudent(It.Is<int>(i => i == 1))) .Verifiable(); IStudentService studentService = new StudentService(mockRepo.Object); // Act Func<Task> act = () => studentService.DeleteStudent(1); // Assert act.Should().ThrowAsync<Exception>(); mockRepo.Verify(repos => repos.GetStudent(It.Is<int>(i => i == 1)), Times.Once); mockRepo.Verify(repos => repos.DeleteStudent(It.Is<int>(i => i == 1)), Times.Never); } }
核心诉求:在不破坏封装的前提下,正确设置Student实例的Courses属性状态,完成对应单元测试。
可行解决方案
以下方案均不需要将Courses属性的setter改为public,不会破坏原有封装设计:
- 方案1:使用内部初始化方法+程序集可见性配置
给Student类添加私有集合后备字段,对外仅暴露只读集合视图,增加一个internal级别的初始化方法供测试和ORM框架使用,不会对外部业务代码开放赋值权限。
实体修改示例:
在实体所在项目的AssemblyInfo.cs中添加配置,放开测试程序集的内部访问权限:public class Student : IStudent { public int Id { get; set; } public string Name { get; set; } private readonly List<Course> _courses = new(); public IReadOnlyCollection<Course> Courses => _courses.AsReadOnly(); // 仅内部可见,外部业务代码无法调用 internal void SetCoursesForTest(IEnumerable<Course> courses) { _courses.Clear(); _courses.AddRange(courses); } }
测试时直接调用[assembly: InternalsVisibleTo("YourTestProjectName")]SetCoursesForTest方法即可完成状态设置,业务代码无权限访问该方法,不会破坏封装规则。 - 方案2:测试代码通过反射直接赋值
不需要修改任何生产代码,在测试项目中实现通用反射辅助方法,直接操作自动属性的私有后备字段完成赋值,EF Core等ORM在预加载导航属性时本身也采用类似机制实现无setter属性的填充。
辅助方法示例:
测试中使用方式:public static void SetPropertyValue<T, TValue>(this T target, Expression<Func<T, TValue>> memberLamda, TValue value) { if (memberLamda.Body is MemberExpression memberSelectorExpression) { var property = memberSelectorExpression.Member as PropertyInfo; // 查找自动属性对应的私有后备字段 var backingField = typeof(T).GetField($"<{property.Name}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic); backingField?.SetValue(target, value); } }var student = new Student { Id = 1, Name = "Test Name" }; student.SetPropertyValue(s => s.Courses, new List<Course> { new Course { Id = 1 } }.AsReadOnly()); - 方案3:按照设计的关联路径构造测试数据
完全遵循定义的关联规则,不从Student端直接赋值Courses,而是从Course端的Students集合添加关联,只要实体的反向导航配置正确(EF Core默认会按约定配置反向导航),关联后Student.Courses会自动填充对应课程。
测试数据构造示例:
这个方案完全符合预设的操作约束,不需要修改任何生产代码,也不需要用反射,是最贴合业务设计的构造方式。var student = new Student { Id = 1, Name = "Test Name" }; var course = new Course { Id = 1 }; // 按照设计逻辑,从Course端添加关联Student course.Students = new List<Student> { student };
内容的提问来源于stack exchange,提问作者Lindstrøm
相关产品推荐
相关产品推荐

