變異測試:自動搜尋不完善的測試

用 pitest 找到不正確與不完整的單元測試案例

Du Spirit
Java Magazine 翻譯系列
20 min readAug 15, 2020

--

Translated from “Mutation Testing: Automate the Search for Imperfect Tests”, Henry Coles, Java Magazine November/December 2016, page 43. Copyright Oracle Corporation.

上週您如果寫了任何程式,您很可能也寫了對應的測試。您並不孤單。近期,很難找到沒有單元測試的程式碼 [譯註:該怎麼說呢?某島上還是有不少公司不寫測試的],很多開發者投資許多時間在測試上,但他們做得很好嗎?

七年前,在一家金融服務公司,我在一個大型的遺留程式碼上進行開發,這問題開始困惱著我,程式碼相當難維護,但作為業務的核心,常需要更新以滿足新需求。

團隊很多時間花在修改程式碼成可維護的形式,這讓企業主緊張,他們理解團隊遇到問題且需要做出改變,但如果團隊出錯,要付出龐大的代價,企業主希望安心,每件事都會很順利。

這程式碼有很多測試,不幸地,過去團隊不需仔細檢視測試,確保測試的品質不比待測程式差,因此,在團隊修改任何程式前,他們得先投資大量的精力改善既有的測試與建立新測試。

在修改前,我的團隊成員總有好的測試,所以我告訴企業主不用擔心:如果重構過程中產生臭蟲,測試會逮到,業主的錢很安全。但是,如果我錯了怎麼辦?如果團隊無法信賴測試套件?如果安全網有很多洞怎麼辦?還有另一個相關的問題。

當團隊成員修改程式時,他們同樣需要修改測試。有時團隊重構測試讓它更簡潔,有時測試需像功能一樣更新,到處搬移,所以即使在寫的當下,測試品質相當好,但團隊如何確保測試修改時不會引入缺陷?

團隊可以用測試捕捉產品程式碼中的錯誤,但測試程式碼中的錯誤如何捕捉?團隊針對測試寫測試?若真是如此,難道團隊不用寫測試去驗證測試的測試?接著驗證測試的測試的測試?這聽起來不像是很好地結束,如果它真的能結束。

幸運的是,這些問題有個解答,如其他團隊,我的團隊用測試涵蓋率工具量測測試程式的分支涵蓋率。測試涵蓋率工具會告訴您哪些程式碼有被良好地測試過,若測試被修改,團隊只需要確保測試涵蓋率和過去一樣,問題解決了,真的嗎?

如此依賴測試涵蓋率有個小問題,如我接下來要解釋的,事實上,它並沒有告訴團隊任何事關於程式如何被測試。

測試涵蓋率有什麼問題?

這問題用一個我在程式裡找到的遺留測試案例來說明。先看一個刻意設計的類別:

我可能找到像這樣的測試:

這測試能提供 100 % 的程式碼與分支涵蓋率,但沒測到任何東西,因為它沒有任何驗證,這測試執行程式,但沒有有意義地測試程式,寫這測試的程式設計師不是忘記加驗證,就是僅是為了提高測試涵蓋率而寫這測試,幸運的是,這類測試很容易用靜態分析工具找出來。

我同樣找到像這樣的測試:

這程式設計師使用 Java 內建的 assert 關鍵字,而不是 JUnit 的驗證,除非這測試是用命令列啟動並開啟 -ea,否則這測試永遠不會出錯。還好,這些不好的測試可以用靜態分析工具輕易地找出來。

不幸的是,這些不是替團隊帶來麻煩的測試,比較麻煩的案例像這樣:

這些測試檢測程式碼中 count 函式的兩個分支,並驗證回傳的值。乍看之下,看起來是很穩健的測試,但問題不是這團隊有的測試,而是團隊所沒有的測試。它應該要有一個測試案例檢查當傳入剛好 10 的時候會發生什麼事情:

如果這測試不存在,臭蟲可能被像下面那樣意外地被加入:

