Spring Boot中使用TestContainer测试缓存机制

缓存已成为现代 Web 应用程序中必不可少的一部分。它帮助我们减少底层数据源的负载,减少响应延迟,并在处理付费第三方 API 时节省成本。

然而,彻底测试应用程序的缓存机制以确保其可靠性和有效性也同样重要。不这样做可能会导致生产中的缓存和数据库之间出现不一致,从而导致向客户端提供过时的数据。

单元测试虽然很有价值,但它无法帮助我们准确模拟应用程序与预配置缓存之间的交互。我们将无法发现与序列化、数据一致性、缓存填充和失效相关的问题。在这种情况下,编写集成测试对于正确确保我们所采用的缓存机制的完整性是绝对必要的。

在本文中,我们将探讨如何利用Testcontainers编写集成测试来验证 Spring Boot 应用程序中的缓存机制。我们将使用的示例应用程序与 Redis 集成,以在 MySQL 数据库前面缓存数据。

本文引用的工作代码可以在Github上找到。

示例 Spring Boot 应用程序概述
我们的示例代码库是一个基于 Java 21 Maven 的 Spring Boot 应用程序。由于使用相同的旧通用示例让我感到无聊,因此我们将为霍格沃茨构建一个基本的巫师管理系统。我们应用程序的服务层预计将公开以下功能:

  • 在数据库中创建新的向导记录
  • 从数据库中检索所有向导记录
为了提高性能,减少数据库调用,我们将在服务层实现缓存。向导记录将在首次检索时缓存,并在创建新记录时使缓存失效,以确保数据的一致性。

MySQL 数据库层
我们的应用程序的数据库层将对 2 个表进行操作,分别名为hogwarts_houses和wizards。我们将利用来Flyway管理我们的数据库迁移脚本。

首先,我们定义一个脚本来创建所需的数据库表:

CREATE TABLE hogwarts_houses (
  id BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID())),
  name VARCHAR(50) NOT NULL UNIQUE
);
 
CREATE TABLE wizards (
  id BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID())),
  name VARCHAR(50) NOT NULL,
  house_id BINARY(16) NOT NULL,
  wand_type VARCHAR(20),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT wizard_fkey_house FOREIGN KEY (house_id)
  REFERENCES hogwarts_houses (id)
);

INSERT INTO hogwarts_houses (name)
VALUES
  ('Gryffindor'),
  ('Slytherin');
V003__adding_wizards.sqlTransact-SQL
SET @gryffindor_house_id = (SELECT id FROM hogwarts_houses WHERE name = 'Gryffindor');
SET @slytherin_house_id = (SELECT id FROM hogwarts_houses WHERE name = 'Slytherin');
 
INSERT INTO wizards (name, house_id, wand_type)
VALUES
  ('Harry Potter', @gryffindor_house_id, 'Phoenix feather'),
  ('Hermione Granger', @gryffindor_house_id, 'Dragon heartstring'),
  ('Tom Riddle', @slytherin_house_id, 'Phoenix feather')


上述脚本创建了两个表:一个hogwarts_houses用于存储房屋名称,另一个wizards用于存储巫师记录。创建的每一个巫师都会与一个房屋相关联,从而在两个表之间建立一对多的关系。

在我们的应用程序中,我们将上面创建的数据库表映射到@Entity类并创建它们相应的存储库,扩展 Spring Data JPA 的JpaRepository。为了忠于本文的主要议程,即测试缓存机制,我们不会深入讨论每个类的实现细节。但是,完整的工作代码可以在Github上引用。

Spring Boot 服务层
我们将创建一个与 MySQL 数据库和 Redis 缓存交互的服务类:

@Service
@RequiredArgsConstructor
public class WizardService {

  private final WizardRepository wizardRepository;
  private final SortingHatUtility sortingHatUtility;

  @Cacheable(value = "wizards")
  public List<WizardDto> retrieve() {
    return wizardRepository.findAll().stream().map(this::convert).toList();
  }

