Spring Boot中使用TestContainer測試快取機制

banq發表於2024-06-07

快取已成為現代 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 = <font>"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<i>
}

  • 服務層的 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(<font>"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<i>
}

透過上述設定,我們成功啟動了已宣告的 Redis 和 MySQL 容器,並使用 @DynamicPropertySource 定義了應用程式連線到這些例項所需的配置屬性。一旦測試類執行完畢,容器將自動銷燬。

同樣重要的是,我們之前在 src/main/resources/db/migration 資料夾中定義的 Flyway 遷移指令碼將在測試執行期間啟動應用程式時自動執行。這將允許我們在假設資料庫中已經存在嚮導記錄的情況下測試應用程式的快取行為。

使用 Spring Boot 編寫測試用例
現在,我們已經成功配置了所需的快取和資料庫容器,可以使用適當的測試用例來測試我們之前在 WizardService 類中暴露的功能:

@SpringBootTest
class WizardServiceIT {

  <font>// Testcontainers setup as seen above<i>

  @Autowired
  private WizardService wizardService;

  @SpyBean
  private WizardRepository wizardRepository;

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

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

   
// Verify subsequent reads are made from cache and database is not queried<i>
    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() {
  <font>// Populate cache by retrieving wizard records<i>
  var wizards = wizardService.retrieve();
  assertThat(wizards).isNotEmpty();

 
// Prepare wizard creation request<i>
  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<i>
  wizardService.create(wizardCreationRequest);

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

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

在測試用例中,我們首先呼叫 retrieve() 方法,以便用嚮導記錄填充快取。然後,我們繼續呼叫服務類的 create() 方法,併發出建立請求示例。現在,為了測試快取是否失效,我們再次獲取嚮導記錄並驗證資料庫是否被查詢,從而確認快取是否因嚮導建立而失效。

以上編寫的測試用例一起執行時將......請擊鼓......失敗!

這是因為我們沒有清理測試用例修改的狀態。在執行涉及快取的多個測試用例時,必須確保每個測試用例都是獨立的,不會受到之前測試的快取資料的影響。為此,我們需要在執行每個測試用例之前清除快取:

@Autowired
private CacheManager cacheManager;

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

透過注入 CacheManager Bean 並定義註釋為 @BeforeEach 的 setup() 方法,我們可以確保快取在每個測試用例執行前失效,從而保證每個測試都以空快取開始,為測試快取行為提供一致的環境。

透過編寫上述全面的測試用例,我們可以確保服務層正常執行,並按照預期與已配置的快取和資料庫進行互動。我們可以放心地將應用程式部署到生產環境中,因為我們知道它已經過全面的測試並透過了模擬環境的驗證。
 

相關文章