Yuanchieh's Blog

Yuanchieh's Blog

生命是長期而持續的累積

06 Aug 2018

使用 Jest 做API 單元測試的範例與細節

 
之前寫測試因為沒有注意細節,導致非常難編寫單元測試;
改以 End-to-End測試,直接用docker 開DB輸入假資料,接著執行 Server App 對API一隻一隻測試。

這樣的好處是測試方法與最終API調用的結果是一樣的,但缺點就是耗時較久,且邊寫測試的成本很高,要做到TDD之類的開發方式非常困難。

另一方面之前用 Mocha,也算是 Nodejs 最大宗使用的測試框架,提供最基本的測試環境與語法定義,其餘的斷言、Mocking都要自己另外想辦法;
這次改用jest,由FB開源可供前後端的測試框架,此次順便分享如何建構程式碼才方便寫測試的小小心得。

Jest 如何使用

Jest 使用上有點像 Mocha + Chai,除了測試環境外還以自定義的斷言,提供多種編寫語意化的測試情境。

// ./sum.js  
function sum(a, b) {  
   return a + b;  
}  
module.exports = sum;

// ./sum.test.js  
const sum = require(‘./sum’);  
test(‘adds 1 + 2 to equal 3’, () => {  
   expect(sum(1, 2)).toBe(3);  
});

// 執行
> jest

另一點方便的是 Jest對於 Callback、Promise、Async/Await支援相當好,當初在Mocha中搞了一陣子….

邊寫測試的小細節

使用Mock,透過預設情景與假資料,不用架設複雜的環境或是真的發出非同步請求,就可以達到測試的效果;
在使用Mock之前,有一點要注意 盡量把API包成函式或整理成物件方法而非直接呼叫

例如說在設計API: GET /user/:userId

// 不要這樣直接呼叫 mongoose 方法  
function getUserById(){  
 let user = await mongoose.findById(….)  
}// 拆開成獨立的函式,方便後續做 Mock  
function getUserById(){  
 let user = await getUserByIdInModel(id)  
}function getUserByIdInModel(){  
 return mongoose.findById(…)  
}

將非同步請求整理包成函式在調用有幾個好處
1. 解耦,多一層抽象化
試想今天是用MongoDB當作資料庫,但如果某天需要換成 PostgreSQL,把資料庫調用寫死在 API邏輯中會導致整份程式碼需要重寫;
但如果拆開成獨立函式,就只要改函式調用的DB Client與對應原本的回傳資料格式即可(親身經驗
2. 方便編寫測試
非同步請求本身也可以獨立寫測試 (如果需要的話

接著看 Jest如何生成假資料。

Mock

產生假資料可以分為 func
假設我們原本的程式碼為

// index.js  
const api = require(‘./apiWrapper’);  
const Obj = require(‘./objectWrapper’);exports.callAPI = async function(t){  
  let content = await api.readFile(t)  
  return content  
}exports.callObject = async function(){  
  let obj = new Obj(“jay”)  
  return obj.sayHi()  
}

// ./util/apiWrapper.js  
const fs = require(‘mz/fs’);exports.readFile = async function(params){  
  return fs.readFile(“./test.txt”);  
}

// ./model/objWrapper.js  
module.exports = class Test{  
  constructor(name){  
    this.name = name  
  }sayHi(){  
    return \`hi from ${this.name}\`  
  }  
}

基本上就是index.js對外輸出兩個函式,此兩個函式內有非同步請求與創建物件的方法,通常個人資料夾目錄是共用的非同步API Call會叫做 util,如果是資料庫相關的物件則是 model。

接著看測試檔 index.test.js

const index = require(“./index”)

jest.mock(“./util/apiWrapper”)  
jest.mock(“./model/objectWrapper”)

test(“mock api test”, async ()=>{  
  let res = await index.callAPI(1);  
  expect(res).toBe(“jest”);  
})

test(“mock api test2”, async ()=>{  
  let res = await index.callAPI(2);  
  expect(res).toBe(“jest2”);  
})

test(“mock obj test”, async ()=>{  
  let res = await index.callObject();  
  expect(res).toBe(“hi from jest”);  
})  

這裡先定義了三個測試情境,並透過 jest.mock()顯式宣告 Mock檔案的位置;
接著注意 mock預設有固定的資料路徑與命名規則。

index.js  
index.test.js   
 — util   
 | — — apiWrapper.js   
 | — — __mocks__  
 | — — apiWrapper.js  

需注意mock要定義在檔案的同層 mocks 資料夾下,並且檔名需要一致且輸出同樣的函式名。
如果是要 mock 如 ‘fs’等原生模組,直接定義在專案最上層的 __mocks__中即可。

在 jest.fn()中,有需多 mock資料的方式 1. mockImplementation(fn),可以取得函式調用的參數,並決定如何回傳

// ./util/__mocks__/apiWrapper.js  
exports.readFile = jest.fn().mockImplementation(params => {  
 if(params == 1){  
    return “jest”  
 }  
 return “jest2”  
})

2. mockReturnValueOnce 一次性的回傳值

jest.fn()  
 .mockReturnValueOnce(10)  
 .mockReturnValueOnce(‘x’)

3. mockReturnValue 固定的回傳值

有定義並宣告使用 mock,jest在執行原腳本時會改呼叫被 mock覆寫定義的函式 jest.fn(),達到產生假資料的效果,同時紀錄 fn()呼叫的次數與傳遞的參數;
物件則同樣也是透過改寫物件的呼叫方法。

其餘的在程式碼中,https://github.com/sj82516/very-simple-jest-example