# Day 51:verifyPassword 的第一個 Vitest 測試;Borda Count 演算法
**Published by:** [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/)
**Published on:** 2026-02-02
**URL:** https://paragraph.com/@gcake/day-51
## Content
今日閱讀:《單元測試的藝術》3/e 第二章 2.4 p.46 - 2.5.9 p.56,並將範例程式碼改寫成 Vitest。練習專案:Password VerifierGitHub repo: https://github.com/gcake119/aout3-samples 用 verifyPassword 驗證密碼格式,練習「測試如何寫得清楚」。規格/情境verifyPassword(input, rules) 接收 rules(規則函式陣列),回傳 input 套用 rules 的結果。每個 rule 回傳物件格式:{ passed: (boolean), reason: (string) }。註:書中示範偏 test...expect 的精簡風格,我一開始用自己習慣的 describe...it...expect 階層式寫法,後面才理解作者是在刻意示範不同風格。單元測試範圍(進入點/工作單元/退出點)graph TB subgraph "單元測試範圍" Entry["🚪 進入點
verifyPassword(input, rules)"] subgraph WorkUnit["⚙️ 工作單元 (Unit of Work)"] Process1["遍歷所有 rules"] Process2["執行每個 rule(input)"] Process3["收集失敗的 reasons"] end Exit1["✅ 退出點 1: 回傳值
return errors"] Exit2["⚠️ 退出點 2: 狀態改變
(本例無)"] Exit3["📞 退出點 3: 第三方調用
(本例無)"] end Test["📝 測試程式碼"] -->|"輸入: 'any input', [fakeRule]"| Entry Entry --> Process1 Process1 --> Process2 Process2 --> Process3 Process3 --> Exit1 Exit1 -.->|"檢查"| Assert1["expect(errors[0])
.toMatch('fake reason')"] Exit2 -.->|"檢查"| Assert2["例: expect(logger.log)
.toHaveBeenCalled()"] Exit3 -.->|"檢查"| Assert3["例: expect(db.save)
.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 票自然語言描述(怎麼算)依「欄」分類:每欄代表一組相同排名的選票,票數不同。再依「候選人」分類:每位候選人在每個順位拿到多少票。依配分(4/3/2/1)乘上票數並加總,得到每位候選人總分 。 www3.nd本題正確計分結果候選人① = 44 分候選人② = 54 分候選人③ = 62 分候選人④ = 40 分當選人:候選人③(最高分 62) www3.nd測試情境(Given/When/Then)正確計分 Given 完整投票資料表(4 組選票,4 位候選人) When 執行 Borda Count 計算 Then 應正確輸出每位候選人的總分:①=44、②=54、③=62、④=40正確回傳當選人 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順位…)值是該候選人在該順位的總票數為什麼這樣設計輸入格式 接近人類閱讀的表格樣貌中間格式 以候選人為維度,方便對每位候選人計分取值直覺: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(...),讓主流程保持乾淨 。 semaphoreif (numberOfCandidates !== ballots[0]?.ranking.length) { throw new Error('Ranking length does not match number of candidates'); } 測試拋錯的寫法expect(() => transformBallotsToCandidateVotes(ballots, 4)).toThrow(); 注意要用 () => 包住函式呼叫,否則錯誤會在 expect 外部就拋出,測試框架接不到。
## Publication Information
- [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/): Publication homepage
- [All Posts](https://paragraph.com/@gcake/): More posts from this publication
- [RSS Feed](https://api.paragraph.com/blogs/rss/@gcake): Subscribe to updates