使用Spring Boot进行测试的陷阱

2023/05/11

1. 概述

编程中最重要的主题之一是测试。Spring Framework和Spring Boot通过提供测试框架扩展并引导我们编写最少的、可测试的代码并在后台进行大量自动化,从而提供了很好的支持。要运行Spring Boot集成测试,我们只需将@SpringBootTest添加到我们的测试类中。我们可以在Spring Boot中的测试中找到简短的介绍。即使我们不使用Spring Boot而使用Spring Framework,我们也可以非常高效地进行集成测试

但是开发测试越容易,陷入陷阱的风险就越大。在本教程中,我们将探讨如何执行Spring Boot测试以及编写测试时必须考虑的事项。

2. 陷阱示例

让我们从一个小例子开始-实现一个管理宠物的服务(PetService):

public record Pet(String name) {}
@Service
public class PetService {

    private final Set<Pet> pets = new HashSet<>();

    public Set<Pet> getPets() {
        return Collections.unmodifiableSet(pets);
    }

    public boolean add(Pet pet) {
        return this.pets.add(pet);
    }
}

该服务不应允许重复,因此测试可能如下所示:

@SpringBootTest
class PetServiceIntegrationTest {

    @Autowired
    PetService service;

    @Test
    void shouldAddPetWhenNotAlreadyExisting() {
        var pet = new Pet("Dog");
        var result = service.add(pet);
        assertThat(result).isTrue();
        assertThat(service.getPets()).hasSize(1);
    }

    @Test
    void shouldNotAddPetWhenAlreadyExisting() {
        var pet = new Pet("Cat");
        var result = service.add(pet);
        assertThat(result).isTrue();
        // try a second time
        result = service.add(pet);
        assertThat(result).isFalse();
        assertThat(service.getPets()).hasSize(1);
    }
}

当我们分别执行每个测试时,一切都很好。但是当我们一起执行它们时,我们会得到一个测试失败:

但是为什么测试会失败呢?我们如何防止这种情况发生?我们将澄清这一点,但首先,让我们从一些基础知识开始。

3. 功能测试的设计目标

我们编写功能测试来记录需求并确保应用程序代码正确实现它们。因此,测试本身也必须是正确的,并且必须易于理解,最好是不言自明。但是,对于本文,我们将关注进一步的设计目标:

  • 回归:测试必须是可重复的。他们必须产生确定性的结果
  • 隔离:测试可能不会相互影响。它们以什么顺序执行,甚至是否并行执行都无关紧要
  • 性能:测试应该尽可能快地运行并尽可能节省资源,尤其是那些属于CI管道或TDD的测试

关于Spring Boot测试,我们需要知道它们是一种集成测试,因为它们会导致ApplicationContext的初始化,即bean使用依赖注入进行初始化和注入。因此隔离需要特别注意-而上面展示的例子似乎存在隔离问题。另一方面,良好的性能也是对Spring Boot测试的挑战。

作为第一个结论,我们可以说避免集成测试是最重要的一点。PetService测试的最佳解决方案是单元测试:

// no annotation here
class PetServiceUnitTest {

    PetService service = new PetService();

    // ...
}

我们应该只在必要时编写Spring Boot测试,例如,当我们想要测试我们的应用程序代码是否被框架正确处理(生命周期管理、依赖注入、事件处理)或者如果我们想要测试一个特殊层(HTTP层、持久层)。

4. 上下文缓存

显然,当我们将@SpringBootTest添加到我们的测试类时,ApplicationContext就会启动,bean也会被初始化。但是,为了支持隔离,JUnit会为每个测试方法初始化此步骤。这将导致每个测试用例有一个ApplicationContext,从而显著降低测试性能。为了避免这种情况,Spring测试框架缓存上下文并允许将其重新用于多个测试用例。当然,这也会导致重用bean实例。这就是PetService测试失败的原因-两种测试方法都处理PetService的同一个实例。

