不要用Spring框架进行单元测试


虽然 Spring Boot 和 Spring 框架添加了许多功能来简化框架上下文中的测试,但它也可能导致测试金字塔的关注点分离和整个测试套件的质量迅速下降。示例包括较慢的测试执行(因此构建时间)、过于复杂的测试以及不必要时的“Springifying”单元测试。

让我们考虑下面的类,其中一些依赖项被自动装配到其中。由于注释,它将被选为托管 Spring bean @Service:

@Service
class MyService {
    @Autowired private DependencyA depA;
    @Autowired private DependencyB depB;
    // [...]
}

这可能是附带的虚构的“Springified”单元测试:

@ExtendWith(SpringExtension.class)
@Import(MyService.class)
class MyServiceTest {
    @Autowired private MyService myService;

    @MockBean private DependencyA depA;
    @MockBean private DependencyB depB;

    @Test
    void typicalTest() {
        // [...] mocking dependencies behavior
       
// test something myService does
       
// assert the results [...]
    }
}

这可能是某人在谷歌上搜索“test bean spring”、解决“”异常No qualifying bean of type 'MyService' available或只是复制stackoverflow ChatGPT 片段的结果。对于使用工具(Spring 框架)但不知道其工作原理的开发人员来说,这种情况很常见。不久之后,您会听到他们抱怨注释如何复杂并使他们遇到的问题变得神秘。

虽然注释可能会减少模板代码,但也会增加一些复杂性,如果不知道这些注释的作用,有些人就会认为 "与魔术无异"。粗略地说

  • @ExtendWith(SpringExtension.class): 将 Spring 测试上下文管理与 JUnit 生命周期挂钩。使用此注解通常表明,在执行测试套件时,至少会有一个受管理的 Spring 应用程序上下文;
  • @Import(MyService.class):将 MyService 作为 Spring 托管 Bean 导入此测试使用的当前应用程序上下文;
  • @Autowired:将导入的 MyService Bean 从应用程序上下文注入测试类;
  • @MockBean:在当前应用上下文中导入一个模拟 Bean,并在测试中对其进行存根处理。这会弄脏应用程序上下文,这意味着如果另一个 Bean 需要不同(非 mock)的实现,它将无法重复使用。

根据测试套件的大小以及测试中使用的上下文的变化情况,每个测试套件可能会有十几个新创建的上下文,这些上下文可能会被缓存,也可能不会被缓存。在这种情况下,上下文是微不足道的,因为它只导入了一个 Bean,虽然存在开销,但很小。不过,也有很多例子表明,有些人为了测试一项服务而启动了昂贵得多的应用程序上下文!这种情况很快就会出现,而且往往是导致构建时间变慢的原因之一。

不需要 Spring 应用程序上下文
由于 MyService 实际上是一个独立单元,因此即使它依赖于配置值或其他 Bean,也没有理由要求单元测试使用 Spring 应用程序上下文。我想不出有什么理由需要进行 "Spring 管理的"/"Spring 化的 "单元测试,尽管网上有很多资料都把初学者引向了错误的方向。在 Spring 应用程序中,MyService 是一个 Bean,Spring 会为你初始化它,但它仍然是一个可以独立实例化的类:

MyService myService = new MyService();

大多数模拟框架(如 Mockito)都支持字段注入,但更好的方法是改变它,使用构造函数注入来确保对象在实例化后即可使用:

class MyService {
    private final DependencyA depA;
    private final DependencyB depB;
    public MyService(DependencyA depA,
                     DependencyB depB) {
        this.depA = depA;
        this.depB = depB;
    } 
    // [...]
}

现在,你可以在单元测试中实例化这个类,而无需任何 Spring 上下文或注解:

class MyServiceTest {
    private DependencyA depA = mock(DependencyA.class);
    private DependencyB depB = mock(DependencyB.class);
    private MyService myService = new MyService(depA, depB);

    // [...] tests [...]

这并不意味着注解或JUnit扩展不好或邪恶。例如,JUnit Mockito 扩展可以移除处理模拟创建、被测对象实例化以及测试运行后根据严格程度对模拟进行验证的模板:

@ExtendWith(MockitoExtension.class)
class MyServiceTest {
    @Mock private DependencyA depA;
    @Mock private DependencyB depB;
    @InjectMocks private MyService myService;

    // [...] tests [...]
}

控制器呢?
如果你坚持 "每个公共单元都应单独测试",那么独立单元测试和与框架集成的测试是有区别的。控制器类不需要应用程序上下文。您可以像测试服务一样测试控制器类:

@ExtendWith(MockitoExtension.class)
class MyControllerTest {
    @Mock private SomeService someService;
    @InjectMocks private MyController myController;

    @Test
    void typicalTest() {
        // [...] mock behavior of someService
       
// call myController.whateverEndpoint() method
       
// assert the results [...]
    }
}

在关注点分离的架构中,控制器的作用并不大。它只是应用程序的(HTTP)入口点,将信息转发给其他组件并返回结果。因此,对基本控制器的单元测试通常只是确保 "控制器单元 "与 "其他组件 "正确交互。

只要你添加 @WebMvcTest 来测试 Spring Web MVC 框架上下文中的控制器,你就不再是在创建单元测试了。即使该测试仍然模拟了控制器类的依赖关系:

@WebMvcTest(MyController.class)
class MyControllerWebMvcTest {
    @MockBean private SomeService someService;
    @Autowired private MockMvc mockMvc;

    @Test
    void typicalTest() {
        // [...] mock behavior of someService
       
// call mockMvc.perform(get(...))
       
// assert the results [...]
    }
}

至此,你已经走上了测试金字塔的阶梯。只要有意识地这样做,这并不是坏事:是否应该用不同的名称或将它们放在其他地方,以明确它们不是单元测试?您是要测试控制器的行为,还是也要测试过滤器和 Spring Security 配置(由于注释的存在,这些配置将被加载)?那么现在在应用程序上下文中激活的控制器建议呢?是对每个控制器都这样做,还是只做一次?

这些都是您可以考虑的一些问题。与往常一样,您应该在知情的情况下做出取舍。否则,您的测试套件很容易成为一个大问号,不清楚您到底要测试什么以及何时测试。

那我的网络过滤器呢?或者 JPA 接口?或者<什么>?
一个经验法则是,一旦你实际上需要框架的实现来做任何有意义的事情,单元测试的价值就会比集成测试降低。对于接口来说,这一点更是如此。接口顾名思义就是接口,而不是实际的实现,因此没有理由对接口进行单元测试。


网络Web过滤器可以作为一个独立单元进行单元测试。您可能想使用一些现有的模拟来简化对 servlet 机制的模拟。由于应用程序中通常不止一个过滤器,而且这些单元之间有很多链,因此针对过滤器链的 Web MVC 集成测试在这方面会有所帮助。对于 JPA 接口,可以考虑使用 @DataJpaTest,它可以加载测试 JPA 实现所需的最小应用程序上下文。

Spring Boot 参考指南中还有很多其他关于测试框架实现的示例,无需立即在 "跳过单元测试 "和 "启动整个应用程序 "之间做出选择。

总结
在任何情况下,您都应了解测试的内容和方式。您的应用程序有明确的单元测试和集成测试的界限吗?或者您甚至不知道您的某些 "单元测试 "实际上是集成测试?

默认情况下,你不应该为了确保测试能正常工作而在测试中加入 @SpringBootTest。你不需要为每个测试编写一份5W2H调查报告,但是你(和代码!)应该能够向同事解释哪个测试是集成测试,以及为什么它需要加载整个应用程序的上下文。

还要注意的是,测试的范围与测试设置的技术细节同样重要(如果不是更重要的话)。你可以拥有毫无意义的 100% 单元测试覆盖率,但你的软件仍然不能带来任何价值!