# Day 51：verifyPassword 的第一個 Vitest 測試；Borda Count 演算法

By [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake) · 2026-02-02

---

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

* * *

練習專案：Password Verifier
----------------------

GitHub repo: [https://github.com/gcake119/aout3-samples](https://github.com/gcake119/aout3-samples)

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

### 規格/情境

*   `verifyPassword(input, rules)` 接收 `rules`(規則函式陣列),回傳 `input` 套用 `rules` 的結果。
    
*   每個 `rule` 回傳物件格式：`{ passed: (boolean), reason: (string) }`。
    

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

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

![](https://storage.googleapis.com/papyrus_images/5f56a6214084b93e0958b544f692cda2e571d0d85409afb68ce9df815d955de5.png)

    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](https://semaphore.io/blog/aaa-pattern-test-automation)

* * *

「測試」測試程式
--------

如果不是在做 TDD,而是在功能完成後補測試,可以在測試通過後故意在原程式加入一個 bug,確認測試會在該失敗時失敗。這能快速檢查測試是否真的有守住需求 。 [semaphore](https://semaphore.io/blog/aaa-pattern-test-automation)

* * *

以 USE 原則命名測試
------------

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

*   unit under test：受測的工作單元
    
*   scenario：情境或輸入
    
*   expectation：期望的行為或退出點
    

### describe/it 的使用策略

*   `describe()` 當外層語意分組,必要時嵌套提高可讀性（常見被稱為 BDD 風格）。
    
*   `it()` 表達單一情境下的單一預期。
    

### 兩種風格選擇

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

*   `test...expect`：精簡、直接
    
*   `describe...it...expect`：階層式、可讀性高
    

Vitest 支援 `describe/it/expect` 等 API,寫法與 Jest 相近 。 [vitest](https://vitest.dev/guide/features)

* * *

重構：從函式改成可配置規則的 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](https://www3.nd.edu/~apilking/math10170/information/Lectures/Lecture-2.Borda%20Method.pdf)

### 計分規則

*   第一順位：4 分
    
*   第二順位：3 分
    
*   第三順位：2 分
    
*   第四順位：1 分
    

### 得票情形

#

8票

2票

6票

4票

第一順位

①

③

②

④

第二順位

③

②

③

③

第三順位

②

④

④

②

第四順位

④

①

①

①

*   總票數：8 + 2 + 6 + 4 = 20 票
    

### 自然語言描述（怎麼算）

1.  依「欄」分類：每欄代表一組相同排名的選票,票數不同。
    
2.  再依「候選人」分類：每位候選人在每個順位拿到多少票。
    
3.  依配分(4/3/2/1)乘上票數並加總,得到每位候選人總分 。 [www3.nd](https://www3.nd.edu/~apilking/math10170/information/Lectures/Lecture-2.Borda%20Method.pdf)
    

### 本題正確計分結果

*   候選人① = 44 分
    
*   候選人② = 54 分
    
*   候選人③ = 62 分
    
*   候選人④ = 40 分
    
*   當選人：候選人③（最高分 62） [www3.nd](https://www3.nd.edu/~apilking/math10170/information/Lectures/Lecture-2.Borda%20Method.pdf)
    

### 測試情境（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](https://semaphore.io/blog/aaa-pattern-test-automation)

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

### 測試拋錯的寫法

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

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

---

*Originally published on [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/day-51)*
