iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 2
4
Modern Web

循序漸進學習 Javascript 測試系列 第 2

Day2 從測試基礎著手:動手做一個超簡易測試工具

有非常多的套件或是框架,可以輔助我們寫出高品質的測試。如何有效地善用這些工具,最好的方法就是去理解工具背後是怎麼運作的。而理解工具背後的運作原理,最好的方式之一就是自己動手實作一個。

現在就來實作一個超級簡易版的測試工具吧!

 

第一步:拋出錯誤

首先建立一個 calculation.js 檔案,裡面有一個叫做 sum 的 function 幫我們計算加法,但其實這個 function 有個 bug:運算符寫成 - 號了。

calculation.js

const sum = (a, b) => a - b // 有 bug

這時候我們要來簡單寫一段程式碼,讓它自動幫我們檢查,如果 sum 執行的結果跟預期的正確結果不一樣的時候,就拋出一個錯誤:

const result = sum(8, 7)

const expected = 15
if (result !== expected) {
	throw new Error(`${result} is not equal to ${expected}`)
}

接下來執行這個檔案:

$ node calculation.js

/Users/shane/Desktop/calculation.js:7
  throw new Error(`${result} is not equal to ${expected}`)
  ^

Error: 1 is not equal to 15
    at Object.<anonymous> (/Users/shane/Desktop/calculation.js:7:9)
    at Module._compile (internal/modules/cjs/loader.js:1201:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1221:10)
    at Module.load (internal/modules/cjs/loader.js:1050:32)
    at Function.Module._load (internal/modules/cjs/loader.js:938:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)
    at internal/main/run_main_module.js:17:47

果然拋出錯誤。恭喜你,已經大功告成了! (欸)

當然還沒啦,不過,目前已經可以體會大部分的測試框架,基本上跟我們剛剛所做的事情目的差不多:如果有非預期的結果的話,就拋出錯誤訊息,目的是讓開發者可以快速辨識問題並解決它。

回到剛剛的 sum,我們將 - 號改正為 + 號,再執行一次 node calculation.js ,正常來說,已經不會看到任何錯誤訊息了。

可以再試著加入一個叫 subtract ****的 function 到 calculation.js 中:

const subtract = (a, b) => a - b

照著剛剛的邏輯也為它加上測試,調整一下程式碼,變成這樣:

const sum = (a, b) => a + b
const subtract = (a, b) => a - b

let result = sum(8, 7)
let expected = 15
if (result !== expected) {
  throw new Error(`${result} is not equal to ${expected}`)
}

result = subtract(8, 7)
expected = 1
if (result !== expected) {
  throw new Error(`${result} is not equal to ${expected}`)
}

執行 node calculation.js ,正常的話,不會拋出任何錯誤。

 

第二步:抽出測試邏輯

現在,我們可以把測試邏輯給抽出來,新建一個 assertion.js 檔案,引入剛剛的 sumsubtract

calculation.js

const sum = (a, b) => a - b // 有 bug
const subtract = (a, b) => a - b

module.exports = {
  sum,
  subtract,
}

asserion.js

const { sum, subtract } = require('./calculation')

let result, expected

result = sum(8, 7)
expected = 15
if (result !== expected) {
  throw new Error(`${result} is not equal to ${expected}`)
}

result = subtract(8, 7)
expected = 1
if (result !== expected) {
  throw new Error(`${result} is not equal to ${expected}`)
}

我們可以進一步抽出重複的邏輯,將他們收斂在一個 function 中,這個 function 會回傳一個包含測試方法的 object:

function expect(result) {
	return {
		toBe(expected) {
			
		}
	}
}

再把剛剛驗證結果是否相等的那段程式碼搬進去:

function expect(result) {
  return {
    toBe(expected) {
      if (result !== expected) {
        throw new Error(`${result} is not equal to ${expected}`)
      }
    },
  }
}

現在可以這樣來寫測試,把剛剛重複的測試邏輯,變得更直覺易讀:

const result = sum(8, 7)
const expected = 15
expect(result).toBe(expected) // 變成易讀的 assertion

這個步驟所建立的 expect function 正是模擬一個 assertion 套件在做的事情:接收一個值,回傳一個包含各種不同 assertion 的 object,來驗證預期的結果

 

第三步:讓每個測試結果都能呈現

