iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0

https://ithelp.ithome.com.tw/upload/images/20250831/20118113BLkCodRJyL.png
由於函數式程式設計要求純函數(Pure function),因為純函數的行為確定,因此FP的優點之一便是易於測試,今天就介紹軟體測試中最基本的單元測試(Unit Test),並且替昨天的內容寫測試。

單元測試-Vitest

單元測試主要用於驗證程式碼中最小的可測試部分(稱為「單元」),通常是函式、方法或類別,由於FP的程式都是由純函數構成,單元浿試扮酌很重要的的角色。Vitest 是一個原生支援 TypeScript 項目設計的試測框架,今天我們用Vitest來進行測試。

安裝vitest

首先我們安裝vitest和@vitest/ui

npm install -D vitest @vitest/ui

接下來在package.json的scripts中加入下列兩行

// @package.json
{
// 其餘省略
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",    
    // 其餘省略
  },
}

如果我們在執行npm run test便會自動將所有檔名為*.test.ts的檔案進行測試,也可以執行npm run test:ui,此時vitest不但會自動將所有檔名為*.test.ts的檔案進行測試,還會提供一個UI頁面,給予詳細的測試結果。

第一支測試程式

我們先在src/main.ts中先一個簡單的加法函數。

// @main.ts
export const add = (x: number) => (y: number) => x + y

然後在src/main.test.ts寫一個簡單的測試程式

import { test, expect } from 'vitest';
import { add } from './main';

test('adds 1 + 2 to equal 3', () => {
  expect(add(1)(2)).toBe(3);
});

test是vitest的測試函數,第一個參數是說明字串;第二個參數是一個函數,裏面是我們要進行測試的主題。expect函數的參數可以是任何的值,expect回傳的物件提供許多的斷言(assertion)方法,最簡單的便是toBe,以上面程式碼來說,如果add(1)(2)的值等於3便通過測試。

接下來我們在terminal執行npm run test,可以得到下面結果。
表示我們測試的檔案有一個(main.test.ts),通過的測試有一個。

如果我們將程式中的expect(add(1)(2)).toBe(3)改成expect(add(1)(2)).toBe(2),則會出現如下錯誤的畫面,裏面提供了完整的錯誤紀錄讓我們可以更容易除錯。

我們也可以執行npm run test:ui,它會以網頁UI的形式提供訊息,更易於操作,兩者的資訊完全一樣,在此便不贅述。無論是terminal或ui方式,兩者都會以watch模式執行,只要程式有所更動,會立即顯示新的測試結果。

gridStore測試

在實務上我們會在根目錄下再建立一個test目錄,它的目錄結構會和src目錄一致。我們便將main.test.ts移至test目錄下,記得將

import { add } from './main';

改成

import { add } from '../src/main';

接下來我們建立一個gridStore.ts的檔案,將昨日day28的程式複製進去,然後建立test/gridStore.test.ts檔案,開始寫測試程式,現在有許多的AI聊天機器人可以幫我們快速的寫好測試,以下許多的測試程式都是由chatGPT協助產生。

第一步先匯入vitest中的函數和要被測試的函數。

import { describe, it, expect } from 'vitest';
import {
  createMatrixStore,
  Grid,
  neighbors,
  lifeRule,
  evolve,
  binaryRender,
} from '../src/gridStore'; 

it是test的別名,通常它的測試說明會以should …開始,以增加閱讀性。
如果我們對一個函數要測試的部分超過1個,我們會使用describe函數將相關的測試集合成一個block,下面的程式碼便是以測試createMatrixStore為例。

describe('createMatrixStore', () => {
  const matrix = [
    [1, 2, 3],
    [4, 5, 6],
  ];
  // 測試正確的註標
  it('should return correct value from inside the matrix', () => {
    const store: Grid = createMatrixStore(matrix, [0, 0]);

    expect(store.peek([0, 0])).toBe(1);
  });
  // 測試超出範圍的註標
  it('should return 0 for out-of-bounds indices', () => {
    const store: Grid = createMatrixStore(matrix, [0, 0]);

    expect(store.peek([-1, 0])).toBe(0); // negative index
    expect(store.peek([5, 0])).toBe(0); // beyond x range
    expect(store.peek([1, 10])).toBe(0); // beyond y range
  });
  // 測試store.pos的位置正確
  it('should set the position correctly', () => {
    const store: Grid = createMatrixStore(matrix, [1, 1]);
    expect(store.pos).toEqual([1, 1]);
  });
});

此時測試結果會出現2個測試檔案,4個測試成功的訊息。這邊我們要注意的是,expect(store.pos)的斷言(assertion)要用toEqual,不能使用toBe。Typescript的物件和陣列都是位址參照,所以toBe會檢查兩個陣列位址是否相同來決定是否通過測試,因此用toBe會無法通過測試,這與我們的本意不合。

