為沒有測試的遺留業務應用程式安全地動手修改 ── 特性化測試與重構的實踐

· · 遺留技術, 既有資產活用, 重構, 測試設計, 特性化測試, C#, .NET, 維護, 判斷表, 技術諮詢

更新紀錄(僅初版,2026年07月17日 發布)
初次發布
引用本文(DOI: 10.5281/zenodo.22174266)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

Go Komura(2026)。〈為沒有測試的遺留業務應用程式安全地動手修改 ── 特性化測試與重構的實踐〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.22174266 https://comcomponent.com/zh-TW/blog/characterization-test-legacy-refactoring/

DOI(最新版本)
10.5281/zenodo.22174266
DOI(此版本)
10.5281/zenodo.22174267

「明明知道哪裡該修,可是一想到動了之後可能會弄壞別的地方,就不敢下手」──這是從接手沒有測試的業務應用程式的人那裡,經常聽到的一句話。

用 VB6、.NET Framework、Access 寫成的業務應用程式,大多沒有自動測試。規格書也早已停止更新,處於「程式碼就是唯一規格書」的狀態。即使如此,業務仍在持續運作,消費稅稅率的變更、報表版面的修正、往來廠商的新增等改修需求,並不會等人。

本部落格曾在「VB6 應用程式能用到什麼時候 ── 執行環境的支援現況與務實的 .NET 遷移做法」中,介紹過將舊系統當作「會動的規格書」,一邊比對輸出結果一邊推進遷移的思路。本文則把這個思路,套用到不是遷移、而是「直接對目前正在運作的程式碼動手修改」的場景上。核心工具是特性化測試(characterization test)。即使程式碼沒有測試,只要事先用測試把接下來可能弄壞的行為固定下來,無論是重構還是功能新增,都會安全許多。

本文設想的讀者與前提如下。不需要自動測試的經驗,內容設計成從一行測試都沒寫過的狀態也能開始閱讀。前提只需要兩件事:(1)能夠自行建置目標應用程式(手邊有原始碼,也有能建置成功的開發環境);(2)處於可以對原始碼進行變更的立場。程式碼範例會以 C#(.NET Framework/.NET 皆可運作的寫法)呈現,但思路與語言無關。反過來說,如果沒有原始碼、無法建置,本文的方法就無法直接套用,需要先從整理這個前提開始。

1. 先講結論

  • 請不要立刻動手修改。先用測試把目前的行為固定下來。即使沒有規格書,目前運作中程式碼的輸出本身就是規格。
  • 用於此目的的工具就是特性化測試。這是一種記錄「目前行為」而非「正確行為」的測試,把報表、CSV、計算結果等輸出原封不動地保存成期望值,並在變更前後比對差異(黃金主檔法)。
  • 對於無法插入測試的結構(直接寫在 UI 事件處理常式裡、直接參照 DateTime.Now 或檔案路徑),要用方法抽出與介面插入這種最小限度的變更,建立「接縫(seam)」。不需要大規模改造。
  • 請不要把重構與功能新增混在同一次提交裡。重構的合格條件是「差異為零」,功能新增的合格條件是「只有預期的差異」,混在一起就無法判斷差異的意義。
  • 測試要整備到什麼程度,取決於改修規模 × 系統剩餘壽命 × 故障時的影響來決定。並不是所有情況都該加上單元測試才是正解,「只做特性化測試」「不動它」也可能是正解。
  • 即使沒有 CI 也能開始。只要有一個測試專案和一個放期望值檔案的資料夾,光是在本機執行,安全性就會有很大的改變。

2. 為什麼遺留程式碼「一動就壞」

遺留程式碼的改修之所以令人害怕,並不是因為程式碼舊,而是因為沒有辦法確認變更的結果是否正確。

Michael Feathers 在其著作《Working Effectively with Legacy Code》中,將遺留程式碼定義為不是「單純的舊程式碼」,而是「沒有測試的程式碼」。1 理由在於,沒有測試的話,每次變更都無法快速確認程式碼是變好了還是變差了。依照這個定義,就算是昨天才寫好的程式碼,只要沒有測試,就是遺留程式碼。

沒有測試的程式碼,會開始陷入以下的惡性循環。

  1. 因為沒有測試,不知道變更的影響範圍,所以很可怕
  2. 因為可怕,所以不敢動既有結構,只用最小限度的複製貼上與增加條件分支來應付
  3. 應急式的修補不斷累積,程式碼變得更難懂、更容易壞
  4. 因為變得更容易壞,所以更加可怕(回到 1)

