「想像一下現在你的面前有兩台飛機,一台從零件、製程到組裝都經過完整的測試,而另外一台僅經過飛行的測試,你會想要搭哪一台飛機?」
很多人以為測試,就是找個人、打開瀏覽器、拿著滑鼠點一點就叫測試,但事情其實並沒有這麼簡單。
測試在工程上一直都是一項非常重要且關鍵的工作,它是整個開發流程的其中一環,也是掌控品質的手段之一。它的本質在於預防,無論程式有多精良,一但將其進行組裝時,也會很容易遇到無法解釋的問題,造成功能上的失效。而一段可被自動化測試的程式,也可以在無形中增加工程師信心。
只是依台灣目前的軟體產業生態,測試這件事一直沒有得到應有的重視,即便是具規模的軟體公司也未必擁有完備的軟體測試政策或流程,真的是非常可惜。
以下是一些常聽到不寫測試或是覺得測試沒必要的原因:
大家回想一下平常我們在開發的時候,最常做的事情是什麼?
功能開發並驗證功能是否正常運作
假設有兩個工程師:小明與小華,他們各方面的能力相近,小明每個功能需要花費十分鐘的時間開發,驗證該功能需要花費一分鐘,總計花費十一分鐘;小華跟小明一樣,每個需要花費十分鐘的時間開發,另外再花十分鐘的時間寫測試,但驗證只需花費十秒鐘,總計花費二十分十秒鐘。
沒錯,就數字上來看,小華所花費的時間比小明多一倍。但通常一個功能開發完之後,都會伴隨著更多的調整。
假設小明每次調整需要花費一分鐘,驗證該次調整也一樣需要花費一分鐘,總計兩分鐘;小華每次調整也都跟小明一樣需花費一分鐘,但小華因為已經有寫好了測試,所以驗證該次調整只需花費十秒鐘,總計一分零十秒。
雖然寫測試在開發時期所花費的時間較多,但開發在整個軟體開發週期佔比其實不多,反而是後續的調整與維護才是最花時間的。從以上敘述中不難看出,只要願意投資時間在測試上,一則這個花費慢慢地就能拉平,甚至可以節省不少時間;二則提高程式碼品質,增加工程師的自信心。
其實大部份真正的理由,大多是不知道怎麼寫測試,或是不知道哪些該測試,甚至是根本不知道有測試這回事。
無論最終是否真的會寫,筆者還是要教大家如何撰寫 Schematics 的測試程式,只要有興趣、想學,隨時都能夠回來看看,而未來的每一個範例,也都會連撰寫測試的部份一起分享給大家。
Schematics 的測試程式主要是使用 Jasmine 的語法與 API 來撰寫,這個部分由於網路上與 Jasmine 官方都已經有很多相關資源,筆者在這邊就不詳加介紹。
接著打開 Schematics 一開始在產生專案時,一併產生出來的檔案 — index_spec.ts
:
import { Tree } from '@angular-devkit/schematics';
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import * as path from 'path';
const collectionPath = path.join(__dirname, '../collection.json');
describe('hello-world', () => {
it('works', () => {
const runner = new SchematicTestRunner('schematics', collectionPath);
const tree = runner.runSchematic('hello-world', {}, Tree.empty());
expect(tree.files).toEqual([]);
});
});
筆者先稍微介紹一下這段程式碼裡的一些關鍵字:
collectionPath
- 顧名思義,就是這個 Schematics 專案的 collection.json
的相對路徑(相對於 index_spec.ts
),後續會用來解析,基本上是固定寫法。describe
- 包含測試的描述與要做哪些測試,單一檔案裡可以有多個 describe
,也可以巢狀包覆,會由上到下依序執行。it
- 包含測試個案的描述與要做的測試內容,是主要執行測試的地方。SchematicTestRunner
- 專門用來寫 Schematic 測試的型別,基本上也是固定寫法。至於這段程式碼裡的 it
在做的事情是:
collection.json
產生出一個 SchematicTestRunner
。SchematicTestRunner
產生出一個 Tree
(也就是最終結果)。其中給 runSchematic
的第一個參數是 Schematic 名稱,也就是 hello-world
;第二個參數是要給這個 Schematic 的參數;第三個參數是要給這個 Schematic 的 Tree
,用以模擬現實的專案環境 。expect
驗證 tree.files
是否等於 []
;接著我們可以輸入以下指令執行測試:
npm test
正常如果有通過的話,應該會出現以下圖示:
綠色的 .
表示該測試案例通過,有幾個 .
就有幾個測試案例通過;而如果是紅色的 F
,則表示該測試案例不通過,有幾個 F
就有幾個測試案例不通過。
那要如何測試我們昨天做的 Schematic 呢?
先來複習一下昨天在 index.ts
的程式碼:
export function helloWorld(_options: any): Rule {
return (tree: Tree, _context: SchematicContext) => {
tree.create(_options.name || 'hello', 'world');
return tree;
};
}
所以我們的需求是:
產生一個檔案,如果使用者有給檔名,則檔名為使用者輸入的內容;如果使用者沒給檔名,則檔名為
hello
。而檔案內容固定為world
。
這個需求有兩個情境,一是有給檔名、二是沒給。我們先從沒給檔名開始:
// 以上省略
describe('hello-world', () => {
it('使用者沒給檔名,則檔名為 \'hello\'', () => {
const runner = new SchematicTestRunner('schematics', collectionPath);
const tree = runner.runSchematic('hello-world', {}, Tree.empty());
expect(tree.files).toContain('/hello');
});
});
接著是有給檔名時:
// 以上省略
describe('hello-world', () => {
it('使用者沒給檔名,則檔名為 \'hello\'', () => {
// 略
});
it('使用者有給檔名,則檔名為使用者給的檔名', () => {
const fileName = 'Leo';
const runner = new SchematicTestRunner('schematics', collectionPath);
const tree = runner.runSchematic('hello-world', { name: fileName }, Tree.empty());
expect(tree.files).toContain(`/${fileName}`);
});
});
再加上驗證檔案內容的部份:
// 以上省略
describe('hello-world', () => {
it('使用者沒給檔名,則檔名為 "/hello",檔案內容為 "world"', () => {
// 略
expect(tree.readContent('/hello')).toEqual('world');
});
it('使用者有給檔名,則檔名為使用者給的檔名,檔案內容為 "world"', () => {
// 略
expect(tree.readContent(`/${fileName}`)).toEqual('world');
});
});
最後將測試程式碼重構一下:
import { Tree } from '@angular-devkit/schematics';
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import * as path from 'path';
const collectionPath = path.join(__dirname, '../collection.json');
describe('hello-world', () => {
const expectResult = (fileName?: string) => {
const fullFileName = `/${fileName || 'hello'}`;
const params = fileName ? { name: fileName } : {};
const runner = new SchematicTestRunner('schematics', collectionPath);
const tree = runner.runSchematic('hello-world', params, Tree.empty())
expect(tree.files).toContain(fullFileName);
expect(tree.readContent(fullFileName)).toEqual('world');
}
it('使用者沒給檔名,則檔名為 "/hello",檔案內容為 "world"', () => {
expectResult();
});
it('使用者有給檔名,則檔名為使用者給的檔名,檔案內容為 "world"', () => {
expectResult('Leo');
});
});
以上每個步驟,筆者其實都有使用 npm test
來確認自己寫的東西是否正確,而且每個驗證的步驟都不用一秒鐘,讓筆者可以非常放心且大膽的重構,這就是寫測試的好處之一。
從上面的程式碼可以看出一件事,寫測試其實就是寫規格,只是我們把規格轉成程式碼的形式呈現而已。
希望透過今天的分享,可以讓越來越多開發者能夠體會測試的好處,進而養成寫測試的習慣。
今天的程式碼一樣會上傳到 Github 上讓大家參考,明天筆者將會介紹 JSON Schema
以及它的一些基本用法,敬請期待。
新手Tips:
執行 npm test 之前,
記得把 day3 加料的 tree.create(options.name || 'hello', 'world'); 移除,
否則會 fail
Hi Sonic,
抱歉我現在才看到你的留言,
請問 Fail 的錯誤訊息是什麼呢?
我也遇到了
Failures:
Hi ch_lute,
你這個錯誤是出現在哪個測試案例呢?
另外,方便擷你的程式碼跟測試程式碼的圖給我看一下嗎?這樣我會比較清楚出錯的地方。
Leo
index.ts:
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
// You don't have to export the function as default. You can also have more than one rule factory
// per file.
export function helloWorld(_options: any): Rule {
return (tree: Tree, _context: SchematicContext) => {
tree.create(_options.name || 'hello', 'world');
return tree;
};
}
index_spec.ts:
import { Tree } from '@angular-devkit/schematics';
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import * as path from 'path';
const collectionPath = path.join(__dirname, '../collection.json');
describe('hello-world', () => {
it('works', () => {
const runner = new SchematicTestRunner('schematics', collectionPath);
const tree = runner.runSchematic('hello-world', {}, Tree.empty());
expect(tree.files).toEqual([]);
});
});
執行npm test就錯了
版本差異的話在package.jasn內
我的dependencies被分出了一個devDependencies
"author": "",
"license": "MIT",
"schematics": "./src/collection.json",
"dependencies": {
"@angular-devkit/core": "^9.1.7",
"@angular-devkit/schematics": "^9.1.7",
"typescript": "~3.8.2"
},
"devDependencies": {
"@types/node": "^12.11.1",
"@types/jasmine": "~3.5.0",
"jasmine": "^3.5.0"
}
其他應該都一樣
是不是因為toEqual裡面是空的[]
剛測試因加料的部分會產生"hello"似乎在test的時候讀入為"/hello",然後跟空白物件做比較所以Fail,我將toEqual改為"/hello"之後是通過的(或把加料的刪除,toEqual維持空白)
Hi ch_lute
你說的沒錯,主要是在 index_spec.ts
裡的 expect(tree.files).toEqual([])
,實際上 tree.files
裡應該已經有一個名為 /hello
的檔案了。
我將toEqual改為"/hello"之後是通過的(或把加料的刪除,toEqual維持空白)
所以你的這個解決方式是對的。
這個需求有兩個情境,一是有給檔名、二是沒給。我們先從沒給檔名開始:
應該是
這個需求有兩個情境,「一是沒給檔名、二是有給」。我們先從沒給檔名開始:
剛好看到~
喔沒有啦,我的意思是先從情境二開始XDD
angular 15
expect(tree.files).toContain('/hello');和
expect(tree.files).toContain(/${fileName}
);
會跳錯誤
類型 'Promise' 沒有屬性 'files'。
要加入async和await
it('使用者沒給檔名,則檔名為 \'hello\'', async () => {
const runner = new SchematicTestRunner('schematics', collectionPath);
const tree = await runner.runSchematicAsync('hello-world', {}, Tree.empty()).toPromise();
expect(tree.files).toContain('/hello');
});
it('使用者有給檔名,則檔名為使用者給的檔名', async () => {
const fileName = 'Leo';
const runner = new SchematicTestRunner('schematics', collectionPath);
const tree = await runner.runSchematicAsync('hello-world', { name: fileName }, Tree.empty()).toPromise();
expect(tree.files).toContain(`/${fileName}`);
});