JUnit 不好用?也許你可以試試這些測試工具

韓楠發表於2022-08-25

在我們日常的 TDD 開發中,永遠繞不過去的就是要編寫測試。而對於一個 Java 程式設計師,JUnit 似乎是一個不二的選擇。它的確是一個十分優秀的工具,在大多數情況下都能夠幫助我們完成測試的工作。

但是在開發過程中,我發現 JUnit 並不總是那麼好用。它在一些情況下需要耗費挺多精力才能編寫出讓人滿意的測試。

JUnit 不擅長的事情

一個讓人滿意的測試,應該能夠清晰的體現被測試的目標、測試的目的以及測試的輸入輸出,並且應遵循  DRY 原則,儘可能的減少測試中的重複內容。

JUnit 可以透過設計測試方法名和組織方法內的程式碼的方式清晰的表達意圖,也可以透過引數化測試來減少相同測試目的的測試程式碼重複。但是它在這些地方都做的不夠好。

清晰表達測試的目的

在使用 JUnit 時,清晰的表達測試意圖並不是總能做到的事情。這主要體現在兩個方面。

如何命名測試方法

第一個體現就是在使用 Java 編寫測試時,採用什麼樣的命名風格來命名測試。是為了程式碼風格的統一而選擇駝峰?還是為了更高的可讀性選擇下劃線?這個問題在不同的專案中有不同的實踐,看起來是沒有一個統一的認識。

而這個問題的根源是 JUnit 的測試名稱是 Java 的方法名,而 Java 的方法名又不能在其中插入空格。所以除了下面要介紹的兩種測試工具外,採用  Kotlin [1] 來編寫 JUnit 也是一種方式。

如何組織方法內的程式碼

第二個體現就是 JUnit 對測試方法內部如何編寫沒有強制的規定。這就意味著我可以在測試裡面任意地組織程式碼,比如在一個測試方法裡面對一個方法呼叫多次並驗證每一次的結果,或者把呼叫測試目錄的邏輯和準備資料的邏輯以及驗證邏輯混合到一起。總之這樣的結果就是測試方法內的程式碼組織方式千奇百怪,每當閱讀別人編寫的測試的時候,總是要花上好幾分鐘才能知道這些程式碼到底在幹什麼。

對於這個問題,我個人會選擇使用註釋來標註一下  given 、  when 、  then,並且給 IDEA 設定了 live template 方便插入它們。

又不是不能用的引數化測試

如果說不能清晰地表達測試意圖這個問題還有一些 workaround 可以繞過去的話,JUnit 那僅僅是能用的引數化測試功能就沒有什麼好辦法可以繞過去了。

JUnit 提供了各種  Source 註解來為引數化測試提供資料,但是這個功能實在是太弱了,很難讓人滿意。

難以讓人滿意的第一個原因是,各種  Source  註解基本上只能支援 7 種基本型別再加上  String,  Enum 和  Class 型別。如果想要使用其他型別的例項作為引數的話,就必須要使用  MethodSource 或者  ArgumentsSource 註解。

這就導致了第二個原因:這兩個註解需要單獨寫一個 靜態或一個  ArgumentProvider 的實現,這就導致很難把測試引數寫到測試程式碼旁邊。並且  Arguments.of() 方法並不利於閱讀測試引數。

MethodSource 要求使用 靜態方法,這在使用 Kotlin 編寫 JUnit 時需要把這些方法寫到  companion object 裡面,並且加上  @JvmStatic 註解。因為 Kotlin 裡面沒有  static 關鍵字。

這兩點導致測試的可讀性下降。而按照“測試即文件”的原則,我們應該盡力去保證測試的可讀性。

第三個原因則是來自  ParameterizedTest 註解。它的  name 欄位可以使用引數的索引值來把引數填入模板中,生成更加可讀的測試名稱。但是它的功能也僅限於此了。因為這個模板只能使用索引值,不能使用索引後再呼叫裡面的方法或者欄位。所以如果我們的引數是一個複雜物件,那麼一定要重寫  toString 方法才能得到滿意的輸出。但是這又違背了編寫測試的原則之一——不能為了測試而新增實現程式碼。如果我們一定要得到一個更加表意的測試名稱,那麼新增一個專用的測試引數也能做到。但是這又會導致 IDE 或者構建工具的警告,因為它們認為這個引數沒有被使用。


總之,儘管 JUnit 可以解決絕大多數問題,但是在這麼幾個小地方卻做的不是那麼完美。