要打破這個惡性循環,入口並不是「鼓起勇氣進行大規模重構」。順序恰好相反,應該是先架設安全網(測試),去除害怕的根源之後,再動手修改。不過這裡有一個雞生蛋、蛋生雞的問題。要寫測試,需要可測試的結構;但要做出可測試的結構,又必須變更(重構)程式碼。結果就變成要在沒有測試的狀態下,變更沒有測試的程式碼。

為了解開這個矛盾,遺留程式碼的改修要依照下面的順序推進。1

  1. 只針對即將變更的部分周邊,從外部把目前的行為固定下來(特性化測試)
  2. 在這張安全網之內,進行弄壞風險極低的最小限度變更(方法抽出等),做出可以插入測試的入口
  3. 一旦結構變得可以撰寫細緻的測試,就著手進行真正想做的變更(重構、功能新增)

以下章節會具體說明這裡的第 1 步與第 2 步。

3. 特性化測試 ── 記錄「目前的行為」

3.1 與一般測試有何不同

一般的測試驗證的是「規格上應該如此」的正確行為。特性化測試不同,它會先擱置「是否正確」的判斷,只記錄目前的程式碼實際上是怎麼運作的。

舉例來說,假設規格書上沒有寫明小數點以下要四捨五入還是無條件捨去。如果目前的程式碼是以無條件捨去的方式在運作,業務也靠這個方式運轉了十年,那麼至少「是無條件捨去」這件事,事實上就是規格。特性化測試會以「目前的輸出是某某值」的形式,原封不動地把這件事固定下來。就算那其實是個 bug,也要先固定下來。要改變行為(修正 bug),是在安全網完成之後,作為預期中的變更另外進行。

3.2 黃金主檔法的步驟

對於輸出單位較大的遺留程式碼,黃金主檔法(Golden Master)是投資報酬率最高的特性化測試方式。步驟很單純。

  1. 鎖定要變更的功能所產生的輸出(報表文字、CSV、計算結果一覽等)
  2. 準備具代表性的輸入資料,執行現行程式碼取得輸出
  3. 把該輸出原封不動地保存為期望值檔案(黃金主檔),放入版本庫
  4. 之後每次變更程式碼時都執行測試,確認輸出與期望值檔案的差異為零

C# 的實作方式,不依賴特定函式庫、像下面這樣單純的做法就足夠了。

[Fact]
public void 月次請求一覧_ゴールデンマスター()
{
    // 1. 讀取具代表性的輸入(如從正式環境遮罩後抽取的資料)
    var input = File.ReadAllLines(TestDataPath("billing-input-202606.csv"));

    // 2. 直接呼叫既有邏輯,取得輸出字串
    string actual = BillingReport.Generate(input);

    // 3. 期望值檔案不存在,代表「測試環境設定有誤」或「第一次執行」。
    //    無論哪一種都不能悄悄放行,而是留下記錄後讓測試務必失敗
    string expectedPath = TestDataPath("billing-expected-202606.txt");
    if (!File.Exists(expectedPath))
    {
        File.WriteAllText(expectedPath + ".candidate", actual);
        Assert.Fail("找不到期望值檔案。請審查 .candidate 的內容," +
                    "確認沒有問題後再將其提交為期望值。");
    }

    // 4. 驗證是否與已保存的行為完全一致
    string expected = File.ReadAllText(expectedPath);
    Assert.Equal(expected, actual);
}

請避免在找不到期望值檔案時,直接把目前的輸出當作期望值保存並讓測試通過的實作方式。一旦發生期望值忘記提交,或測試環境放置錯誤的情況,CI 就會在沒有偵測到退化的情況下顯示綠燈通過。第一次的記錄應該像上面那樣輸出候選檔案(.candidate),並讓測試明確失敗,改成由人審查之後才提交為期望值的單向流程。

出現差異時,只看 Assert.Equal 的訊息很難追蹤,實務上可以在失敗時把實際輸出另外寫成 billing-actual-202606.txt 之類的檔案,用 WinMerge 之類的差異比對工具與期望值比較,這樣調查起來會快很多。

另外,這段程式碼範例的前提是「測試專案可以直接呼叫既有邏輯的 BillingReport.Generate」。在遺留現場最先卡關的往往就是這一步,接法整理在 3.4 節。