  @CacheEvict(value =
"wizards", allEntries = true)
  public void create(@NonNull final WizardCreationRequestDto wizardCreationRequest) {
    final var house = sortingHatUtility.sort();
    final var wizard = new Wizard();
    wizard.setName(wizardCreationRequest.getName());
    wizard.setWandType(wizardCreationRequest.getWandType());
    wizard.setHouseId(house.getId());
    wizardRepository.save(wizard);
  }

 
// other service methods
}
  • 服务层的 retrieve() 方法会获取数据库中存储的所有向导记录。我们用 @Cacheable 对该方法进行了注解,表示返回的结果应针对关键向导进行缓存。在后续调用中,除非缓存失效,否则无需查询数据库即可返回缓存结果。
  • 服务层的 reate() 方法会根据参数中提供的详细信息在数据库中保存一条新的向导记录。该方法使用 @CacheEvict 进行注解,指定在成功执行后,缓存应被作废,即针对关键字 wizardssh 存储的条目应被删除。这将确保后续对 retrieve() 方法的任何调用都将查询数据库,从而确保数据库和缓存之间的一致性。

测试缓存机制
缓存机制实施后,让我们通过一些测试来确保其正确性

要编写应用程序的集成测试,我们首先要查看 pom.xml 文件中的所需依赖项:

<!-- Test dependencies -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>mysql</artifactId>
  <scope>test</scope>
</dependency>

Spring Boot Starter Test(又称 "测试军刀")为我们提供了基本的测试工具箱,因为它临时包含了 JUnit、AssertJ、Mockito 和其他实用库,这些都是我们编写断言和运行测试所需的。

Testcontainers 的 MySQL 模块允许我们在一次性 Docker 容器内运行 MySQL 实例,为我们的集成测试提供隔离环境。

该模块过渡性地包含了 Testcontainers 核心库,我们还将用它来启动 Redis 实例。虽然没有 Redis 专用模块,但 Testcontainers 中的通用容器支持可让我们轻松为测试设置和管理 Redis 容器。

此外,我们还将使用 Maven Failsafe 插件来运行集成测试,方法是添加以下插件配置:

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-failsafe-plugin</artifactId>
      <executions>
        <execution>
          <goals>
            <goal>integration-test</goal>
            <goal>verify</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

Maven Failsafe 插件旨在将集成测试与单元测试分开运行。该插件在名称以 IT 结尾的类(集成测试类的约定)中执行测试方法。

前提条件运行 Docker
通过 Testcontainers 运行所需的 Redis 和 MySQL 实例的先决条件,正如你所猜测的那样,是一个正常运行的 Docker 实例。我们需要确保在本地或使用 CI/CD 管道运行测试套件时满足这一前提条件。

通过 Testcontainers 启动 Redis 和 MySQL 容器
我们需要启动 Redis 容器进行缓存,并启动 MySQL 数据库容器作为底层数据源,以便在执行文本时正确启动应用程序上下文。如前所述,由于 Testcontainers 没有 Redis 专用模块,我们将使用 GenericContainer 类,该类允许我们启动任何 Docker 镜像:

@SpringBootTest
class WizardServiceIT {

  private static final int REDIS_PORT = 6379;  
  private static final String REDIS_PASSWORD = RandomString.make(10);
  private static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:7.2.4");

  private static final GenericContainer<?> REDIS_CONTAINER =  new GenericContainer<>(REDIS_IMAGE)
          .withExposedPorts(REDIS_PORT)
          .withCommand(
"redis-server", "--requirepass", REDIS_PASSWORD);
  
  private static final DockerImageName MYSQL_IMAGE = DockerImageName.parse(
"mysql:8");  
  private static final MySQLContainer<?> MYSQL_CONTAINER = new MySQLContainer<>(MYSQL_IMAGE);

  static {
    REDIS_CONTAINER.start();
    MYSQL_CONTAINER.start();
  }
  
  @DynamicPropertySource
  static void properties(DynamicPropertyRegistry registry) {
    registry.add(
"spring.data.redis.host", REDIS_CONTAINER::getHost);
    registry.add(
"spring.data.redis.port", () -> REDIS_CONTAINER.getMappedPort(REDIS_PORT));  
    registry.add(
"spring.data.redis.password", () -> REDIS_PASSWORD);

    registry.add(
"spring.datasource.url", MYSQL_CONTAINER::getJdbcUrl);
    registry.add(
"spring.datasource.username", MYSQL_CONTAINER::getUsername);  
    registry.add(
"spring.datasource.password", MYSQL_CONTAINER::getPassword);
  }
  
 
// test cases
}

