1. 簡介
在本文中,我們將對使用 Spring 進行集成測試進行全面討論,並探討如何優化它們。
首先,我們將簡要討論集成測試的重要性及其在現代軟件開發中扮演的角色,重點關注 Spring 生態系統。
稍後,我們將涵蓋多個場景,重點關注 Web 應用。
接下來,我們將討論一些提高測試速度的策略,通過學習不同的方法,這些方法將影響我們如何設計測試以及如何設計應用程序本身。
在開始之前,請記住這篇博文是一篇基於經驗的觀點文章。有些內容可能適合您,有些可能不適合。
最後,本文使用 Kotlin 作為代碼示例,以保持代碼簡潔,但這些概念並不特定於該語言,代碼片段應對 Java 和 Kotlin 開發者都具有意義。
2. 集成測試
集成測試是自動化測試套件的重要組成部分。 儘管我們應該遵循 健康的測試金字塔,避免像單元測試那樣大量使用它們。 依賴像 Spring 這樣的框架,我們需要進行相當多的集成測試,以降低系統某些行為的風險。
隨着我們使用 Spring 模塊(數據、安全、社交等)來簡化代碼,集成測試的需求就會越來越大。 尤其當我們將基礎設施中的一些組件移入 @Configuration 類時,這種趨勢更加明顯。
我們不應該“測試框架”,但我們當然應該驗證框架是否按照我們的需求進行配置。
集成測試可以幫助我們建立信心,但它們也伴隨着代價:
- 執行速度較慢,這意味着構建時間較長
- 此外,集成測試意味着更廣泛的測試範圍,這在大多數情況下並不是理想的選擇
考慮到以上問題,我們將嘗試尋找一些解決方案來緩解這些問題。
3. 測試 Web 應用
Spring 提供了幾種用於測試 Web 應用的選項,大多數 Spring 開發人員都熟悉這些選項,它們是:
- MockMvc:模擬 Servlet API,適用於非響應式 Web 應用
- TestRestTemplate:可以指向我們的應用,適用於非響應式 Web 應用,不希望模擬 Servlet 時
- WebTestClient:是用於響應式 Web 應用的測試工具,既可以模擬請求/響應,也可以命中真實服務器
正如我們已經有文章涵蓋這些主題,因此我們不會花時間討論它們。
請隨意查看,如果您想深入瞭解,請這樣做。
4. 優化執行時間
集成測試非常棒。它們能給我們帶來相當程度的信心。如果正確實施,它們也能以一種清晰的方式描述我們的應用程序意圖,減少了大量的模擬和設置噪音。
然而,隨着應用程序的成熟和開發積壓,構建時間不可避免地會增加。隨着構建時間的增加,運行所有測試可能變得不切實際。
這會影響我們的反饋循環,並偏離最佳開發實踐。
此外,集成測試本質上是昂貴的。啓動某種持久性、通過(即使它們從未離開 localhost)發送請求或進行 IO 操作都需要時間。
密切關注我們的構建時間,包括測試執行至關重要。 我們可以應用一些技巧來在 Spring 中降低構建時間。
在下一部分,我們將涵蓋一些有助於我們優化構建時間以及可能影響其速度的陷阱:
- 明智地使用 profiles – profiles 對性能的影響
- 重新考慮 @MockBean – 模擬對性能的影響
- 重構 @MockBean – 提高性能的替代方案
- 仔細考慮 @DirtiesContext – 一個有用的但危險的註解以及如何避免使用它
- 使用 test slices – 一種有用的工具,可以幫助我們
- 使用類繼承 – 以安全的方式組織測試的一種方式
- 狀態管理 – 避免 flaky 測試的最佳實踐
- 重構為單元測試 – 實現一個堅實且快速的構建的最佳方式
讓我們開始吧!
4.1. 謹慎使用配置文件
配置文件是一個相當不錯的工具。 簡單來説,它們可以啓用或禁用應用程序的某些部分。 甚至可以使用它們來實現特徵標誌!
隨着配置文件的豐富,人們可能會在集成測試中時不時地切換它們。 像 <em >@ActiveProfiles</em> 這樣的便捷工具可以幫助我們做到這一點。 但是,每次我們用新的配置文件拉取測試時,都會創建一個新的 <em >ApplicationContext</em>。
對於一個空無一物標準的 Spring Boot 應用程序,創建應用程序上下文可能很快。 添加一個 ORM 和幾個模塊後,它會迅速飆升到 7 秒以上。
添加大量的配置文件,並將其分散在幾個測試中,我們很快就會得到一個 60 秒以上的構建時間(假設我們在構建過程中運行測試——而且我們應該這樣做)。
當應用程序變得足夠複雜時,解決這個問題變得令人望而卻步。 但是,如果我們在使用前仔細計劃,那麼保持合理的構建時間就變得輕而易舉。
在集成測試中,使用配置文件時,可以考慮以下幾點:
- 創建一個聚合配置文件,即 `test`,其中包含所有需要的配置文件——在所有地方堅持使用我們的測試配置文件
- 在設計配置文件時,要考慮到可測試性。 如果我們最終需要切換配置文件,那麼可能還有更好的方法
- 在中心化的位置聲明我們的測試配置文件——稍後我們會討論這個話題
- 避免測試所有配置文件組合。 另一種方法是,我們可以為每個環境創建一個端到端測試套件,使用該特定配置文件集測試應用程序
4.2. 關於 @MockBean 的問題
@MockBean 是一個相當強大的工具。
當我們需要一些 Spring 的魔法,但又想 mock 一個特定的組件時,@MockBean 非常實用。但它也伴隨着代價。
每次在類中出現 @MockBean,ApplicationContext 緩存會被標記為髒,因此測試類執行完畢後,運行器會清理緩存。 這再次為我們的構建增加了額外的幾秒鐘。
這是一個有爭議的地方,但對於這個特定場景,嘗試直接運行實際的應用程序,而不是進行 mock,可能會有所幫助。當然,這裏沒有靈丹妙藥。當我們不允許自己 mock 依賴項時,界限變得模糊。
我們可能會認為:為什麼我們要進行持久化,當所有我們想要測試的是 REST 層呢?這是一個合理的觀點,並且總會有妥協。
然而,只要心中有幾個原則,這個實際上可能變成一個優勢,從而改善測試和應用程序的設計,並減少測試時間。
4.3. 重構 <em @MockBean</h3
在本節中,我們將嘗試使用 重構一個“緩慢”的測試,以重用緩存的 ApplicationContext。
假設我們要測試一個創建用户的 POST 請求。如果我們使用 進行模擬,我們只需驗證我們的服務是否被調用並傳遞了序列化的用户即可。
如果正確測試我們的服務,這種方法就足夠了:
class UsersControllerIntegrationTest : AbstractSpringIntegrationTest() {
@Autowired
lateinit var mvc: MockMvc
@MockBean
lateinit var userService: UserService
@Test
fun links() {
mvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""{ "name":"jose" }"""))
.andExpect(status().isCreated)
verify(userService).save("jose")
}
}
interface UserService {
fun save(name: String)
}我們希望避免使用 @MockBean。因此,我們將持久化實體(假設服務確實這樣做)。
最簡單粗暴的方法是測試副作用:在 POST 之後,我的用户就在我的數據庫中,在我們的例子中,這會使用 JDBC。
然而,這違反了測試邊界:
@Test
fun links() {
mvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""{ "name":"jose" }"""))
.andExpect(status().isCreated)
assertThat(
JdbcTestUtils.countRowsInTable(jdbcTemplate, "users"))
.isOne()
}在本例中,我們故意打破測試邊界,將我們的應用程序視為一個 HTTP 黑盒,發送用户請求,但隨後又使用實現細節進行斷言,即用户已被持久化到某個數據庫。
如果我們通過 HTTP 交互應用程序,是否也可以通過 HTTP 斷言結果?
@Test
fun links() {
mvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""{ "name":"jose" }"""))
.andExpect(status().isCreated)
mvc.perform(get("/users/jose"))
.andExpect(status().isOk)
}如果我們採用這種方法,將會有幾個優勢:
- 我們的測試將更快啓動(儘管可能需要稍微多一點時間執行,但應該能得到回報)
- 此外,我們的測試不瞭解與 HTTP 邊界無關的副作用,例如數據庫
- 最後,我們的測試清晰地表達了系統的意圖:如果使用 POST,您將能夠獲取 Users
當然,這可能並不總是可以實現的,原因有很多:
- 我們可能沒有“副作用”端點:這裏的一個選項是考慮創建“測試端點”
- 複雜度過高,無法覆蓋整個應用程序:這裏的一個選項是考慮切片(稍後我們會討論它們)
4.4. 仔細考慮 @DirtiesContext </h3
有時,我們可能需要在測試中修改 ApplicationContext。 為了應對這種情況,@DirtiesContext 提供了恰好所需的功能。
由於上述原因,@DirtiesContext 在執行時間方面是一個非常昂貴的資源,因此我們應該謹慎使用。
@DirtiesContext 的一些濫用包括應用緩存重置或內存數據庫重置。 在集成測試中處理這些場景有更好的方法,並在後續部分中我們會介紹一些。
4.5. 使用測試片(Test Slices)
測試片是 Spring Boot 從 1.4 版本開始引入的一個特性。其核心思想很簡單,Spring 會為您的應用程序的特定片(slice)創建一個簡化的應用上下文。
此外,框架會自動配置最基本的內容。
Spring Boot 自帶了足夠數量的測試片,我們也可以創建自己的測試片:
- @JsonTest: 註冊與 JSON 相關的組件
- @DataJpaTest: 註冊 JPA 實體,包括可用的 ORM
- @JdbcTest: 適用於原始 JDBC 測試,處理數據源和不帶 ORM 複雜性的內存數據庫
- @DataMongoTest: 嘗試提供內存 MongoDB 測試設置
- @WebMvcTest: 一個不包含應用程序其餘部分的小型 MVC 測試片
- … (您可以查看 源代碼 以查找所有測試片)
如果使用得當,這個特性可以幫助我們構建精簡的測試,而不會對性能產生過大的影響,尤其對於小型/中型應用程序而言。
然而,如果我們的應用程序不斷增長,它也會隨着創建每個(小型)應用上下文而堆積。
4.6. 使用類繼承
使用單個 <em >AbstractSpringIntegrationTest</em> 類作為所有集成測試的父類,是一種簡單、強大且務實的保持構建速度的方法。
如果我們提供一個可靠的設置,我們的團隊只需進行擴展,並知道“一切正常運行”。 這樣,我們就可以減少管理狀態或配置框架的精力,專注於解決手頭的問題。
我們可以在此設置所有測試要求:
- Spring 運行器 – 或更理想情況下,如果以後需要其他運行器,則使用規則
- profile – 理想情況下,我們的聚合 test profile
- 初始配置 – 設置應用程序的狀態
讓我們來看一個簡單的基類,它處理上述要點:
@SpringBootTest
@ActiveProfiles("test")
abstract class AbstractSpringIntegrationTest {
@Rule
@JvmField
val springMethodRule = SpringMethodRule()
companion object {
@ClassRule
@JvmField
val SPRING_CLASS_RULE = SpringClassRule()
}
}4.7. 狀態管理
記住“unit”在單元測試中的來源至關重要,參考 這裏。 簡單來説,它意味着我們可以在任何時候運行單個測試(或子集),並獲得一致的結果。
因此,測試開始前狀態應該保持清潔且已知。
換句話説,無論測試是單獨執行還是與其他測試一起執行,測試結果都應該保持一致。
這個概念同樣適用於集成測試。 我們需要確保應用程序在開始新測試之前具有已知的(且可重複的)狀態。 隨着我們重用越來越多的組件(如應用程序上下文、數據庫、隊列、文件等)以加快速度,狀態污染的風險也會增加。
假設我們完全採用類繼承,現在我們有一箇中心位置來管理狀態。
讓我們增強我們的抽象類,以確保在運行測試之前應用程序處於已知的狀態。
在我們的示例中,我們假設存在多個倉庫(來自各種數據源),以及一個 Wiremock 服務器:
@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureWireMock(port = 8666)
@AutoConfigureMockMvc
abstract class AbstractSpringIntegrationTest {
//... spring rules are configured here, skipped for clarity
@Autowired
protected lateinit var wireMockServer: WireMockServer
@Autowired
lateinit var jdbcTemplate: JdbcTemplate
@Autowired
lateinit var repos: Set<MongoRepository<*, *>>
@Autowired
lateinit var cacheManager: CacheManager
@Before
fun resetState() {
cleanAllDatabases()
cleanAllCaches()
resetWiremockStatus()
}
fun cleanAllDatabases() {
JdbcTestUtils.deleteFromTables(jdbcTemplate, "table1", "table2")
jdbcTemplate.update("ALTER TABLE table1 ALTER COLUMN id RESTART WITH 1")
repos.forEach { it.deleteAll() }
}
fun cleanAllCaches() {
cacheManager.cacheNames
.map { cacheManager.getCache(it) }
.filterNotNull()
.forEach { it.clear() }
}
fun resetWiremockStatus() {
wireMockServer.resetAll()
// set default requests if any
}
}4.8. 將集成測試重構為單元測試
這是其中一個最重要的方面。我們經常會發現,某些集成測試實際上在測試應用程序的高層策略。
每當我們發現集成測試正在測試核心業務邏輯的多個用例時,就應該重新評估我們的方法,並將它們分解為單元測試。
一種可能的方法是:
- 識別那些測試核心業務邏輯的多個用例的集成測試
- 複製測試套件,並將副本重構為單元測試——在此階段,我們可能需要分解生產代碼以使其可測試
- 確保所有測試通過
- 保留一個足夠顯著的“happy path”樣本,該樣本在集成測試套件中,我們可能需要重構或合併和重塑幾個
- 刪除剩餘的集成測試
Michael Feathers 在《Working Effectively with Legacy Code》一書中涵蓋了許多技術來實現這一目標以及更多技術。
5. 總結
本文介紹了與 Spring 相關的集成測試,重點在於集成測試。
首先,我們討論了集成測試的重要性以及它們在 Spring 應用中的相關性。
隨後,我們總結了一些可能對特定類型的 Web 應用集成測試有用的工具。
最後,我們回顧了一系列可能降低測試執行時間的問題,以及如何改進它們。