那麼有沒有什麼工具可以作為 JUnit 的替代呢?當然是有的。下面我將按照我接觸的順序來介紹兩種種測試框架。可以在 GitHub 上找到下面例子的 完整程式碼 [2]

使用 Spock 作為測試框架

Spock [3]是一個用  Groovy 編寫的測試框架,按照  given/when/then 的結構定義 dsl,能夠讓測試更加的語義化。它的一大特點是  Data Driven Test [4],可以方便的編寫引數化測試。

我曾在兩個專案上嘗試過使用  Spock 作為測試框架,幾乎沒有遇到過無法解決的問題。

如何使用 Spock

由於  Spock 是使用  Groovy 來編寫測試的,所以我們要使用它時,除了要引用它本身,還需要新增對  Groovy 的支援。以  gradle 為例:

第二個依賴提供了一些對 Spring 的支援,比如可以使用  @SpringBean  註解來讓被 mock 的物件注入到測試的容器中。

我們先看一個最簡單的例子:

  1. 每一個測試都需要繼承抽象類  Specification
  2. 可以使用字串來命名測試
  3. Spock 定義了一些 block,這裡的  given 、  when 、  then 都是 block。
    1. given block 負責測試的 setup 工作
    2. when block 可以是任意程式碼,不過最好是對測試目標的呼叫。它總是和  then 一起出現
    3. then block 用來斷言。這裡不需要任何的 assertion,只需要編寫返回值是 boolean 的表示式即可

Spock 有非常友好的測試報告輸出。如果我們把上面的斷言特意改錯,就能得到這樣的測試輸出:

在這個輸出裡面,我們可以清晰的看出表示式兩端的值是什麼,非常便於 debug。

接下來再看一個和  Spring 整合的簡單例子:

  1. 使用  SpringBean 註解注入到測試的上下文中; Mock 是  Spock 提供的 mock 方法
  2. 為 mock 物件的方法呼叫設定返回值
  3. 驗證 mock 物件的方法被呼叫的次數和引數

特點

語義化的結構

在前面的例子中,我們看到了 block 的概念。它可以幫助我們更好的組織程式碼結構,寫出更加便於閱讀的程式碼。其實在每一個 block 宣告之後,我們還可以在新增一個字串,達到註釋的作用。比如:

除了上面的例子裡看到的, Spock  還提供了  cleanup  、  expect  、  where  這三個 block。詳細資訊可以看看它的 文件 [5]

簡潔的斷言

在上面的例子中,我們看到  Spock 的斷言十分簡潔,不需要像使用  assertj 一樣寫很長的  assertThat(xxx).isEqualTo(yyy),只需要一個返回 boolean 的表示式就可以了。甚至可以把多行斷言提取到一個方法中,返回他們與運算的結果。

使用 data table 構造引數化測試

對於引數化測試,我們再來看一個例子。

我們可以看到程式碼的最後一段是一個  where  block,這是  Spock  中用來定義資料測試的資料的地方。例子中的寫法被稱作 data table。儘管  Spock  還支援一些其他的寫法,但是我個人認為 data table 是一個更加可讀的寫法,所以這也是我最常使用的寫法。並且這個寫法不需要手動調整格式,IDEA 支援自動 format,堪稱完美。

我們還可以留意一下方法名。方法名中有幾個以  # 開頭的字串,它們其實是在引用 data table 中定義的變數。這種透過變數名引用的方式可讀性遠遠大於 JUnit 的索引值的方式。並且我們可以看到  #movedPosition.x 這樣的表示式,它們可以直接使用這些物件中的欄位值來生成方法名,不需要依賴於物件的  toString 方法。

它的測試輸出也非常便於定位失敗的測試資料。

使用 Groovy 編寫測試

使用  Groovy 來編寫測試,可以說既是優點,也是缺點。

優點是在於  Groovy 是動態語言,我們可以利用這一特性在測試中少寫一些囉嗦的程式碼。並且在前面的例子中,斷言裡面獲取的  marsRover.position 欄位本身是 private 欄位,測試仍然可以正常執行。這些都是由  Groovy 帶來的靈活性。

缺點在於這是一個相對小眾的語言。如果不是因為 Gradle,或許不會有多少人熟悉它的語法。這也會導致人們在選擇它時會變得更加謹慎。

與 IDE 的完美整合

我一直使用的 IDEA 是能夠完美適配它的。除了前面提到的 format data table ,最主要的是 IDEA 能像執行 JUnit 一樣執行它,並且不需要任何的配置。

缺點

我在第一個專案裡面使用  Spock 時,幾乎沒有發現它有什麼缺點,以至於在後來的專案中總是在問 TL 能不能把它加到專案裡來 ?

