上一章已經把遺留系統的執行路徑、功能規則與例外情況整理成可實作的規格。目標系統完成對應功能後,還需要確認實際結果是否符合這些規格。程式可以順利執行,只能表示沒有在當下中斷,無法說明條件判斷、狀態變化與外部影響是否正確。
本系列採用完整替換的方向。遺留系統與目標系統的內部結構可以不同,驗證重點是兩者在相同條件下是否產生預定的可觀察結果。需要保留的行為應該一致,經過確認的功能調整則應該符合新的驗收條件。所有無法解釋的差異,都要先確認原因才能判定功能已經完成。
開始比較前,要先說明判斷正確與否的依據。遺留系統目前的結果可以協助建立基準,卻不代表每一項結果都符合現在的需求。上一章已經將行為分為預期規則、已知錯誤、歷史相容行為與待確認行為,這些分類要進一步轉換成明確的驗證方式。
| 行為分類 | 目標系統的驗證方式 |
|---|---|
| 預期規則 | 目標系統在相同條件下應該產生相同的功能結果。 |
| 已知錯誤 | 目標系統應該符合已確認的修正結果,測試要同時保留遺留系統的原始結果以說明差異。 |
| 歷史相容行為 | 先確認相容對象是否仍在替換範圍內,再按照保留、轉換或移除的決定建立預期結果。 |
| 待確認行為 | 暫時保留為未決問題,不能把目前觀察到的結果直接當成通過條件。 |
每項允許不同的行為都要記錄需求內容、決定結果、適用範圍與驗收條件。如果只有「這次順便修正」或「新的做法比較合理」等說明,就不足以判定差異已經獲得確認。目標系統出現不同結果時,應該先回到這份紀錄判斷它是預定調整,還是尚未說明的偏差。
比較範圍也要涵蓋失敗結果。成功輸出相同,但拒絕條件、錯誤分類、部分完成狀態或重新執行結果不同,仍可能改變功能行為。定義一致行為時,至少要確認:
驗證案例要從已確認的功能規則與輸入輸出範例產生,不能只挑目前容易執行的正常路徑。每項案例都要描述初始條件、操作方式、觀察位置與預期結果,讓遺留系統和目標系統可以使用相同條件執行。
一項可重複使用的案例至少要包含下列內容:
| 項目 | 需要記錄的內容 |
|---|---|
| 案例識別 | 可供規格、執行結果與差異紀錄共同引用的唯一名稱。 |
| 對應規則 | 這項案例要驗證的功能規則、需求差異或例外情況。 |
| 初始狀態 | 執行前必須存在或不得存在的保存內容、設定與相依項目狀態。 |
| 輸入內容 | 實際輸入值、格式、順序及必要的觸發方式。 |
| 執行步驟 | 從功能入口到取得結果所需的最少步驟。 |
| 觀察項目 | 需要比較的輸出、狀態變化、錯誤與外部影響。 |
| 預期結果 | 每個觀察項目應該得到的值或符合的條件。 |
| 清除方式 | 執行後如何移除產生的內容,使下一次測試能從相同狀態開始。 |
案例範圍應該涵蓋主要正常路徑、邊界值、缺少必要內容、格式不符、重複執行、部分完成及相依項目失敗。如果規則會受到多項條件共同影響,應該使用規則表找出會產生不同結果的條件組合,不必列出對結果沒有影響的所有排列。
例如,上一章的匯入功能可能同時處理有效與無效內容。驗證案例不能只檢查畫面顯示的成功數量,還要按照規格確認有效內容的保存結果、無效內容的錯誤資訊、整批處理狀態,以及相同檔案再次執行時的結果。這些觀察項目共同構成功能行為,漏掉其中一項就可能讓目標系統在局部結果正確時被誤判為通過。
相同輸入不一定會產生可比較結果。功能可能受到保存狀態、執行設定、目前時間、自動產生值或相依項目回應影響。如果兩套系統從不同條件開始,執行結果的差異就無法直接歸因於功能實作。
每次比較前,應該確認下列條件:
如果兩套系統使用不同的內部資料結構,不需要強迫它們載入完全相同的保存內容。資料準備流程應該把同一個功能情境分別轉換成各系統需要的形式,再確認兩邊表達的欄位意義、關聯與初始狀態一致。
正式資料可能包含不適合進入測試環境的內容。此時應該建立受控樣本,保留會影響功能規則的格式、關聯與邊界情況,並且移除或替換不應保留的內容。自行建立的樣本也要和上一章發現的歷史例外對照,避免只涵蓋理想資料。
測試工具可以協助重複執行案例、建立初始條件及顯示結果差異,但通過條件仍要來自已確認的功能規格。應該優先沿用目標系統主要程式語言與建置流程支援的測試框架,再按照相依項目與輸出型態補充其他工具。
| 使用情境 | 推薦工具 | 使用方式與限制 |
|---|---|---|
| 目標程式使用 JavaScript 或 TypeScript | Vitest | 用來執行單元測試、整合測試、模擬函式及檔案快照。本章範例使用這項工具。 |
| 目標程式使用 Python | pytest | 使用斷言、測試資料準備函式及參數化測試重複執行相同案例。 |
| 目標程式使用 Java | JUnit | 使用測試生命週期、斷言及參數化測試組織功能案例。 |
| 功能已確認會透過 HTTP 與相依項目互動 | WireMock | 固定相依項目的回應內容、錯誤或延遲,讓失敗路徑可以重現。沒有 HTTP 互動時不需要加入。 |
| 整合測試需要可實際執行的相依項目,而且測試環境可以啟動容器 | Testcontainers | 啟動指定版本的暫時性相依項目,載入案例所需的初始狀態,測試後再停止。這種方式不適合取代速度較快的單元測試。 |
測試框架的語法不同,案例內容仍應該保持一致,包括案例識別、初始狀態、輸入、操作及預期結果。如果遺留系統和目標系統使用不同語言,可以讓兩邊各自使用合適的測試框架,最後輸出共同的比較格式,不需要為了共用工具而改變系統程式。
以下範例延續前面的匯入功能。importFile 代表目標系統已經完成的功能入口,案例中的函式名稱、輸入格式、狀態名稱與錯誤代碼都要替換成實際規格。第一項案例同時檢查整批狀態、有效內容的保存呼叫與無效內容的拒絕原因。
import { expect, test, vi } from 'vitest'
import { importFile } from './import-file.js'
test('IMP-001:混合內容會保存有效項目並記錄拒絕原因', async () => {
const save = vi.fn().mockResolvedValue(undefined)
const result = await importFile(
{
fileId: 'file-001',
rows: [
{ row: 1, code: 'A01', amount: 120 },
{ row: 2, code: '', amount: 80 },
],
},
{
wasFileImported: vi.fn().mockResolvedValue(false),
save,
},
)
expect(result).toEqual({
status: 'partial',
imported: 1,
rejected: [{ row: 2, reason: 'code_required' }],
})
expect(save).toHaveBeenCalledTimes(1)
expect(save).toHaveBeenCalledWith({ code: 'A01', amount: 120 })
})
重複執行案例則要先建立「相同檔案已經處理」的初始狀態,再確認功能沒有再次保存內容。這項案例和上一項案例共用同一個功能入口,但驗證的是不同規則。
test('IMP-003:相同檔案重複執行時不會再次保存內容', async () => {
const save = vi.fn()
const result = await importFile(
{
fileId: 'file-001',
rows: [{ row: 1, code: 'A01', amount: 120 }],
},
{
wasFileImported: vi.fn().mockResolvedValue(true),
save,
},
)
expect(result).toEqual({
status: 'skipped',
imported: 0,
rejected: [],
})
expect(save).not.toHaveBeenCalled()
})
模擬函式適合快速驗證目標程式如何使用相依項目。完整路徑的整合測試仍要改用受控的實際保存方式,執行後直接確認保存狀態,避免只驗證函式曾經被呼叫。
如果匯入功能已確認會透過 HTTP 傳送處理結果,可以使用 WireMock 建立固定回應。下列對映讓指定路徑穩定回傳 503,用來驗證功能在相依項目失敗時是否保留預定狀態及錯誤資訊。
{
"request": {
"method": "POST",
"urlPath": "/import-results"
},
"response": {
"status": 503,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"code": "temporarily_unavailable"
}
}
}
測試時要將案例使用的相依項目位置指向 WireMock,並且確認遺留系統與目標系統收到語意相同的回應。實際功能如果沒有 HTTP 互動,應該改用該互動方式可控制的替代項目,不需要為了使用工具而增加 HTTP 介面。
特徵測試(Characterization Test)用來記錄系統在固定條件下目前會產生的可觀察結果。這份結果可以建立遺留系統的行為基準,但不會自動判定該行為符合現在的需求。已知錯誤與尚未確認的行為仍要保留分類,不能只因為測試重現成功就改成目標系統的預期結果。
建立特徵測試時,可以按照下列步驟進行:
遺留程式碼難以拆開時,可以先從功能入口與可觀察輸出建立整合測試(Integration Test),不必先修改內部程式。這種做法能一次保護完整路徑,適合確認現有行為。缺點是失敗時涵蓋範圍較大,定位問題需要更多資訊。
目標系統可以針對已拆分的功能規則建立單元測試(Unit Test),快速指出哪一項判斷不符合預期。同時仍要保留完整路徑的比較案例,確認各項規則整合後的輸出、狀態變化與外部影響一致。這裡的重點是驗證單一功能的替換結果,整套目標系統的測試層級與執行策略會在後續章節說明。
建立遺留系統基準後,可以對目標系統執行相同案例。兩套系統應該在彼此隔離的環境中處理各自的測試內容,避免同時修改同一份狀態,使後執行的一方受到前一方影響。
一次完整比對可以依照下列流程進行:
比較時要保留兩套系統的原始結果與整理後結果。原始結果可以協助確認整理規則是否隱藏重要差異,整理後結果則用來排除已知且沒有功能意義的變動。例如,自動產生的識別資料不需要在兩套系統中具有相同字面值,但兩者都必須符合規定格式,並且在後續輸出中維持正確關聯。
欄位順序、空白或日期格式是否可以忽略,要按照功能規格決定。如果輸出格式是外部項目使用的契約,字面差異就可能具有功能影響。只有已經確認不影響需求的差異,才能加入整理規則。
如果兩套系統都能透過測試轉接函式執行,就可以用參數化測試重複套用相同案例。下列範例只適用於規格要求結果一致的案例。normalizeResult 只能處理已確認沒有功能意義的變動欄位。
import { expect, test } from 'vitest'
import { preservedBehaviorCases } from './import-cases.js'
import { normalizeResult, runLegacyCase, runTargetCase } from './system-runners.js'
test.each(preservedBehaviorCases)('$id:遺留系統與目標系統結果一致', async ({ input }) => {
const legacyResult = normalizeResult(await runLegacyCase(input))
const targetResult = normalizeResult(await runTargetCase(input))
expect(targetResult).toEqual(legacyResult)
})
已核准的功能調整不能套用相同比對斷言。這類案例要另外記錄目標系統的預期結果,並且確認遺留系統的原始結果、需求差異及目標結果都能由同一個案例識別追溯。
輸出內容龐大、結構深或欄位數量多時,逐項撰寫預期值可能容易遺漏。黃金主檔測試(Golden Master Testing)可以先保存一份已確認的完整輸出,後續執行時再將新結果與它比較。這種方式適合結果檔案、結構化內容或包含多個區塊的輸出。
建立黃金主檔時,需要記錄:
黃金主檔只會指出結果和既定內容不同,無法自行判定新結果是否更正確。發現差異時,仍要回到功能規格確認原因。直接用最新輸出覆蓋黃金主檔,會使未確認的變更成為新的通過條件,因此每次更新都要保留差異內容與核准紀錄。
如果輸出包含大量無功能意義的變動值,應該先建立結構化的正規化與比較規則。忽略整個區塊雖然可以讓測試穩定,卻可能同時排除真正需要保護的結果。較安全的方式是只處理已知變動欄位,並對欄位格式、關聯或範圍建立獨立檢查。
Vitest 的檔案快照可以把整理後的完整結果保存成黃金主檔。下列範例先獨立檢查每次執行都會變動的識別資料與完成時間,再將它們替換成固定標記,避免直接忽略整個區塊。
import { expect, test } from 'vitest'
import { runTargetCase } from './system-runners.js'
test('IMP-004:複雜匯入結果符合已確認的黃金主檔', async () => {
const result = await runTargetCase('IMP-004')
expect(result.runId).toEqual(expect.any(String))
expect(result.runId.length).toBeGreaterThan(0)
expect(result.finishedAt).toEqual(expect.any(String))
expect(Number.isNaN(Date.parse(result.finishedAt))).toBe(false)
const comparableResult = {
...result,
runId: '<generated-run-id>',
finishedAt: '<generated-finished-at>',
}
await expect(JSON.stringify(comparableResult, null, 2))
.toMatchFileSnapshot('./golden/IMP-004.json')
})
第一次建立或更新檔案快照後,仍要逐項檢查差異,確認內容符合功能規格再納入版本控制。測試工具提供的更新指令只能產生候選檔案,不能取代黃金主檔的確認流程。
雙系統結果不同時,不能直接假設目標系統有錯,也不能因為新結果較符合直覺就接受。每項差異都要先重現,再按照功能規格、原始結果與執行條件判斷原因。
| 差異分類 | 判斷方式 | 處理方式 |
|---|---|---|
| 目標系統缺陷 | 遺留系統結果符合已確認規則,目標系統結果不符合。 | 修正目標程式,再重新執行相關案例。 |
| 遺留系統已知錯誤 | 遺留系統重現已確認的錯誤,目標系統符合核准後的結果。 | 保留差異紀錄,確認修正案例與相關驗收條件都通過。 |
| 資料或初始狀態問題 | 兩套系統的輸入意義、關聯、設定或前置狀態不一致。 | 修正準備流程,確認條件一致後重新比較。 |
| 已核准的功能調整 | 目標結果與遺留系統不同,但符合已記錄的需求差異。 | 將決定與驗收結果連回案例,保留為預期差異。 |
| 尚未確認的差異 | 現有規格不足以判斷哪一項結果正確。 | 記錄影響與確認方式,取得決定前不得標示為通過。 |
一項差異可能同時出現在多個觀察位置。例如,前面的條件判斷不同,可能連帶造成保存狀態與輸出內容不同。分類時要先找出最早發生的功能差異,再記錄後續影響,避免把同一原因拆成多個互不相關的缺陷。
修正完成後,要重新執行原本失敗的案例,以及使用相同規則或共用元件的相關案例。這樣可以確認修正沒有改變先前已經通過的功能行為。完整的迴歸測試(Regression Testing)範圍會在後續章節納入整體測試策略,本章先確保單一功能的相關案例能重複通過。
案例、規格、系統版本與執行結果需要建立明確關係。只留下「測試成功」無法說明測了哪些規則、使用甚麼資料,以及當時允許哪些差異。可以使用下列表格追蹤每項功能行為:
| 案例識別 | 對應規則 | 遺留系統結果 | 目標系統預期 | 比較結果 | 差異分類 | 處理狀態 |
|---|---|---|---|---|---|---|
| IMP-001 | 有效內容應該完成匯入 | 已完成 | 已完成 | 一致 | 無 | 已通過 |
| IMP-002 | 無效內容應該記錄原因 | 缺少原因 | 包含原因 | 不一致 | 已核准的功能調整 | 已通過 |
| IMP-003 | 重複內容不得產生另一份結果 | 未產生新內容 | 產生新內容 | 不一致 | 目標系統缺陷 | 待修正 |
表格內容只是示意。實際紀錄還要連結案例使用的輸入、初始狀態、完整輸出、程式版本與需求決定。包含自動化測試時,執行結果也要能對應到相同案例識別,避免人工紀錄與測試名稱各自發展。
驗證結果應該隨目標程式一起更新。功能規則改變時,要先更新需求差異與預期結果,再修改測試。測試只因目前程式無法通過而降低檢查範圍,會失去保護功能行為的作用。
功能可以標示為通過前,至少要確認:
單一代表性功能通過,只能說明相應範圍已經符合替換條件。其餘納入範圍的功能仍要使用相同方式建立案例、執行比較與處理差異。等所有功能、資料與整合條件都通過後,才能進一步判斷目標系統是否具備正式切換條件。