通过上述设置,我们成功启动了已声明的 Redis 和 MySQL 容器,并使用 @DynamicPropertySource 定义了应用程序连接到这些实例所需的配置属性。一旦测试类执行完毕,容器将自动销毁。

同样重要的是,我们之前在 src/main/resources/db/migration 文件夹中定义的 Flyway 迁移脚本将在测试执行期间启动应用程序时自动执行。这将允许我们在假设数据库中已经存在向导记录的情况下测试应用程序的缓存行为。

使用 Spring Boot 编写测试用例
现在,我们已经成功配置了所需的缓存和数据库容器,可以使用适当的测试用例来测试我们之前在 WizardService 类中暴露的功能:

@SpringBootTest
class WizardServiceIT {

  // Testcontainers setup as seen above

  @Autowired
  private WizardService wizardService;

  @SpyBean
  private WizardRepository wizardRepository;

  @Test
  void shouldRetrieveWizardRecordFromCacheAfterInitialDatabaseRetrieval() {
   
// Invoke method under test initially
    var wizards = wizardService.retrieve();
    assertThat(wizards).isNotEmpty();

   
// Verify that the database was queried
    verify(wizardRepository, times(1)).findAll();

   
// Verify subsequent reads are made from cache and database is not queried
    Mockito.clearInvocations(wizardRepository);
    final var queryTimes = 100;
    for (int i = 1; i < queryTimes; i++) {
      wizards = wizardService.retrieve();
      assertThat(wizards).isNotEmpty();
    }
    verify(wizardRepository, times(0)).findAll();
  }

}


在上述测试用例中,我们验证了在首次调用 retrieve() 方法后,后续调用应从缓存中获取数据,而不是查询数据库。

我们首先检索了所有向导记录,然后验证是否使用 Mockito 查询了数据库。由于我们在测试类中已将 WizardRepository 声明为 @SpyBean,因此我们能够做到这一点。现在,我们反复多次调用服务层,断言与数据库的交互为零,确认初始结果已被缓存并在随后返回。

现在,让我们继续验证新向导创建时缓存是否成功失效:

@Test
void shouldInvalidateCachePostWizardCreation() {
  // Populate cache by retrieving wizard records
  var wizards = wizardService.retrieve();
  assertThat(wizards).isNotEmpty();

 
// Prepare wizard creation request
  final var name = RandomString.make();
  final var wandType = RandomString.make();
  final var wizardCreationRequest = new WizardCreationRequestDto();
  wizardCreationRequest.setName(name);
  wizardCreationRequest.setWandType(wandType);

 
// Invoke method under test
  wizardService.create(wizardCreationRequest);

 
// Retrieve wizards post creation and verify database interaction
  Mockito.clearInvocations(wizardRepository);
  wizards = wizardService.retrieve();
  verify(wizardRepository, times(1)).findAll();

 
// assert the fetched response contains new wizard data
  assertThat(wizards)
      .anyMatch(
          wizard ->
              wizard.getName().contentEquals(name) && wizard.getWandType().contains(wandType));
}

在测试用例中,我们首先调用 retrieve() 方法,以便用向导记录填充缓存。然后,我们继续调用服务类的 create() 方法,并发出创建请求示例。现在,为了测试缓存是否失效,我们再次获取向导记录并验证数据库是否被查询,从而确认缓存是否因向导创建而失效。

以上编写的测试用例一起执行时将......请击鼓......失败!

这是因为我们没有清理测试用例修改的状态。在运行涉及缓存的多个测试用例时,必须确保每个测试用例都是独立的,不会受到之前测试的缓存数据的影响。为此,我们需要在执行每个测试用例之前清除缓存:

@Autowired
private CacheManager cacheManager;

@BeforeEach
void setup() {
  cacheManager.getCache("wizards").invalidate();
}

通过注入 CacheManager Bean 并定义注释为 @BeforeEach 的 setup() 方法,我们可以确保缓存在每个测试用例执行前失效,从而保证每个测试都以空缓存开始,为测试缓存行为提供一致的环境。

通过编写上述全面的测试用例,我们可以确保服务层正常运行,并按照预期与已配置的缓存和数据库进行交互。我们可以放心地将应用程序部署到生产环境中,因为我们知道它已经过全面的测试并通过了模拟环境的验证。