跳到內容

變異測試詳解

Spec: ISO/IEC/IEEE 29119-4 Spec: PHPUnit Evidence: Test-backed

行覆蓋率只告訴你某一行在測試套件執行期間跑過了。它並未告訴你:如果那一行寫錯,是否有任何測試會失敗。變異測試會刻意破壞程式碼,並檢查測試是否察覺得到,藉此補上這個落差。本頁說明變異分數的意義,以及 NextPDF 如何把它當作診斷工具,而不是獎盃。

覆蓋率是測試領域中最常被信賴的指標,也是最容易誤導人的指標之一。一個只呼叫方法卻不做任何斷言的測試,會執行其中每一行——覆蓋率完美,偵測力卻是零。標準文獻明確指出,覆蓋率準則之間的排序,並無法說明它們揭露錯誤的能力。那種能力正是它所稱的測試有效性ISO/IEC/IEEE 29119-4,§C.2.4)。覆蓋率百分比與找錯保證,是兩種不同的主張。

對 PDF 引擎而言,這並非學術空談。一個簽章位元組範圍檢查、一個交叉參照偏移量、一個編碼分支——測試可以完全「覆蓋」這一切,卻從未斷言真正重要的那個值。建立在薄弱測試之上的綠燈套件,比一個誠實的缺口更糟,因為它會讓所有人以為不必追查。

  • 變異測試會對原始碼進行數以千計、刻意的微小修改(變異體)——把 < 改成 <=、把 + 改成 -、改動一個 return 值——並針對每一個變異體重新執行測試。
  • 若某個測試在某個變異體上失敗,該變異體就被殺死(killed):表示確實有測試斷言了那項行為。若所有測試仍然通過,該變異體就逃脫(escaped):那項行為被執行了,卻從未受到檢查。
  • **變異分數指標(MSI)**大致上是「被殺死的變異體」除以「非等價變異體總數」。它衡量的是你的測試是否偵測到變更,而不是它們是否執行了程式碼。
  • 有些變異體是等價的(equivalent)——它們無法改變可觀察的行為,因此沒有任何測試能殺死它們。把這些當成失敗來計算並不誠實。NextPDF 會加以證明並記入帳本,而不是私下略過。
  • NextPDF 運用 MSI 來找出並強化薄弱的測試。它是持續整合中的一道診斷閘門,而不是行銷數字。

變異測試在引擎中透過 Infection 變異器執行。它配置於正式環境的原始碼樹之上,並啟用算術、布林、條件邊界、相等性、回傳值與移除等變異器家族——這些運算子正會揭露「已執行但未斷言」的邏輯。這個流程是機械化的:

  1. Start green The suite must pass before mutation begins.
  2. Mutate Apply one small, deliberate change to the source.
  3. Re-run Run the tests that cover the mutated line.
  4. Killed A test failed — the behaviour is genuinely asserted.
  5. Escaped All tests still pass — a weak spot to strengthen.
  6. Equivalent No test can kill it because behaviour is unchanged — proven and ledgered, not scored as a miss.
NextPDF 執行的變異測試迴圈:取得綠燈的測試、產生一個變異體、重新執行涵蓋該處的測試,並把該變異體分類為被殺死(有測試攔截它)、逃脫(一個有覆蓋卻無斷言的缺口,需修補),或已證明等價(沒有任何測試能殺死它;記入帳本,不計入分數)。

有兩項設計選擇讓這個數字值得信賴。第一,這個分數被串接成一道閘門。持續整合會強制要求一個最低 MSI(以及一個最低的已覆蓋 MSI),並針對變更的程式行執行差異範圍版本。因此,一項只新增程式碼、卻未加入真正斷言的變更,會在審查階段就被攔下,而不是事後才被發現。第二,NextPDF 不會悄悄地把不便處理的變異體打折扣。那些真正語意等價的變異體——例如當嚴格型別保證兩個運算元共用同一型別時,!== 相對於 !=——會連同一個明確的等價性證明測試,記錄到變異帳本中。因此,逃脫計數反映的是真實缺口,而非帳務操作。PHPStan Level 10 加上 strict_types 再加上具型別的屬性,正是讓那些等價性證明站得住腳的關鍵。

Evidence: Test-backed 變異測試的配置位於引擎內,涵蓋正式環境的原始碼目錄,並啟用能揭露行為的變異器家族。它被當作一道持續整合閘門來強制執行, 搭配一個最低 MSI 與一個差異範圍版本。它是一項建置檢查,而非事後補充。

Evidence: Test-backed 等價變異體問題的處理方式很誠實。語意等價的變異體會被分類,並由變異帳本中專屬的等價性證明測試支撐,而每項證明的健全性皆建立在 PHPStan Level 10 加上嚴格型別之上。因此逃脫計數代表的是真實、未被偵測到的行為,而非被灌水成讓分數更難看的不可殺死雜訊。

