由於函數式程式設計要求純函數(Pure function),因為純函數的行為確定,因此FP的優點之一便是易於測試,今天就介紹軟體測試中最基本的單元測試(Unit Test),並且替昨天的內容寫測試。
單元測試主要用於驗證程式碼中最小的可測試部分(稱為「單元」),通常是函式、方法或類別,由於FP的程式都是由純函數構成,單元浿試扮酌很重要的的角色。Vitest 是一個原生支援 TypeScript 項目設計的試測框架,今天我們用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模式執行,只要程式有所更動,會立即顯示新的測試結果。
在實務上我們會在根目錄下再建立一個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函數的測試比較簡單,也就沒有什麼問題,上以便是今日所有函數的測試的過程。
由今天的測試過程可以體會到函數式程式設計的測試優點,你完全可以預期一個純函數執行的結果會什麼,進而檢查自己程式的問題,發揮測試的價值。另外,以前的程式開發人員會覺得寫測試是很無聊的一件事,而且耗費許多時間;今日生成式人工智慧的迅速的發展,已經讓測試的工作更為快速和便捷,測試是何樂而不為的事了。一個程式唯有透過測試,你才能更放心的讓它上線工作。
今天分享的內容就到這裏,也是倒數第二天的內容了,明天就要和鐵人賽真正的再見了!