Post

SpringBoot Repository Integrated Test with Real DB

SpringBoot Repository Integrated Test with Real DB

最初版本:直接用整個 Spring Boot app 起測試

一開始最直覺的寫法,是把它當成一般 integration test 來寫,大概像這樣:

1
2
3
4
5
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("dbtest")
class FtctlDepartRepositoryIT {  
    ...
}

這種寫法背後的想法很簡單:

想連真的 DB,
→ repository 需要 Spring 管理
→ 那就直接讓 Spring Boot 整個起來

這個版本會用到的 import,大概是這些

1
2
3
4
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

如果 class 裡還有 assertion,通常還會有:

1
import static org.assertj.core.api.Assertions.assertThat;

為什麼這個版本有問題?
這個版本的問題不是「不能連 DB」,而是 載太多東西。

因為 @SpringBootTest 幾乎等於:

  • 把整個 application context 拉起來
  • 掃 config
  • 掃 security
  • 掃 repository
  • 掃 JPA 相關設定
  • 掃一堆這支 test 根本不需要的 bean

所以雖然我們要測的只有 FtctlDepartRepository

  • SQL
  • 它對 DB 的查詢結果

但實際上被一起啟動的卻是整包應用程式。

這時候實際碰到的錯誤類型 後來會冒出一些和目標無關的錯,例如:

  • HttpSecurity 相關錯誤
  • No qualifying bean
  • Not a managed type
  • 其他 repository / JPA entity 的錯

這些錯的共同點是:

問題不是 FtctlDepartRepository 本身,而是 test context 太大。

第二步:不再載整個 app,改成縮成「只載某些 auto-config」

發現 @SpringBootTest 太寬之後,下一個很自然的想法是:

那我不要整包 application,我只載和 DB 有關的 auto-config 就好。

所以這一步的方向,是把 FtctlDepartRepositoryIT 改成:

  • 不掃整個 app
  • 不碰 security
  • 不碰多餘的 repository
  • 只保留 DB 存取需要的設定
  • 這一步的想法,通常會長這樣

想留下來的通常是:

  • DataSource
  • JdbcTemplate
  • transaction manager
  • SQL initialization 也就是把測試上下文縮成「資料庫 access 專用」。

這種版本會多出哪類 import 大概會變成這種方向:

1
2
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.context.annotation.Import;

然後搭配某些 Boot 的 auto-config class。

雖然已經比整包 @SpringBootTest 小很多了, 後來又發現一個問題:

test 開始依賴 Spring Boot 內部的 auto-config class 名稱與位置。

也就是說,test 會開始綁死在:

  • 某些 auto-config class 到底在哪個 package
  • 現在這個 Spring Boot 版本到底怎麼切
  • annotation 指到的 class 名稱是不是完全正確

結果就容易出現這類錯:

  • ClassNotFoundException
  • TypeNotPresentException
  • 某個 AutoConfiguration 類別根本不在你以為的位置

所以這一步的問題不是「縮小失敗」,而是:

它雖然比整包 app 小,但 still 太依賴 Spring Boot 內部結構。

第三步:再往下縮,不靠 auto-config 類名,直接在 test 裡自己定義需要的 bean

真正需要的只有這幾樣:

  • DataSource
  • JdbcTemplate
  • FtctlDepartRepository

最好的方式不是再猜 Spring Boot 要怎麼 auto-config
而是:

直接在 FtctlDepartRepositoryIT 裡,把這幾個 bean 自己定義出來。

最後穩定版本的核心寫法,不只是「概念上」自己補 DataSource,
而是連測試專用 context 都在 TestConfig 裡明確定義出來。

實際可用、之後也能直接照抄的版本,大概像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@SpringBootTest(
		classes = FtctlDepartRepositoryIT.TestConfig.class,  
		webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@ActiveProfiles("dbtest")
@EnabledIfEnvironmentVariable(named = "DBTEST_PERSON_SN", matches = ".+")  
class FtctlDepartRepositoryIT {

	@SpringBootConfiguration
	@Import(FtctlDepartRepository.class)  
	static class TestConfig {
		@Bean(destroyMethod = "close")
		HikariDataSource dataSource(
				@Value("${spring.datasource.url}") String url,  
				@Value("${spring.datasource.username}") String username,
				@Value("${spring.datasource.password}") String password,
				@Value("${spring.datasource.driver-class-name}") String driverClassName
		) {  
			HikariDataSource dataSource = new HikariDataSource();
			dataSource.setDriverClassName(driverClassName);
			dataSource.setJdbcUrl(url);
			dataSource.setUsername(username);  
			dataSource.setPassword(password);
			dataSource.setReadOnly(true);
			return dataSource;
		}  

		@Bean
		JdbcTemplate jdbcTemplate(DataSource dataSource) {
			return new JdbcTemplate(dataSource);  
		}
	}

	@Autowired  
	private FtctlDepartRepository repository;

	@Value("${dbtest.person-sn:}")
	private String personSn;  

	@Test
	void findLeaderUnits_executesQueryAndMapsRows() {
		assertThat(personSn)  
				.as("Set DBTEST_PERSON_SN before running this integration test.")
				.isNotBlank();

		List<DepartLeaderUnit> units = repository.findLeaderUnits(personSn);  

		assertThat(units).isNotNull();  
		assertThat(units)  
				.allSatisfy(unit -> {
					assertThat(unit.dpname()).isNotBlank();  
					assertThat(unit.dpdegree()).isNotBlank();  
				});
	}
}  

負責做的事有幾個:

  • 用 @SpringBootConfiguration 告訴 Spring Boot:測試要用這個 inner class 當作設定入口
  • 用 @Import(FtctlDepartRepository.class) 明確只載入這次要測的 repository
  • 手動建立 DataSource,直接吃 spring.datasource.* 設定
  • 用 @Bean(destroyMethod = “close”) 確保 HikariDataSource 用完會正常釋放
  • 額外補一個 JdbcTemplate bean,讓 repository 需要的依賴完整成立
  • 用 setReadOnly(true) 表達這支 test 是查詢用途,不希望碰寫入

最後比較重要的 import :

1
2
3
4
5
6
7
8
9
10
import javax.sql.DataSource;

import com.zaxxer.hikari.HikariDataSource;

import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;

以及測試本身常見的:

1
2
3
4
5
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

只依賴:

  • 一個 DataSource
  • 一個 JdbcTemplate
  • 一個 FtctlDepartRepository
  • 一個 dbtest profile

它不再偷偷依賴:

  • 整個 application context
  • security config
  • 其他 repository
  • JPA entity scan
  • Spring Boot 內部 auto-config class 名稱

所以這時候如果 test 失敗,原因通常就會真的接近目標本身:

  • DB 連線有問題
  • SQL 有問題
  • schema / 權限有問題
  • person_sn 對不到資料 這才是我們真正想要的 integration test。

整個演進過程,最簡單的版本 如果只用最短的方式描述,整個 FtctlDepartRepositoryIT 的演進就是這樣:

一開始用 @SpringBootTest 是太多。
後來縮成只載 auto-config,方向對了,但還不夠。
最後改成 test 自己提供 DataSource 和 JdbcTemplate,
才真正把測試控制在 FtctlDepartRepository 需要的範圍內。

This post is licensed under CC BY 4.0 by the author.