Testing JPA Queries with Spring Boot and @DataJpaTest

信碼由韁發表於2021-11-04

【注】本文譯自: Testing JPA Queries with Spring Boot and @DataJpaTest - Reflectoring

除了單元測試,整合測試在生產高質量的軟體中起著至關重要的作用。一種特殊的整合測試處理我們的程式碼和資料庫之間的整合。
通過 @DataJpaTest 註釋,Spring Boot 提供了一種便捷的方法來設定一個具有嵌入式資料庫的環境,以測試我們的資料庫查詢。
在本教程中,我們將首先討論哪些型別的查詢值得測試,然後討論建立用於測試的資料庫模式和資料庫狀態的不同方法。

 程式碼示例

本文附有 GitHub 上的工作程式碼示例

依賴

在本教程中,除了通常的 Spring Boot 依賴項之外,我們使用 JUnit Jupiter 作為我們的測試框架,使用 H2 作為記憶體資料庫。

dependencies {
  compile('org.springframework.boot:spring-boot-starter-data-jpa')
  compile('org.springframework.boot:spring-boot-starter-web')
  runtime('com.h2database:h2')
  testCompile('org.springframework.boot:spring-boot-starter-test')
  testCompile('org.junit.jupiter:junit-jupiter-engine:5.2.0')
}

測試什麼?

首先要回答我們自己的問題是我們需要測試什麼。 讓我們考慮一個負責 UserEntity 物件的 Spring Data 儲存庫:

interface UserRepository extends CrudRepository<UserEntity, Long> {
    // query methods
}

我們有不同的選項來建立查詢。讓我們詳細看看其中的一些,以確定我們是否應該用測試來覆蓋它們。

推斷查詢

第一個選項是建立一個推斷查詢:

UserEntity findByName(String name);

我們不需要告訴 Spring Data 要做什麼,因為它會自動從方法名稱的名稱推斷 SQL 查詢。
這個特性的好處是 Spring Data 還會在啟動時自動檢查查詢是否有效。如果我們將方法重新命名為 findByFoo() 並且 UserEntity 沒有屬性 foo ,Spring Data 會向我們丟擲一個異常來指出這一點:

org.springframework.data.mapping.PropertyReferenceException:
  No property foo found for type UserEntity!

因此,只要我們至少有一個測試嘗試在我們的程式碼庫中啟動 Spring 應用程式上下文,我們就不需要為我們的推斷查詢編寫額外的測試。

請注意,對於從 findByNameAndRegistrationDateBeforeAndEmailIsNotNull() 等長方法名稱推斷出的查詢,情況並非如此。這個方法名很難掌握,也很容易出錯,所以我們應該測試它是否真的符合我們的預期。

話雖如此,將此類方法重新命名為更短、更有意義的名稱並新增 @Query 註釋以提供自定義 JPQL 查詢是一種很好的做法。

使用 @Query 自定義 JPQL 查詢

如果查詢變得更復雜,提供自定義 JPQL 查詢是有意義的:

@Query("select u from UserEntity u where u.name = :name")
UserEntity findByNameCustomQuery(@Param("name") String name);

與推斷查詢類似,我們可以免費對這些 JPQL 查詢進行有效性檢查。使用 Hibernate 作為我們的 JPA 提供者,如果發現無效查詢,我們將在啟動時得到一個 QuerySyntaxException

org.hibernate.hql.internal.ast.QuerySyntaxException:
unexpected token: foo near line 1, column 64 [select u from ...]

但是,自定義查詢比通過單個屬性查詢條目要複雜得多。例如,它們可能包括與其他表的連線或返回複雜的 DTO 而不是實體。
那麼,我們應該為自定義查詢編寫測試嗎?令人不滿意的答案是,我們必須自己決定查詢是否複雜到需要測試。

使用 @Query 的本地查詢

另一種方法是使用本地查詢

@Query(
  value = "select * from user as u where u.name = :name",
  nativeQuery = true)
UserEntity findByNameNativeQuery(@Param("name") String name);

我們沒有指定 JPQL 查詢(它是對 SQL 的抽象),而是直接指定一個 SQL 查詢。此查詢可能使用特定資料庫的 SQL 方言。
需要注意的是,Hibernate 和 Spring Data 都不會在啟動時驗證本地查詢。由於查詢可能包含特定於資料庫的 SQL,因此 Spring Data 或 Hibernate 無法知道要檢查什麼。
因此,本地查詢是整合測試的主要候選者。但是,如果他們真的使用特定資料庫的 SQL,那麼這些測試可能不適用於嵌入式記憶體資料庫,因此我們必須在後臺提供一個真實的資料庫(比如,在持續整合管道中按需設定的 docker 容器中)。