一個像這樣的小錯誤,在商業上可能造成每天數萬元的損失,直到被找到並修復。這類問題無法用靜態分析工具找到,它可能在同儕審查被找到,但也可能不會。理論上,如果使用測試驅動開發 (TDD),在沒有對應的測試前,不該寫下這樣的程式,但 TDD 無法神奇地阻止開發者犯下錯誤。

所以,您無法依賴測試涵蓋率工具來告訴您程式有被測試過,它們仍然很有用,但是針對不同的用途,它們告訴您那些程式碼肯定沒被測試到,當您想改某段程式時,您能用它們快速知道想改的程式是否沒有保護網。

用變異測試有更好的涵蓋率

寫好一個測試後,我習慣透過註解掉部分剛寫好的實作,或是如前面的例子,引入像是將 <= 改成 < 的小改變,來仔細檢查我的測試,如果我執行測試但測試沒有失敗,那肯定是我哪裡做錯了。

這給了我一個想法,如果我有個工具可以自動做出這些改變?如果有個工具可以自動加入臭蟲然後執行測試,因為在引入臭蟲後測試沒失敗,我將會知道可能沒有被良好測試到的任何一行程式,我有信心知道我的測試套件是否有做好它的工作。

如大多數好的想法,事實證明,我不會是第一個想到的,這想法有個名字:變異測試,在 1970 年代發明的,這主題已經被廣泛研究了 40 年,研究社群環繞著它建立此術語。

我手動作的各種不同類型的改變稱作變異運算元,每個運算元特定類型的小改變,例如將 >= 改成 >,將 1 改成 0,或是將某個函數呼叫註解掉。

當一個變異運算元套用到某些程式碼上,一個變異產生,當對變異版本執行測試,只要有個測試失敗,這變異將被殺到,若沒有測試失敗,這變異存活下來。

學者們研究各式可能的變異運算元,研究哪些較有效,並研究這些能檢測人為錯誤的測試套件是否能找出實際的錯誤,他們還開發數種自動化變異測試工具,其中一些是針對 Java 的工具。

所以為什麼過去不曾聽過變異測試?為什麼不是所有的開發人員都使用變異測試工具?我現在將討論一個問題,另一個稍後討論。

第一個問題很直覺:變異測試在運算上非常昂貴。事實上,直到 2009 年為止,多數學術研究只探討少於百行程式的玩具專案,為了瞭解為什麼如此昂貴,我們來看一個變異測試工具需要做什麼。

想像您要對 Joda-Time 套件 [譯註:Java 引入成為 Java 8 的新功能之一],一個處理日期與時間的小套件,程式碼約 68,000 行,及近 70,000 行的測試,編譯大概需要 10 秒,執行所有的單元測試大約需要 16 秒。

現在,假設您的變異測試工具每七行植入一個錯誤,您大約會有 10,000 個錯誤,每次您改變一個類別並植入一個錯誤,您需要編譯程式,也許花一秒,因此需要約 10,000 秒的編譯時間來產生變異 (稱之為生成成本),大概是兩個半小時,您還需要為每個變異執行測試,那會是 160,000 秒,超過 44 小時,因此對 Joda-Time 套件執行變異測試將耗費近兩天。

早期多數變異測試工具的運作類似這樣的假設工具,您偶而會發現某些人試圖賣這些工具,但使用這樣的工具不實際。

當我開始對變異測試有興趣,我尋找是否有開放原始碼工具,我找到最好的一個是 Jumble,它比上述的工具要快上許多,但它仍然很慢,且它有其他問題讓它難以使用。

我想我是否能做得更好,我已經有些程式也許有幫助,能平行執行程式,它能用不同的類別載入器來執行程式,因此當遺留程式碼中某些存在靜態變數的狀態改變,不會影響到其他測試,我稱之為平行隔離測試 (Parallel Isolated Test, PIT)。

數夜的實驗後,我設法做得更好,我的 PIT 變異測試工具能在三分鐘內對Joda-Time 套件完成 10, 000 個變異分析

介紹 Pitest

我的工具保留了程式庫剛開始成長時的縮寫,但它現在也稱作 “Pitest” ,全世界都有人在使用。