不同的ApplicationContext仅在它们彼此不同时才会创建-例如,如果它们包含不同的beans或具有不同的应用程序属性。我们可以在Spring测试框架文档中找到有关这方面的详细信息。因为ApplicationContext配置是在类级别完成的,所以默认情况下,测试类中的所有方法都共享相同的上下文。

下图显示了这种情况:

上下文缓存作为一种性能优化与隔离相矛盾,因此只有在确保测试之间的隔离时,我们才能重用ApplicationContext。这是Spring Boot测试只应在满足某些条件时才在同一JVM中并行运行的最重要原因。我们可以使用不同的JVM进程运行测试(例如,通过为Maven Surefire插件设置forkMode,但随后我们绕过了缓存机制。

4.1 PetService示例解决方案

关于PetService测试,可能有多种解决方案。所有这些都适用,因为PetService是有状态的。

一种解决方案是使用@DirtiesContext标注每个测试方法。这会将ApplicationContext标记为脏,因此在测试后将其关闭并从缓存中删除。这会阻止性能优化,并且永远不应该是首选方式:

@SpringBootTest
class PetServiceIntegrationTest {

    @Autowired
    PetService service;

    @Test
    @DirtiesContext
    void shouldAddPetWhenNotAlreadyExisting() {
        // ...
    }

    @Test
    @DirtiesContext
    void shouldNotAddPetWhenAlreadyExisting() {
        // ...
    }
}

另一种解决方案是在每次测试后重置PetService的状态:

@SpringBootTest
class PetServiceIntegrationTest {

    @Autowired
    PetService service;

    @AfterEach
    void resetState() {
        service.clear(); // deletes all pets
    }

    // ...
}

但是,最好的解决方案是实现PetService无状态。目前,宠物没有存储在内存中,这永远不是一个好的做法,尤其是在可扩展的环境中。

4.2 陷阱:上下文太多

为了避免无意识地初始化额外的ApplicationContexts,我们需要知道是什么导致了不同的配置。最明显的是bean的直接配置,例如使用@ComponentScan、@Import、@AutoConfigureXXX(例如@AutoConfigureTestDatabase)。但是派生也可能是由启用Profiles(@ActiveProfiles)或记录事件(@RecordApplicationEvents)引起的:

@SpringBootTest
// each of them derives from the original (cached) context
@ComponentScan(basePackages = "cn.tuyucheng.taketoday.sample.blogposts")
@Import(PetServiceTestConfiguration.class)
@AutoConfigureTestDatabase
@ActiveProfiles("test")
@RecordApplicationEvents
class PetServiceIntegrationTest {
    // ...
}

我们可以在Spring测试框架文档中找到详细信息。

4.3 陷阱:Mocking

Spring测试框架包括Mockito来创建和使用Mock。使用@MockBean时,我们让Mockito创建一个Mock实例并将其放入ApplicationContext中。此实例特定于测试类。结果是我们不能与其他测试类共享ApplicationContext

@SpringBootTest
class PetServiceIntegrationTest {

    // context is not shareable with other test classes
    @MockBean
    PetServiceRepository repository;

    // ... 
}

一个建议可能是避免使用Mock并测试整个应用程序。但是如果我们想测试异常处理,我们不能总是阻止Mock。如果我们仍然想与其他测试类共享ApplicationContext,我们还必须共享Mock实例。当我们定义一个创建Mock并替换ApplicationContext中的原始bean的@TestConfiguration时,这是可能的。但是,我们必须意识到隔离问题。

正如我们所知,缓存和重用ApplicationContext假定我们在测试后重置上下文中的每个有状态bean。Mock是一种特殊的有状态bean,因为它们被配置为返回值或抛出异常,并且它们记录每个方法调用以对每个测试用例进行验证。测试后,我们也需要重置它们。这是在使用@MockBean时自动完成的,但是当我们在@TestConfiguration中创建Mock时,我们负责重置。幸运的是,Mockito本身提供了设置。所以整个解决方案可能是:

@TestConfiguration
public class PetServiceTestConfiguration {

    @Primary
    @Bean
    PetServiceRepository createRepositoryMock() {
        return mock(
              PetServiceRepository.class,
              MockReset.withSettings(MockReset.AFTER)
        );
    }
}
@SpringBootTest
@Import(PetServiceTestConfiguration.class) // if not automatically detected
class PetServiceIntegrationTest {

    @Autowired
    PetService repository;
    @Autowired // Mock
    PetServiceRepository repository;

    // ... 
}

4.4 配置上下文缓存

如果我们想了解在测试执行期间ApplicationContext的初始化频率,我们可以在application.properties中设置日志记录级别:

logging.level.org.springframework.test.context.cache=DEBUG

然后我们得到一个包含如下统计信息的日志输出:

org.springframework.test.context.cache:
  Spring test ApplicationContext cache statistics:
  [DefaultContextCache@34585ac9 size = 1, maxSize = 32, parentContextCount = 0, hitCount = 8, missCount = 1]

默认缓存大小为32(LRU)。如果我们想增加或减少它,我们可以指定另一个缓存大小:

spring.test.context.cache.maxSize=50

如果我们想深入研究缓存机制的代码,可以从org.springframework.test.context.cache.ContextCache接口开始。

5. 上下文配置

不仅为了缓存目的,而且为了ApplicationContext初始化性能,我们可能会优化配置。初始化越少,测试设置越快。我们可以为惰性bean初始化配置测试,但我们必须注意潜在的副作用。另一种可能性是减少bean的数量。

5.1 配置检测

默认情况下,@SpringBootTest开始在测试类的当前包中搜索,然后在包结构中向上搜索,寻找带有@SpringBootConfiguration注解的类,然后从中读取配置以创建应用程序上下文。此类通常是我们的主应用程序,因为@SpringBootApplication注解包含@SpringBootConfiguration注解。然后,它会创建一个类似于将在生产环境中启动的应用程序上下文。

5.2 最小化ApplicationContext

如果我们的测试类需要一个不同的(最小的)ApplicationContext,我们可以创建一个静态内部@Configuration类:

@SpringBootTest
class PetServiceIntegrationTest {

    @Autowired
    PetService service;

    @Configuration
    static class MyCustomConfiguration {

        @Bean
        PetService createMyPetService() {
            // create your custom pet service
        }
    }

    // ...
}

与使用@TestConfiguration相比,这完全阻止了@SpringBootConfiguration的自动检测。

另一种减小ApplicationContext大小的方法是使用@SpringBootTest(classes=…)。这也将忽略内部@Configuration类并仅初始化给定的类。

@SpringBootTest(classes = PetService.class)
public class PetServiceIntegrationTest {

    @Autowired
    PetService service;

    // ...
}

如果我们不需要任何Spring Boot功能,例如Profile和读取应用程序属性,我们可以替换@SpringBootTest。让我们来看看这个注解背后的内容:

@ExtendWith(SpringExtension.class)
@BootstrapWith(SpringBootTestContextBootstrapper.class)
public @interface SpringBootTest {
    // ...
}

我们可以看到这个注解只启用了JUnit SpringExtension(它是Spring Framework的一部分,而不是Spring Boot的一部分)并声明了Spring Boot提供的TestContextBootstrapper,它实现了搜索机制。如果我们删除@BootstrapWith,则使用DefaultTestContextBootstrapper,它不是Spring Boot感知的。然后我们必须使用@ContextConfiguration指定上下文:

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = PetService.class)
class PetServiceIntegrationTest {

