1. 引言
Spring Boot 是一款流行的 Java 框架,為集成測試提供了豐富平台。它非常方便和靈活;然而,在大型項目中,當項目包含數百甚至數千個集成測試,並且這些測試使用了大量的重型組件(如 TestContainers 管理的 Bean)時,可能會出現性能問題和其他問題。
在本文中,我們將深入瞭解框架的內部工作原理,探討其可能導致速度慢和資源消耗過多的原因,以及如何提升性能。 通過對這些細節的瞭解,您將能夠高效地擴展您的測試套件。
2. Spring Context 緩存原理説明
Spring Context 緩存機制允許 Spring 容器在需要時,從緩存中檢索 bean 定義,而不是每次都從 XML 文件或註解中解析。這可以顯著提高應用程序的啓動速度和性能,尤其是在應用程序啓動時,bean 定義的解析需要消耗大量資源。
以下是 Spring Context 緩存機制的關鍵方面:
- 緩存存儲: Spring Context 緩存將 bean 定義存儲在內存中。
- 緩存命中: 當 Spring 容器需要一個 bean 定義時,它首先檢查緩存中是否存在該定義。如果存在(緩存命中),則直接從緩存中返回該定義,而無需重新解析。
- 緩存失效: 當 bean 定義發生更改時,緩存會失效。Spring 容器會檢測到更改,並清除緩存中的相關條目。
- 配置選項: 可以通過配置選項控制緩存的行為,例如緩存大小、緩存策略等。
以下是一個簡單的示例,展示瞭如何配置 Spring Context 緩存:
@Configuration
public class AppConfig {
@Bean
public MyService myService() {
return new MyService();
}
}
在這個例子中,myService bean 的定義會被存儲在 Spring Context 緩存中,以便在後續的請求中快速訪問。
2.1. <em>MergedContextConfiguration</em> 的定義
下面我們來看一個簡單的 Spring Boot 集成測試。它聲明配置,並且可以有父超類、注入字段和 @Test 方法。
Spring Test 框架讀取測試類簽名並決定是創建新的 Spring 上下文還是重用緩存中的現有上下文。下面我們來看一些聲明配置、配置文件或屬性的註解:
@ContextConfiguration(classes = {
FeatureServiceIntTest.Configuration.class
})
@ActiveProfiles("test")
@TestPropertySource(properties = {
"parameter = value"
})
public class FeatureServiceIntTest extends AbstractIntTest {
@MockBean
private FeatureRepository featureRepository;
// ...
}總共,Spring 從測試類及其超類中收集約十二個這樣的參數,並將其聚合到一個 org.springframework.test.context.MergedContextConfiguration 類對象:
- locations, classes, contextInitializerClasses, contextLoader (來自 @ContextConfiguration)
- activeProfiles (來自 @ActiveProfiles)
- propertySourceDescriptors, propertySourceLocations, propertySourceProperties (來自 @TestPropertySource)
- contextCustomizers (來自 @ContextCustomizerFactory) – 例如:@DynamicPropertySource, @MockBean/@MockitoBean 以及 @SpyBean/@MockitoSpyBean
- parent (用於具有繼承層次結構的上下文)
MergedContextConfiguration 是 Spring 上下文緩存的關鍵。這意味着如果所有這些字段都相等,則可以重用現有的 Spring 上下文。否則,Spring 會創建一個新的上下文,將其放入與此鍵相對應的緩存中,並將其用於集成測試。
2.2. 測試套件執行示例
考慮一個包含八個測試類,並且具有四種不同配置(根據其 MergedContextConfiguration)的測試套件。如果運行這些測試,最終將會存在四個獨立的 Spring 運行時環境,並且每個運行時環境都是按需創建的 (Test1IT、Test3IT、Test4IT 和 Test5IT 創建新的運行時環境;Test2IT、Test6IT 和 Test8IT 重新使用現有運行時環境)。相同顏色表示測試類具有相同的配置:
Spring 會在 JVM 停止鈎子上關閉所有這些運行時環境,但可能太晚了,意味着此時已經發生了一些事情:
- 測試類之間由於資源衝突(如固定端口)而發生衝突
- 過多的活躍運行時環境共享過多的重量級 Spring Bean(如通過 TestContainers 管理),導致 OOM 或 Docker 主機過載
此外,每個運行時環境的初始化可能相當耗時。對於一個豐富的運行時環境,該環境會啓動一個帶有數據庫和大量組件的 Web 應用程序,初始化時間通常比測試執行時間要長得多。
因此,我們可以得出以下一些中間結論:
- 以某種方式限制當前活躍的 Spring 運行時環境的數量
- 為了優化測試,我們需要減少唯一的運行時環境配置的數量
- 增加共享狀態以減少後續運行時環境初始化的開銷
- 最終,我們是否可以重新審視標準行為以獲得最大的收益?
讓我們逐一探討這些要點。
3. 經典優化
3.1. @DirtiesContext 註解
@DirtiesContext 註解在測試方法或測試類之前/之後關閉 Spring 上下文。該註解的目的是避免修改共享的 Spring 上下文,該上下文可能與其它測試不兼容。
在最極端的場景下,當該註解添加到集成測試父類時,會導致大量的重新初始化。雖然這可能解決一些測試衝突問題,但
3.2. 上下文緩存大小
默認情況下,上下文緩存大小為 32 (對於重量級 Bean 而言,這可能過高),並且可以調整為較小的數值。可以通過在 classpath 下的 spring.properties 文件中指定屬性來設置:
spring.test.context.cache.maxSize=4或者,也可以在 Maven 或 Gradle 構建的設置中指定:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${maven-surefire.version}</version>
<configuration>
<argLine>-Dspring.test.context.cache.maxSize=1 ...</argLine>
</configuration>
</plugin>使用大小為 1 (one) 的緩存時,新的時間圖將如下所示:
您會注意到,當具有相同上下文配置的測試依次執行時,上下文將保持存活。這已經比使用全局配置的 @DirtiesContext 註解 更好。
此外,請注意,存在一個小的上下文疊加(舊上下文僅在創建新上下文後關閉),這在固定服務器端口用於測試時可能至關重要,正如我們將在下面進一步解釋的。
3.3. 引入常用測試父類
為了減少唯一上下文配置的數量,一種簡單的方法是引入一個通用的集成測試父類。 將所有需要的配置都添加到其中。
儘可能,子類不應聲明額外的配置(包括 <em @MockBean</em> 和 <em @SpyBean</em> 註解),因為這些也是上下文配置自定義,會導致創建單獨的 Spring 上下文:
3.4. 正確定義 @MockBean
使用 @MockitoBean(在最新版本的 Spring 發佈中替換了 @MockBean)和 @SpyBean(也已替換為 @MockitoSpyBean)提供了一種便捷且靈活的方法,用於覆蓋行為或在上下文中定義缺失的 Bean。
然而,正如之前提到的,它屬於所謂的定製器(參見 MergedContextConfiguration的定義)。 儘可能在父級集成測試類或共享的 @TestConfiguration類中查找 @MockBean/ @SpyBean的聲明。
3.5. 可複用靜態 Docker 容器 Bean
與其為每個 Spring 上下文創建由 TestContainer 管理的 Docker 容器作為 Bean,不如使用專門的靜態 Bean 聲明:
@TestConfiguration
public class LocalStackS3TestConfiguration {
private static LocalStackContainer localStackS3;
// override destroy method to empty to avoid closing docker container
// bean on closing Spring context
@Bean(destroyMethod = "")
public LocalStackContainer localStackS3Container() {
synchronized (LocalStackS3TestConfiguration.class) {
if (localStackS3 == null) {
localStackS3 = new LocalStackContainer(DockerImageName.parse("localstack/localstack:4.6.0"))
.withServices(LocalStackContainer.Service.S3);
localStackS3.start();
}
return localStackS3;
}
}
}<div>
<p>請注意,<i class="annotation">destroyMethod</i> 註解參數應被覆蓋,以避免在上下文中關閉。</p>
</div>
3.6. 數據庫容器的惰性初始化
如果應用程序只有一個數據庫,則不那麼關鍵。但是,如果存在多個 DataSource 訪問不同的模式,按需啓動數據庫容器(惰性啓動)是有意義的。 就像在許多測試中一樣,這些初始化的操作通常是多餘的(很少的集成測試會使用所有可能的 DataSource)。 從技術上講,可以這樣實現:
- 不要立即啓動 Container 對象
- 創建一個包裝 DataSource 對象的對象,該對象會在第一次調用 getConnection() 時啓動底層容器
我們可以基於 Spring 的 DelegatingDataSource (它也應該是一個 Closeable 對象,以委託 Bean 關閉):
public class LateInitDataSource extends DelegatingDataSource implements Closeable {
private final Supplier<DataSource> dataSourceSupplier;
public LateInitDataSource(Supplier<DataSource> dataSourceSupplier) {
// SingletonSupplier: call dataSourceSupplier.get() not more than once
this.dataSourceSupplier = SingletonSupplier.of(() -> {
DataSource dataSource = dataSourceSupplier.get();
setTargetDataSource(dataSource);
return dataSource;
});
}
@Override
public void afterPropertiesSet() {
// no op to skip getTargetDataSource setup
}
@Override
protected DataSource obtainTargetDataSource() {
return dataSourceSupplier.get();
}
@Override
public void close() throws IOException {
DataSource targetDataSource = getTargetDataSource();
if (targetDataSource instanceof AutoCloseable) {
try {
((AutoCloseable) targetDataSource).close();
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("Error while closing targetDataSource", e);
}
}
}
@Override
public String toString() {
return "LateInitDataSource{" + ", delegate=" + getTargetDataSource() + '}';
}
}然後,我們還需要聲明 DataSource Bean:
@Bean
public DataSource dataSource(PostgreSQLContainer<?> container) {
// lazy late initialization
return new LateInitDataSource(() -> {
LOGGER.info("Late initialization data source docker container {}", container);
// start only on demand
container.start();
return createHikariDataSourceForContainer(container);
});
}3.7. 壞做法:固定端口
使用固定端口號(正如我們在生產環境中通常配置的那樣)對於集成測試非常方便;但是,它限制了測試執行的並行化可能性。例如,它會阻止同一模塊或多個模塊中的多個測試類同時執行。我們可以觀察到測試服務器初始化問題,例如:
Caused by: java.io.IOException: Failed to bind to address 0.0.0.0/0.0.0.0:8080 (address already in use)<p>與其配置固定端口用於 HTTP、gRPC 和 TestContainer 端口:</p>
@SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT)首選使用 WebEnvironment.RANDOM_PORT。
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
// will inject actual dynamic port
@LocalServerPort
private int port;對於手動 Server Socket 初始化的情況,使用服務器 Socket 端口 0(零);它將自動分配一個隨機可用的服務器端口。請相應地配置測試客户端。
3.8. 壞做法:容器不是 @Bean
TestContainers 管理的 Docker 容器應該通過 Spring 實現生命週期管理。避免使用諸如以下聲明:
@TestConfiguration
public class DockerDataSourceTestConfiguration {
@Bean
public DataSource dataSource() {
// not a manageable bean!
var container = new PostgreSQLContainer("postgres:9.6");
container.start();
return createDataSource(container);
}
private static DataSource createDataSource(JdbcDatabaseContainer container) {
var hikariDataSource = new HikariDataSource();
hikariDataSouce.setJdbcUrl(container.getJdbcUrl());
...
return hikariDataSource;
}
}改為聲明 Container 為一個 Bean,並將其注入為 DataSource 創建參數:
@TestConfiguration
public class DockerDataSourceTestConfiguration {
// will be terminated with Spring context
@Bean(initMethod = "start")
public PostgreSQLContainer postgreSQLContainer() {
return new PostgreSQLContainer("postgres:9.6");
}
@Bean
public DataSource dataSource(PostgreSQLContainer postgreSQLContainer) {
return createDataSource(postgreSQLContainer);
}
// ...
}3.9. 不良實踐:ExecutorService 未正確關閉
與在類初始化期間創建的 ExecutorService 類似,它也需要得到妥善管理。否則,運行時可能會有大量的活躍線程,從而使測試失敗分析變得複雜,增加資源消耗,並且可能會導致執行測試時失敗的任務(例如,仍在活動的定時任務)的測試日誌中出現令人困惑的錯誤信息。為了解決這個問題,請添加缺失的 @PreDestroy 方法:
@Service
public class DefaultScheduler {
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(16);
public void scheduleNow(Runnable command, long periodSeconds) {
scheduler.scheduleAtFixedRate(command, 0L, periodSeconds, TimeUnit.SECONDS);
}
// to avoid thread leakage in test execution
@PreDestroy
public void shutdown() {
scheduler.shutdown();
}
}這種簡單方法也會對程序的正常關閉產生積極影響。
4. 重新審視標準測試執行行為
在測試執行期間,可以最大限度地優化資源消耗。當測試引擎啓動測試套件時,我們已經知道測試類列表。 這樣,我們就可以準確地預測 Spring 上下文何時停止使用,並立即關閉它: