iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Software Development

諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道系列 第 15

Day 15 -「機智奧德修斯」故意搞破壞的 Mutation Test

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260814/20182564NiOPmKQKIJ.png

特洛伊戰爭還沒開打,希臘聯軍就先遇到一個很麻煩的問題。

預言說,沒有阿基里斯,他們不可能攻下特洛伊,但阿基里斯的母親忒提斯也早已知道,兒子只要參戰就會死。

於是忒提斯讓阿基里斯換上女子的衣服,把他送到斯庫羅斯島,藏在國王呂科墨得斯的女兒之中,希臘人知道他就在島上,卻不知道眼前哪一位才是他,總不能站在門口一個個問:

「不好意思,請問你是阿基里斯嗎?」

希臘人於是派使者前往斯庫羅斯島,呂科墨得斯雖然否認阿基里斯藏在宮中,最後仍允許他們進宮搜尋。

使者之中,就有我們在特洛伊木馬那篇見過的奧德修斯,他是個很聰明的人,既然光靠外表分不出來,他乾脆帶來一批送給王宮女子的禮物,再偷偷把盾牌與長矛混在裡面,接著命人突然吹響戰爭號角,製造武器碰撞與敵人來襲的聲響。

號角一響,其中一個人立刻扯開身上的女裝,拿起盾牌與長矛準備迎敵。

「找到他了!」

奧德修斯沒有繼續觀察誰比較高、誰的手臂比較粗,也不是因為他看過了島上每一個人,就相信自己一定找得到阿基里斯,他刻意製造了一個情境,讓阿基里斯自己現身。


突變測試 (Mutation Test)

我們知道了要替 Legacy Code 補上特徵測試,但是「有測試」和「有用的測試」之間,還是會有落差,而是否能寫出有用的測試則需要我們省思這幾個點:

  • 這些測試到底有沒有覆蓋到我們真正在意的地方?
  • 如果今天把程式碼改壞了,這些測試能不能讓我們知道?
  • 這些測試真的是已確認的需求,還是我們腦補的?

而這些事情,我們似乎都沒辦法當下就顧慮到,甚至有時候要到 QA 甚至是客戶端才會告訴我們,所以今天要講的 突變測試 (Mutation Test) 就是要幫助我們檢驗測試到底有沒有能力偵測重要的行為變化。

突變測試做的事情很簡單,它會暫時在 production code 裡放進一個很小的改動,例如將 >= 改成 >&& 改成 ||,再用這個版本執行原本的測試,而每一次的搞破壞後的程式碼就會是一個 突變體 (mutant)

Legacy Code 特別需要做這件事情,因為我們很少有一份可以完全信任的規格,剛補上的 Characterization Test 又往往是看著現有 code 寫出來的,很容易在不知不覺中忽略了實作的每一個細節,突變測試剛好可以在我們鋪好網子的時候,再做一次有用的檢驗。


覆蓋率

今日範例 是一個很簡單的門票價格計算器:

public class ConcertTicketPriceCalculator
{
    private bool _salesOpen;
    private int _remainingTickets;

    public ConcertTicketPriceCalculator(int ticketInventory)
    {
        _salesOpen = false;
        _remainingTickets = ticketInventory;
    }

    public void OpenSales() => _salesOpen = true;

    public decimal Calculate(decimal originalPrice, int daysBeforeEvent)
    {
        if (!_salesOpen || _remainingTickets <= 0)
			throw new SoldOutException();


        var discountRate = daysBeforeEvent >= 30 ? 0.8m : 1m;
        return originalPrice * discountRate;
    }
}

我們大概看了一下程式碼,並且描述一下現在的行為:

  • 還沒開賣,或剩餘票數小於等於 0 時不給買
  • 開賣而且還有票時,活動前 30 天以上打八折,未滿 30 天則維持原價

我們就接著來寫特徵測試:

[Theory]
[InlineData(29, 1000)]
[InlineData(45, 800)]
public void Calculate_開賣且尚有庫存_依購票時間回傳票價(
    int daysBeforeEvent,
    decimal expectedPrice)
{
    var calculator = new ConcertTicketPriceCalculator(ticketInventory: 10);
    calculator.OpenSales();

    var result = calculator.Calculate(
        originalPrice: 1000m,
        daysBeforeEvent: daysBeforeEvent);

    Assert.Equal(expectedPrice, result);
}

接著寫另外兩條拒絕情境的測試:

[Fact]
public void Calculate_尚未開賣_應拒絕計價()
{
    var calculator = new ConcertTicketPriceCalculator(ticketInventory: 10);

    Assert.Throws<SoldOutException>(() => calculator.Calculate(
        originalPrice: 1000m,
        daysBeforeEvent: 45));
}

[Fact]
public void Calculate_已售罄_應拒絕計價()
{
    var calculator = new ConcertTicketPriceCalculator(ticketInventory: 0);
    calculator.OpenSales();

    Assert.Throws<SoldOutException>(() => calculator.Calculate(
        originalPrice: 1000m,
        daysBeforeEvent: 45));
}