@DataJpaTest 簡介

為了測試 Spring Data JPA 儲存庫或任何其他與 JPA 相關的元件,Spring Boot 提供了 @DataJpaTest 註解。我們可以將它新增到單元測試中,它將設定一個 Spring 應用程式上下文:

@ExtendWith(SpringExtension.class)
@DataJpaTest
class UserEntityRepositoryTest {

  @Autowired private DataSource dataSource;
  @Autowired private JdbcTemplate jdbcTemplate;
  @Autowired private EntityManager entityManager;
  @Autowired private UserRepository userRepository;

  @Test
  void injectedComponentsAreNotNull(){
    assertThat(dataSource).isNotNull();
    assertThat(jdbcTemplate).isNotNull();
    assertThat(entityManager).isNotNull();
    assertThat(userRepository).isNotNull();
  }
}
@ExtendWith
本教程中的程式碼示例使用 @ExtendWith 註解告訴 JUnit 5 啟用 Spring 支援。從 Spring Boot 2.1 開始,我們不再需要載入 SpringExtension,因為它作為元註解包含在 Spring Boot 測試註解中,例如 @DataJpaTest、@WebMvcTest 和 @SpringBootTest。本教程中的程式碼示例使用 @ExtendWith 註解告訴 JUnit 5 啟用 Spring 支援。從 Spring Boot 2.1 開始,我們不再需要載入 SpringExtension,因為它作為元註解包含在 Spring Boot 測試註解中,例如 @DataJpaTest@WebMvcTest@SpringBootTest

這樣建立的應用程式上下文將不包含我們的 Spring Boot 應用程式所需的整個上下文,而只是它的一個“切片”,其中包含初始化任何 JPA 相關元件(如我們的 Spring Data 儲存庫)所需的元件。
例如,如果需要,我們可以將 DataSource@JdbcTemplate@EntityManage 注入我們的測試類。此外,我們可以從我們的應用程式中注入任何 Spring Data 儲存庫。上述所有元件將自動配置為指向嵌入式記憶體資料庫,而不是我們可能在 application.propertiesapplication.yml 檔案中配置的“真實”資料庫。
請注意,預設情況下,包含所有這些元件(包括記憶體資料庫)的應用程式上下文在所有 @DataJpaTest 註解的測試類中的所有測試方法之間共享。
這就是為什麼在預設情況下每個測試方法都在自己的事務中執行的原因,該事務在方法執行後回滾。這樣,資料庫狀態在測試之間保持原始狀態,並且測試保持相互獨立。

建立資料庫模式

在我們可以測試對資料庫的任何查詢之前,我們需要建立一個 SQL 模式來使用。讓我們看看一些不同的方法來做到這一點。

使用 Hibernate ddl-auto

預設情況下,@DataJpaTest 會配置 Hibernate 為我們自動建立資料庫模式。對此負責的屬性是 spring.jpa.hibernate.ddl-auto,Spring Boot 預設將其設定為 create-drop,這意味著模式在執行測試之前建立並在測試執行後刪除。
因此,如果我們對 Hibernate 為我們建立模式感到滿意,我們就不必做任何事情。

使用 schema.sql

Spring Boot 支援在應用程式啟動時執行自定義 schema.sql 檔案。
如果 Spring 在類路徑中找到 schema.sql 檔案,則將針對資料來源執行該檔案。 這會覆蓋上面討論的 Hibernate 的 ddl-auto 配置。
我們可以使用屬性 spring.datasource.initialization-mode 控制是否應該執行 schema.sql。預設值是嵌入的,這意味著它只會對嵌入的資料庫執行(即在我們的測試中)。如果我們將其設定為 always,它將始終執行。
以下日誌輸出確認檔案已被執行:

Executing SQL script from URL [file:.../out/production/resources/schema.sql]

設定 Hibernate 的 ddl-auto 配置以在使用指令碼初始化架構時進行驗證是有意義的,以便 Hibernate 在啟動時檢查建立的模式是否與實體類匹配:

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySource(properties = {
        "spring.jpa.hibernate.ddl-auto=validate"
})
class SchemaSqlTest {
  ...
}