這種手法在不同文獻中有不同的稱呼,除了黃金主檔測試之外,也被稱為承認測試(approval testing)、快照測試(snapshot testing)。也有人把同樣的想法函式庫化,在 .NET 中具代表性的有 ApprovalTests.Net 與 Verify。它們可以代勞期望值檔案的命名規則、差異比對工具的自動啟動、期望值的核准操作等,在上面的程式碼中自行實作的部分。先用上面這種單純的實作開始,等到期望值檔案增加、管理變得麻煩時,再考慮導入函式庫,這樣的順序就足夠了。搜尋時用「approval testing」「snapshot testing」會比「golden master」找到更多資訊。

3.3 輸入資料的選擇方式與輸出的正規化

輸入資料以「代表性 + 邊界值」為原則來挑選。先準備 1~2 個一般情況,再加上月底結帳、零筆資料、負值、特定往來廠商的例外處理等,讀程式碼找出的每個分支都能被走到的輸入資料。如果能用遮罩過的正式環境資料,那是最能貼近現實分支的方式。

輸出中混雜的非決定性數值,要在比較前先正規化。列印日期時間、處理耗時、GUID、自動編號等每次執行都會改變,如果原封不動比較,每次都會出現差異。可以在產生輸出之後,先用正規表示式把 印刷日時: 2026/07/17 16:00 替換成 印刷日時: <DATE> 之類的前處理,再進行比較。

整理一下什麼樣的輸出適合作為黃金主檔的參考標準。

輸出類型 適用性 補充
CSV、固定長度檔案 ◎ 可直接保存、比較。最先該鎖定的對象
報表(文字、列印預覽的原始資料) ◎ 抓在轉成 PDF 之前的字串。避免直接比對 PDF 二進位內容
計算結果一覽(金額、庫存數量等) ◎ 也可以另外加一個把結果輸出成 CSV 等格式的測試專用方法
寫入資料庫的內容 ◯ 對寫入後的資料表內容執行 SELECT,轉成 CSV 後再比較
畫面顯示本身 △ 若能轉成字串就可以。畫面操作的自動化需要另一套工具,在「Windows 桌面應用程式的 UI 自動測試」中有處理
對外部系統的傳送 △ 需要有能抓住傳送前一刻資料的接縫(下一章)

3.4 讓測試專案能夠呼叫既有應用程式

既有的 WinForms/WPF 應用程式是 EXE 專案。「加了測試專案,卻發現看不到主體的類別」是遺留改修常遇到的第一道關卡,因此先整理一下接法。

1. 新增一個測試專案。在既有的方案(solution)中加入一個新的測試專案。即使維持 .NET Framework,MSTest/NUnit/xUnit 都可以使用。測試專案的目標框架原則上要與主體一致(如果主體是 .NET Framework 4.8,測試專案也要是 4.8)。這裡如果不一致,加入參照的當下就會出現警告或載入錯誤。

2. 加入對主體專案的參照。連接的方式有兩種,原則上優先採用前者。

接法 使用場景 做法
專案參照(建議) 有主體的原始碼,且可以在同一個方案中建置 在測試專案上按右鍵 → 加入參照 → 專案 → 選擇主體的 EXE 專案。EXE 專案本身也是組件(assembly),因此可以被參照(「因為是 EXE 所以不能參照」是誤解)
DLL/EXE 檔案參照 主體無法納入同一個方案,手邊只有已建置完成的二進位檔 加入參照 → 瀏覽 → 直接指定主體 bin 資料夾中的 EXE/DLL 檔案。不過要留意,每次重新建置主體時都要注意參照對象不要過舊

參照方向只能是測試 → 主體的單向。如果讓主體參照測試,就會形成循環參照。

3. 想在保持 internal 的狀態下測試,就使用 InternalsVisibleTo。如同第 4 章會看到的,用方法抽出把邏輯切出來時,很多時候會想維持 internal(因為不會增加公開 API 也能測試)。這時候要在主體側的組件中加上下面這一行屬性。2

// 放在主體側的 AssemblyInfo.cs 或任意原始碼檔案的開頭
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("MyApp.Tests")]