它用於學術研究以及一些安全性極為關鍵的專案,例如 CERN 大型強子對撞機的控制系統,但它主要是幫助多數開發者平日開發的非關安全的程式,所以它如何比之前的系統快上許多?

首先,它從 Jumble 借鏡一個技巧:與其花上二個半小時編譯程式碼,Pitest 直接修改 bytecode,這讓它不用一秒就能產生成千上萬的變異。

但更重要的是,它不對每個變異執行所有的測試,取而代之,它只執行可能會殺死變異的測試,怎麼知道那些測試能做到,使用測試涵蓋率數據。

Pitest 第一件事是收集每個測試的測試涵蓋數據,所以它能知道哪個測試會執行到哪幾行程式,可能殺掉變異的是那些有執行到植入變異那幾行程式的測試,執行其他測試是在浪費時間。

接著 Pitest 使用啟發式方式從那些有涵蓋到的測試中選擇哪個先執行,如果一個變異可以被一個測試殺死,Pitest 通常只需要一到兩次的嘗試就拿找到它。

最大的加速來自當你發現有個變異沒有任何測試會執行到,過去的方式,您需要執行所有的測試套件來確定這個變異無法被殺死,以測試涵蓋率為基礎的方式,您能幾乎不需要任何計算的成本就能直接確認這件事。

測試涵蓋率能找到沒有被測到的程式碼,如果變異所在的程式碼沒有測試涵蓋率數據,那測試套件中沒有任何測試有機會殺掉該變異,Pitest 能階將該變異標記為存活,不做任何更進一步的工作。

使用 Pitest

為您的專案設定 Pitest 非常簡單,Eclipse 和 IntelliJ IDEA 有內建的插件,但我個人偏好使用建置腳本,從命列工具來添加變異,Pitest 有些非常有用的功能只能用這種方式使用,您稍後就會看到。

我通常使用 Maven 作為我的建置工具,但 Pitest 也有 Gradle 和 Ant 的插件。

在 Maven 中設定 Pitest 很直接,我通常用名為 pitest 的 profile 將 Pitest 綁定在測試階段 [譯註:我不確定這個情境下 profile 的慣用翻譯是什麼,所以保留],然後用 -P 啟動 profile 來執行 Pitest:

作為例子,我在 GitHub 建立一個 Google 驗證套件 Truth 的分支,然後將 Pitest 加到建置中,您能在這裡看到專案物件模型 (POM) 相關的部分。

讓我們一步步看下去。

<threads>2</threads> 告訴 Pitest 使用兩個執行緒執行變異測試,變異測試通常擴展地很好,所以您如果有兩個以上的核心,增加執行緒的數量是值得的。

<timestampedReports>false</timestampedReports> 告訴 Pitest 在固定的位置產生報表。

<mutators><value>STRONGER</value></mutators> 告訴 Pitest 使用較預設值更大的變異運算元集合,POM 檔中這一段目前被註解掉,我稍後再啟用,如果您剛開始在您的專案中嘗試變異測試,我建議您可以先用預設值。

Maven 的 Pitest 插件假設您的專案遵循常見的慣例:group ID 符合您的套件結構,也就是,如果您的程式碼在名為 com.mycompany.myproject 套件中,它預期 group ID 也是 com.mycompany.myproject,如果不是這樣,您可能會在執行 Pitest 時看到像這樣的錯誤訊息:No mutations found. This probably means there is an issue with either the supplied classpath or filters [譯註:這裡翻譯也沒用,實際遇到的錯誤訊息是沒翻譯的]。

Google Truth 的 group 不符合套件結構,因此我加入這一段:

注意套件名稱結果的 *

Pitest 在 bytecode 層級運作,是透過符合已加載的類別名稱的 glob 來配置 [譯註:我也不確定 glob 是指什麼?],而不是原始碼的路徑,這是人們第一次使用時常被搞混的地方。

另一個在第一次設定 Pitest 時常見的問題是這個訊息:All tests did not pass without mutation when calculating line coverage. Mutation testing requires a green suite. [譯註:這就不翻譯了]

當您有失敗的測試會產生這訊息,當有失敗的測試,它不可能執行變異測試,因為這麼做會錯殺該測試所涵蓋到的任何變異。有時,當您使用 mvn 通過所有的測試,您仍有機會會看到這訊息,如果真的發生,有幾個可能的原因。