但是後來在一個 Kotlin 專案中嘗試使用它時,卻遇到一些問題。

與 Kotlin 的整合問題

無法識別 Kotlin 的語法糖

Groovy 不能直接識別到  Kotlin 程式碼中的各種語法糖,這就讓測試寫起來有那麼一點點不舒服。

比如命名引數。其實  Groovy 也支援命名引數,但是語法和  Kotlin 不同。這就顯得有一點尷尬。不過這個問題可以透過為測試編寫一些 fixutre 之類的程式碼來幫助處理這裡問題。比如下面這個  Kotlin 型別:

我們可以為它編寫一個 fixture,就能在測試裡面也使用命名引數了

其他的一些語法問題也基本都能繞過,本質思路就是把要測試的程式碼想象成編譯後的  Java,這樣就能找到繞過的辦法。

沒有對 final class 的 mock 支援

這是一個基本繞不過去的問題。 Kotlin 裡面的型別預設都是  final,不能再被繼承。但是  Spock 的 mock 卻需要為要 mock 的物件的型別建立一個子類。這就導致我們不能去 mock 那些型別。其實這個問題不是  Spock 特有的, Mockito 也有這個問題。只不過在使用 JUnit 時我們會選擇用  MockK 作為  Kotlin 專案的 mock 工具,而不是  Mockito

解決這個問題的策略有好幾個:

  1. 儘可能不去 mock。這要求我們設計出更容易測試的程式碼,這樣就可以避免在測試中使用 mock。
  2. 使用 Spring 的 Kotlin 外掛  org.jetbrains.kotlin.plugin.spring[](org.jetbrains.kotlin.plugin.spring "") 。它可以把  Spring 的 component 類都修改成 open,這樣就能 mock 了

在寫這篇文章的時候,發現一個很久沒有更新的倉庫  kotlin-test-runner [6],也許可以借鑑一下這裡的思路來解決這個問題。

與 JUnit 的相容問題

對於上一個問題,我們當時還有一個 workaround,那就是使用  JUnit5 +  MockK 來編寫那些需要 mock 的測試。但是那個時候的  Kotlin 版本還比較低,沒有遇到和 JUnit 的相容問題。

相容問題是 JUnit 在編寫 Spring 整合測試的時候,如果有 mock bean 的需求,需要使用  springmock [7] 裡面的  @MockkBean 註解。但是從 kotlin 1.5.30 開始,這個庫就不能和 Spock 編寫的 Spring 整合測試相容,會出現 NPE 問題。這個問題在使用  Kotlin 對  Specification 子類進行反射時會出現。如果對這個問題感興趣,可以看看這個  issue [8] 。

Groovy 語言的學習成本

就像前面提到過的,使用  Groovy 還是有一些學習成本的。如果團隊裡沒有熟悉它的人,可能會走一點彎路。

使用 Kotest 作為測試框架

Kotest 是在無意中發現的測試框架,還沒有在實際的專案中實踐過。所以這裡只能分享一下如何使用,沒有什麼經驗分享。

如何使用 Kotest

Kotest 的測試目標是  Kotlin 程式碼,所以它除了支援  Java 以外,還支援用  Kotlin 編譯的 JS。不過這裡,我們就只試試 JVM 平臺就好。

這樣我們就能使用  Kotest 最基礎的功能了。接著我們來看一個例子。

這是一種 BDD 風格的測試。 Kotest 使用  BehaviorSpec 類封裝起來。

在  then 中,我們沒有看到常見的  assertThat() 語句,取而代之的是  Kotest 的 assertion 庫提供的方法。

接著我們再看一個 Spring 整合測試的例子,這需要多引入一個依賴  io.kotest.extensions:kotest-extensions-spring。

在這個例子中,除了  override fun extensions() 那一行以外,其他部分的程式碼和 JUnit 的整合測試幾乎沒有區別。而這一行就好像 JUnit 中的  @ExtendWith(SpringExtension) 一樣。我們甚至可以抽出所有的整合測試程式碼到單獨的  sourceSet 中對 extensions 進行全域性配置。 Spring | Kotest [9]

特點

豐富的測試風格支援

除了上面的例子,我們還有很多的測試風格可以選擇,這在它的文件中有介紹: Testing Styles | Kotest [10]

簡潔的斷言

在上面的例子裡面我們看到, Kotest 提供了自己的斷言庫,不需要再寫冗長的  assertThat() 之類的語句

使用 Kotlin 編寫,能與 Kotlin 專案完美結合