Evidence: Standard-backed 變異是一項公認的技術, 並非 NextPDF 的發明。 Spec: ISO/IEC/IEEE 29119-4, §B.2.4 說明的是:對規格的各個元素套用通用變異,藉以衍生出用於測試的特定變異。之所以需要這項技術,正是因為同一份標準指出:覆蓋率準則的包含(subsumes)排序,並不會依揭露錯誤的能力來排序它們 (ISO/IEC/IEEE 29119-4,§C.2.4)。

Evidence: Standard-backed 覆蓋率本身定義明確, 但有其侷限。 Spec: PHPUnit 區分了行、分支與路徑覆蓋率。行覆蓋率只記錄一條可執行的程式行跑過了。明白這個定義,正是讓它的不足顯而易見的原因。

重點不在於那個指令——而在於一個逃脫的變異體告訴了你什麼:

<?php
declare(strict_types=1);
final class ByteRange
{
// Suppose the production guard is:
// if ($offset < 0) { throw new InvalidByteRange(); }
public function assertNonNegative(int $offset): void
{
if ($offset < 0) {
throw new InvalidByteRange('offset must be >= 0');
}
}
}
// A test that EXECUTES this line but does not assert the boundary:
// $byteRange->assertNonNegative(5); // no exception expected, none asserted
// gives 100% line coverage of assertNonNegative().
//
// Mutation flips `< 0` to `<= 0`. Behaviour now differs ONLY at $offset === 0.
// If no test passes 0 and asserts what happens, every test still passes:
// the mutant ESCAPED. Coverage said "tested"; mutation said "the boundary
// is unasserted". The fix is a test that pins offset === 0, not a higher
// target.
//
// composer mutation:diff → mutate only changed lines, enforce min MSI
// composer mutation:full → full-tree mutation gate

那個逃脫的變異體,正是整個價值主張所在。它定位出一個真實、具體、缺少的斷言,而覆蓋率報告卻把它標示為已完整測試。

頭號誤解,是把變異分數當成一個必須最大化的成績。靠著寫測試來殺死變異體而達成的極高 MSI,跟靠著呼叫方法卻不做斷言而達成的高覆蓋率一樣空洞。此時指標已被操弄,不再衡量偵測力。NextPDF 運用 MSI 來找出薄弱的測試。交付成果是一個更好的斷言;炫耀不是目的,這點很明確。

第二個誤解是:每一個存活下來的變異體都代表測試中的一項缺陷。有些變異體確實是等價的,無法被任何測試殺死,因為它們並不會改變可觀察的行為。把那些當成失敗,會產生一個不誠實、被人為壓低的分數,也會讓人習慣忽略這份報告。NextPDF 的做法是明確證明等價性並記入帳本,而非悄悄地壓下它,或假裝這個數字比實際更差。

變異測試衡量的是測試是否偵測到被注入的變更。它並不能證明程式碼是正確的。它不衡量效能或合規性。它無法殺死一個真正等價的變異體。目前的變異分數、現行的最低 MSI 門檻、已記入帳本的等價體數量,以及任何覆蓋率數字,都是由持續整合產物產生、並隨建置發布的即時品質訊號。它們在此刻意從缺,因為貼進文字敘述裡的數字會過時,最後變成一個小小的謊言。本頁唯一陳述的穩定事實是 PHPStan Level 10,而這是一項支撐等價性證明的配置屬性,並非一項測量值。

變異器的選擇、各項門檻與帳本政策,皆由引擎的變異配置管理,且可能演進。若該配置與本頁有任何不一致,以該配置為準。本頁不對任何其他函式庫的測試有效性作出任何主張。

  • 變異體(Mutant)——對原始碼進行的單一、刻意的微小變更,用以測試測試套件是否會察覺該變更。
  • 被殺死的變異體(Killed mutant)——一個使至少一項測試失敗的變異體;該行為確實被斷言。
  • 逃脫的變異體(Escaped mutant)——一個讓每一項測試都仍然通過的變異體。該行為被執行了,卻從未被斷言——這是一個待修補的弱點。
  • 等價變異體(Equivalent mutant)——一個無法改變可觀察行為的變異體,因此沒有任何測試能殺死它。NextPDF 會證明這些變異體並記入帳本。
  • MSI(變異分數指標,Mutation Score Indicator)——大致上是被殺死的變異體除以非等價變異體總數;它是偵測力的衡量,而非執行的衡量。
  • 行覆蓋率(Line coverage)——一項只記錄某條可執行程式行在套件期間跑過了的指標;由 PHPUnit 定義,單靠它並不足夠。