通过模拟被测对象的所有依赖项来编写单元测试是一种正确的方法吗?

Is it a proper way to write unit tests by mocking all the dependencies of an object under test?

请看我下面的例子。我有一个 class (MyService),我在其中尝试对特定方法 (handleStudentActivity) 进行单元测试。 MyService 依赖于 InputValidatorStudentRepository(以及其他一些 - 这超出了我的测试范围)。

所以在我的测试中 class,MyServiceTest 我为这些创建了 @Mock 个对象,并在我的测试对象上使用了 @InjectMocks

在单元测试方法中,对依赖对象的任何调用我模拟了调用如下...

doReturn(student).when(repository).save(student);
doThrow(InputValidatonException.class).when(validator).validateStudentData(student);

并且我的测试用例按预期通过(它抛出异常,因为学生姓名为空)。

后来我修改了源码如下,里面InputValidator去掉了validateStudentData方法

的body

public void validateStudentData(Student student) {}

我运行又考了一次,这次也通过了。我预计测试用例会失败,因为当前代码没有抛出异常。

单元测试的写法正确吗?因为对源代码的修改不会破坏现有的测试。

或者因为更改在测试目标(MyService)之外并且相关对象的更改由其相应的测试处理,所以可以吗?

这是我的 classes(删除了不相关的行)

1.我的服务

@Service
public class MyService {

    @Autowired
    private InputValidator validator;

    @Autowired
    private StudentRepository repository;
    
    //More autowirings here 
    
    public void handleStudentActivity(Student student) {
        
        //Some logic here...
        
        Student savedEntry = repository.save(student);      
        validator.validateStudentData(savedEntry);
        
        //Some logic here for handleStudentActivity with savedEntry
    }
    
    //Other stuff...
}

2。输入验证器

@Component
public class InputValidator {

    @Autowired
    private ManagementProfilerClient profilerClient;

    @Autowired
    private MonitorClient monitorClient;
    
    public void validateStudentData(Student student) {
    
        if (StringUtils.isBlank(student.getName()))
            throw new InputValidatonException("Student name can't be blank.");
            
        // Other validations follows...
    }
    
    //Other validator methods here
}

3.我的服务测试

public class MyServiceTest {

    @Mock
    private InputValidator validator;

    @Mock
    private StudentRepository repository;
    
    @InjectMocks
    private MyService subject;
    
    @BeforeClass
    public void setup() {
        MockitoAnnotations.initMocks(this);
    }

    @Test(expectedExceptions = InputValidatonException.class)
    public void testHandleStudentActivityTrowsExceptionForInvalidStudentInput() {

        // Arrange 
        Student student = new Student();
        student.setName(""));

        doReturn(student).when(repository).save(student); //Mock repository method call
        doThrow(InputValidatonException.class).when(validator).validateStudentData(student); // Mock validator call    
        
        //Act
        subject.handleStudentActivity(student);
    }

}

您的单元测试测试单个代码单元。对于MyServiceTest,单位是MyService。

您的测试不包括 InputValidator,它具有正面和负面影响:它是正面的,因为 InputValidator 中的错误不会导致您的测试失败,并且因为如果 InputValidator 很慢或具有严重的依赖性,您将不会在编写 MyService 的重点测试时需要担心这些方面。但是,这是负面的,因为单靠单元测试不会告诉您整个系统都在工作,并且可能意味着您在单元测试设置中写入了不正确的假设。

你的 Mockito 模拟是一种自动化的 test double 类型,它编码对你 system-under-test 之外的世界所做的假设(SUT):你的测试替身可能 运行 更快并且更少的依赖性,但它们也容易变得陈旧,因为它们与您的实际实现不同(正如您在清除 validateStudentData 方法时所描述的)。这并不是 Mockito 固有的问题,因为任何伪造的实现都会受到相同假设的影响,但在任何情况下,这确实意味着您的测试可能会使您的代码不如您想象的安全。

这并不意味着您的单元测试是错误的,或者它必须使用真正的 InputValidator:相反,您不应该只依赖 单元测试,而是添加系统测试集成测试,它们使用更少的测试替身和更多的真实系统。与单元测试相比,您可能需要更少的系统测试,因为它们可能比 运行 更重,并且在失败时识别问题时不太具体,但它们仍然是确保您的系统在组装时协同工作所必需的就像在您的实际应用程序中一样。

为了它的价值,我在这里写了另一个关于类似软件工程 StackExchange 问题的答案:Are (database) integration tests bad?。其他贡献者也回答了这个问题,因此您可以在那里比较我们的观察结果。