使用 Spring Boot 進行測試的陷阱
一、概述
編程中最重要的主題之一是測試。 Spring Framework 和 Spring Boot 通過提供測試框架擴展並引導我們編寫最少的、可測試的代碼並在後台進行大量自動化,從而提供了很好的支持。要運行 Spring Boot 集成測試,我們只需將@SpringBootTest
添加到我們的測試類中。我們可以在 Testing in Spring Boot 中找到簡短的介紹。即使我們不使用Spring Boot而使用Spring Framework,我們也可以非常高效地進行集成測試。
但是開發測試越容易,陷入陷阱的風險就越大。在本教程中,我們將探討如何執行 Spring Boot 測試以及編寫測試時必須考慮的事項。
2.陷阱示例
讓我們從一個小例子開始:讓我們實現一個管理寵物的服務( PetService)
:
public record Pet(String name) {}
@Service
public class PetService {
private final Set<Pet> pets = new HashSet<>();
public Set<Pet> getPets() {
return Collections.unmodifiableSet(pets);
}
public boolean add(Pet pet) {
return this.pets.add(pet);
}
}
該服務不應允許重複,因此測試可能如下所示:
@SpringBootTest
class PetServiceIntegrationTest {
@Autowired
PetService service;
@Test
void shouldAddPetWhenNotAlreadyExisting() {
var pet = new Pet("Dog");
var result = service.add(pet);
assertThat(result).isTrue();
assertThat(service.getPets()).hasSize(1);
}
@Test
void shouldNotAddPetWhenAlreadyExisting() {
var pet = new Pet("Cat");
var result = service.add(pet);
assertThat(result).isTrue();
// try a second time
result = service.add(pet);
assertThat(result).isFalse();
assertThat(service.getPets()).hasSize(1);
}
}
當我們分別執行每個測試時,一切都很好。但是當我們一起執行它們時,我們會得到一個測試失敗:
但是為什麼測試會失敗呢?我們如何防止這種情況發生?我們將澄清這一點,但首先,讓我們從一些基礎知識開始。
3. 功能測試的設計目標
我們編寫功能測試來記錄需求並確保應用程序代碼正確實現它們。因此,測試本身也必須正確,並且必須易於理解,最好是不言自明。但是,對於本文,我們將關注進一步的設計目標:
- 回歸:測試必須是可重複的。他們必須產生確定性的結果
- 隔離:測試可能不會相互影響。它們的執行順序無關緊要,即使它們並行執行也不重要
- 性能:測試應該盡可能快地運行並儘可能節省資源,尤其是那些屬於 CI 管道或 TDD 的部分
關於 Spring Boot 測試,我們需要知道它們是一種集成測試,因為它們會導致ApplicationContext
的初始化,即 bean 使用依賴注入進行初始化和連接。所以隔離需要特別注意——而上面展示的例子似乎存在隔離問題。另一方面,良好的性能也是對Spring Boot測試的挑戰。
作為第一個結論,我們可以說避免集成測試是最重要的一點。 PetService
測試的最佳解決方案是單元測試:
// no annotation here
class PetServiceUnitTest {
PetService service = new PetService();
// ...
}
我們應該只在必要時編寫 Spring Boot 測試,例如,當我們想要測試我們的應用程序代碼是否被框架正確處理(生命週期管理、依賴注入、事件處理)或者如果我們想要測試一個特殊層(HTTP 層) , 持久層)。
4.上下文緩存
顯然,當我們將@SpringBootTest
添加到我們的測試類時, ApplicationContext
就會啟動,bean 也會被初始化。但是,為了支持隔離,JUnit 會為每個測試方法初始化此步驟。這將導致每個測試用例一個ApplicationContext
,從而顯著降低測試性能。為避免這種情況,Spring 測試框架緩存上下文並允許將其重新用於多個測試用例。當然,這也會導致重新使用 bean 實例。這就是PetService
測試失敗的原因——兩種測試方法都處理PetService
的同一個實例。
不同的ApplicationContext
僅在它們彼此不同時才會創建——例如,如果它們包含不同的 beans 或具有不同的應用程序屬性。我們可以在Spring Test Framework 文檔中找到有關它的詳細信息。因為ApplicationContext
配置是在類級別完成的,所以默認情況下,測試類中的所有方法都共享相同的上下文。
下圖顯示了這種情況:
上下文緩存作為一種性能優化與隔離相矛盾,因此我們只能在確保測試之間的隔離的情況下重新使用ApplicationContext
。這是Spring Boot 測試只應在滿足某些條件時才在同一 JVM 中並行運行的最重要原因。我們可以使用不同的 JVM 進程運行測試(例如,通過為 Maven Surefire 插件設置forkMode
),但隨後我們繞過了緩存機制。
4.1. PetService
示例解決方案
關於PetService
測試,可能有多種解決方案。它們都適用,因為PetService
是有狀態的。
一種解決方案是使用@DirtiesContext
註釋每個測試方法。這將ApplicationContext
標記為臟,因此在測試後將其關閉並從緩存中刪除。這會阻止性能優化,並且永遠不應該是首選方式:
@SpringBootTest
class PetServiceIntegrationTest {
@Autowired
PetService service;
@Test
@DirtiesContext
void shouldAddPetWhenNotAlreadyExisting() {
// ...
}
@Test
@DirtiesContext
void shouldNotAddPetWhenAlreadyExisting() {
// ...
}
}
另一種解決方案是在每次測試後重置PetService
的狀態:
@SpringBootTest
class PetServiceIntegrationTest {
@Autowired
PetService service;
@AfterEach
void resetState() {
service.clear(); // deletes all pets
}
// ...
}
然而,最好的解決方案是實現PetService
stateless 。現在,寵物沒有存儲在內存中,這永遠不是一個好的做法,尤其是在可擴展的環境中。
4.2.陷阱:上下文太多
為了避免無意識地初始化額外的ApplicationContexts
,我們需要知道是什麼導致了不同的配置。最明顯的是 bean 的直接配置,例如使用@ComponentScan
、 @Import
、 @AutoConfigureXXX
(例如@AutoConfigureTestDatabase
)。但是派生也可能是由啟用配置文件( @ActiveProfiles
)或記錄事件( @RecordApplicationEvents
)引起的:
@SpringBootTest
// each of them derives from the original (cached) context
@ComponentScan(basePackages = "com.baeldung.sample.blogposts")
@Import(PetServiceTestConfiguration.class)
@AutoConfigureTestDatabase
@ActiveProfiles("test")
@RecordApplicationEvents
class PetServiceIntegrationTest {
// ...
}
我們可以在Spring Test Framework 文檔中找到詳細信息。
4.3.陷阱:嘲諷
Spring Test Framework 包括 Mockito 來創建和使用模擬。使用@MockBean,
我們讓 Mockito 創建一個模擬實例並將其放入ApplicationContext
中。此實例特定於測試類。結果是我們不能與其他測試類共享ApplicationContext
:
@SpringBootTest
class PetServiceIntegrationTest {
// context is not shareable with other test classes
@MockBean
PetServiceRepository repository;
// ...
}
一個建議可能是避免使用模擬並測試整個應用程序。但是如果我們想測試異常處理,我們不能總是阻止模擬。如果我們仍然想與其他測試類共享ApplicationContext
,我們還必須共享模擬實例。當我們定義一個創建模擬並替換ApplicationContext
中的原始 bean 的@TestConfiguration
時,這是可能的。但是,我們必須意識到隔離問題。
正如我們所知,緩存和重用ApplicationContext
假定我們在測試後重置上下文中的每個有狀態 bean。 Mocks 是一種特殊的有狀態 bean,因為它們被配置為返回值或拋出異常,並且它們記錄每個方法調用以對每個測試用例進行驗證。測試後,我們也需要重新設置它們。這是在使用@MockBean
時自動完成的,但是當我們在@TestConfiguration
中創建模擬時,我們負責重置。幸運的是,Mockito 本身提供了設置。所以整個解決方案可能是:
@TestConfiguration
public class PetServiceTestConfiguration {
@Primary
@Bean
PetServiceRepository createRepositoryMock() {
return mock(
PetServiceRepository.class,
MockReset.withSettings(MockReset.AFTER)
);
}
}
@SpringBootTest
@Import(PetServiceTestConfiguration.class) // if not automatically detected
class PetServiceIntegrationTest {
@Autowired
PetService repository;
@Autowired // Mock
PetServiceRepository repository;
// ...
}
4.4.配置上下文緩存
如果我們想了解在測試執行期間ApplicationContext
的初始化頻率,我們可以在application.properties
中設置日誌記錄級別:
logging.level.org.springframework.test.context.cache=DEBUG
然後我們得到一個包含如下統計信息的日誌輸出:
org.springframework.test.context.cache:
Spring test ApplicationContext cache statistics:
[[email protected] size = 1, maxSize = 32, parentContextCount = 0, hitCount = 8, missCount = 1]
默認緩存大小為 32 (LRU)。如果我們想增加或減少它,我們可以指定另一個緩存大小:
spring.test.context.cache.maxSize=50
如果我們想深入研究緩存機制的代碼,可以從org.springframework.test.context.cache.ContextCache
接口開始。
5.上下文配置
不僅為了緩存目的,而且為了ApplicationContext
初始化性能,我們可能會優化配置。初始化越少,測試設置越快。我們可以為惰性 bean 初始化配置測試,但我們必須注意潛在的副作用。另一種可能性是減少 bean 的數量。
5.1.配置檢測
@SpringBootTest,
默認情況下,開始在測試類的當前包中搜索,然後向上搜索包結構,尋找帶有@SpringBootConfiguration,
然後從中讀取配置以創建應用程序上下文。此類通常是我們的主要應用程序,因為@SpringBootApplication
註釋包含@SpringBootConfiguration
註釋。然後,它會創建一個類似於將在生產環境中啟動的應用程序上下文。
5.2.最小化ApplicationContext
如果我們的測試類需要一個不同的(最小的) ApplicationContext
,我們可以創建一個靜態內部@Configuration
類:
@SpringBootTest
class PetServiceIntegrationTest {
@Autowired
PetService service;
@Configuration
static class MyCustomConfiguration {
@Bean
PetService createMyPetService() {
// create your custom pet service
}
}
// ...
}
與使用@TestConfiguration
相比,這完全阻止了@SpringBootConfiguration
的自動檢測。
另一種減小ApplicationContext
大小的方法是使用@SpringBootTest(classes=…)
。這也會忽略內部@Configuration
類並僅初始化給定的類。
@SpringBootTest(classes = PetService.class)
public class PetServiceIntegrationTest {
@Autowired
PetService service;
// ...
}
如果我們不需要任何 Spring Boot 功能,例如配置文件和讀取應用程序屬性,我們可以替換@SpringBootTest
。我們來看看這個註解背後的內容:
@ExtendWith(SpringExtension.class)
@BootstrapWith(SpringBootTestContextBootstrapper.class)
public @interface SpringBootTest {
// ...
}
我們可以看到這個註釋只啟用了 JUnit SpringExtension
(它是 Spring Framework 的一部分,而不是 Spring Boot 的一部分)並聲明了 Spring Boot 提供的[TestContextBootstrapper](https://docs.spring.io/spring-framework/docs/6.0.0/javadoc-api/org/springframework/test/context/TestContextBootstrapper.html)
並實現了搜索機制。如果我們刪除@BootstrapWith
,則使用DefaultTestContextBootstrapper
,它不是 SpringBoot 感知的。然後我們必須使用@ContextConfiguration:
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = PetService.class)
class PetServiceIntegrationTest {
@Autowired
PetService service;
// ...
}
5.3.測試切片
Spring Boot 的自動配置系統適用於應用程序,但有時對於測試來說可能太過分了。僅加載測試應用程序“切片”所需的部分配置通常很有幫助。例如,我們可能想要測試 Spring MVC 控制器是否正確映射 URL,並且我們不想在這些測試中涉及數據庫調用;或者我們可能想要測試 JPA 實體,並且在這些測試運行時我們對 Web 層不感興趣。
我們可以在Spring Boot 文檔中找到可用測試切片的概述。
5.4.上下文優化與緩存
上下文優化導致單個測試的啟動時間更快,但我們應該意識到這將導致不同的配置,從而導致更多的ApplicationContext
初始化。總之,整個測試執行時間可能會增加。因此,跳過上下文優化可能會更好,但使用符合測試用例要求的現有配置。
6. 建議:自定義切片
正如我們所了解的,我們必須在ApplicationContext
的數量和大小之間找到一個折衷方案。挑戰在於跟踪配置。解決這個問題的一個可能的解決方案是定義幾個自定義切片(可能每層一個,整個應用程序一個)並在所有測試中專門使用它們,即我們必須避免在測試類中使用@MockBean
進行進一步配置和模擬.
Pet Domain Layer 的解決方案可能是:
@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@ExtendWith(SpringExtension.class)
@ComponentScan(basePackageClasses = PetsDomainTest.class)
@Import(PetsDomainTest.PetServiceTestConfiguration.class)
// further features that can help to configure and execute tests
@ActiveProfiles({"test", "domain-test"})
@Tag("integration-test")
@Tag("domain-test")
public @interface PetsDomainTest {
@TestConfiguration
class PetServiceTestConfiguration {
@Primary
@Bean
PetServiceRepository createRepositoryMock() {
return mock(
PetServiceRepository.class,
MockReset.withSettings(MockReset.AFTER)
);
}
}
}
然後可以如下所示使用它:
@PetsDomainTest
public class PetServiceIntegrationTest {
@Autowired
PetService service;
@Autowired // Mock
PetServiceRepository repository;
// ...
}
7. 進一步的陷阱
7.1.派生測試配置
集成測試的一個原則是我們測試應用程序盡可能接近生產狀態。我們只推導特定的測試用例。不幸的是,測試框架本身會重新配置我們應用程序的行為,我們應該意識到這一點。例如,內置的可觀察性功能在測試期間被禁用,因此如果我們想在我們的應用程序中測試觀察,我們明確需要使用@AutoConfigureObservability
重新啟用它。
7.2.封裝結構
當我們想要測試應用程序的切片時,我們需要聲明必須在ApplicationContext
中初始化哪些組件。我們可以通過列出相應的類來做到這一點,但為了獲得更穩定的測試配置,最好指定包。例如,我們有一個這樣的映射器:
@Component
public class PetDtoMapper {
public PetDto map(Pet source) {
// ...
}
}
我們在測試中需要這個映射器;我們可以使用這個精益解決方案配置測試:
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = PetDtoMapper.class)
class PetDtoMapperIntegrationTest {
@Autowired
PetDtoMapper mapper;
// ...
}
如果我們用MapStruct
替換 mapper 實現, PetDtoMapper
類型將成為一個接口,然後MapStruct
在同一個包中生成實現類。所以給定的測試失敗,除非我們導入整個包:
@ExtendWith(SpringExtension.class)
public class PetDtoMapperIntegrationTest {
@Configuration
@ComponentScan(basePackageClasses = PetDtoMapper.class)
static class PetDtoMapperTestConfig {}
@Autowired
PetDtoMapper mapper;
// ...
}
這具有初始化放置在同一包和子包中的所有其他 bean 的副作用。這就是為什麼我們應該根據切片的結構創建一個包結構。這包括特定於域的組件、安全的全局配置、Web 或持久層或事件處理程序。
八、結論
在本教程中,我們探索了編寫 Spring Boot 測試的陷阱。我們了解到ApplicationContext
是被緩存和重用的,所以我們需要考慮隔離。
像往常一樣,所有代碼實現都可以在 GitHub 上找到。