Pitest 試著解析 Surefire 測試執行插件的配置,然後轉成 Pitest 可以理解的選項 (Surefire 是 Maven 預設用來執行單元測試的插件,通常不需要任何配置,但有時候某些測試需要一些特別的配置才能運作,這些配置需在 pom.xml 中提供)。

不幸地,Pitest 尚無法轉換 Surefile 所有類型的配置,如果您的測試依賴設定系統屬性或命令列參數,您需要再次在 Pitest 中配置它們。

另一個難以察覺的問題是測試的順序關係,Pitest 用不同的順序多次執行您的測試,但您可能有個測試會失敗,如果其他測試在它之前執行。

例如,如果您有個測試 FooTest,它把某個類別的靜態變數設為 false,然後有另一個測試 BarTest 假設那個變數是 true,如此一來,如果 BarTestFooTest 之前執行的話會成功,但如果在之後執行就會失敗。預設情況下,Surefile 以隨機但固定的順序 [譯註:隨機但固定,要不是後面有解釋,還真是一個很怪的翻譯] 執行測試,當有個新測試加入,執行的順序改變,但您可能永遠不會以揭露關係的順序執行,當 Pitest 執行測試,它第一次使用的順序有可能揭露順序的關係。

測試順序的關係很難察覺,要避免它們,測試開始前,您可以讓測試防禦性地將共享的狀態設定成需要的正確值,然後在結束時,清理掉這些值。最好的方法,是首先在您的程式中避免可修改的共享狀態。

最後,針對 Google Truth 套件的設定包含這一段:

這配置避免在所有名稱以 AutoValue_Expect_ExpectationFailure 結尾的類別中植入變異,這些類別是 Google Truth 建置腳本自動產生的,對它們植入變異是毫無價值,任何植入的變異會難以理解,因為您沒有原始碼。

Pitest 提供其他方式將某些程式排除變異測試,細節請見 Pitest 網站。

解讀 Pitest 報告

讓我們做些示範的測試然後看產生的結果。首先,從 Google Truth 取出原始碼然後用 Maven 執行 Pitest:

Maven 下載需要的相依套件後應該會用大概 60 秒執行測試,完成後您可以在 target/pitReports 目錄找到一份 HTML 的報告,以 Truth 專案來說,您可以在 core/target/pitReports 目錄找到這份報告。

Pitest 的測試報告很像一般測試涵蓋率工具所產生的報告,但它含有一些額外的資訊,條列每個套件的整體涵蓋率及變異分數,您可以向下鑽研每個原始碼檔案的報告,如圖 1 所示。

圖 1 Pitest 產生的報告

涵蓋率以與頁面齊寬的塊狀顏色顯示,綠色表示這一行有被測試執行到,紅色則表示沒有。

每一行建立的變異數量顯示在行號與程式碼之間,如果將指標停在數字上,您會看到變異的描述及它的狀態,如果所有的變異都被殺死,程式碼的底色會是深綠色,如果有一個以上的變異存活,程式碼會以紅色標示 [譯註:第 84 行,測試有跑到,所以整塊是綠色,有兩個變異,一個存活,所以程式碼的部分底色是紅色]。

在報表的底部有額外有用的資訊,這個檔案用來挑戰變異所使用的測試案例,以及個別花多少時間,在這之前是所有的變異列表,如果將指標停在他們上面,可以看到殺死該變異的測試名稱。

Google Truth 的開發沒有借助 Pitest 或其他變異測試工具,整體來說,開發它的團隊表現很好,88% 的變異測試成績不容易達成,但仍然有漏洞。

最有趣的變異是那些出現在綠色區塊的程式碼上,那表示有被測試涵蓋到,如果有個變異沒被測試涵蓋到,那它存活下來且無法提供額外的資訊並不易外,但如果有個變異被涵蓋到,您必須進行一些調查。

例如,看一下 PrimitiveIntArraySubject.java第 73 行,Pitest 建立一個變異描述如下:

這告訴您 Pitest 將呼叫此函式的程式碼註解掉。

如名稱所提示的,failWithRawMessage 的用途是拋出一個 RuntimeException 例外。Google Truth 是個驗證套件,所以核心的事情之一是當條件沒達成時拋出 AssertionError 例外。

讓我們看一下涵蓋此類別的測試案例,下面的測試似乎是要測試這功能。

您能看到哪裡錯了嗎?這是一個經典的測試錯誤:這測試檢查驗證的訊息,但如果沒有例外拋出,這測試會通過。這類測試的模式通常會包含一個 fail() 的呼叫,由於 Truth 團隊預期的例外是一個 AssertionError,其他測試的模式是拋出一個 Error

如果在這測試加入 throw,那變異就被殺死了。

Pitest 能找其他問題?有個類似的問題,PrimitiveDoubleArraySubject.java 的第 121 行,Pitest 再次移除對 failWithRawMessage 的呼叫。

但是,如果您看一下測試案例,它確實在沒有任何例外拋出時拋出 Error,所以發生什麼事了?這是一個等效變異,我們深入研究一下這類變異。

等效變異

等效變異是我在簡介時提到學術研究找到的另一個問題。

有時,如果您對某些程式做修改,您其實沒有改變任何行為,改變後的程式和原先的程式在邏輯上是等效的,這情況下,不可能寫出一個測試會讓變異失敗,同樣也無法讓沒更改過的程式失敗。不幸地,不可能自動地判斷一個變異存活下來是因為等效變異而還是因為缺乏有效的測試,這情形需要人介入判斷程式碼,那會需要一些時間

有些研究表示,平均需 15 分鐘才能判斷一個變異是否等效,所以如果您在專案結束時使用變異測試,然後有上百個變異生還,您需要數天去評估這些生還的變異是否為等效。

這似乎是個變異測試能實際使用前要解決的大問題,但是,早期變異測試的研究有個沒明講的假設,它假設變異測試是在開發流程的後期導入,例如有個單獨的 QA 流程,現代的開發方式並非如此。

Pitest 過來人的經驗:等效變異不是個大問題,事實上,它們有時很有用。

執行變異測試最有效的時間是當您寫程式的時候,如果您這麼做,您一次只需要評估少量生還的變異,但是,更重要的是,您在能採取行動的位子上,評估每個生還的變異會遠小於平均的 15 分鐘,因為程式碼與測試都很新鮮地在您腦中。

當有個變異從您剛寫得程式中生還,這提示您做下面三件事:

  • 如果變異不是等效的,您很有可能要加個測試
  • 如果變異是等效的,您將很常刪除某些程式,一個常見的等效變異是變異出現在不需要的程式碼中。
  • 如果程式碼是需要的,等效變異可能提示您要檢驗程式碼的目的與它怎麼被實作的。

PrimitiveDoubleArraySubject.java 的 121 行,您剛剛看過的,是最後一種類型的例子,我們來看完整的函式。

Pitest 修改了一個用 == 比對兩個陣列後有條件的函式呼叫。如果程式沒在這個時間點拋出例外,它將繼續執行,進行兩個陣列的深度比較,如果它們不相同,程式會拋出例外,就跟 == 回傳 true 時拋出的例外一樣

所以,變異所在的程式僅是為效能考量所加的,目的是避免更昂貴的深度比對,大多數的等效變異都是這種類型,這程式碼是需要的但其考量的點不是用單元測試可以測的。

提出的第一個疑問是,這個函式的行為在給定兩個相同陣列 [譯註:這裡指的是相同的 reference 或換個說法相同記憶體位置],以及兩個不同陣列但內容卻是一樣的是否相同?[譯註:若將陣列視為 entity,那只有 reference 相同才視為相同,若將陣列視為 value object,則內容相同就視為相同,但通常不會把一個陣列視為 entity]

我的觀點是不應該相同,如果我正在使用一個驗證套件,我告訴它,我預期兩個陣列不相同,然後傳給它同一個陣列兩次,透過訊息告訴我,也許在錯誤訊息的尾巴加個 “(in fact, it is the same array)”,我會覺得很有用。