有兩個注意事項。2

  • 主體與測試的簽章狀態要一致。兩者必須都是未簽章,或都是具強式名稱(strong name)。如果主體是強式名稱,要像 InternalsVisibleTo("MyApp.Tests, PublicKey=0024...") 這樣寫出完整的公開金鑰(不是公開金鑰語彙基元/token)。公開金鑰可以用 sn -p 和 sn -tp 取得。
  • private 不會被看見。InternalsVisibleTo 只對 internal/protected internal/private protected 生效。如果想測試 private 方法,那正是「這個類別太大了」的訊號,用第 4 章的方法抽出把它切出來會更直接。

4. 確認資料檔案的存放位置。測試執行時的目前工作目錄,會是測試的輸出資料夾(bin\Debug\...)。期望值檔案與輸入 CSV 建議放在 TestData 資料夾中,並把屬性中的「複製到輸出目錄」設為「有更新時才複製」,這樣 3.2 節的 TestDataPath 就能寫得很直接。如果主體側有讀取 app.config 或設定檔,測試專案側有時也需要對應的設定。

做到這裡,3.2 節的黃金主檔測試就可以直接寫出來了。

4. 建立可插入測試的「接縫(seam)」

一旦動手寫黃金主檔測試,在許多遺留程式碼上都會撞牆。邏輯直接寫在 UI 事件處理常式裡,不啟動畫面就無法執行。這時候需要的,就是能從測試程式碼替換、觀察行為的位置,也就是 Feathers 所說的接縫(seam)。1

4.1 用方法抽出將邏輯從 UI 中剝離

典型的 Before 是這樣的:計算、資料庫存取、依賴時刻、畫面更新全都擠在同一個事件處理常式裡。

// Before: 全部直接寫在事件處理常式裡
private void btnCalc_Click(object sender, EventArgs e)
{
    var rows = LoadRowsFromDb();                          // 直接存取資料庫
    var now = DateTime.Now;                               // 依賴目前時刻
    decimal total = 0;
    foreach (var row in rows)
    {
        if (row.SalesDate.Year == now.Year &&
            row.SalesDate.Month == now.Month)              // 只彙總當月的資料
        {
            total += Math.Floor(row.Amount * 1.1m);        // 小數處理這項業務規則
        }
    }
    lblTotal.Text = total.ToString("N0");                  // 直接反映到畫面
}

在這個狀態下,要測試「彙總當月資料」的邏輯,需要畫面、資料庫,還有「今天的日期」。要用最小限度的變更做到可測試化,定石是只把計算部分抽出成一個方法,並把外部依賴(資料庫的結果與目前時刻)改成用參數傳入。使用 Visual Studio 的方法抽出重構功能(Ctrl+R, M),也能減少手動改寫時的失誤。3

// After: 只抽出計算部分,並把「資料庫的結果」與「目前時刻」以參數接收
internal static decimal CalcMonthlyTotal(IEnumerable<SalesRow> rows, DateTime now)
{
    decimal total = 0;
    foreach (var row in rows)
    {
        if (row.SalesDate.Year == now.Year &&
            row.SalesDate.Month == now.Month)
        {
            total += Math.Floor(row.Amount * 1.1m);
        }
    }
    return total;
}

private void btnCalc_Click(object sender, EventArgs e)
{
    var rows = LoadRowsFromDb();
    lblTotal.Text = CalcMonthlyTotal(rows, DateTime.Now).ToString("N0");
}

事件處理常式這一側,就變成「讀取 → 計算 → 顯示」三行,被抽出的方法則可以用任意的資料列與任意的日期進行測試。月底、月初、閏年這類與時刻相關的邊界情況,只要傳入像 new DateTime(2028, 2, 29) 這樣的日期就能重現。

4.2 用介面插入讓相依項目可切換

對於用參數化無法解決規模的依賴(例如到處都在參照 DateTime.Now、檔案路徑直接寫死等),要把依賴用介面包起來再插入。Microsoft Learn 的 .NET 單元測試最佳實務中,也把對 DateTime.Now 的直接依賴列為測試無法控制的典型案例,並介紹了用介面包起來、導入接縫(seam)的方法。4

public interface IClock
{
    DateTime Now { get; }
}

public sealed class SystemClock : IClock
{
    public DateTime Now => DateTime.Now;
}

// 測試端插入一個回傳固定時刻的實作
public sealed class FixedClock : IClock
{
    private readonly DateTime _fixed;
    public FixedClock(DateTime value) => _fixed = value;
    public DateTime Now => _fixed;
}

如果在既有類別的建構函式中加入 IClock,會需要修改所有呼叫端,因此在過渡期間,現實的做法是並行提供一個「無參數建構函式就使用 SystemClock」的預設建構函式,逐步修改呼叫端。檔案路徑或資料庫連線字串的直接寫死,也可以用同樣的方式,包成只有「讀寫」操作的小型介面。

另外,Visual Studio 內建了從既有類別抽出介面的重構功能(Extract Interface),可以機械式地完成這類變更。3

建立接縫時要遵守的原則只有一個。建立接縫的這個變更本身,行為必須一絲一毫都不能改變。方法抽出與介面插入,都是可以靠編譯器與 IDE 支援機械式完成、行為保存性很高的操作。這個階段很容易想「順便」把邏輯也改一改,但那是安全網架好之後才該做的事。

5. 該做到什麼程度的判斷表

特性化測試與建立接縫也需要工時。要為所有遺留程式碼整備同一水準的測試,在中小規模的現場並不現實,也沒有這個必要。判斷軸有三個。

  • 改修的規模:只是幾行的錯誤修正,還是功能新增,是否伴隨結構變更
  • 系統的剩餘壽命:再過一到兩年就要遷移或廢除,還是要繼續用五年以上
  • 故障時的影響:只是報表版面跑掉的程度,還是會算錯請款金額或庫存數量
改修規模 剩餘壽命 故障時的影響 建議水準
輕微(幾行、設定值變更) 短(~2 年) 小(版面跑掉程度) 只做特性化測試。固定相關輸出後進行變更,確認差異即可結束
輕微~中 短 大(涉及金額、庫存) 只做特性化測試,但要做得更充分。增加輸入樣式,並涵蓋邊界情況
中(功能新增、邏輯變更) 長(5 年~) 小~中 特性化測試 +只在變更部分周邊整備單元測試(建立接縫)
中~大 長 大 特性化測試 + 整備單元測試 +把發布單位切得更細
大(需要結構性革新) 短 ─ 不動它。不進行改修,改為運用面迴避,工時投入遷移/汰換
─(本來就沒有改修需求) ─ ─ 不動它。不對正常運作中的程式碼進行預防性重構

下面兩列的「不動它」並不是消極的選擇,而是積極的判斷。對剩餘壽命短的系統投資內部品質,是無法回收的。這部分的工時,應該用在「VB6 / Access 業務應用程式的延命與遷移判斷表」中整理過的遷移判斷,以及遷移目標的設計上。

另外,即使進展到「整備單元測試」,也需要劃清什麼寫進單元測試、什麼留給整合測試(使用真實資料庫、真實檔案的測試)的界線。這條界線,已在「如何劃分單元測試與整合測試的界線」中以判斷表的形式整理過,請一併參考。基於單元測試應該具備 fast/isolated/repeatable 這樣的性質,4 會碰觸資料庫或檔案的特性化測試,最好分到與單元測試不同的專案、不同的執行單位裡比較穩妥。

6. 運用規則 ── 為了不破壞安全網

特性化測試寫完之後,如果運用方式錯誤,很容易就變得有名無實。這裡把最低限度的規則濃縮成三條。

6.1 不要把重構與功能新增混在同一次提交裡

重構,指的是在不改變行為的前提下,讓程式碼更容易理解、更容易維護的變更。5 也就是說,合格條件是與黃金主檔的差異為零。另一方面,功能新增、錯誤修正的合格條件則是只出現預期中的差異。把這兩者混在同一次提交裡,一旦出現差異,就無法判斷究竟是「預期中的變更」還是「弄壞了」。

變更類型 黃金主檔的處理 合格條件
重構(結構變更) 不更新 差異為零
錯誤修正、功能新增(行為變更) 差異審查後更新 只有預期中的差異
建立接縫(方法抽出、介面插入) 不更新 差異為零
期望值正規化規則變更 重新產生 在提交訊息中明確寫出變更理由

發布單位也是一樣的道理。「只有重構的發布」理應不會改變行為,所以一旦出問題,可以立刻懷疑是重構造成的。混在一起的話,這種釐清就會失效。

6.2 期望值的更新要按照「差異審查 → 覆寫」的順序進行

一旦刻意改變了行為,黃金主檔也要跟著更新。請固定住這個流程。

  1. 產生變更後的輸出,用肉眼審查與目前期望值之間的差異
  2. 確認差異只包含預期中的變更(只要有一行不是預期中的變更,就要去調查)
  3. 用新的輸出覆寫期望值檔案,並與程式碼放在同一次提交,留下歷史紀錄