接下來我們再加入neighbors函數的測試程式碼。

describe('neighbors', () => {
  const matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9],
  ];

  it('should return all 9 neighbors including itself for a center cell', () => {
    const grid = createMatrixStore(matrix, [1, 1]); // position = (1,1) -> value 5
    const result = neighbors(grid);

    // Expected order: scanning dx=-1..1, dy=-1..1
    expect(result).toEqual([
      1,
      4,
      7, // left column
      2,
      5,
      8, // middle column
      3,
      6,
      9, // right column
    ]);
  });

  it('should return neighbors with 0 padding for out-of-bounds positions', () => {
    const grid = createMatrixStore(matrix, [0, 0]); // top-left corner
    const result = neighbors(grid);

    // At (0,0), neighbors include some out-of-bound accesses -> should yield 0
    expect(result).toEqual([
      0,
      0,
      0, // left column (all out-of-bounds)
      0,
      1,
      4, // middle column (partially out-of-bounds)
      0,
      2,
      5, // right column (partially out-of-bounds)
    ]);
  });

  it('should return correct values for bottom-right corner', () => {
    const grid = createMatrixStore(matrix, [2, 2]); // bottom-right corner (value 9)
    const result = neighbors(grid);

    expect(result).toEqual([
      5,
      8,
      0, // above row (partially out-of-bounds)
      6,
      9,
      0, // current row (partially out-of-bounds)
      0,
      0,
      0, // below row (all out-of-bounds)
    ]);
  });
});

這個測試讓我們很清楚知道我們得到某個位置的neighbors回傳的值,chatGPT原本給createMatrixStore(matrix, [1, 1])的參照執行結果是[1, 2, 3, 4, 5, 6, 7, 8, 9],這個陣列無法通過測試,然後回頭去看自己的程式碼,應該要以行為先,改為[1, 4, 7, 2, 5, 8, 3, 6, 9]才通過測試。在這裏,我們也體驗到測試的好處,透過測試,我們能更熟悉我們自己程式的邏輯。

緊接著我們加入lifeRule的測試。

describe('lifeRule', () => {
  it('should keep a live cell alive with 2 neighbors', () => {
    const matrix = [
      [0, 1, 0],
      [1, 1, 0],
      [0, 0, 0],
    ];
    const store = createMatrixStore(matrix, [1, 1]); // center is alive
    expect(lifeRule(store)).toBe(1);
  });

  it('should keep a live cell alive with 3 neighbors', () => {
    const matrix = [
      [1, 1, 0],
      [0, 1, 0],
      [0, 1, 0],
    ];
    const store = createMatrixStore(matrix, [1, 1]); // center alive
    expect(lifeRule(store)).toBe(1);
  });

  it('should kill a live cell with fewer than 2 neighbors (underpopulation)', () => {
    const matrix = [
      [0, 0, 0],
      [0, 1, 1],
      [0, 0, 0],
    ];
    const store = createMatrixStore(matrix, [1, 1]); // only 1 neighbor
    expect(lifeRule(store)).toBe(0);
  });

  it('should kill a live cell with more than 3 neighbors (overpopulation)', () => {
    const matrix = [
      [1, 1, 1],
      [1, 1, 0],
      [0, 1, 0],
    ];
    const store = createMatrixStore(matrix, [1, 1]);
    expect(lifeRule(store)).toBe(0);
  });

  it('should revive a dead cell with exactly 3 neighbors (reproduction)', () => {
    const matrix = [
      [0, 1, 0],
      [1, 0, 0],
      [0, 1, 0],
    ];
    const store = createMatrixStore(matrix, [1, 1]); // dead center
    expect(lifeRule(store)).toBe(1);
  });

  it('should keep a dead cell dead with fewer than 3 neighbors', () => {
    const matrix = [
      [0, 1, 0],
      [0, 0, 0],
      [0, 0, 0],
    ];
    const store = createMatrixStore(matrix, [1, 1]);
    expect(lifeRule(store)).toBe(0);
  });

  it('should keep a dead cell dead with more than 3 neighbors', () => {
    const matrix = [
      [1, 1, 1],
      [1, 0, 0],
      [0, 1, 0],
    ];
    const store = createMatrixStore(matrix, [1, 1]);
    expect(lifeRule(store)).toBe(0);
  });
});

值得一提的是程式中neighbors函數,原來有A.filter(([dx, dy]) => !(dx === 0 && dy === 0))在「接管(pipe)」來濾掉自已,後來為了和averageRule相容把它刪掉,因此lifeRule的邏輯和原來生命遊戲的結論已不相同,因此chatGPT給的結論有好幾個不通過,
因為這個測試才發現這個問題,因此將lifeRule的if (alive === 1 && (count === 2 || count === 3)) return 1;這一行改為if (alive === 1 && (count === 3 || count === 4)) return 1;才通過測試。