使用 Flyway

Flyway 是一種資料庫遷移工具,允許指定多個 SQL 指令碼來建立資料庫模式。它會跟蹤目標資料庫上已經執行了這些指令碼中的哪些指令碼,以便只執行之前沒有執行過的指令碼。
要啟用 Flyway,我們只需要將依賴項放入我們的 build.gradle 檔案中(如果我們使用 Maven,則類似):

compile('org.flywaydb:flyway-core')

如果我們沒有專門配置 Hibernate 的 ddl-auto 配置,它會自動退出,因此 Flyway 具有優先權,並且預設情況下會針對我們的記憶體資料庫測試執行它在資料夾 src/main/resources/db/migration 中找到的所有 SQL 指令碼。
同樣,將 ddl-auto 設定為 validate 是有意義的,讓 Hibernate 檢查 Flyway 生成的模式是否符合我們的 Hibernate 實體的期望:

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySource(properties = {
        "spring.jpa.hibernate.ddl-auto=validate"
})
class FlywayTest {
  ...
}

在測試中使用 Flyway 的價值

如果我們在生產中使用 Flyway,也能在上面描述的那樣在 JPA 測試中使用它,那就太好了。只有這樣我們才能在測試時知道 flyway 指令碼按預期工作。
但是,這僅適用於指令碼包含在生產資料庫和測試中使用的記憶體資料庫(我們的示例中為 H2 資料庫)上都有效的 SQL。如果不是這種情況,我們必須在我們的測試中禁用 Flyway,方法是將 spring.flyway.enabled 屬性設定為 false,並將 spring.jpa.hibernate.ddl-auto 屬性設定為 create-drop 以讓 Hibernate 生成模式。
無論如何,讓我們確保將 ddl-auto 屬性在生產配置檔案中設定為 validate!這是我們抵禦 Flyway 指令碼錯誤的最後一道防線!無論如何,讓我們確保將 ddl-auto 屬性在生產配置檔案中設定為 validate!這是我們抵禦 Flyway 指令碼錯誤的最後一道防線!

使用 Liquibase

Liquibase 是另一種資料庫遷移工具,其工作方式類似於 Flyway,但支援除 SQL 之外的其他輸入格式。例如,我們可以提供定義資料庫架構的 YAML 或 XML 檔案。
我們只需新增依賴項即可啟用它:

compile('org.liquibase:liquibase-core')

預設情況下,Liquibase 將自動建立在 src/main/resources/db/changelog/db.changelog-master.yaml 中定義的模式。
同樣,設定 ddl-autovalidate 是有意義的:

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySource(properties = {
        "spring.jpa.hibernate.ddl-auto=validate"
})
class LiquibaseTest {
  ...
}

在測試中使用 Liquibase 的價值

由於 Liquibase 允許多種輸入格式充當 SQL 上的抽象層,因此即使它們的 SQL 方言不同,也可以跨多個資料庫使用相同的指令碼。這使得在我們的測試和生產中使用相同的 Liquibase 指令碼成為可能。
不過,YAML 格式非常敏感,而且我最近在維護大型 YAML 檔案集合時遇到了麻煩。這一點,以及儘管我們實際上必須為不同的資料庫編輯這些檔案的抽象,最終導致轉向 Flyway。

填充資料庫

現在我們已經為我們的測試建立了一個資料庫模式,我們終於可以開始實際的測試了。在資料庫查詢測試中,我們通常會向資料庫新增一些資料,然後驗證我們的查詢是否返回正確的結果。
同樣,有多種方法可以將資料新增到我們的記憶體資料庫中,所以讓我們逐一討論。

使用 data.sql

schema.sql 類似,我們可以使用包含插入語句的 data.sql 檔案來填充我們的資料庫。上述規則同樣適用。

可維護性

data.sql 檔案迫使我們將所有 insert 語句放在一個地方。每一個測試都將依賴於這個指令碼來設定資料庫狀態。這個指令碼很快就會變得非常大並且難以維護。如果有需要衝突資料庫狀態的測試怎麼辦?
因此,應謹慎考慮這種方法。

手動插入實體

為每個測試建立特定資料庫狀態的最簡單方法是在執行被測查詢之前在測試中儲存一些實體:

@Test
void whenSaved_thenFindsByName() {
  userRepository.save(new UserEntity(
          "Zaphod Beeblebrox",
          "zaphod@galaxy.net"));
  assertThat(userRepository.findByName("Zaphod Beeblebrox")).isNotNull();
}

這對於上面示例中的簡單實體來說很容易。但在實際專案中,這些實體的構建和與其他實體的關係通常要複雜得多。此外,如果我們想測試比 findByName 更復雜的查詢,很可能我們需要建立比單個實體更多的資料。這很快變得非常令人厭煩。
控制這種複雜性的一種方法是建立工廠方法,可能結合 Objectmother 和 Builder 模式。
在 Java 程式碼中“手動”對資料庫進行程式設計的方法比其他方法有很大的優勢,因為它是重構安全的。程式碼庫中的更改會導致我們的測試程式碼中出現編譯錯誤。在所有其他方法中,我們必須執行測試才能收到有關重構導致的潛在錯誤的通知。使用

Spring DBUnit

DBUnit 是一個支援將資料庫設定為某種狀態的庫。Spring DBUnit 將 DBUnit 與 Spring 整合在一起,因此它可以自動與 Spring 的事務等一起工作。
要使用它,我們需要向 Spring DBUnit 和 DBUnit 新增依賴項:

compile('com.github.springtestdbunit:spring-test-dbunit:1.3.0')
compile('org.dbunit:dbunit:2.6.0')

然後,對於每個測試,我們可以建立一個包含所需資料庫狀態的自定義 XML 檔案:

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <user
        id="1"
        name="Zaphod Beeblebrox"
        email="zaphod@galaxy.net"
    />
</dataset>

預設情況下,XML 檔案(我們將其命名為 createUser.xml)位於測試類旁邊的類路徑中。
在測試類中,我們需要新增兩個 TestExecutionListeners 來啟用 DBUnit 支援。要設定某個資料庫狀態,我們可以在測試方法上使用 @DatabaseSetup

@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestExecutionListeners({
        DependencyInjectionTestExecutionListener.class,
        TransactionDbUnitTestExecutionListener.class
})
class SpringDbUnitTest {

  @Autowired
  private UserRepository userRepository;

  @Test
  @DatabaseSetup("createUser.xml")
  void whenInitializedByDbUnit_thenFindsByName() {
    UserEntity user = userRepository.findByName("Zaphod Beeblebrox");
    assertThat(user).isNotNull();
  }
}

對於更改資料庫狀態的測試查詢,我們甚至可以使用 @ExpectedDatabase 來定義資料庫在測試預期處於的狀態。
但是請注意,自 2016 年以來,Spring DBUnit 沒有再維護

@DatabaseSetup 不起作用?

在我的測試中,我遇到了 @DatabaseSetup 註釋被默默忽略的問題。原來有一個 ClassNotFoundException 因為找不到某些 DBUnit 類。不過,這個異常被吞了。
原因是我忘記包含對 DBUnit 的依賴,因為我認為 Spring Test DBUnit 可遞進地含它。因此,如果您遇到相同的問題,請檢查您是否包含了這兩個依賴項。

使用 @Sql

一個非常相似的方法是使用 Spring 的 @Sql 註解。我們沒有使用 XML 來描述資料庫狀態,而是直接使用 SQL:

INSERT INTO USER
            (id,
             NAME,
             email)
VALUES      (1,
             'Zaphod Beeblebrox',
             'zaphod@galaxy.net');

在我們的測試中,我們可以簡單地使用 @Sql 註解來引用 SQL 檔案來填充資料庫:

@ExtendWith(SpringExtension.class)
@DataJpaTest
class SqlTest {

  @Autowired
  private UserRepository userRepository;

  @Test
  @Sql("createUser.sql")
  void whenInitializedByDbUnit_thenFindsByName() {
    UserEntity user = userRepository.findByName("Zaphod Beeblebrox");
    assertThat(user).isNotNull();
  }

}

如果我們需要多個指令碼,我們可以使用 @SqlGroup 來組合它們。

結論

為了測試資料庫查詢,我們需要建立模式並用一些資料填充它的方法。由於測試應該相互獨立,因此最好對每個測試分別執行此操作。
對於簡單的測試和簡單的資料庫實體,通過建立和儲存 JPA 實體手動建立狀態就足夠了。對於更復雜的場景,@DatabaseSetup@Sql 提供了一種在 XML 或 SQL 檔案中外部化資料庫狀態的方法。

相關文章