測試程式碼和產品程式碼一樣重要——但若放錯位置、缺乏組織,辛苦寫的測試不是沒人跑,就是難以維護。這一頁只談結構與命名:測試方法怎麼命名、測試類別怎麼對應到產品碼、快測試與慢測試怎麼分家、共用的 setup/teardown 有哪些陷阱,以及怎麼用一份「填空」骨架把同一組測試套到多個型別上。至於一個測試內部該怎麼寫得像故事,交給 整潔測試:FIRST 與 AAA。
🧠 Why It Matters
- 找得到,才會被跑:好的組織讓人看到一個專案、一個類別、一個方法,都能立刻找到對應的所有測試。找不到測試,就等於沒有測試。
- 命名慣例三段式:測試方法用
MethodName_Scenario_ExpectedBehavior——被測的入口、觸發的情境、期望的行為。名字本身就是規格,失敗時一眼看出哪個工作單元、在什麼情境下壞了。 - 對應到產品碼的層級:測試專案取被測專案同名加後綴(
.UnitTests/.IntegrationTests);測試類別取被測類別名加Tests後綴;當某個方法的案例多到讀不下去,就為它拆出一個專屬測試類別(例如LoginManagerTests之外再開LoginManagerChangePasswordTests)。 - 快慢分家=安全綠區:把慢的整合測試和快的單元測試放到不同資料夾/命名空間/甚至獨立專案。單元測試自成一個「安全綠區」,開發者拿到最新碼隨手一跑就該全綠;一旦紅了,代表真的有問題,而不是某台機器沒設定好。
NOTE
為什麼一定要分開?如果把「需要特定環境配置才能跑」的整合測試混進單元測試,就像一籃水果裡混了壞蘋果——一顆偶爾紅燈,大家就養成「喔那個測試有時候會壞,沒差」的習慣,最後所有測試都失去可信度。整合測試不是不用全綠,而是它天生慢,適合留給夜間建置跑;把它獨立成「整合區域」,順便在那裡放說明如何配置環境的文件。
⚖️ 命名與組織的具體做法
命名慣例 — 方法名說出「入口_情境_期望」
// UnitOfWork_Scenario_ExpectedBehavior:被測入口 + 情境 + 期望行為
describe("LogAnalyzer", () => {
test("Analyze_EmptyFileName_Throws", () => {
const analyzer = new LogAnalyzer();
expect(() => analyzer.analyze("")).toThrow();
});
test("Analyze_ValidFileName_ReturnsTrue", () => {
const analyzer = new LogAnalyzer();
expect(analyzer.analyze("file.txt")).toBe(true);
});
});三段式讓「哪個方法、什麼情境、期望什麼」直接寫在名字裡。對應層級也一併到位:一個 LogAnalyzer 產品類別,對一個 LogAnalyzerTests 測試類別(本例的 describe 區塊)。當某個方法測試爆量時,再把它抽成獨立類別,例如 LoginManagerChangePasswordTests,避免單一測試類別長到沒人想讀。
teardown 重置共用狀態 — 但別把 setup 藏進基底類別
// 橫切關注點(如「現在時間」)用一個可控的 SystemTime 取代直接讀時鐘
class SystemTime {
static _fixed = null;
static set(date) {
SystemTime._fixed = date;
}
static reset() {
SystemTime._fixed = null;
}
static now() {
return SystemTime._fixed ?? new Date();
}
}
describe("TimeLogger", () => {
// 測試裡把時間釘死,就能斷言輸出
test("CreateMessage_Always_ContainsFixedDate", () => {
SystemTime.set(new Date("2000-01-01"));
expect(TimeLogger.createMessage("a")).toContain("2000");
});
// teardown 只做一件事:把共用狀態還原,避免污染下一個測試
afterEach(() => SystemTime.reset());
});用 afterEach(teardown)把測試改動過的全域狀態還原,是共用 setup/teardown 少數安全的用法。反過來,不要把初始化塞進共用的 beforeEach/基底類別的 [SetUp]:讀者得跳到別處才知道 arrange 做了什麼,可讀性瞬間崩塌。寧可寫一個名字清楚、由測試明確呼叫的工具方法(如 fakeTheLogger())。
填空式測試骨架 — 一組測試套到多個型別
// 基底把「測試邏輯」寫死一次,留下抽象鉤子讓子類別填輸入/工廠
function defineStringParserTests(makeParser, inputs) {
test("GetVersion_SingleDigit_Found", () => {
expect(makeParser(inputs.singleDigit).getVersion()).toBe("1");
});
test("GetVersion_WithMinorVersion_Found", () => {
expect(makeParser(inputs.withMinor).getVersion()).toBe("1.1");
});
test("GetVersion_WithRevision_Found", () => {
expect(makeParser(inputs.withRevision).getVersion()).toBe("1.1.1");
});
}
// 每個型別只「填空」:提供工廠與各情境的輸入,測試邏輯全繼承自骨架
describe("XmlStringParser", () =>
defineStringParserTests((s) => new XmlStringParser(s), {
singleDigit: "<Header>1</Header>",
withMinor: "<Header>1.1</Header>",
withRevision: "<Header>1.1.1</Header>",
}));
describe("StandardStringParser", () =>
defineStringParserTests((s) => new StandardStringParser(s), {
singleDigit: "version=1",
withMinor: "version=1.1",
withRevision: "version=1.1.1",
}));當一群型別共享同一份行為契約(都實作同一介面),把測試邏輯集中在一份骨架、只讓各型別填入工廠方法與輸入資料。這樣做的最大價值:身為架構師,你等於發下一份「衍生型別必須通過的測試清單」,開發者不會漏掉重要案例。
WARNING
測試類別繼承/骨架很好用,但過猶不及:避免超過一層繼承,也別在基底類別藏共用 setup。一旦測試變得要翻好幾層才讀得懂,就代表該回頭改用單純的 helper 類別與工具方法,而不是繼續疊繼承。
TIP
把測試碼放進原始碼控制,且與被測版本綁在一起——拿 1.0.1 的產品就拿到 1.0.1 的測試。這也是自動化建置能對每個版本跑對應測試的前提:CI 建置只編譯+跑快速單元測試求最短回饋,較慢的測試留給夜間建置。
🔑 Takeaways
- 測試方法用
MethodName_Scenario_ExpectedBehavior三段命名,名字即規格。 - 測試對應產品碼三層:專案加
.UnitTests/.IntegrationTests後綴、類別加Tests後綴、方法爆量就拆專屬測試類別。 - 快的單元測試與慢的整合測試分家,建立所有測試都該全綠的安全綠區,慢測試獨立成整合區域交給夜間建置。
- teardown 用來還原共用狀態是安全的;但別把 setup 藏進基底類別,也別疊超過一層繼承——讀不懂就改用 helper。
- 用「填空式」骨架把同一組測試套到多個共享契約的型別,等於發一份必過測試清單。
- 一頁只談結構與命名;一個測試內部怎麼寫得像故事,見 整潔測試:FIRST 與 AAA。
No notes yet — jot your takeaways or Q&A here.