到目前為止,我們寫的這個測試工具有個缺點:當某個測試拋出錯誤之後,就不會繼續跑後面的測試了,所以無法看到每個測試的結果。

而且錯誤訊息不清楚,我們只知道哪一行 throw 了 error、錯誤訊息是什麼,無法明確知道到底是 sum 還是 subtract 出錯。

所以,我們現在要來模擬框架在做的事:幫助開發者快速辨識確切出錯的位置,提供明確的錯誤訊息,而且確保每個欲驗證的測試都能呈現結果。

現在創建一個 framework.js 的檔案,宣告一個 test 的 function,接收第一個參數 title 作為這個測試的名稱,接收的第二個參數為 callback 這個 function,當 callback 拋出錯誤的時候,可以 catch 並印出 error:

framework.js

function test(title, callback) {
  try {
    callback()
  } catch (error) {
    console.error(error)
  }
}

接著,試著加入更明確的訊息,告訴我們這些測試是成功或是失敗:

function test(title, callback) {
  try {
    callback()
    console.log(`✅ ${title}`)
  } catch (error) {
    console.error(`❌ ${title}`)
    console.error(error)
  }
}

現在我們可以呼叫 test ,將第二步寫的測試搬進 callback function 中,將測試改寫成這樣:

test('add numbers', () => {
  const result = sum(8, 7)
  const expected = 15
  expect(result).toBe(expected)
})

test('subtract numbers', () => {
  const result = subtract(8, 7)
  const expected = 1
  expect(result).toBe(expected)
})

執行 node framework.js ,現在可以更明確知道每個測試的結果了:

$ node framework.js

❌ add numbers
Error: 1 is not equal to 15
    at Object.toBe (/Users/shane/Desktop/assertion.js:7:15)
    at /Users/shane/Desktop/framework.js:17:18
    at test (/Users/shane/Desktop/framework.js:6:5)
    at Object.<anonymous> (/Users/shane/Desktop/framework.js:14:1)
    at Module._compile (internal/modules/cjs/loader.js:1201:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1221:10)
    at Module.load (internal/modules/cjs/loader.js:1050:32)
    at Function.Module._load (internal/modules/cjs/loader.js:938:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)
    at internal/main/run_main_module.js:17:47
✅ subtract numbers

 

第四步:兼顧非同步

前面都是同步的情況,那如果我們要測試非同步的結果呢?

你可能會很直覺地想說:把傳進去的 callback function 改成 async function 就好了吧:

test('add numbers', async () => {
  const result = await sumAsync(8, 7)
  const expected = 15
  expect(result).toBe(expected)
})
test('subtract numbers', async () => {
  const result = await subtractAsync(8, 7)
  const expected = 1
  expect(result).toBe(expected)
})

乍看之下好像沒什麼問題,但執行一下發現:

$ node async-await.js

✅ add numbers
✅ subtract numbers
(node:7053) UnhandledPromiseRejectionWarning: Error: 1 is not equal to 15
    at Object.toBe (/Users/shane/Desktop/assertion.js:7:15)
    at /Users/shane/Desktop/async-await.js:17:18
(Use `node --trace-warnings ...` to show where the warning was created)
(node:7053) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
(node:7053) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

事情不是你想的那樣,應該報錯的測試居然通過了,這未免太不尋常。還看到一個 UnhandledPromiseRejectionWarning 的警告,這個警告就是在提醒我們有漏接的錯誤,正是那個應該報錯的測試拋出來的。

因為 callback functon 改成 async function 的形式了,它會回傳一個 promise。如果我們沒有等待 promise 的結果,就會造成剛剛的情況: test function 永遠都會先執行 try block 裡面的 console.log ,等到 promise 的狀態轉為 rejected 時,拋出的錯誤就被漏接了。

所以,現在要做的是:將 test function 也改為 async function, await 裡面的 callback

async function test(title, callback) {
  try {
    await callback()
    console.log(`✅ ${title}`)
  } catch (error) {
    console.error(`❌ ${title}`)
    console.error(error)
  }
}

執行之後,測試就是我們預期的結果了。

現在的 test function 可以同時套用在同步與非同步的測試。

 

第五步:在全域都能使用我們實作的工具

我們目前可以將 testexpect 放進 module 裡,在測試檔案裡引入。但這樣需要在每個測試檔案裡 require module,有點不方便。我們可以嘗試像大部分的套件一樣,不需要在測試檔案裡引入 module,直接執行檔案。