接著我們先來看看這幾個測試在 production code 有多少覆蓋率,我們需要先在測試專案 test 中安裝 coverlet.msbuild

dotnet add test/test.csproj package coverlet.msbuild

Coverlet 也有 coverlet.collector 這種整合方式,不過兩者不用同時安裝。這裡選擇的是 coverlet.msbuild,所以接下來會透過 MSBuild properties 收集 coverage。

接著再執行測試:

dotnet test test/test.csproj \
  /p:CollectCoverage=true \
  /p:Include="[src]src.ConcertTicketPriceCalculator"

我們來講解一下指令在做什麼:

  • dotnet test test/test.csproj:執行 test 專案中的測試。
  • /p:CollectCoverage=true:在測試執行期間收集 production code 的覆蓋資料。
  • /p:Include="[src]src.ConcertTicketPriceCalculator":這裡只統計src.ConcertTicketPriceCalculator的覆蓋率,避免其他也一起做統計。

那如果只想跑某一組測試,可以在 dotnet test 後面加上 filter,例如:

--filter "Calculate_開賣且尚有庫存_依購票時間回傳票價"

這次我們要一起確認前面寫的所有案例,所以先不用 filter,直接全部執行,接著就會從 terminal 中看到以下內容:

https://ithelp.ithome.com.tw/upload/images/20260814/20182564abXKnUh4IR.png

這張表把 coverage 分成三種指標:

  • Line:可執行的程式碼行中,有多少曾被測試跑過。
  • Branch:像 if|| 與三元運算子產生的分支中,被走過的路徑涵蓋多少。
  • Method:被納入統計的 method 中,有多少至少被呼叫過一次。

Total 是把所有納入統計的 modules 合在一起計算,Average 則是各 module 覆蓋率的平均,可以看到數值都是 100%,看起來似乎是個很完美的測試對吧?

記得剛開始學會寫測試的時候看到 100% 都很開心,於是便開始無情重構,吃鱉了好幾次 XD


Stryker.NET

接下來我們要開始做突變測試了,我們可以透過 Stryker.NET 這個套件工具來幫我們做,它可以幫我們產生多個突變體,接著我們再從報告中得知我們的測試究竟可不可靠。

那麼首先我們必須先安裝 Stryker.NET,指令如下:

cd test
dotnet new tool-manifest
dotnet tool install dotnet-stryker

在 test 目錄用 dotnet tool 安裝,可以讓整個團隊共用同一版本,安裝後記得把產生的 dotnet-tools.json commit 進 repository,其他成員就能用 dotnet tool restore 還原相同的版本。

另外可以先確認自己專案去決定安裝的版本與 runtime。

接著我們就可以直接執行:

dotnet stryker

https://ithelp.ithome.com.tw/upload/images/20260814/20182564gka2A65QXg.png

https://ithelp.ithome.com.tw/upload/images/20260814/20182564OfzrgRG0hP.png

執行完畢後可以看到有一堆數值,但是我們並沒有辦法從這裡看到實際上套件破壞了哪裡,也沒辦法去定位問題,所以!我們在剛剛執行指令的同時,套件也同時幫我們產生了一份方便我們查看的 html 檔案,路徑可以在執行指令後的這一行提示中找到:

https://ithelp.ithome.com.tw/upload/images/20260814/20182564ybQrEwFj70.png

我們就來打開它!

https://ithelp.ithome.com.tw/upload/images/20260814/20182564NvTPDGbb87.png

我們來講解一下幾個必須要知道的狀態:

  • Killed:突變體執行測試亮紅燈的次數,代表這次破壞的程式碼有被測試抓到。
  • Survived:突變體執行測試仍然保持全部綠燈,代表目前的測試沒有偵測到這個突變所造成的差異。
  • No coverage:沒有任何測試跑到突變體所在的位置,因此它連被抓到的機會都沒有。
  • Ignored:雖然產生了突變體,卻因為設定或工具本身的判斷而沒有執行它,因此不會被算進統計。

IgnoredNo coverage 不一樣,後者是沒有測試覆蓋到突變位置,前者則是這個突變體沒有進入實際測試流程,可能來自設定,也可能是工具本身的判斷,因此最好再點開個別項目確認原因,因為也是可以透過設定或註解主動忽略的突變體。

在 Total 中我們可以看到 Stryker 一共列出 15 個突變體,其中 12 個 Killed、1 個 Survived,另外 2 個是 Ignored,它不會納入突變統計,所以真正進入計分的是前面跑過的 13 個有效突變體,分數就是 12 ÷ 13 = 92.31%,而不是拿 12 去除以 Total 的 15。

因此我們可以看到有一個突變體現在是被列為 Survived 的狀態,點進 ConcertTicketPriceCalculator.cs,再勾選 Survived,報告就會把突變的段落標記出來,讓我們看到是哪一段邏輯沒被測試保護到:

https://ithelp.ithome.com.tw/upload/images/20260814/20182564YCp3ClBJ72.png

紅點落在第 23 行的早鳥折扣判斷,點開紅點後,我們就能看到 Stryker 實際對這行做了什麼改動:

https://ithelp.ithome.com.tw/upload/images/20260814/20182564gt6CuJtEAg.png

上面的紅色 - 是原始程式碼,下面的綠色 + 是這次拿來執行測試的突變體,它沒有把整段折扣邏輯刪掉,只把 >= 30 換成 > 30,也就是悄悄拿掉了「剛好第 30 天」這個邊界。

在下方的資訊把 More 展開,就能看到覆蓋這個突變體的測試:

https://ithelp.ithome.com.tw/upload/images/20260814/20182564e6F2DzsvgX.png

畫面列出的兩筆,其實就是同一個測試裡的 29 天與 45 天案例,29 在 >= 30> 30 都不成立,結果一樣都是原價所以都會保持綠燈,而 45 在兩個版本都成立,結果也一樣是八折。因此它們確實執行了第 23 行,卻完全分辨不出等號有沒有消失。

因此,透過突變測試,即便剛才的覆蓋率看到全部都是 100%,我們還是抓到了一個覆蓋率看不見的缺口,測試確實走過第 23 行,也走過三元運算子的兩條分支,卻對 >= 裡的那個等號完全沒有反應。

不過,有時候 Survived 也不一定是在宣判 production code 寫錯了,而是告訴我們也許應該把問題問清楚:

「剛好第 30 天,到底算不算早鳥?」

下一步不能直接為了分數補一個 Assert,更不能看到突變體活著就去改產品 code,我們得先找出能讓兩個版本產生不同結果的輸入,再確認哪一個結果才是既有契約。


急急,護法現身

這正是 Legacy Code 最危險的地方,我們常常看得出兩段 code 不一樣,卻不知道那個差異是不是重要規格。

突變測試可以先把問題縮小成「30 天是不是契約邊界」,接著仍然要回到需求單或真正懂規則的人確認,如果需求明確回答「30 天以上包含第 30 天」,我們才有資格做改動。

規格確認後,要殺掉這個突變體,就得先找出突變體第一次產生不同答案的輸入,>= 30> 30 的差別只發生在 30,所以補的案例就只需要這一行:

[InlineData(30, 800)]

再跑一次,突變體將 >= 改成 > 時,30 天案例會從預期的 800 變成 1000,測試這時候就會亮紅燈,被測試發現的突變體就會遭到殲滅,分數也從 92.31% 變成了 100%。

不過實務上當然不可能追求 100%。有些突變的段落雖然語法不同,外部行為其實是相同的,這類等價突變本來就不一定有合理的測試可以處理,另外像 log 文字或這次根本不會動到的低風險程式,也未必值得投入同樣的成本。

Stryker.NET 也很貼心地提供了 mutation-level,用來決定這一輪要啟用哪些突變組合,每一級都會包含前一級的突變集合,等級越高,通常會產生越多突變體,執行時間也會跟著增加。

目前一共有四個等級:

等級 突變範圍 突變範例
Basic 基本運算與流程突變 算術、邏輯、位元運算、移除區塊
Standard 加入常用程式語法突變(預設) 相等比較、布林值、指定運算、字串、LINQ
Advanced 加入較進階的 API 突變 Regex、Math、字串 method
Complete 啟用所有支援的突變 包含所有突變類型

我們剛才直接執行 dotnet stryker,沒有指定等級,所以使用的就是 Standard,如果要暫時提高到 Advanced,可以這樣執行:

dotnet stryker --mutation-level Advanced

不過,等級升高不代表每次都會多出突變體,真正還是會依照 production code 裡有哪些可以被突變的語法,各位可以試試看。


總結

突變測試做的事情,其實就像奧德修斯突然吹響號角,他不是傻傻的在一座城裡面找阿基里斯,而是故意製造一個情境,看誰會做出反應,我們也只是暫時把 production code 改壞一點,看看測試到底會不會有反應。

所以覆蓋率高並不是沒有用,它能告訴我們哪些路徑沒走到,但一味地追高也可能會浪費很多成本,突變測試則會繼續追查,走過的那些路徑,我們到底有沒有注意到重要的細節,至於分數要到什麼程度,就要看這次準備修改的範圍和風險,先處理真的可能傷到業務規則的缺口,比把每一個數字刷得漂漂亮亮實際多了。

在現在這個時代,LLM 產生能通過而且覆蓋率 100% 的測試是幾分鐘的事情,不過我們沒辦法知道他產出的那麼多測試,又有幾個是對我們有幫助的,所以我們真正需要的是盡可能的去貼近需求,並理解它,這才是真正能夠幫我們找到並保護問題的方式,再好的工具沒有好的理解與判斷力去支撐,也是沒辦法發揮它最大的效益!

所以明天我們就來看看:看不懂系統時,該怎麼先試著去理解?

Reference


上一篇
Day 14 -「涅索斯愛情魔藥」改一個地方,到底會波及哪裡?
下一篇
Day 16 -「冥府之旅」別只用看的,透過草稿重構直接動手
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言