測試的組織與命名 (Test Organization & Naming)

★★★★★Draft

FromThe Art of Unit Testing

📚 From the Books

測試的組織與命名 (Test Organization & Naming)Testing

測試程式碼和產品程式碼一樣重要——但若放錯位置、缺乏組織,辛苦寫的測試不是沒人跑,就是難以維護。這一頁只談結構與命名:測試方法怎麼命名、測試類別怎麼對應到產品碼、快測試與慢測試怎麼分家、共用的 setup/teardown 有哪些陷阱,以及怎麼用一份「填空」骨架把同一組測試套到多個型別上。至於一個測試內部該怎麼寫得像故事,交給 整潔測試:FIRST 與 AAA

🧠 Why It Matters

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

✍️ My Notes

No notes yet — jot your takeaways or Q&A here.

📖 Further Reading