    @Autowired
    PetService service;

    // ...
}

5.3 测试切片

Spring Boot的自动配置系统适用于应用程序,但有时对于测试来说可能太过分了。仅加载测试应用程序“切片”所需的部分配置通常很有帮助。例如,我们可能想要测试Spring MVC控制器是否正确映射URL,并且我们不想在这些测试中涉及数据库调用;或者我们可能想要测试JPA实体,并且在这些测试运行时我们对Web层不感兴趣。

我们可以在Spring Boot文档中找到可用测试切片的概述。

5.4 上下文优化与缓存

上下文优化可以加快单个测试的启动时间,但我们应该意识到这将导致不同的配置,从而导致更多的ApplicationContext初始化。总而言之,整个测试执行时间可能会增加。因此,跳过上下文优化可能会更好,但使用符合测试用例要求的现有配置。

6. 建议:自定义切片

正如我们所了解的,我们必须在ApplicationContext的数量和大小之间找到一个折衷方案。挑战在于跟踪配置。解决这个问题的一个可能的解决方案是定义几个自定义切片(可能每层一个,整个应用程序一个)并在所有测试中专门使用它们,即我们必须避免在测试类中使用@MockBean进行进一步配置和Mock。

Pet域层的解决方案可能是:

@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@ExtendWith(SpringExtension.class)
@ComponentScan(basePackageClasses = PetsDomainTest.class)
@Import(PetsDomainTest.PetServiceTestConfiguration.class)
// further features that can help to configure and execute tests
@ActiveProfiles({"test", "domain-test"})
@Tag("integration-test")
@Tag("domain-test")
public @interface PetsDomainTest {

