Day 10:測試驅動開發 (TDD) 的原則與紀律

複習與更新:JS 程式碼執行整體流程

Video

JavaScript Visualized - Promise Execution

Video

JavaScript Visualized - Event Loop, Web APIs, (Micro)task Queue

Promise

影片版: JavaScript Visualized: Promise Execution

文字版: JavaScript Visualized: Promise Execution

post image

Promise 執行順序重點

  • Constructor 是同步的;executor 會立刻進入 Call Stack 執行。

  • then/catch/finally 的回呼排入 Microtask Queue;setTimeout 等 Web APIs 的回呼排入 Task Queue(Macro task)。

  • Event Loop 順序:清空 Call Stack → 取出所有 Microtasks → 取出一個 Task → 可能觸發渲染再循環。

Event Loop

影片版: JavaScript Visualized - Event Loop, Web APIs, (Micro)task Queue

文字版: JavaScript Visualized - Event Loop, Web APIs, (Micro)task Queue

Web APIs

post image
  • callback based

  • promise based

Clean Craftsmanship 的影片示範實作練習題

Uncle Bob 為這本書的內容錄製了幾支示範影片,台灣讀者在博碩出版商網站可以下載影片檔、含時間戳的 txt 檔和第五章的範例程式碼 txt 檔(方便直接複製到 IDE 閱讀和執行體驗該章「重構」的內容)。

第一支影片用 Java 實作一個 stack 示範實作「在幾秒鐘內完成紅燈→綠燈→重構」循環。 照著書中建議先看影片再看文字說明,深刻感受到看影片體驗到的節奏感真的不是文字描述可以表達的。也發現我前面幾天自己看文件理解練習的 TDD 是「邏輯對了,實作流程不太對」的版本:

我把一題裡面會用到的 function 的測試全部寫完(包含定義、輸入、預期輸出、例外),才去寫產品程式碼中的 function

這樣整塊整塊做確實減少很多切換視窗的麻煩,但沒有完整跑過「編譯錯誤→測試行為→清理程式碼/重構」的思考邏輯。
我現在的理解是:TDD 這個方法核心概念是要把寫程式碼的思考和實作步驟拆到幾行以內,確保跨出每一個小步都是正確合理的,只要通過測試的程式碼就是可以安全部署 (deploy) 的程式碼。也企圖以此儘可能避免「一個看起來可以動的程式碼」後續要大量 debug 的工作。

今天的 TDD 練習:
以 JavaScript function 完成整數 queue 的 circular buffer,物件導向的寫法之後再說😆
寫起來還是滿卡的,思路不是很順,要習慣一下每次只推進幾行的思考方式

整數堆疊

書中題目敘述:

按照示範的方式實作一個先進先出的整數堆疊 (queue) 。請使用固定大小的陣列來存放整數。你可能會需要兩個指標 (pointer) 來追蹤元素的新增位置和移除位置。完成後,你會發現自己實作了一個循環緩衝區 (circular buffer)。

實作架構選擇

  • 資料結構(Plain Object):

{
buffer: [], // 固定大小陣列
capacity: 3, // 容量上限
headIndex: 0, // 讀取位置
tailIndex: 0, // 寫入位置
size: 0 // 當前元素數量
}
  • 繞圈(Wrap Around)核心:以模運算限制索引在 0..capacity-1

  • tailIndex = (tailIndex + 1) % capacity

  • headIndex = (headIndex + 1) % capacity
    JavaScript 的餘數運算子 % 可用於此,確保索引不會超出陣列邊界並能回到 0。


TDD 實作紀錄:紅燈、綠燈、重構

  1. Step 1: 建立空 Queue

  • 測試目標:size === 0、isEmpty(queue) === true、isFull(queue) === false。

  • 語言特性參考:陣列與物件屬性皆為標準內建類型與結構。

  1. Step 2: 單一 Enqueue / Dequeue (Happy Path)

  • 測試目標:enqueue 後 size === 1 且 peek(queue) 為該值;dequeue 能拿回該值並回到空。

  • Debug 經驗:

    • ReferenceError: peak is not defined → 函式命名誤用(應為 peek)。

    • 取得 undefined → 少了真正的賦值動作(queue.buffer[queue.tailIndex] = value),屬於語法疏漏由測試攔截。

  1. Step 3: 邊界條件 (Edge Cases)

  • isFull(queue) 當 size === capacity 時為真;isEmpty(queue) 當 size === 0 時為真。

  • 空取值/滿插入應拋出錯誤(queue is empty / queue is full),測試用 toThrow 斷言此行為。

  1. Step 4: 整合測試 - 繞圈行為 (Circular Buffer Logic)

  • 測試描述:在 buffer 內多次反覆填值和取值,部分取出與再填入的次數不規則時,仍能按照先進先出的正確順序取值。

  • 驗證重點:tailIndex / headIndex 超過末端後能回到 index 0 繼續運作,取出順序保持 FIFO。