但如果我錯了,也許現在的方式是更好的,如果行為維持相同,該做些什麼讓等效變異消失?

我不喜歡這個 isNotEqualTo 函式,它肩負兩個責任,一個是比較陣列的相等性,另一個則是當相同時拋出例外。如果像下面那樣,將兩個責任分成兩個函式,會發生什麼事?

現在,等效變異消失了,變異告訴我需要重構讓程式更加清楚,更甚,我可使用新 areEqual 的函式移除這類別其他地方重複的邏輯,因此所減程式碼的數量。

不幸地,不是所有的等效變異可以用不同程式碼表達的方式移除,如果在 Pitest 配置中開啟 (先前關閉) 使用更強大的變異運算元集合,然後再次執行測試,我會得到一個變異在新的 areEqual 函式。

Pitest 將程式修改如下:

我無法在保留效能最佳化的同時用重構消除等效變異。所以,不是所有的等效變異都是有用的,但他們也不像研究說的那樣普遍。

Pitest 旨在減少等效變異發生:使用預設的運算元,多數團隊從未遇到過,您會遇到多少取決於您寫的程式碼類型與您的編碼風格。

那實際大的專案呢?

我提到的範例沒有一個是真的非常大的專案,在實際大的專案上使用變異測試可行嗎?可行。

正如我討論的,使用變異測試最有效率的方式是在您開發時就跑測試,當您以這方式使用,專案大小不是重點,像 Truth 這樣的專案,最簡單的是對整個專案植入變異,但您不需這麼做。

需要植入變異的程式碼是您剛寫好或更動的程式碼,即使您的有百萬行的程式碼,您的程式碼變更也很難影響超過一百個類別。

Pitest 透過整合版本控管系統,讓這更容易,目前這功能僅限於使用 Maven 插件。如果您有在 POM 檔正確配置 Maven 的板控資訊,您可以使用 Pitest 的 scmMutationCoverage goal [譯註:和 profile 一樣,不翻譯] 分析本地變更的程式碼。

Google Truth 的 POM 已經將這 gaol 與 pitest-local 的 profile 綁定:

如果您對簽出的程式沒做任何修改,那只會跑測試然後結束,如果做了變動,它將只分析更改的檔案,植入變異然後執行測試。

這方式只提供您剛好需要的資訊,您的程式已經通過良好的測試?Pitest 同樣可以在持續整合 (CI) 的伺服器上設定,分析最後一次簽入的程式碼。

但如果想知道整個專案的測試是否良好的全貌,該怎麼辦?

最終,除非您願意等上數小時,否則您會遇到能夠進行變異測試的專案大小的極限,但 Pitest 提供一個實驗性的選項將這極限推得更遠。

回到 Google Truth 專案,然後用下列指令執行測試:

和先前您執行時看起來沒有太大差別,如果您再次執行指令,它應該在數秒內結束。

withHistory 告訴 Pitest 儲存每次執行的資訊,然後用它來最佳化下次的測試,例如,如果一個類別以及涵蓋它的測試沒有改變,那就不用回傳該類別的分析,有許多使用歷史紀錄進行類似的優化。

這功能還在早期階段,但如果在專案一開始就使用它,那無論專案成長到多大都應該能分析整個專案。

結論

我希望我已經說服您變異測試是強大且實用的技術,它幫助建立強建的測試套件,能幫助您寫出更簡潔的程式碼。

但我想用警告作結,變異測試不保證您會有好的測試,它只能保證您有強健的測試,嚴格來說,我指的是當重要的程式碼行為改變時測試應該失敗,但這只是全貌的一半,同樣重要的是,程式碼改變但行為不變時,測試不該失敗。

learn more
Mutation testing on Wikipedia

譯者的告白

還在使用 logdown 的期間,這一篇就已經排進要翻譯的清單,和先前幾篇 JUnit 5 相關的文章一起,但這篇真的很長,每次打開它就又把它關起來,一直到最近才花數天的一兩個小時把它翻完,終於把 2016 年 (沒看錯) 的 JUnit 5 專刊中與測試有關的文章的翻譯完了,灑花~

--

--