創建一個 global-framework.js 檔案,把testexpect 放進去:

async function test(title, callback) {
    try {
        await callback();
        console.log(`✅ ${title}`);
    } catch (error) {
        console.error(`❌ ${title}`);
        console.error(error);
    }
}

function expect(result) {
    return {
        toBe(expected) {
            if (result !== expected) {
                throw new Error(`${result} is not equal to ${expected}`);
            }
        },
    };
}

一樣在 global-framework.js 裡加上 global method:

global.test = test;
global.expect = expect;

另外,建立一個 test-global.js 的檔案,裡面有我們想進行的測試,不用另外引入 testexpect module:

const { sum, subtract, sumAsync, subtractAsync } = require('./calculation')

test('add numbers', () => {
    const result = sum(8, 7);
    const expected = 15;
    expect(result).toBe(expected);
});
test('subtract numbers', () => {
    const result = subtract(8, 7);
    const expected = 1;
    expect(result).toBe(expected);
});
test('add numbers async', async () => {
    const result = await sumAsync(8, 7);
    const expected = 15;
    expect(result).toBe(expected);
});
test('subtract numbers async', async () => {
    const result = await subtractAsync(8, 7);
    const expected = 1;
    expect(result).toBe(expected);
});

現在可以透過下面這樣來測試 test-global.js

node --require ./global-framework.js test-global.js

node 後面加上 --require 是指在執行之前預先載入特定 module,這裡指的是載入 global-framework.js

到這個步驟,我們大致完成了這個超級簡易的測試工具。(拍手

 

用 Jest 來測試

前面寫的這個超簡易測試工具,其實跟 Jest 這個測試框架有 87% 像。當然不是像 Jest 那麼強大,而是我們模擬的功能,就是在體會 Jest 要幫我們達成的目的。

Jest 預設會執行所有 test.js 結尾的檔案 ,所以我們新增一下 jest.test.js 檔案,把剛剛 test-global.js 的程式碼搬進來,現在直接用 Jest 來跑測試看看:

npx jest

路徑下需要有 jest.config.js 才不會報錯

會發現,Jest 有非常清晰的資訊,甚至還有程式碼框框,一眼就能看出錯誤在哪一行,這就是測試框架很強大的細節之一。

FAIL  ./jest.test.js
  × add numbers (7 ms)
  √ subtract numbers
  × add numbers async (12 ms)
  √ subtract numbers async (11 ms)

  ● add numbers

    expect(received).toBe(expected) // Object.is equality

    Expected: 15
    Received: 1

      4 |     const result = sum(8, 7);
      5 |     const expected = 15;
    > 6 |     expect(result).toBe(expected);
        |                    ^
      7 | });
      8 | test('subtract numbers', () => {
      9 |     const result = subtract(8, 7);

      at Object.<anonymous> (jest.test.js:6:20)

  ● add numbers async

    expect(received).toBe(expected) // Object.is equality

    Expected: 15
    Received: 1

      14 |     const result = await sumAsync(8, 7);
      15 |     const expected = 15;
    > 16 |     expect(result).toBe(expected);
         |                    ^
      17 | });
      18 | test('subtract numbers async', async () => {
      19 |     const result = await subtractAsync(8, 7);

      at Object.<anonymous> (jest.test.js:16:20)

Test Suites: 1 failed, 1 total
Tests:       2 failed, 2 passed, 4 total
Snapshots:   0 total
Time:        8.309 s
Ran all test suites.

 

結論

經過這篇文章自己動手實作後,可以深刻體會使用測試框架是要幫助我們達成什麼:

  • 確保開發者知道每個測試的結果。
  • 如果有非預期的結果出現,就拋出錯誤訊息。
  • 提供明確的錯誤訊息,幫助開發者快速辨識確切出錯的位置,並修正 bug。

上一篇
Day 1 開始之前,先理解為什麼要寫測試
下一篇
Day3 靜態分析:用 ESLint 檢查語法錯誤
系列文
循序漸進學習 Javascript 測試30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

0
lovedrink
iT邦新手 5 級 ‧ 2022-01-06 23:36:47

感謝教學, 這篇對我這個JS新手而言,學到很多JS的技巧

0
greenriver
iT邦研究生 5 級 ‧ 2022-05-23 15:14:53

感謝教學, 讓我學到很多觀念與技巧

我要留言

立即登入留言