知識庫 / Spring RSS 訂閱

大規模 Spring Integration 測試優化

Spring,Testing
HongKong
4
10:42 AM · Dec 06 ,2025

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 運行時環境,並且每個運行時環境都是按需創建的 (Test1ITTest3ITTest4ITTest5IT 創建新的運行時環境;Test2ITTest6ITTest8IT 重新使用現有運行時環境)。相同顏色表示測試類具有相同的配置:

Spring 會在 JVM 停止鈎子上關閉所有這些運行時環境,但可能太晚了,意味着此時已經發生了一些事情:

  1. 測試類之間由於資源衝突(如固定端口)而發生衝突
  2. 過多的活躍運行時環境共享過多的重量級 Spring Bean(如通過 TestContainers 管理),導致 OOM 或 Docker 主機過載

此外,每個運行時環境的初始化可能相當耗時。對於一個豐富的運行時環境,該環境會啓動一個帶有數據庫和大量組件的 Web 應用程序,初始化時間通常比測試執行時間要長得多。

因此,我們可以得出以下一些中間結論:

  1. 以某種方式限制當前活躍的 Spring 運行時環境的數量
  2. 為了優化測試,我們需要減少唯一的運行時環境配置的數量
  3. 增加共享狀態以減少後續運行時環境初始化的開銷
  4. 最終,我們是否可以重新審視標準行為以獲得最大的收益?

讓我們逐一探討這些要點。

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 上下文何時停止使用,並立即關閉它:

在時間圖中,我們可以看到相同的上下文用於 Test1ITTest2ITTest7IT。 這意味着在完成 Test7IT 之後,我們可以終止上下文,釋放所有資源。 同樣,Test3ITTest4ITTest8IT 也是如此。

讓我們將此方法與第二次優化相結合——重新排序測試執行,以按順序執行共享相同上下文的測試:

現在,在任何時間點,我們最多隻有一個活躍的 Spring 上下文。 這樣,測試套件只需要最小的可行資源量(如 CPU 和內存)。 這也將減少 Docker 環境對管理 TestContainer Spring bean 的負載。

為了支持這種行為,我們需要實現:

  • 測試類重新排序
  • 自動關閉 Spring 上下文

Spring Framework 不能控制測試類的順序;這是測試引擎(如 TestNG 或 JUnit)的責任。 JUnit 5 通過專門的監聽器 org.junit.jupiter.api.ClassOrderer 支持測試排序。 這種重新排序監聽器的實現是 spring-test-smart-context 項目的一部分。

實現 ClassOrderer 的類應在包含測試的模塊的類路徑中,以便通過 junit-platform.properties 進行自動發現。 排序邏輯基於測試類的計算 MergedContextConfiguration 對象。

為了自動關閉 Spring 上下文,請使用 SmartDirtiesContextTestExecutionListener 或基於它構建您的實現。

4.1. 易於使用的解決方案

這種邏輯可以在項目中實現,但使用一個簡單的插件庫,通過類路徑自動發現會更方便。 步驟如下:

首先,我們需要將 該庫 添加到測試類路徑中:

<dependency>
    <groupId>com.github.seregamorph</groupId>
    <artifactId>spring-test-smart-context</artifactId>
    <version>0.14</version>
    <scope>test</scope>
</dependency>

對於Gradle,我們添加:

testImplementation("com.github.seregamorph:spring-test-smart-context:0.14")

然後,從測試中移除(尤其是父級測試類)所有使用過的 @DirtiesContext 註解,或者將所有使用該註解的地方替換為聲明。

@TestExecutionListeners(listeners = {
    SmartDirtiesContextTestExecutionListener.class,
})

可選地,啓用 INFO 級別的日誌記錄,用於 com.github.seregamorph.testsmartcontext 日誌器,以便查看更多詳細信息。

在測試執行期間的示例日誌輸出可能如下所示:

<h3>4.2. 隱性收益</h3>
<p>除了使用智能測試排序和上下文關閉所帶來的所有優勢外,還有一些額外的優勢。當測試引擎以單個線程執行所有測試時,在上下文關閉時釋放所有分配的資源會更容易進行JVM監控分析,以瞭解堆和線程泄漏情況:</p>
</div>
<img src="/file/story/attachments/image/l/a688b2bf-474b-4fde-bda4-ab4ba6fb67ec">
<div>
 <p>如圖所示,活躍線程數圖顯示出下降趨勢——這些是Spring上下文關閉。但圖表上存在明顯的上升趨勢,表明線程泄漏。類似的堆轉儲圖也可能突出顯示我們是否正確地關閉了分配的資源。</p>

5. 結論

檢查和優化 Spring 集成測試可以顯著減少所需的資源,例如 CPU 和內存,並可能穩定測試執行。此外,隨着更少的資源分配,測試執行將始終更快。這有簡單的解釋:系統不會在冗餘 Docker 容器管理、線程池等上浪費資源。

解決集成測試中遇到的問題也可以增強應用程序的正確優雅關閉週期,使部署更加無縫。

在罕見情況下,它甚至可以幫助發現影響生產代碼的泄漏!

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.