    @TestConfiguration
    class PetServiceTestConfiguration {

        @Primary
        @Bean
        PetServiceRepository createRepositoryMock() {
            return mock(
                  PetServiceRepository.class,
                  MockReset.withSettings(MockReset.AFTER)
            );
        }
    }
}

然后可以按如下所示使用它:

@PetsDomainTest
public class PetServiceIntegrationTest {

    @Autowired
    PetService service;
    @Autowired // Mock
    PetServiceRepository repository;

    // ...
}

7. 进一步的陷阱

7.1 派生测试配置

集成测试的一个原则是,我们尽可能接近生产状态测试应用程序。我们只针对特定的测试用例派生。不幸的是,测试框架本身会重新配置我们应用程序的行为,我们应该意识到这一点。例如,内置的可观察性功能在测试期间被禁用,因此如果我们想在我们的应用程序中测试观察,我们需要明确使用@AutoConfigureObservability重新启用它。

7.2 包结构

当我们想要测试应用程序的切片时,我们需要声明必须在ApplicationContext中初始化哪些组件。我们可以通过列出相应的类来做到这一点,但为了获得更稳定的测试配置,最好指定包。例如,我们有一个这样的映射器:

@Component
public class PetDtoMapper {

    public PetDto map(Pet source) {
        // ...
    }
}

我们在测试中需要这个映射器;我们可以使用这个精益解决方案配置测试:

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = PetDtoMapper.class)
class PetDtoMapperIntegrationTest {

    @Autowired
    PetDtoMapper mapper;

    // ...
}

如果我们用MapStruct替换mapper实现,PetDtoMapper类型将成为一个接口,然后MapStruct在同一个包中生成实现类。因此,除非我们导入整个包,否则给定的测试将失败:

@ExtendWith(SpringExtension.class)
public class PetDtoMapperIntegrationTest {

    @Configuration
    @ComponentScan(basePackageClasses = PetDtoMapper.class)
    static class PetDtoMapperTestConfig {}

    @Autowired
    PetDtoMapper mapper;

    // ...
}

这样做的副作用是初始化放置在同一包和子包中的所有其他bean。这就是为什么我们应该根据切片的结构创建一个包结构。这包括特定于域的组件、安全的全局配置、Web或持久层或事件处理程序。

8. 总结

在本教程中,我们探讨了编写Spring Boot测试的陷阱。我们了解到ApplicationContext是被缓存和重用的,因此我们需要考虑隔离。

与往常一样,本教程的完整源代码可在GitHub上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章