變異測試詳解
Spec: ISO/IEC/IEEE 29119-4 ISO/IEC/IEEE 29119-4 Spec: PHPUnit PHPUnit Evidence: Test-backed
行覆蓋率只告訴你某一行在測試套件執行期間跑過了。它並未告訴你:如果那一行寫錯,是否有任何測試會失敗。變異測試會刻意破壞程式碼,並檢查測試是否察覺得到,藉此補上這個落差。本頁說明變異分數的意義,以及 NextPDF 如何把它當作診斷工具,而不是獎盃。
為什麼這很重要
標題為「為什麼這很重要」的區段覆蓋率是測試領域中最常被信賴的指標,也是最容易誤導人的指標之一。一個只呼叫方法卻不做任何斷言的測試,會執行其中每一行——覆蓋率完美,偵測力卻是零。標準文獻明確指出,覆蓋率準則之間的排序,並無法說明它們揭露錯誤的能力。那種能力正是它所稱的測試有效性(ISO/IEC/IEEE 29119-4,§C.2.4)。覆蓋率百分比與找錯保證,是兩種不同的主張。
對 PDF 引擎而言,這並非學術空談。一個簽章位元組範圍檢查、一個交叉參照偏移量、一個編碼分支——測試可以完全「覆蓋」這一切,卻從未斷言真正重要的那個值。建立在薄弱測試之上的綠燈套件,比一個誠實的缺口更糟,因為它會讓所有人以為不必追查。
簡短版
標題為「簡短版」的區段- 變異測試會對原始碼進行數以千計、刻意的微小修改(變異體)——把
<改成<=、把+改成-、改動一個return值——並針對每一個變異體重新執行測試。 - 若某個測試在某個變異體上失敗,該變異體就被殺死(killed):表示確實有測試斷言了那項行為。若所有測試仍然通過,該變異體就逃脫(escaped):那項行為被執行了,卻從未受到檢查。
- **變異分數指標(MSI)**大致上是「被殺死的變異體」除以「非等價變異體總數」。它衡量的是你的測試是否偵測到變更,而不是它們是否執行了程式碼。
- 有些變異體是等價的(equivalent)——它們無法改變可觀察的行為,因此沒有任何測試能殺死它們。把這些當成失敗來計算並不誠實。NextPDF 會加以證明並記入帳本,而不是私下略過。
- NextPDF 運用 MSI 來找出並強化薄弱的測試。它是持續整合中的一道診斷閘門,而不是行銷數字。
NextPDF 如何處理它
標題為「NextPDF 如何處理它」的區段變異測試在引擎中透過 Infection 變異器執行。它配置於正式環境的原始碼樹之上,並啟用算術、布林、條件邊界、相等性、回傳值與移除等變異器家族——這些運算子正會揭露「已執行但未斷言」的邏輯。這個流程是機械化的:
- Start green The suite must pass before mutation begins.
- Mutate Apply one small, deliberate change to the source.
- Re-run Run the tests that cover the mutated line.
- Killed A test failed — the behaviour is genuinely asserted.
- Escaped All tests still pass — a weak spot to strengthen.
- Equivalent No test can kill it because behaviour is unchanged — proven and ledgered, not scored as a miss.
有兩項設計選擇讓這個數字值得信賴。第一,這個分數被串接成一道閘門。持續整合會強制要求一個最低 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 ISO/IEC/IEEE 29119-4 §B.2.4 說明的是:對規格的各個元素套用通用變異,藉以衍生出用於測試的特定變異。之所以需要這項技術,正是因為同一份標準指出:覆蓋率準則的包含(subsumes)排序,並不會依揭露錯誤的能力來排序它們 (ISO/IEC/IEEE 29119-4,§C.2.4)。
Evidence: Standard-backed 覆蓋率本身定義明確, 但有其侷限。 Spec: PHPUnit 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,而這是一項支撐等價性證明的配置屬性,並非一項測量值。
變異器的選擇、各項門檻與帳本政策,皆由引擎的變異配置管理,且可能演進。若該配置與本頁有任何不一致,以該配置為準。本頁不對任何其他函式庫的測試有效性作出任何主張。
相關文件
標題為「相關文件」的區段- NextPDF 測試金字塔——這五個層級的測試,正是變異測試用來稽核並驗證其實際錯誤偵測力的對象。
- 嚴格型別,無處不在——PHPStan Level 10 與嚴格型別如何讓等價變異體的證明站得住腳。
- 黃金檔案測試——另一個層級;變異測試有助於驗證其偵測能力。
詞彙表
標題為「詞彙表」的區段- 變異體(Mutant)——對原始碼進行的單一、刻意的微小變更,用以測試測試套件是否會察覺該變更。
- 被殺死的變異體(Killed mutant)——一個使至少一項測試失敗的變異體;該行為確實被斷言。
- 逃脫的變異體(Escaped mutant)——一個讓每一項測試都仍然通過的變異體。該行為被執行了,卻從未被斷言——這是一個待修補的弱點。
- 等價變異體(Equivalent mutant)——一個無法改變可觀察行為的變異體,因此沒有任何測試能殺死它。NextPDF 會證明這些變異體並記入帳本。
- MSI(變異分數指標,Mutation Score Indicator)——大致上是被殺死的變異體除以非等價變異體總數;它是偵測力的衡量,而非執行的衡量。
- 行覆蓋率(Line coverage)——一項只記錄某條可執行程式行在套件期間跑過了的指標;由 PHPUnit 定義,單靠它並不足夠。