You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

单元测试中无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框架使用,不会对外部业务代码开放赋值权限。
    实体修改示例:
    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);
        }
    }
    
    在实体所在项目的AssemblyInfo.cs中添加配置,放开测试程序集的内部访问权限:
    [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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 22:24:16