使用  Kotlin 來編寫測試,可以使用到  Kotlin 裡面的各種語法糖。這樣就不用像  Spock 一樣在語法切換中掙扎。

支援 MockK

同樣的,因為  Kotest 的測試使用  Kotlin 編寫,自然是支援  MockK 的。這樣就能利用  MockK 的特性,支援對 final class 的 mock。

與 JUnit 相容

因為  Kotest 是基於 JUnit 平臺的,所以是能和 JUnit 相容的,不會出現上面的  Spock 那樣的問題。

對 data driven test 的支援

Kotest 提供了擴充套件來支援 data driven test。當然,不使用這個擴充套件也可以進行,比如用 list 構造好資料之後 foreach 建立測試。不過這裡的例子我們還是使用這個擴充套件來演示。(需要新增依賴  io.kotest:kotest-framework-datatest )

雖然這個 data driven test 相對於  Spock 的 data table 來講沒有那麼直觀,但是對比 JUnit 的話,能夠方便的自定義測試方法名,並且測試資料與測試程式碼放在一起,已經算是一個巨大的進步了。

缺點

因為沒有在實際的專案中實踐過,所以目前沒有發現很多的缺點。

與 IDEA 和 Gradle 的整合不夠完美

這個問題的表現是在 IDEA 裡面無法執行單個測試方法。但是細究後發現,實際上是和 gradle 的整合不夠好。

預設情況下,IDEA 會使用 gradle 來執行測試。執行單個測試的命令是  gradle test --tests "xxx.Class.yyyMethod"。對於 JUnit,這裡的 class 和 method 是很直觀的類名和方法名。但是  Kotest 的寫法卻不是編寫類裡面的方法,而是呼叫方法生成測試。所以 gradle 的這個命令就沒有辦法生效,也就沒有辦法只執行一個測試方法了。

在把 IDEA 的配置更新成使用 IDEA 來執行測試後,在 mac 上能夠正常執行一個測試方法。但是在 Windows + WSL 的環境上卻會出錯。(看起來像是 IDEA 的問題,因為它嘗試把 WSL 裡面的 java 當作 Windows 的程式執行)

不要使用 Spek

前面介紹了兩種值得一試的測試框架,這裡再介紹一種不建議使用的框架。

當初想要嘗試這個框架,是因為看到有網友說這是  Kotlin 版本的  Spock  ?。但是實踐下來並沒有發現它有和  Spock 類似的功能,並且還出現了這些痛點:

  1. 與其他測試框架混合使用的問題 當與其他測試框架混合使用時, Spek 測試總是會先執行。哪怕我們在 IDEA 裡面只想執行一個 JUnit 的單元測試, Spek 也會先把自己的所有測試跑完,然後才會執行到我們想要執行的測試。這就意味著在  Speck  測試編寫了很多之後,執行其他測試就會等待很久。
  2. 不能編寫 Spring 整合測試
  3. 我在寫 demo 的時候發現,它的 IDEA 外掛在 Windows 上面無法工作

如果這些痛點你都能忍,那我也不建議使用這個框架,畢竟上面已經有更好的選擇了。


總結

現在我們有了兩個 JUnit 以外的測試框架選擇。當然它們也不是完美的,JUnit 仍然是那個最穩定、風險最低的那一個。但如果你想嘗試一下這兩個框架的話,可以考慮一下這些方面:

  1. 生產程式碼的程式語言
    1. 如果是 Kotlin,那麼可以考慮  Kotest,不要考慮  Spock
    2. 如果是 Java,那麼這兩個都值得考慮
  2. 語言熟悉程度
    1. Kotlin 明顯是比 Groovy 更加流行,這個角度考慮的話  Kotest 是更優的選擇
  3. 測試框架的流行程度(這方面我不知道有什麼評價標準,只是作為參考)
    1. 兩個框架在 GitHub 上的 star 數量半斤八兩,一個 3.1k,一個 3.2k(JUnit 也才 4.9k)
    2. 在 MVNRepository 上, Spock [11] 的 usage 明顯高於  Kotest [12]
  4. IDEA 的整合
    1. Spock 在這方面完全沒有問題
    2. Kotest 需要安裝外掛,並且在 Windows + WSL 的模式下不能執行單個測試
  5. Gradle 整合
    1. Spock 完美整合
    2. Kotest 不能執行單個測試

參考資料

來自 “ 無糖拿鐵謝謝 ”, 原文作者:不準叫我坤坤;原文連結:https://mp.weixin.qq.com/s/Bc40tMeoAYzLA5iLqnlW0A,如有侵權,請聯絡管理員刪除。

相關文章