Day 51:verifyPassword 的第一個 Vitest 測試;Borda Count 演算法

今日閱讀:《單元測試的藝術》3/e 第二章 2.4 p.46 - 2.5.9 p.56,並將範例程式碼改寫成 Vitest。


練習專案:Password Verifier

GitHub repo: https://github.com/gcake119/aout3-samples

用 verifyPassword 驗證密碼格式,練習「測試如何寫得清楚」。

規格/情境

  • verifyPassword(input, rules) 接收 rules(規則函式陣列),回傳 input 套用 rules 的結果。

  • 每個 rule 回傳物件格式:{ passed: (boolean), reason: (string) }。

註:書中示範偏 test...expect 的精簡風格,我一開始用自己習慣的 describe...it...expect 階層式寫法,後面才理解作者是在刻意示範不同風格。

單元測試範圍(進入點/工作單元/退出點)

post image
graph TB
    subgraph "單元測試範圍"
        Entry["🚪 進入點<br/>verifyPassword(input, rules)"]
        
        subgraph WorkUnit["⚙️ 工作單元 (Unit of Work)"]
            Process1["遍歷所有 rules"]
            Process2["執行每個 rule(input)"]
            Process3["收集失敗的 reasons"]
        end
        
        Exit1["✅ 退出點 1: 回傳值<br/>return errors"]
        Exit2["⚠️ 退出點 2: 狀態改變<br/>(本例無)"]
        Exit3["📞 退出點 3: 第三方調用<br/>(本例無)"]
    end
    
    Test["📝 測試程式碼"] -->|"輸入: 'any input', [fakeRule]"| Entry
    Entry --> Process1
    Process1 --> Process2
    Process2 --> Process3
    Process3 --> Exit1
    
    Exit1 -.->|"檢查"| Assert1["expect(errors[0])<br/>.toMatch('fake reason')"]
    Exit2 -.->|"檢查"| Assert2["例: expect(logger.log)<br/>.toHaveBeenCalled()"]
    Exit3 -.->|"檢查"| Assert3["例: expect(db.save)<br/>.toHaveBeenCalledWith(...)"]
    
    style Entry fill:#a8dadc
    style WorkUnit fill:#f1faee
    style Exit1 fill:#06d6a0
    style Exit2 fill:#ddd,stroke-dasharray: 5 5
    style Exit3 fill:#ddd,stroke-dasharray: 5 5

第一個 Vitest 測試:先讓「失敗理由」出得來

// password-verifier0.js
export const verifyPassword = (input, rules) => {
  const errors = [];
  rules.forEach((rule) => {
    const result = rule(input);
    if (!result.passed) {
      errors.push(`error ${result.reason}`);
    }
  });
  return errors;
};

// password-verifier0.test.js
import { describe, it, expect } from 'vitest';
import { verifyPassword } from './password-verifier0';

describe('Password Verifier', () => {
  it('Badly named test', () => {
    // Arrange - 設定測試輸入
    const fakeRule = (input) => ({ passed: false, reason: 'fake reason' });

    // Act - 用輸入呼叫進入點
    const errors = verifyPassword('any input', [fakeRule]);

    // Assert - 檢查退出點
    expect(errors[0]).toMatch('fake reason');
  });
});

Arrange-Act-Assert (AAA) 模式

測試結構可以用「Arrange-Act-Assert」三段式來看:Arrange 準備資料,Act 執行受測行為,Assert 驗證結果。這個結構能讓測試更易讀,也更容易維護 。 semaphore


「測試」測試程式

如果不是在做 TDD,而是在功能完成後補測試,可以在測試通過後故意在原程式加入一個 bug,確認測試會在該失敗時失敗。這能快速檢查測試是否真的有守住需求 。 semaphore


以 USE 原則命名測試

USE 是我用來提醒自己「測試名稱應包含的三件事」:

  • unit under test:受測的工作單元

  • scenario:情境或輸入

  • expectation:期望的行為或退出點

describe/it 的使用策略

  • describe() 當外層語意分組,必要時嵌套提高可讀性(常見被稱為 BDD 風格)。

  • it() 表達單一情境下的單一預期。

兩種風格選擇

取決於複雜度與表達需求:

  • test...expect:精簡、直接

  • describe...it...expect:階層式、可讀性高

Vitest 支援 describe/it/expect 等 API,寫法與 Jest 相近 。 vitest


重構:從函式改成可配置規則的 class

// password-verifier1.js
export class PasswordVerifier1 {
  constructor() {
    this.rules = [];
  }

  addRule(rule) {
    this.rules.push(rule);
  }

  verify(input) {
    if (this.rules.length === 0) {
      throw new Error('There are no rules configured');
    }

    const errors = [];
    this.rules.forEach((rule) => {
      const result = rule(input);
      if (result.passed === false) {
        errors.push(result.reason);
      }
    });

    return errors;
  }
}

// password-verifier1.test.js
import { describe, it, expect } from 'vitest';
import { PasswordVerifier1 } from './password-verifier1';