我們再看看evolve函數的測試。

describe('evolve', () => {
  it('should keep a stable 2x2 block (still life)', () => {
    const block = [
      [0, 0, 0, 0],
      [0, 1, 1, 0],
      [0, 1, 1, 0],
      [0, 0, 0, 0],
    ];
    const next = evolve(lifeRule)(block);
    expect(next).toEqual(block); // block should remain unchanged
  });

  it('should oscillate a blinker (period 2)', () => {
    const blinker1 = [
      [0, 0, 0, 0, 0],
      [0, 0, 0, 0, 0],
      [0, 1, 1, 1, 0],
      [0, 0, 0, 0, 0],
      [0, 0, 0, 0, 0],
    ];

    const blinker2 = [
      [0, 0, 0, 0, 0],
      [0, 0, 1, 0, 0],
      [0, 0, 1, 0, 0],
      [0, 0, 1, 0, 0],
      [0, 0, 0, 0, 0],
    ];

    const next = evolve(lifeRule)(blinker1);
    expect(next).toEqual(blinker2);

    const back = evolve(lifeRule)(blinker2);
    expect(back).toEqual(blinker1); // oscillates back
  });

  it('should move like spaceship', () => {
    const spaceship1 = [
      [0, 0, 1, 0, 0],
      [1, 0, 1, 0, 0],
      [0, 1, 1, 0, 0],
      [0, 0, 0, 0, 0],
      [0, 0, 0, 0, 0],
    ];

    const spaceship2 = [
      [0, 1, 0, 0, 0],
      [0, 0, 1, 1, 0],
      [0, 1, 1, 0, 0],
      [0, 0, 0, 0, 0],
      [0, 0, 0, 0, 0],
    ];

    const next = evolve(lifeRule)(spaceship1);
    expect(next).toEqual(spaceship2);
  });

  it('should kill isolated cells', () => {
    const single = [
      [0, 0, 0],
      [0, 1, 0],
      [0, 0, 0],
    ];
    const expected = [
      [0, 0, 0],
      [0, 0, 0],
      [0, 0, 0],
    ];
    const next = evolve(lifeRule)(single);
    expect(next).toEqual(expected);
  });

  it('should revive a cell with exactly 3 neighbors', () => {
    const triangle = [
      [0, 1, 0],
      [1, 0, 0],
      [0, 1, 0],
    ];
    const expected = [
      [0, 0, 0],
      [1, 1, 0], // middle revived
      [0, 0, 0],
    ];
    const next = evolve(lifeRule)(triangle);
    expect(next).toEqual(expected);
  });
});

這個部分chatGPT給的參照結果也有好多個不對,必須自行依據規則去一一驗證才能給出正確的參照答案來通過測試。我們可以在維基百科康威生命遊戲中,得到更多的測試範例。

最後是binaryRender函數的測試。

describe('binaryRender', () => {
  it('renders an empty grid', () => {
    const matrix = [
      [0, 0],
      [0, 0],
    ];
    const expected = '⬛⬛\n⬛⬛';
    expect(binaryRender(matrix)).toBe(expected);
  });

  it('renders a full grid', () => {
    const matrix = [
      [1, 1],
      [1, 1],
    ];
    const expected = '⬜⬜\n⬜⬜';
    expect(binaryRender(matrix)).toBe(expected);
  });

  it('renders a mixed grid', () => {
    const matrix = [
      [1, 0, 1],
      [0, 1, 0],
    ];
    const expected = '⬜⬛⬜\n⬛⬜⬛';
    expect(binaryRender(matrix)).toBe(expected);
  });

  it('renders a non-square matrix', () => {
    const matrix = [
      [1, 0],
      [0, 1],
      [1, 1],
    ];
    const expected = '⬜⬛\n⬛⬜\n⬜⬜';
    expect(binaryRender(matrix)).toBe(expected);
  });
});

binaryRender函數的測試比較簡單,也就沒有什麼問題,上以便是今日所有函數的測試的過程。

今日小結

由今天的測試過程可以體會到函數式程式設計的測試優點,你完全可以預期一個純函數執行的結果會什麼,進而檢查自己程式的問題,發揮測試的價值。另外,以前的程式開發人員會覺得寫測試是很無聊的一件事,而且耗費許多時間;今日生成式人工智慧的迅速的發展,已經讓測試的工作更為快速和便捷,測試是何樂而不為的事了。一個程式唯有透過測試,你才能更放心的讓它上線工作。

今天分享的內容就到這裏,也是倒數第二天的內容了,明天就要和鐵人賽真正的再見了!


上一篇
Day 28. 生命遊戲 - Store
下一篇
Day 30. 啟程fp-ts - 轉進Haskell
系列文
數學老師學函數式程式設計 - 以fp-ts啟航30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言