【注】本文譯自: 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.properties
或 application.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-auto
為 validate
是有意義的:
@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 文件中外部化數據庫狀態的方法。