describe('Password Verifier', () => {
  describe('with a failing rule', () => {
    it('has an error message based on the rule.reason', () => {
      // Arrange
      const verifier = new PasswordVerifier1();
      const fakeRule = (input) => ({ passed: false, reason: 'fake reason' });

      // Act
      verifier.addRule(fakeRule);
      const errors = verifier.verify('any value');

      // Assert
      expect(errors[0]).toMatch('fake reason');
    });

    it('has exactly one error message', () => {
      // Arrange
      const verifier = new PasswordVerifier1();
      const fakeRule = (input) => ({ passed: false, reason: 'fake reason' });

      // Act
      verifier.addRule(fakeRule);
      const errors = verifier.verify('any value');

      // Assert
      expect(errors.length).toBe(1);
    });
  });
});

Borda Count 演算法

Borda Count 是一種「排序投票的計分法」:選民對候選人排名,系統依排名給分,最後加總分數決定結果 。 www3.nd

計分規則

  • 第一順位:4 分

  • 第二順位:3 分

  • 第三順位:2 分

  • 第四順位:1 分

得票情形

#

8票

2票

6票

4票

第一順位

①

③

②

④

第二順位

③

②

③

③

第三順位

②

④

④

②

第四順位

④

①

①

①

  • 總票數:8 + 2 + 6 + 4 = 20 票

自然語言描述(怎麼算)

  1. 依「欄」分類:每欄代表一組相同排名的選票,票數不同。

  2. 再依「候選人」分類:每位候選人在每個順位拿到多少票。

  3. 依配分(4/3/2/1)乘上票數並加總,得到每位候選人總分 。 www3.nd

本題正確計分結果

  • 候選人① = 44 分

  • 候選人② = 54 分

  • 候選人③ = 62 分

  • 候選人④ = 40 分

  • 當選人:候選人③(最高分 62) www3.nd

測試情境(Given/When/Then)

  1. 正確計分
    Given 完整投票資料表(4 組選票,4 位候選人)
    When 執行 Borda Count 計算
    Then 應正確輸出每位候選人的總分:①=44、②=54、③=62、④=40

  2. 正確回傳當選人
    Given 已計算出所有候選人的總分
    When 比較各候選人分數
    Then 應回傳分數最高的候選人(③獲勝)


資料結構設計

輸入格式:票數 + 排名陣列

const ballots = [
  { count: 8, ranking: [1, 3, 2, 4] },
  { count: 2, ranking: [3, 2, 4, 1] },
  { count: 6, ranking: [2, 3, 4, 1] },
  { count: 4, ranking: [4, 3, 2, 1] },
];
  • ranking[0] = 第一順位候選人號次

  • count = 該排序的票數

中間格式:候選人 → 各順位得票數

{
  1: [8, 0, 0, 12], // 候選人①:第1順位8票、第4順位12票
  2: [6, 2, 12, 0], // 候選人②:第1順位6票、第2順位2票、第3順位12票
  3: [2, 18, 0, 0], // 候選人③:第1順位2票、第2順位18票
  4: [4, 0, 8, 8],  // 候選人④:第1順位4票、第3順位8票、第4順位8票
}
  • 陣列 index 對應順位(0=第1順位、1=第2順位…)

  • 值是該候選人在該順位的總票數

為什麼這樣設計

  1. 輸入格式 接近人類閱讀的表格樣貌

  2. 中間格式 以候選人為維度,方便對每位候選人計分

  3. 取值直覺:candidateVotes [vitest](https://vitest.dev/guide/features) 就是候選人③的第二順位票數


解決方案分解

步驟一:轉換資料結構

函式:transformBallotsToCandidateVotes(ballots, numberOfCandidates)

流程:

  • 初始化每位候選人的票數陣列為 Array(numberOfCandidates).fill(0)

  • 走訪每張選票 { count, ranking }

  • 對 ranking 中的每個候選人,在對應順位累加票數

步驟二:計算 Borda Count 分數

函式:calculateBordaCount(votes)

流程:

  • 定義配分陣列 [4, 3, 2, 1]

  • 票數陣列與配分陣列同 index 相乘

  • reduce 加總得到總分


使用的 JS API(本題會用到)

  • Array.from({ length: n }, (_, i) => i + 1):建立候選人清單

  • Array(n).fill(0):初始化每位候選人的票數陣列

  • forEach:做累加副作用(把票數加到 candidateVotes)

  • map:票數 × 配分的轉換

  • reduce((acc, cur) => acc + cur, 0):加總分數


錯誤處理的學習重點

Guard Clause 模式

在函式開頭檢查前置條件,不符合就直接 throw new Error(...),讓主流程保持乾淨 。 semaphore

if (numberOfCandidates !== ballots[0]?.ranking.length) {
  throw new Error('Ranking length does not match number of candidates');
}

測試拋錯的寫法

expect(() => transformBallotsToCandidateVotes(ballots, 4)).toThrow();

注意要用 () => 包住函式呼叫,否則錯誤會在 expect 外部就拋出,測試框架接不到。