Vitest:describe / it / expect 常用法

  • 每個 it 皆建立自己的 queue 實例,避免測試間狀態汙染;必要時使用 beforeEach 於每次測試前建立新實例。

  • 函式版 API 時需呼叫函式判斷狀態:isEmpty(queue) / isFull(queue);非 queue.isEmpty / queue.isFull(除非以 class getter 設計)。

  • 常見斷言:

  • 等值:expect(value).toBe(expected)。

  • 丟錯:expect(() => fn()).toThrow('message')。

參考文件:

  • Vitest API 參考(describe/it/expect/toThrow 等):https://vitest.dev/api/

  • 測試常見錯誤與隔離原則:Vitest Common Errors / Environment。


JS 語言與資料結構文件

附錄

完整測試程式碼(Vitest)

// int-queue.test.js
import { describe, expect, it } from 'vitest';
import {
  createIntQueue,
  isEmpty,
  isFull,
  enqueue,
  dequeue,
  peek,
} from './int-queue.js';

describe(`IntQueue`, ()=>{
    it(`create an empty queue`, ()=>{
        const queue = createIntQueue(3);

        expect(queue.size).toBe(0);
        expect(isEmpty(queue)).toBe(true);
        expect(isFull(queue)).toBe(false) 
    })
})

it(`enqueues and dequeues one value`,()=>{
    const queue = createIntQueue(3);

    enqueue(queue, 42);

    expect(queue.size).toBe(1)
    expect(isEmpty(queue)).toBe(false)
    expect(peek(queue)).toBe(42)

    const value = dequeue(queue);

    expect(value).toBe(42)
    expect(queue.size).toBe(0)
    expect(isEmpty(queue)).toBe(true)
})

it('becomes full after enqueuing capacity times',()=>{
    const queue = createIntQueue(3);

    expect(isFull(queue)).toBe(false);

    enqueue(queue, 1);
    enqueue(queue, 2);
    enqueue(queue, 3);
    
    expect(isFull(queue)).toBe(true);
    expect(queue.size).toBe(3);
})

it('throws error when enqueue to a full queue',()=>{
    const queue = createIntQueue(3);

    enqueue(queue, 1);
    enqueue(queue, 2);
    enqueue(queue, 3);

    expect(() => enqueue(queue, 4)).toThrowError('queue is full');
});

it('throws error when dequeue from an empty queue',()=>{
    const queue = createIntQueue(3);

    expect(() => dequeue(queue)).toThrowError('queue is empty');
});

it('在多次不規則的新增與取出後,索引繞圈時仍維持先進先出的正確順序', () => {
  const queue = createIntQueue(3)

  // 初次填入兩個
  enqueue(queue, 1)
  enqueue(queue, 2)

  expect(dequeue(queue)).toBe(1) // head: 0 -> 1,取出 1
  expect(peek(queue)).toBe(2)    // 目前隊首是 2

  // 填滿剩下的空間,讓 tail 繞回 index 0
  enqueue(queue, 3) // buffer: [ , 2, 3],head = 1, tail = 0
  enqueue(queue, 4) // buffer: [4, 2, 3],head = 1, tail = 1 (繞回到 0 再往前)

  // 依序取出,確認順序仍然正確
  expect(dequeue(queue)).toBe(2) // head: 1 -> 2,取出 2
  expect(dequeue(queue)).toBe(3) // head: 2 -> 0,取出 3(head 繞回 0)
  expect(dequeue(queue)).toBe(4) // head: 0 -> 1,取出 4

  expect(isEmpty(queue)).toBe(true)
})

完整功能程式碼

// int-queue.js
export function createIntQueue(capacity){
    if(!Number.isInteger(capacity)||capacity<=0){
        throw new Error('capacity must be a positive integer')
    }

    return {buffer:new Array(capacity),
        capacity,
        headIndex:0,
        tailIndex:0,
        size:0
    }
}

export function isEmpty(queue){
    return queue.size === 0;
}

export function isFull(queue){
    return queue.size === queue.capacity;
}

export function enqueue(queue, value){
    if(!Number.isInteger(value)){
        throw new Error('value must be an integer');
    }

    if (isFull(queue)){
        throw new Error('queue is full');
    }

    queue.buffer[queue.tailIndex]=value;
    queue.tailIndex = (queue.tailIndex + 1) % queue.capacity;
    queue.size += 1;
}

export function peek(queue){
    if (isEmpty(queue)){
        throw new Error('queue is empty');
    }
    return queue.buffer[queue.headIndex];
}

export function dequeue(queue){
    if(isEmpty(queue)){
        throw new Error('queue is empty');
    }

    const value = queue.buffer[queue.headIndex];
    queue.buffer[queue.headIndex]=undefined;
    queue.headIndex=(queue.headIndex + 1) % queue.capacity;
    queue.size -= 1;

    return value;

}