iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 5
2
Modern Web

高效 Coding 術:Angular Schematics 實戰三十天系列 第 5

[高效 Coding 術:Angular Schematics 實戰三十天] Day04 - 為你的 Schematics 撰寫測試程式

  • 分享至 

  • xImage
  •  

什麼是測試?

「想像一下現在你的面前有兩台飛機,一台從零件、製程到組裝都經過完整的測試,而另外一台僅經過飛行的測試,你會想要搭哪一台飛機?」

很多人以為測試,就是找個人、打開瀏覽器、拿著滑鼠點一點就叫測試,但事情其實並沒有這麼簡單。

測試在工程上一直都是一項非常重要且關鍵的工作,它是整個開發流程的其中一環,也是掌控品質的手段之一。它的本質在於預防,無論程式有多精良,一但將其進行組裝時,也會很容易遇到無法解釋的問題,造成功能上的失效。而一段可被自動化測試的程式,也可以在無形中增加工程師信心。

只是依台灣目前的軟體產業生態,測試這件事一直沒有得到應有的重視,即便是具規模的軟體公司也未必擁有完備的軟體測試政策或流程,真的是非常可惜。

為什麼不寫測試

以下是一些常聽到不寫測試或是覺得測試沒必要的原因:

  • 都沒時間開發了,哪還有時間寫測試。
  • 之前本來就沒在寫了。
  • 測試很難維護。

大家回想一下平常我們在開發的時候,最常做的事情是什麼?

功能開發並驗證功能是否正常運作

假設有兩個工程師:小明與小華,他們各方面的能力相近,小明每個功能需要花費十分鐘的時間開發,驗證該功能需要花費一分鐘,總計花費十一分鐘;小華跟小明一樣,每個需要花費十分鐘的時間開發,另外再花十分鐘的時間寫測試,但驗證只需花費十秒鐘,總計花費二十分十秒鐘。

沒錯,就數字上來看,小華所花費的時間比小明多一倍。但通常一個功能開發完之後,都會伴隨著更多的調整。

假設小明每次調整需要花費一分鐘,驗證該次調整也一樣需要花費一分鐘,總計兩分鐘;小華每次調整也都跟小明一樣需花費一分鐘,但小華因為已經有寫好了測試,所以驗證該次調整只需花費十秒鐘,總計一分零十秒。

雖然寫測試在開發時期所花費的時間較多,但開發在整個軟體開發週期佔比其實不多,反而是後續的調整與維護才是最花時間的。從以上敘述中不難看出,只要願意投資時間在測試上,一則這個花費慢慢地就能拉平,甚至可以節省不少時間;二則提高程式碼品質,增加工程師的自信心。

其實大部份真正的理由,大多是不知道怎麼寫測試,或是不知道哪些該測試,甚至是根本不知道有測試這回事。

無論最終是否真的會寫,筆者還是要教大家如何撰寫 Schematics 的測試程式,只要有興趣、想學,隨時都能夠回來看看,而未來的每一個範例,也都會連撰寫測試的部份一起分享給大家。

如何撰寫 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 在做的事情是:

  1. 用當前專案內的 collection.json 產生出一個 SchematicTestRunner
  2. SchematicTestRunner 產生出一個 Tree (也就是最終結果)。其中給 runSchematic 的第一個參數是 Schematic 名稱,也就是 hello-world;第二個參數是要給這個 Schematic 的參數;第三個參數是要給這個 Schematic 的 Tree ,用以模擬現實的專案環境 。
  3. expect 驗證 tree.files 是否等於 [] ;

接著我們可以輸入以下指令執行測試:

npm test

正常如果有通過的話,應該會出現以下圖示:

Imgur

綠色的 . 表示該測試案例通過,有幾個 . 就有幾個測試案例通過;而如果是紅色的 F ,則表示該測試案例不通過,有幾個 F 就有幾個測試案例不通過。

撰寫第一個 Schematics 測試程式

那要如何測試我們昨天做的 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 以及它的一些基本用法,敬請期待。

參考資料


上一篇
[高效 Coding 術:Angular Schematics 實戰三十天] Day03 - 第一個 Schematic
下一篇
[高效 Coding 術:Angular Schematics 實戰三十天] Day05 - JSON Schema
系列文
高效 Coding 術:Angular Schematics 實戰三十天32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
1
Sonic
iT邦新手 5 級 ‧ 2020-02-07 13:00:32

新手Tips:
執行 npm test 之前,
記得把 day3 加料的 tree.create(options.name || 'hello', 'world'); 移除,
否則會 fail

Leo iT邦新手 3 級 ‧ 2020-05-26 11:24:25 檢舉

Hi Sonic,

抱歉我現在才看到你的留言,

請問 Fail 的錯誤訊息是什麼呢?

0
ch_lute
iT邦新手 5 級 ‧ 2020-05-21 15:40:04

我也遇到了
Failures:

  1. hello-world works
    Message:
    Expected $.length = 1 to equal 0.
    Unexpected $[0] = '/hello' in array.
看更多先前的回應...收起先前的回應...
Leo iT邦新手 3 級 ‧ 2020-05-26 11:27:10 檢舉

Hi ch_lute,

你這個錯誤是出現在哪個測試案例呢?

另外,方便擷你的程式碼跟測試程式碼的圖給我看一下嗎?這樣我會比較清楚出錯的地方。

ch_lute iT邦新手 5 級 ‧ 2020-05-27 14:57:56 檢舉

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"
  }

其他應該都一樣

ch_lute iT邦新手 5 級 ‧ 2020-05-27 15:20:21 檢舉

是不是因為toEqual裡面是空的[]

ch_lute iT邦新手 5 級 ‧ 2020-05-27 15:47:15 檢舉

剛測試因加料的部分會產生"hello"似乎在test的時候讀入為"/hello",然後跟空白物件做比較所以Fail,我將toEqual改為"/hello"之後是通過的(或把加料的刪除,toEqual維持空白)

Leo iT邦新手 3 級 ‧ 2020-05-28 17:14:13 檢舉

Hi ch_lute

你說的沒錯,主要是在 index_spec.ts 裡的 expect(tree.files).toEqual([]) ,實際上 tree.files 裡應該已經有一個名為 /hello 的檔案了。

我將toEqual改為"/hello"之後是通過的(或把加料的刪除,toEqual維持空白)

所以你的這個解決方式是對的。

0
thenkyle
iT邦新手 5 級 ‧ 2022-04-29 17:24:02

這個需求有兩個情境,一是有給檔名、二是沒給。我們先從沒給檔名開始:

應該是

這個需求有兩個情境,「一是沒給檔名、二是有給」。我們先從沒給檔名開始:

剛好看到~

Leo iT邦新手 3 級 ‧ 2022-04-29 17:26:47 檢舉

喔沒有啦,我的意思是先從情境二開始XDD

0
Charles Wang
iT邦新手 5 級 ‧ 2023-04-14 15:10:25

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}`);
});

我要留言

立即登入留言