危險的做法是「因為測試變紅了,所以覆寫期望值讓它變綠」這種運用方式。這樣做會讓退化原封不動地被記錄成「正確」,安全網就不再是安全網。

判斷方式最快的辦法就是實際看差異,因此舉一個例子。假設進行了「對消費稅率 10% 的商品加入 8% 輕減稅率」的改修,期望值出現了以下差異。

  2026/06/30,A商事,事務用品,       10000,   1000,  11000
- 2026/06/30,A商事,飲料(軽減),      5000,    500,   5500
+ 2026/06/30,A商事,飲料(軽減),      5000,    400,   5400
  2026/06/30,A商事,小計,           15000,   1500,  16500
- 2026/06/30,B工業,機械部品,      200000,  20000, 220000
+ 2026/06/30,B工業,機械部品,      200000,  20001, 220001

上面兩行(輕減稅率商品的稅額從 500 變成 400)是預期中的變更,正是這次改修的目的,可以更新期望值。但下面兩行,照理說與輕減稅率無關的 B 工業的稅額卻差了 1 圓,這是退化。原因很可能是動到了共用的小數處理函式。如果在這裡覺得「反正只差 1 圓」就把期望值覆寫過去,這 1 圓的偏差就會從此被當作「正確行為」固定下來。

運用規則可以濃縮成一句話。如果無法針對差異的每一行,說明「為什麼這一行會變」,就不能更新期望值。只要有一行無法說明,就要調查到查明原因為止。順帶一提,在上面的例子中,還可以注意到 小計 這一行沒有被更新(輕減稅率的那一行變了,小計理應也要跟著變)。應該變卻沒有變的行,同樣是差異審查應該找出的對象。

6.3 即使沒有 CI,也要建立可在本機執行的最小組態

即使現場沒有 CI 伺服器,只要有下面這套最小組態,今天就能開始。

  • 在方案中新增一個測試專案(即使維持 .NET Framework,MSTest/NUnit/xUnit 都能運作。接法見 3.4 節)
  • 期望值檔案與輸入資料放在 TestData 資料夾中,與程式碼一起納入版本控制
  • 把「提交前手動執行測試」訂為團隊的約定
  • 為了不忘記確認執行結果,在發布程序文件中加上一行「執行測試並確認差異為零」

執行方式並不只有 dotnet test。在混雜了舊格式(非 SDK 風格)csproj 的方案中,dotnet test 有時無法如預期般運作。這種情況下有兩個選項。

  • Visual Studio 的測試總管。只要建置,測試就會自動被偵測到,可以從 GUI 執行。對於沒有測試經驗的成員來說,這種方式導入起來比較輕鬆。
  • vstest.console.exe。這是一個直接指定已建置完成的測試 DLL 來執行的命令列工具,可以從 Developer Command Prompt 使用。6
vstest.console.exe MyApp.Tests\bin\Debug\MyApp.Tests.dll /logger:trx

要用哪一種,可以依環境自行決定。重要的是「提交前一定要跑一次」這個約定本身。

在比對測試執行結果與應用程式輸出時,日誌整備得越完善,調查原因就會越快。日誌該保留什麼內容,在「自製 Logger 的最小要件與整合測試檢查清單」中有處理。

7. 總結

  • 遺留程式碼指的是「沒有測試的程式碼」,1 一動就壞的真正原因,在於沒有辦法確認變更的結果。動手修改之前,先用測試把目前的行為固定下來。
  • 特性化測試是一種記錄「目前行為」而非「正確行為」的測試。把報表、CSV、計算結果原封不動保存成期望值檔案並比較差異的黃金主檔法(承認測試/快照測試),只靠單純的 C# 程式碼就能開始。
  • 第一道關卡是「讓測試專案能夠呼叫既有 EXE 的程式碼」。EXE 專案也可以用專案參照,如果想在保持 internal 的狀態下測試,只要在主體側加上一行 InternalsVisibleTo(3.4 節)。2
  • 對於無法插入測試的結構,要用方法抽出與介面插入來建立接縫(seam)。把 DateTime.Now 這類依賴包起來的手法,也是 Microsoft 的單元測試指南所示範的定石。43
  • 要整備到什麼程度,取決於改修規模 × 剩餘壽命 × 故障時的影響來決定。「只做特性化測試」「不動它」也是很出色的判斷。
  • 在運用上要遵守不要把重構(合格條件是差異為零)與功能新增(合格條件是只有預期中的差異)混在一起,5 期望值的更新一定要經過差異審查。即使沒有 CI,光是約定好在本機執行測試,安全性就會有很大的改變。

相關文章

相關諮詢領域

合同會社小村軟體提供沒有測試的既有業務應用程式導入特性化測試、朝可測試結構進行階段性重構,以及該投資改修還是遷移的判斷整理等服務。

參考連結

  1. Michael C. Feathers, “Working Effectively with Legacy Code” (Prentice Hall, 2004)。關於將遺留程式碼定義為「沒有測試的程式碼」、透過特性化測試(characterization test)先記錄目前行為再著手變更的流程,以及用於插入測試的接縫(seam)概念的說明。 ↩ ↩2 ↩3 ↩4

  2. Microsoft Learn, InternalsVisibleToAttribute Class。關於此屬性可讓通常只在同一組件內可見的型別・成員,對指定的友元組件(friend assembly)可見;對象為 internal/protected internal/private protected,不包含 private;目前組件與友元組件必須都是未簽章,或都是具強式名稱;具強式名稱時需要指定完整的公開金鑰而非公開金鑰語彙基元,可用 sn -p 與 sn -tp 取得的說明。 ↩ ↩2 ↩3

  3. Microsoft Learn, Extract and inline refactorings (Visual Studio)。關於 Visual Studio 針對 C#/Visual Basic 的方法抽出(Ctrl+R, M)以及介面抽出(Extract Interface)重構功能操作方式的說明。 ↩ ↩2 ↩3

  4. Microsoft Learn, Unit testing best practices for .NET。關於良好單元測試應具備的性質(fast/isolated/repeatable/self-checking/timely)、把 DateTime.Now 這類無法控制的依賴用介面包起來以導入接縫(seam)的手法,以及不應把基礎設施依賴帶入單元測試、而應分到整合測試中的說明。 ↩ ↩2 ↩3

  5. Microsoft Learn, Refactor code (Visual Studio)。關於重構的定義是「在不改變行為的前提下,為了讓程式碼更容易維護、理解、擴充而進行變更的過程」的說明。 ↩ ↩2

  6. Microsoft Learn, VSTest.Console.exe command-line options。關於 VSTest.Console.exe 是用來執行測試的命令列工具、可以直接指定測試檔案(DLL)來執行、可以從 Developer Command Prompt 使用的說明。 ↩

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

什麼是特性化測試(characterization test)?
這是一種原封不動記錄「目前行為」而非「正確行為」的測試。在沒有留下規格書的遺留程式碼中,往往沒有辦法確認什麼才是正確的,因此先把目前運作中程式碼的輸出(報表、CSV、計算結果等)當作期望值保存下來,並以機械方式確認變更前後輸出沒有改變。基本用法是先架設固定行為的安全網,再進入重構或功能新增。
完全沒有測試的遺留程式碼,該從哪裡下手?
務實的做法是只針對接下來要變更的部分周邊撰寫特性化測試。要為整個系統架設測試,工時上大多不可行,也沒有這個必要。首先鎖定要變更的功能所產生的輸出(報表、CSV、寫入資料庫的內容等),將代表性輸入所對應的輸出保存成檔案並固定下來。在這張安全網之內進行方法抽出等小規模重構,把邏輯切割成可測試的形式後,再著手進行真正想做的變更。
為什麼不能把重構與功能新增混在同一次提交(commit)裡?
因為一旦輸出出現差異,就無法釐清原因出在哪裡。重構要確認的是「行為沒有改變」,功能新增要確認的是「只有預期的部分行為改變了」,兩者的驗收條件正好相反。混在一起的話,就無法判斷與黃金主檔之間的差異究竟是「預期中的變更」還是「弄壞了」。安全的做法是分開確認:重構的提交要求差異為零,功能新增的提交只允許出現預期中的差異。
黃金主檔(期望值檔案)應該在什麼時候更新?
只有在刻意改變行為的時候,也就是功能新增或修正錯誤的提交時機才更新。更新時,要用肉眼審查變更前後的輸出差異,確認其中只包含預期的變更之後,才用新的輸出取代期望值。如果只因為測試變紅就機械式地覆寫期望值,會把退化(非預期的行為變化)原封不動地當作「正確」納入,安全網就失去了意義。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