全棧測試實戰:用Jest測試Vue+Koa全棧應用

Molunerfinn發表於2017-11-16

本文首發於我的部落格,歡迎踩點~

前言

今年一月份的時候我寫了一個Vue+Koa的全棧應用,以及相應的配套教程,得到了很多的好評。同時我也在和讀者交流的過程中不斷認識到不足和缺點,於是也對此進行了不斷的更新和完善。本次帶來的完善是加入和完整的前後端測試。相信對於很多學習前端的朋友來說,測試這個東西似乎是個熟悉的陌生人。你聽過,但是你未必做過。如果你對前端(以及nodejs端)測試很熟悉,那麼本文的幫助可能不大,不過我很希望能得到你們提出的寶貴意見!

簡介

和上一篇全棧開發實戰:用Vue2+Koa1開發完整的前後端專案一樣,本文從測試新手的角度出發(預設了解Koa並付諸實踐,瞭解Vue並付諸實踐,但是並無測試經歷),在已有的專案上從0開始構建我們的全棧測試系統。可以瞭解到測試的意義,Jest測試框架的搭建,前後端測試的異同點,如何寫測試用例,如何檢視測試結果並提升我們的測試覆蓋率,100%測試覆蓋率是否是必須,以及在搭建測試環境、以及測試本身過程中遇到的各種疑難雜症。希望可以作為入門前端以及Node端測試的文章吧。

專案結構

有了之前的專案結構作為骨架,加入Jest測試框架就很簡單了。

.
├── LICENSE
├── README.md
├── .env  // 環境變數配置檔案
├── app.js  // Koa入口檔案
├── build // vue-cli 生成,用於webpack監聽、構建
│   ├── build.js
│   ├── check-versions.js
│   ├── dev-client.js
│   ├── dev-server.js
│   ├── utils.js
│   ├── webpack.base.conf.js
│   ├── webpack.dev.conf.js
│   └── webpack.prod.conf.js
├── config // vue-cli 生成&自己加的一些配置檔案
│   ├── default.conf
│   ├── dev.env.js
│   ├── index.js
│   └── prod.env.js
├── dist // Vue build 後的資料夾
│   ├── index.html // 入口檔案
│   └── static // 靜態資源
├── env.js // 環境變數切換相關 <-- 新
├── .env // 開發、上線時的環境變數 <-- 新
├── .env.test // 測試時的環境變數 <-- 新
├── index.html // vue-cli生成,用於容納Vue元件的主html檔案。單頁應用就只有一個html
├── package.json // npm的依賴、專案資訊檔案、Jest的配置項 <-- 新
├── server // Koa後端,用於提供Api
│   ├── config // 配置資料夾
│   ├── controllers // controller-控制器
│   ├── models // model-模型
│   ├── routes // route-路由
│   └── schema // schema-資料庫表結構
├── src // vue-cli 生成&自己新增的utils工具類
│   ├── App.vue // 主檔案
│   ├── assets // 相關靜態資源存放
│   ├── components // 單檔案元件
│   ├── main.js // 引入Vue等資源、掛載Vue的入口js
│   └── utils // 工具資料夾-封裝的可複用的方法、功能
├── test
│   ├── sever // 服務端測試 <-- 新
│   └── client // 客戶端(前端)測試 <-- 新
└── yarn.lock // 用yarn自動生成的lock檔案複製程式碼

可以看到新增的或者說更新的東西只有幾個:

  1. 最主要的test資料夾,包含了客戶端(前端)和服務端的測試檔案
  2. env.js以及配套的.env.env.test,是跟測試相關的環境變數
  3. package.json,更新了一些依賴以及Jest的配置項

主要環境:Vue2,Koa2,Nodejs v8.9.0

測試用到的一些關鍵依賴

以下依賴的版本都是本文所寫的時候的版本,或者更舊一些

  1. jest: ^21.2.1
  2. babel-jest: ^21.2.0
  3. supertest: ^3.0.0
  4. dotenv: ^4.0.0

剩下依賴可以專案demo倉庫

搭建Jest測試環境

對於測試來說,我也是個新手。至於為什麼選擇了Jest,而不是其他框架(例如mocha+chai、jasmine等),我覺得有如下我自己的觀點(當然你也可以不採用它):

  1. 由Facebook開發,保證了更新速度以及框架質量
  2. 它有很多整合的功能(比如斷言庫、比如測試覆蓋率)
  3. 文件完善,配置簡單
  4. 支援typescript,我在學習typescript的時候也用了Jest來寫測試
  5. Vue官方的單元測試框架vue-test-utils專門有配合Jest的測試說明
  6. 支援快照功能,對前端單元測試是一大利好
  7. 如果你是React技術棧,Jest天生就適配React

安裝

yarn add jest -D

#or

npm install jest --save-dev複製程式碼

很簡單對吧。

配置

由於我專案的Koa後端用的是ES modules的寫法而不是Nodejs的Commonjs的寫法,所以是需要babel的外掛來進行轉譯的。否則你執行測試用例的時候,將會出現如下問題:

 ● Test suite failed to run

    /Users/molunerfinn/Desktop/work/web/vue-koa-demo/test/sever/todolist.test.js:1
    ({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,global,jest){import _regeneratorRuntime from 'babel-runtime/regenerator';import _asyncToGenerator from 'babel-runtime/helpers/asyncToGenerator';var _this = this;import server from '../../app.js';
                                                                                             ^^^^^^

    SyntaxError: Unexpected token import

      at ScriptTransformer._transformAndBuildScript (node_modules/jest-runtime/build/script_transformer.js:305:17)
          at Generator.next (<anonymous>)
          at new Promise (<anonymous>)複製程式碼

看了官方github的README發現應該是babel-jest沒裝。

yarn add babel-jest -D

#or

npm install babel-jest --save-dev複製程式碼

但是奇怪的是,文件裡說:Note: babel-jest is automatically installed when installing Jest and will automatically transform files if a babel configuration exists in your project. 也就是babel-jest在jest安裝的時候便會自動安裝了。這點需要求證。

然而發現執行測試用例的時候還是出了上述問題,查閱了相關issue之後,我給出兩種解決辦法:

都是修改專案目錄下的.babelrc配置檔案,增加env屬性,配置test環境如下:

1. 增加presets

"env": {
  "test": {
    "presets": ["env", "stage-2"] // 採用babel-presents-env來轉譯
  }
}複製程式碼

2. 或者增加plugins

"env": {
  "test": {
    "plugins": ["transform-es2015-modules-commonjs"] // 採用plugins來講ES modules轉譯成Commonjs modules
  }
}複製程式碼

再次執行,編譯通過。

通常我們將測試檔案(*.test.js或*.spec.js)放置在專案的test目錄下。Jest將會自動執行這些測試用例。值得一提的是,通常我們將基於TDD的測試檔案命名為*.test.js,把基於BDD的測試檔案命名為*.spec.js。這二者的區別可以看這篇文章

我們可以在package.jsonscripts欄位里加入test的命令(如果原本存在則換一個名字,不要衝突)

"scripts": {
  // ...其他命令
  "test": "jest"
  // ...其他命令
},複製程式碼

這樣我們就可以在終端直接執行npm test來執行測試了。下面我們先來從後端的Api測試開始寫起。

Koa後端Api測試

重現一下之前的應用的操作流程,可以發現應用分為登入前和登入後兩種狀態。

可以根據操作流程或者後端api的結構來寫測試。如果根據操作流程來寫測試就可以分為登入前和登入後。如果根據後端api的結構的話,就可以根據routes或者controllers的結構、功能來寫測試。

由於本例登入前和登入後的api基本上是分開的,所以我主要根據上述後者(routes或controllers)來寫測試。

到此需要解釋一下一般來說(寫)測試的步驟:

  1. 寫測試說明,針對你的每條測試說明測試了什麼功能,預期結果是什麼。
  2. 寫測試主體,通常是 輸入 -> 輸出。
  3. 判斷測試結果,拿輸出和預期做對比。如果輸出和預期相符,則測試通過。反之,不通過。

test資料夾下新建一個server資料夾。然後建立一個user.spec.js檔案。

我們可以通過

import server from '../../app.js'複製程式碼

的方式將我們的Koa應用的主入口檔案引入。但是此時遇到了一個問題。我們如何對這個server發起http請求,並對其的返回結果做出判斷呢?

在閱讀了Async testing Koa with Jest以及A clear and concise introduction to testing Koa with Jest and Supertest這兩篇文章之後,我決定使用supertest這個工具了。它是專門用來測試nodejs端HTTP server的測試工具。它內封了superagent這個著名的Ajax請求庫。並且支援Promise,意味著我們對於非同步請求的結果也能通過async await的方式很好的控制了。

安裝:

yarn add supertest -D

#or

npm install supertest --save-dev複製程式碼

現在開始著手寫我們第一個測試用例。先寫一個針對登入功能的吧。當我們輸入了錯誤的使用者名稱或者密碼的時候將無法登入,後端返回的引數裡,success會是false。

// test/server/user.spec.js

import server from '../../app.js'
import request from 'supertest'

afterEach(() => {
  server.close() // 當所有測試都跑完了之後,關閉server
})

// 如果輸入使用者名稱為Molunerfinn,密碼為1234則無法登入。正確應為molunerfinn和123。
test('Failed to login if typing Molunerfinn & 1234', async () => { // 注意用了async
  const response = await request(server) // 注意這裡用了await
                    .post('/auth/user') // post方法向'/auth/user'傳送下面的資料
                    .send({
                      name: 'Molunerfinn',
                      password: '1234'
                    })
  expect(response.body.success).toBe(false) // 期望回傳的body的success值是false(代表登入失敗)
})複製程式碼

上述例子中,test()方法能接受3個引數,第一個是對測試的描述(string),第二個是回撥函式(fn),第三個是延時引數(number)。本例不需要延時。然後expect()函式裡放輸出,再用各種match方法來將預期和輸出做對比。

在終端執行npm test,緊張地希望能跑通也許是人生的第一個測試用例。結果我得到如下關鍵的報錯資訊:

 ● Post todolist failed if not give the params

    TypeError: app.address is not a function
 ...

 ● Post todolist failed if not give the params

    TypeError: _app2.default.close is not a function複製程式碼

這是怎麼回事?說明我們import進來的server看來並沒有close、address等方法。原因在於我們在app.js裡最後一句:

export default app複製程式碼

此處export出來的是一個物件。但我們實際上需要一個function。

在谷歌的過程中,找到兩種解決辦法:

參考解決辦法1解決辦法2

1. 修改app.js

app.listen(8889, () => {
  console.log(`Koa is listening in 8889`)
})

export default app複製程式碼

改為

export default app.listen(8889, () => {
  console.log(`Koa is listening in 8889`)
})複製程式碼

即可。

2. 修改你的test檔案:

在裡要用到server的地方都改為server.callback()

const response = await request(server.callback())
                    .post('/auth/user')
                    .send({
                      name: 'Molunerfinn',
                      password: '1234'
                    })複製程式碼

我採用的是第一種做法。

改完之後,順利通過:

 PASS  test/sever/user.test.js
  ✓ Failed to login if typing Molunerfinn & 1234 (248ms)複製程式碼

然而此時發現一個問題,為何測試結束了,jest還佔用著終端程式呢?我想要的是測試完jest就自動退出了。查了一下文件,發現它的cli有個引數--forceExit能解決這個問題,於是就把package.json裡的test命令修改一下(後續我們還將修改幾次)加上這個引數:

"scripts": {
  // ...其他命令
  "test": "jest --forceExit"
  // ...其他命令
},複製程式碼

再測試一遍,發現沒問題。這樣一來我們就可以繼續依葫蘆畫瓢,把auth/*這個路由的功能都測試一遍:

// server/routes/auth.js

import auth from '../controllers/user.js'
import koaRouter from 'koa-router'
const router = koaRouter()

router.get('/user/:id', auth.getUserInfo) // 定義url的引數是id
router.post('/user', auth.postUserAuth)

export default router複製程式碼

測試用例如下:

import server from '../../app.js'
import request from 'supertest'

afterEach(() => {
  server.close()
})

test('Failed to login if typing Molunerfinn & 1234', async () => {
  const response = await request(server)
                    .post('/auth/user')
                    .send({
                      name: 'Molunerfinn',
                      password: '1234'
                    })
  expect(response.body.success).toBe(false)
})

test('Successed to login if typing Molunerfinn & 123', async () => {
  const response = await request(server)
                    .post('/auth/user')
                    .send({
                      name: 'Molunerfinn',
                      password: '123'
                    })
  expect(response.body.success).toBe(true)
})

test('Failed to login if typing MARK & 123', async () => {
  const response = await request(server)
                    .post('/auth/user')
                    .send({
                      name: 'MARK',
                      password: '123'
                    })
  expect(response.body.info).toBe('使用者不存在!')
})

test('Getting the user info is null if the url is /auth/user/10', async () => {
  const response = await request(server)
                    .get('/auth/user/10')
  expect(response.body).toEqual({})
})

test('Getting user info successfully if the url is /auth/user/2', async () => {
  const response = await request(server)
                    .get('/auth/user/2')
  expect(response.body.user_name).toBe('molunerfinn')
})複製程式碼

都很簡潔易懂,看描述+預期你就能知道在測試什麼了。不過需要注意一點的是,我們用到了toBe()toEqual()兩個方法。乍一看好像沒有區別。實際上有大區別。

簡單來說,toBe()適合===這個判斷條件。比如1 === 1'hello' === 'hello'。但是[1] === [1]是錯的。具體原因不多說,js的基礎。所以要判斷比如陣列或者物件相等的話需要用toEqual()這個方法。

OK,接下去我們開始測試api/*這個路由。

test目錄下建立一個叫做todolits.spec.js的檔案:

有了上一個測試的經驗,測試這個其實也不會有多大的問題。首先我們來測試一下當我們沒有攜帶上JSON WEB TOKEN的header的話,服務端是不是返回401錯誤:

import server from '../../app.js'
import request from 'supertest'

afterEach(() => {
  server.close()
})

test('Getting todolist should return 401 if not set the JWT', async () => {
  const response = await request(server)
                    .get('/api/todolist/2')
  expect(response.status).toBe(401)
})複製程式碼

一切看似沒問題,但是執行的時候卻報錯了:

console.error node_modules/jest-jasmine2/build/jasmine/Env.js:194
    Unhandled error

console.error node_modules/jest-jasmine2/build/jasmine/Env.js:195
  Error: listen EADDRINUSE :::8888
      at Object._errnoException (util.js:1024:11)
      at _exceptionWithHostPort (util.js:1046:20)
      at Server.setupListenHandle [as _listen2] (net.js:1351:14)
      at listenInCluster (net.js:1392:12)
      at Server.listen (net.js:1476:7)
      at Application.listen (/Users/molunerfinn/Desktop/work/web/vue-koa-demo/node_modules/koa/lib/application.js:64:26)
      at Object.<anonymous> (/Users/molunerfinn/Desktop/work/web/vue-koa-demo/app.js:60:5)
      at Runtime._execModule (/Users/molunerfinn/Desktop/work/web/vue-koa-demo/node_modules/jest-runtime/build/index.js:520:13)
      at Runtime.requireModule (/Users/molunerfinn/Desktop/work/web/vue-koa-demo/node_modules/jest-runtime/build/index.js:332:14)
      at Runtime.requireModuleOrMock (/Users/molunerfinn/Desktop/work/web/vue-koa-demo/node_modules/jest-runtime/build/index.js:408:19)複製程式碼

看來是因為同時執行了兩個Koa例項導致了監聽埠的衝突。所以我們需要讓Jest按順序執行。查閱官方文件,發現了runInBand這個引數正是我們想要的。

所以修改package.json裡的test命令如下:

"scripts": {
  // ...其他命令
  "test": "jest --forceExit --runInBand"
  // ...其他命令
},複製程式碼

再次執行,成功通過!

接下來遇到一個問題。我們的JWT的token原本是登入成功後生成並派發給前端的。如今我們測試api的時候並沒有經過登入那一步。所以要測試的時候要用的token的話,我覺得有兩種辦法:

  1. 增加測試的時候的api介面,不需要經過koa-jwt的驗證。但是這種方法對專案有入侵性的影響,如果有的時候我們需要從token獲取資訊的話就有問題了。
  2. 後端預先生成一個合法的token,然後測試的時候用上這個測試的token即可。不過這種辦法的話就需要保證token不能洩露。

我採用第二種辦法。為了讀者使用方便我是預先生成一個token然後用一個變數存起來的。(真正的開發環境下應對將測試的token放置在專案環境變數.env中)

接下來我們測試一下資料庫的四大操作:增刪改查。不過我們為了一次性將這四個介面都測試一遍可以按照這個順序:增查改刪。其實就是先增加一個todo,然後查詢的時候將id記錄下來。隨後可以用這個id進行更新和刪除。

import server from '../../app.js'
import request from 'supertest'

afterEach(() => {
  server.close()
})

const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoibW9sdW5lcmZpbm4iLCJpZCI6MiwiaWF0IjoxNTA5ODAwNTg2fQ.JHHqSDNUgg9YAFGWtD0m3mYc9-XR3Gpw9gkZQXPSavM' // 預先生成的token

let todoId = null // 用來存放測試生成的todo的id

test('Getting todolist should return 401 if not set the JWT', async () => {
  const response = await request(server)
                    .get('/api/todolist/2')
  expect(response.status).toBe(401)
})

// 增
test('Created todolist successfully if set the JWT & correct user', async () => { 
  const response = await request(server)
                    .post('/api/todolist')
                    .send({
                      status: false,
                      content: '來自測試',
                      id: 2
                    })
                    .set('Authorization', 'Bearer ' + token) // header處加入token驗證
  expect(response.body.success).toBe(true)
})

// 查
test('Getting todolist successfully if set the JWT & correct user', async () => {
  const response = await request(server)
                    .get('/api/todolist/2')
                    .set('Authorization', 'Bearer ' + token)
  response.body.result.forEach((item, index) => {
    if (item.content === '來自測試') todoId = item.id // 獲取id
  })
  expect(response.body.success).toBe(true)
})

// 改
test('Updated todolist successfully if set the JWT & correct todoId', async () => {
  const response = await request(server)
                    .put(`/api/todolist/2/${todoId}/0`) // 拿id去更新
                    .set('Authorization', 'Bearer ' + token)
  expect(response.body.success).toBe(true)
})

// 刪
test('Removed todolist successfully if set the JWT & correct todoId', async () => {
  const response = await request(server)
                    .delete(`/api/todolist/2/${todoId}`)
                    .set('Authorization', 'Bearer ' + token)
  expect(response.body.success).toBe(true)
})複製程式碼

對照著api的4大介面,我們已經將它們都測試了一遍。那是不是我們對於服務端的測試已經結束了呢?其實不是的。要想保證後端api的健壯性,我們得將很多情況都考慮到。但是人為的去排查每個條件、語句什麼的必然過於繁瑣和機械。於是我們需要一個指標來幫我們確保測試的全面性。這就是測試覆蓋率了。

後端api測試覆蓋率

上面說過,Jest是自帶了測試覆蓋率功能的(其實就是基於istanbul這個工具來生成測試覆蓋率的)。要如何開啟呢?這裡我還走了不少坑。

通過閱讀官方的配置文件,我確定了幾個需要開啟的引數:

  1. coverageDirectory,指定輸出測試覆蓋率報告的目錄
  2. coverageReporters,指定輸出的測試覆蓋率報告的形式,具體可以參考istanbul的說明
  3. collectCoverage,是否要收集覆蓋率資訊,當然是。
  4. mapCoverage,由於我們的程式碼經過babel-jest轉譯,所以需要開啟sourcemap來讓Jest能夠把測試結果定位到原始碼上而不是編譯的程式碼上。
  5. verbose,用於顯示每個測試用例的通過與否。

於是我們需要在package.json裡配置一個Jest欄位(不是在scripts欄位裡配置,而是和scripts在同一級的欄位),來配置Jest。

配置如下:

"jest": {
  "verbose": true,
  "coverageDirectory": "coverage",
  "mapCoverage": true,
  "collectCoverage": true,
  "coverageReporters": [
    "lcov", // 會生成lcov測試結果以及HTML格式的漂亮的測試覆蓋率報告
    "text" // 會在命令列介面輸出簡單的測試報告
  ]
}複製程式碼

然後我們再進行一遍測試,可以看到在終端裡已經輸出了簡易的測試報告總結:

從中我們能看到一些欄位是100%,而一些不是100%。最後一列Uncovered Lines就是告訴我們,測試裡沒有覆蓋到的程式碼行。為了更直觀地看到測試的結果報告,可以到專案的根目錄下找到一個coverage的目錄,在lcov-report目錄裡有個index.html就是輸出的html報告。開啟來看看:

首頁是個概覽,跟命令列裡輸出的內容差不多。不過我們可以往深了看,可以點選左側的File提供的目錄:

然後我們可以看到沒有被覆蓋到程式碼行數(50)以及有一個函式沒有被測試到:

通常我們沒有測試到的函式也伴隨著程式碼行數沒有被測試到。我們可以看到在本例裡,app的error事件沒有被觸發過。想想也是的,我們的測試都是建立在合法的api請求的基礎上的。所以自然不會觸發error事件。因此我們需要寫一個測試用例來測試這個.on('error')的函式。

通常這樣的測試用例並不是特別好寫。不過好在我們可以嘗試去觸發server端的錯誤,對於本例來說,如果向服務端建立一個todo的時候,沒有附上相應的資訊(id、status、content),就無法建立相應的todo,會觸發錯誤。


// server/models/todolist.js

const createTodolist = async function (data) {
  await Todolist.create({
    user_id: data.id,
    content: data.content,
    status: data.status
  })
  return true
}複製程式碼

上面是server端建立todo的相關函式,下面是針對它的錯誤進行的測試:

// test/server/todolist.spec.js
// ...
test('Failed to create a todo if not give the params', async () => {
  const response = await request(server)
            .post('/api/todolist')
            .set('Authorization', 'Bearer ' + token) // 不傳送建立的引數
  expect(response.status).toBe(500) // 服務端報500錯誤
})複製程式碼

再進行測試,發現之前對於app.js的相關測試都已經是100%了。

不過controllers/todolist.js裡還是有未測試到的行數34,以及我們可以看到% Branch這列的數字顯示的是50而不是100。Branch的意思就是分支測試。什麼是分支測試呢?簡單來說就是你的條件語句測試。比如一個if...else語句,如果測試用例只跑過if的條件,而沒有跑過else的條件,那麼Branch的測試就不完整。讓我們來看看是什麼條件沒有測試到?

可以看到是個三元表示式並沒有測試完整。(三元表示式也算分支)我們測試了0的情況,但是沒有測試非零的情況,所以再寫一個非零的情況:

test('Failed to update todolist  if not update the status of todolist', async () => {
  const response = await request(server)
                    .put(`/api/todolist/2/${todoId}/1`) // <- 這裡最後一個引數改成了1
                    .set('Authorization', 'Bearer ' + token)
  expect(response.body.success).toBe(false)
})複製程式碼

再次跑測試:

哈,成功做到了100%測試覆蓋率!

埠占用和環境變數的引入

雖然做到了100%測試覆蓋率,但是有一個問題卻是不容忽視的。那就是我們現在測試環境和開發環境下的服務端監聽的埠是一致的。意味著你不能在開發環境下測試你的程式碼。比如你寫完一個api之後馬上要寫一個測試用例的時候,如果測試環境和開發環境的服務端監聽的埠一致的話,測試的時候就會因為埠被佔用而無法被監聽到。

所以我們需要指定一下測試環境下的埠,讓它和開發乃至生產環境的埠不一樣。我一開始想法很簡單,指定一下NODE_ENV=test的時候用8888埠,開發環境下用8889埠。在app.js裡就是這樣寫:

// ...
let port = process.env.NODE_ENV === 'test' ? 8888 : 8889
// ...
export default app.listen(port, () => {
  console.log(`Koa is listening in ${port}`)
})複製程式碼

接下去就遇到了兩個問題:

  1. 需要解決跨平臺env設定
  2. 這樣設定的話一旦在測試環境下,對於port這句話,Branch測試是無法完全通過的——因為始終是在test環境下,無法執行到port = 8889那個條件

跨平臺env設定

跨平臺env主要涉及到windows、linux和macOS。要在三個平臺在測試的時候都跑著NODE_ENV=test的話,我們需要藉助cross-env來幫助我們。

yarn add cross-env -D

#or

npm install cross-env --save-dev複製程式碼

然後在package.json裡修改test的命令如下:

"scripts": {
  // ...其他命令
  "test": "cross-env NODE_ENV=test jest --forceExit --runInBand"
  // ...其他命令
},複製程式碼

這樣就能在後端程式碼裡,通過process.env.NODE_ENV這個變數訪問到test這個值。這樣就解決了第一個問題。

埠分離並保證測試覆蓋率

目前為止,我們已經能夠解決測試環境和開發環境的監聽埠一致的問題了。不過卻帶來了測試覆蓋率不全的問題。

為此我找到兩種解決辦法:

  1. 通過istanbul特殊的ignore註釋來忽略測試環境下的一些測試分支條件
  2. 通過配置環境變數檔案,不同環境下采用不同的環境變數檔案

第一種方法很簡單,在需要忽略的地方,輸入/* istanbul ignore next *//* istanbul ignore <word>[non-word] [optional-docs] */等語法忽略程式碼。不過考慮到這是涉及到測試環境和開發環境下的環境變數問題,如果不僅僅是埠問題的話,那麼就不如採用第二種方法來得更加優雅。(比如開發環境和測試環境的資料庫使用者和密碼都不一樣的話,還是需要寫在對應的環境變數的)

此時我們需要另外一個很常用的庫dotenv,它能預設讀取.env檔案裡的值,讓我們的專案可以通過不同的.env檔案來應對不同的環境要求。

步驟如下:

1. 安裝dotenv
yarn add dotenv

#or

npm install dotenv --save複製程式碼
2. 在專案根目錄下建立.env.env.test兩個檔案,分別應用於開發環境和測試環境

// .env

DB_USER=xxxx # 資料庫使用者
DB_PASSWORD=yyyy # 資料庫密碼
PORT=8889 # 監聽埠複製程式碼

// .env.test

DB_USER=xxxx # 資料庫使用者
DB_PASSWORD=yyyy # 資料庫密碼
PORT=8888 # 監聽埠複製程式碼
3. 建立一個env.js檔案,用於不同環境下采用不同的環境變數。程式碼如下:
import * as dotenv from 'dotenv'
let path = process.env.NODE_ENV === 'test' ? '.env.test' : '.env'
dotenv.config({path, silent: true})複製程式碼
4. 在app.js開頭引入env
import './env'複製程式碼

然後把原本那句port的話改成:

let port = process.env.PORT複製程式碼

再把資料庫連線的使用者密碼也用環境變數來代替:

// server/config/db.js

import '../../env'
import Sequelize from 'sequelize'

const Todolist = new Sequelize(`mysql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@localhost/todolist`, {
  define: {
    timestamps: false // 取消Sequelzie自動給資料表加入時間戳(createdAt以及updatedAt)
  }
})複製程式碼

不過需要注意的是,.env和.env.js檔案都不應該納入git版本庫,因為都是比較重要的內容。

這樣就能實現不同環境下用不同的變數了。慢著!這樣不是還沒有解決問題嗎?env.js裡的條件還是無法被測試覆蓋啊——你肯定有這樣的疑問。不用緊張,現在給出解決辦法——給Jest指定收集測試覆蓋率的範圍:

修改package.jsonjest欄位如下:

"jest": {
  "verbose": true,
  "coverageDirectory": "coverage",
  "mapCoverage": true,
  "collectCoverage": true,
  "coverageReporters": [
    "lcov",
    "text"
  ],
  "collectCoverageFrom": [ // 指定Jest收集測試覆蓋率的範圍
    "!env.js", // 排除env.js
    "server/**/*.js",
    "app.js"
  ]
}複製程式碼

做完這些工作之後,再跑一次測試,一次通過:

這樣我們就完成了後端的api測試。完成了100%測試覆蓋率。下面我們可以開始測試Vue的前端專案了。

Vue前端測試

Vue的前端測試我就要推薦來自官方的vue-test-utils了。當然前端測試大致分成了單元測試(Unit test)和端對端測試(e2e test),由於端對端的測試對於測試環境的要求比較嚴苛,而且測試起來比較繁瑣,而且官方給出的測試框架是單元測試框架,因此本文對於Vue的前端測試也僅介紹配合官方工具的單元測試。

在Vue的前端測試中我們能夠了解到jest的mock、snapshot等特性和用法和vue-test-utils提供的mount、shallow、setData等一系列操作。

安裝vue-test-utils

根據官網的介紹我們需要安裝如下:

yarn add vue-test-utils vue-jest jest-serializer-vue -D

#or

npm install vue-test-utils vue-jest jest-serializer-vue --save-dev複製程式碼

其中,vue-test-utils是最關鍵的測試框架。提供了一系列對於Vue元件的測試操作。(下面會提到)。vue-jest用於處理*.vue的檔案,jest-serializer-vue用於快照測試提供快照序列化。

配置vue-test-utils以及jest

1. 修改.babelrc

testenv裡增加或修改presets

{
  "presets": [
    ["env", { "modules": false }],
    "stage-2"
  ],
  "plugins": [
    "transform-runtime"
  ],
  "comments": false,
  "env": {
    "test": {
      "plugins": ["transform-es2015-modules-commonjs"],
      "presets": [
        ["env", { "targets": { "node": "current" }}] // 增加或修改
      ]
    }
  }
}複製程式碼

2. 修改package.json裡的jest配置:

"jest": {
  "verbose": true,
  "moduleFileExtensions": [
    "js"
  ],
  "transform": { // 增加transform轉換
    ".*\\.(vue)$": "<rootDir>/node_modules/vue-jest",
    "^.+\\.js$": "<rootDir>/node_modules/babel-jest"
  },
  "coverageDirectory": "coverage",
  "mapCoverage": true,
  "collectCoverage": true,
  "coverageReporters": [
    "lcov",
    "text"
  ],
  "moduleNameMapper": { // 處理webpack alias
    "@/(.*)$": "<rootDir>/src/$1"
  },
  "snapshotSerializers": [ // 配置快照測試
    "<rootDir>/node_modules/jest-serializer-vue"
  ],
  "collectCoverageFrom": [
    "!env.js",
    "server/**/*.js",
    "app.js"
  ]
}複製程式碼

前端單元測試的一些說明

關於vue-test-utils和Jest的配合測試,我推薦可以檢視這個系列的文章,講解很清晰。

接著,明確一下前端單元測試都需要測試些什麼東西。引用vue-test-utils的說法:

對於 UI 元件來說,我們不推薦一味追求行級覆蓋率,因為它會導致我們過分關注元件的內部實現細節,從而導致瑣碎的測試。

取而代之的是,我們推薦把測試撰寫為斷言你的元件的公共介面,並在一個黑盒內部處理它。一個簡單的測試用例將會斷言一些輸入 (使用者的互動或 prop 的改變) 提供給某元件之後是否導致預期結果 (渲染結果或觸發自定義事件)。

比如,對於每次點選按鈕都會將計數加一的 Counter 元件來說,其測試用例將會模擬點選並斷言渲染結果會加 1。該測試並沒有關注 Counter 如何遞增數值,而只關注其輸入和輸出。

該提議的好處在於,即便該元件的內部實現已經隨時間發生了改變,只要你的元件的公共介面始終保持一致,測試就可以通過。

所以,相對於後端api測試看重測試覆蓋率而言,前端的單元測試是不必一味追求測試覆蓋率的。(當然你要想達到100%測試覆蓋率也是沒問題的,只不過如果要達到這樣的效果你需要撰寫非常多繁瑣的測試用例,佔用太多時間,得不償失。)替代地,我們只需要迴歸測試的本源:給定輸入,我只關心輸出,不考慮內部如何實現。只要能覆蓋到和使用者相關的操作,能測試到頁面的功能即可。

和之前類似,我們在test/client目錄下書寫我們的測試用例。對於Vue的單元測試來說,我們就是針對*.vue檔案進行測試了。由於本例裡的app.vue無實際意義,所以就測試Login.vueTodolist.vue即可。

運用vue-test-utils如何來進行測試呢?簡單來說,我們需要的做的就是用vue-test-utils提供的mount或者shallow方法將元件在後端渲染出來,然後通過一些諸如setDatapropsDatasetMethods等方法模擬使用者的操作或者模擬我們的測試條件,最後再用jest提供的expect斷言來對預期的結果進行判斷。這裡的預期就很豐富了。我們可以通過判斷事件是否觸發、元素是否存在、資料是否正確、方法是否被呼叫等等來對我們的元件進行比較全面的測試。下面的例子裡也會比較完整地介紹它們。

Login.vue的測試

建立一個login.spec.js檔案。

首先我們來測試頁面裡是否有兩個輸入框和一個登入按鈕。根據官方文件,我首先注意到了shallow rendering,它的說明是,對於某個元件而言,只渲染這個元件本身,而不渲染它的子元件,讓測試速度提高,也符合單元測試的理念。看著好像很不錯的樣子,拿過來用。

查詢元素測試

import { shallow } from 'vue-test-utils'
import Login from '../../src/components/Login.vue'

let wrapper

beforeEach(() => {
  wrapper = shallow(Login) // 每次測試前確保我們的測試例項都是是乾淨完整的。返回一個wrapper物件
})

test('Should have two input & one button', () => {
  const inputs = wrapper.findAll('.el-input') // 通過findAll來查詢dom或者vue例項
  const loginButton = wrapper.find('.el-button') // 通過find查詢元素
  expect(inputs.length).toBe(2) // 應該有兩個輸入框
  expect(loginButton).toBeTruthy() // 應該有一個登入按鈕。 只要斷言條件不為空或這false,toBeTruthy就能通過。
})複製程式碼

一切看起來很正常。執行測試。結果報錯了。報錯是input.length並不等於2。通過debug斷點檢視,確實並沒有找到元素。

這是怎麼回事?哦對,我想起來,形如el-inputel-button其實也相當於是子元件啊,所以shallow並不能將它們渲染出來。在這種情況下,用shallow來渲染就不合適了。所以還是需要用mount來渲染,它會將頁面渲染成它應該有的樣子。

import { mount } from 'vue-test-utils'
import Login from '../../src/components/Login.vue'

let wrapper

beforeEach(() => {
  wrapper = mount(Login) // 每次測試前確保我們的測試例項都是是乾淨完整的。返回一個wrapper物件
})

test('Should have two input & one button', () => {
  const inputs = wrapper.findAll('.el-input') // 通過findAll來查詢dom或者vue例項
  const loginButton = wrapper.find('.el-button') // 通過find查詢元素
  expect(inputs.length).toBe(2) // 應該有兩個輸入框
  expect(loginButton).toBeTruthy() // 應該有一個登入按鈕。 只要斷言條件不為空或這false,toBeTruthy就能通過。
})複製程式碼

測試,還是報錯!還是沒有找到它們。為什麼呢?再想想。應該是我們並沒有將element-ui引入我們的測試裡。因為.el-input實際上是element-ui的一個元件,如果沒有引入它,vue自然無法將一個el-input渲染成<div class="el-input"><input></div>這樣的形式。想通了就好說了,把它引進來。因為我們的專案裡在webpack環境下是有一個main.js作為入口檔案的,在測試裡可沒有這個東西。所以Vue自然也不知道你測試裡用到了什麼依賴,需要我們單獨引入:

import Vue from 'vue'
import elementUI from 'element-ui'
import { mount } from 'vue-test-utils'
import Login from '../../src/components/Login.vue'

Vue.use(elementUI)

// ...複製程式碼

再次執行測試,通過!

快照測試

接下來,使用Jest內建的一個特別棒的特性:快照(snapshot)。它能夠將某個狀態下的html結構以一個快照檔案的形式儲存下來,以後每次執行快照測試的時候如果發現跟之前的快照測試的結果不一致,測試就無法通過。

當然如果是以後頁面確實需要發生改變,快照需要更新,那麼只需要在執行jest的時候增加一個-u的引數,就能實現快照的更新。

說完了原理來實踐一下。對於登入頁,實際上我們只需要確保html結構沒問題那麼所有必要的元素自然就存在。因此快照測試寫起來特別方便:

test('Should have the expected html structure', () => {
  expect(wrapper.element).toMatchSnapshot() // 呼叫toMatchSnapshot來比對快照
})複製程式碼

如果是第一次進行快照測試,那麼它會在你的測試檔案所在目錄下新建一個__snapshots__的目錄存放快照檔案。上面的測試就生成了一個login.spec.js.snap的檔案,如下:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Should have the expected html structure 1`] = `
<div
  class="el-row content"
>
  <div
    class="el-col el-col-24 el-col-xs-24 el-col-sm-6 el-col-sm-offset-9"
  >
    <span
      class="title"
    >

     歡迎登入

    </span>

    <div
      class="el-row"
    >
      <div
        class="el-input"
      >
        <!---->
        <!---->
        <input
          autocomplete="off"
          class="el-input__inner"
          placeholder="賬號"
          type="text"
        />
        <!---->
        <!---->
      </div>

      <div
        class="el-input"
      >
        <!---->
        <!---->
        <input
          autocomplete="off"
          class="el-input__inner"
          placeholder="密碼"
          type="password"
        />
        <!---->
        <!---->
      </div>

      <button
        class="el-button el-button--primary"
        type="button"
      >
        <!---->
        <!---->
        <span>
          登入
        </span>
      </button>
    </div>
  </div>
</div>
`;複製程式碼

可以看到它將整個html結構以快照的形式儲存下來了。快照測試能確保我們的前端頁面結構的完整性和穩定性。

methods測試

很多時候我們需要測試在某些情況下,Vue中的一些methods能否被觸發。比如本例裡的,我們點選登入按鈕應對要觸發loginToDo這個方法。於是就涉及到了methods的測試,這個時候vue-test-utils提供的setMethods這個方法就很有用了。我們可以通過設定(覆蓋)loginToDo這個方法,來檢視它是否被觸發了。

注意,一旦setMethods了某個方法,那麼在某個test()內部,這個方法原本的作用將完全被你的新function覆蓋。包括這個Vue例項裡其他methods通過this.xxx()方式呼叫也一樣。

test('loginToDo should be called after clicking the button', () => {
  const stub = jest.fn() // 偽造一個jest的mock funciton
  wrapper.setMethods({ loginToDo: stub }) // setMethods將loginToDo這個方法覆寫
  wrapper.find('.el-button').trigger('click') // 對button觸發一個click事件
  expect(stub).toBeCalled() // 檢視loginToDo是否被呼叫
})複製程式碼

注意到這裡我們用到了jest.fn這個方法,這個在下節會詳細說明。此處你只需要明白這個是jest提供的,可以用來檢測是否被呼叫的方法。

mock方法測試

接下去就是對登入這個功能的測試了。由於我們之前把Koa的後端api進行了測試,所以我們在前端測試中,可以預設後端的api介面都是返回正確的結果的。(這也是我們先進行了Koa端測試的原因,保證了後端api的健壯性回到前端測試的時候就能很輕鬆)

雖然道理是說得通的,但是我們如何來預設、或者說“偽造”我們的api請求,以及返回的資料呢?這個時候就需要用上Jest一個非常有用的功能mock了。可以說mock這個詞對很多做前端的朋友來說,不是很陌生。在沒有後端,或者後端功能還未完成的時候,我們可以通過api的mock來實現偽造請求和資料。

Jest的mock也是同理,不過它更厲害的一點是,它能偽造庫。比如我們接下去要用的HTTP請求庫axios。對於我們的頁面來說,登入只需要傳送post請求,判斷返回的success是否是true即可。我們先來mock一下axios以及它的post請求。

jest.mock('axios', () => ({
  post: jest.fn(() => Promise.resolve({
    data: {
      success: false,
      info: '使用者不存在!'
    }
  }))
}))複製程式碼

然後我們可以把axios引入我們的專案了:

import Vue from 'vue'
import elementUI from 'element-ui'
import { mount } from 'vue-test-utils'
import Login from '../../src/components/Login.vue'
import axios from 'axios'

Vue.use(elementUI)

Vue.prototype.$http = axios

jest.mock(....)複製程式碼

等會,你肯定會提出疑問,jest.mock()方法寫在了import axios from 'axios'下面,那麼不就意味著axios是從node_modules裡引入的嗎?其實不是的,jest.mock()會實現函式提升,也就是實際上上面的程式碼其實和下面的是一樣的:

jest.mock(....)
import Vue from 'vue'
import elementUI from 'element-ui'
import { mount } from 'vue-test-utils'
import Login from '../../src/components/Login.vue'
import axios from 'axios' // 這裡的axios是來自jest.mock()裡的axios

Vue.use(elementUI)

Vue.prototype.$http = axios複製程式碼

看起來甚至有些var的變數提升的味道。

不過這樣的好處是很明顯的,我們可以在不破壞eslint的規則的情況下采用第一種的寫法而達到一樣的目的。

然後你還會注意到我們用到了jest.fn()的方法,它是jest的mock方法裡很重要的一部分。它本身是一個mock function。通過它能夠實現方法呼叫的追蹤以及後面會說到的能夠實現建立複雜行為的模擬功能。

繼續我們沒寫完的測試:

test('Failed to login if not typing the correct password', async () => {
  wrapper.setData({
    account: 'molunerfinn',
    password: '1234'
  }) // 模擬使用者輸入資料
  const result = await wrapper.vm.loginToDo() // 模擬非同步請求的效果
  expect(result.data.success).toBe(false) // 期望返回的資料裡success是false
  expect(result.data.info).toBe('密碼錯誤!')
})複製程式碼

我們通過setData來模擬使用者在兩個input框內輸入了資料。然後通過wrapper.vm.loginToDo()來顯式呼叫loginTodo的方法。由於我們返回的是一個Promise物件,所以可以用async await將resolve裡的資料拿出來。然後測試是否和預期相符。我們這次是測試了輸入錯誤的情況,測試通過,沒有問題。那如果我接下去要再測試使用者密碼都通過的測試怎麼辦?我們mockaxiospost方法只有一個,難不成還能一個方法輸出多種結果?下一節來詳細說明這個問題。

建立複雜行為測試

回顧一下我們的mock寫法:

jest.mock('axios', () => ({
  post: jest.fn(() => Promise.resolve({
    data: {
      success: false,
      info: '使用者不存在!'
    }
  }))
}))複製程式碼

可以看到,採用這種寫法的話,post請求始終只能返回一種結果。如何做到既能mock這個post方法又能實現多種結果測試?接下去就要用到Jest另一個殺手鐗的方法:mockImplementationOnce。官方的示例如下:

const myMockFn = jest.fn(() => 'default')
  .mockImplementationOnce(() => 'first call')
  .mockImplementationOnce(() => 'second call');

console.log(myMockFn(), myMockFn(), myMockFn(), myMockFn());
// > 'first call', 'second call', 'default', 'default'複製程式碼

4次呼叫同一個方法卻能給出不同的執行結果。這正是我們想要的。

於是在我們測試登入成功這個方法的時候我們需要改寫一下我們對axios的mock方法:

jest.mock('axios', () => ({
  post: jest.fn()
        .mockImplementationOnce(() => Promise.resolve({
          data: {
            success: false,
            info: '使用者不存在!'
          }
        }))
        .mockImplementationOnce(() => Promise.resolve({
          data: {
            success: true,
            token: 'xxx' // 隨意返回一個token
          }
        }))
}))複製程式碼

然後開始寫我們的測試:

test('Succeeded to login if typing the correct account & password', async () => {
  wrapper.setData({
    account: 'molunerfinn',
    password: '123'
  })
  const result = await wrapper.vm.loginToDo()
  expect(result.data.success).toBe(true)
})複製程式碼

就在我認為跟之前的測試沒有什麼兩樣的時候,報錯傳來了。先來看看當success為true的時候,loginToDo在做什麼:

if (res.data.success) { // 如果成功
  sessionStorage.setItem('demo-token', res.data.token) // 用sessionStorage把token存下來
  this.$message({ // 登入成功,顯示提示語
    type: 'success',
    message: '登入成功!'
  })
  this.$router.push('/todolist') // 進入todolist頁面,登入成功
}複製程式碼

很快我就看到了錯誤所在:我們的測試環境裡並沒有sessionStorage這個原本應該在瀏覽器端的東西。以及我們並沒有使用vue-router,所以就無法執行this.$router.push()這個方法。

關於前者,很容易找到問題解決辦法

首先安裝一下mock-local-storage這個庫(也包括了sessionStorage)

yarn add mock-local-storage -D

#or

npm install mock-local-storage --save-dev複製程式碼

然後配置一下package.json裡的jest引數:

"jest": {
  // ...
  "setupTestFrameworkScriptFile": "mock-local-storage"
}複製程式碼

對於後者,閱讀過官方的建議,我們不應該引入vue-router,這樣會破壞我們的單元測試。相應的,我們可以mock它。不過這次是用vue-test-utils自帶的mocks特性了:

const $router = { // 宣告一個$router物件
  push: jest.fn()
}

beforeEach(() => {
  wrapper = mount(Login, {
    mocks: {
      $router // 在beforeEach鉤子裡掛載進mount的mocks裡。
    }
  })
})複製程式碼

通過這個方式,會把$router這個物件掛載到例項的prototype上,就能實現在元件內部通過this.$router.push()的方式來呼叫了。

上述兩個問題解決之後,我們的測試也順利通過了:

接下去開始測試Todolist.vue這個元件了。

Todolist.vue的測試

鍵盤事件測試以及隱式事件觸發

類似的我們在test/client目錄下建立一個叫做todolist.spec.js的檔案。

先把上例中的一些環境先預置進來:

import Vue from 'vue'
import elementUI from 'element-ui'
import { mount } from 'vue-test-utils'
import Todolist from '../../src/components/Todolist.vue'
import axios from 'axios'

Vue.use(elementUI)

jest.mock(...) // 後續補充

Vue.prototype.$http = axios

let wrapper

beforeEach(() => {
  wrapper = mount(Todolist)
  wrapper.setData({
    name: 'Molunerfinn', // 預置資料
    id: 2
  })
})複製程式碼

先來個簡單的,測試資料是否正確:

// test 1
test('Should get the right username & id', () => {
  expect(wrapper.vm.name).toBe('Molunerfinn')
  expect(wrapper.vm.id).toBe(2)
})複製程式碼

不過需要注意的是,todolist這個頁面在created階段就會觸發getUserInfogetTodolist這兩個方法,而我們的wrapper是相當於在mounted階段之後的。所以在我們拿到wrapper的時候,createdmounted等生命週期的鉤子其實已經執行了。本例裡getUserInfo是從sessionStorage裡取值,不涉及ajax請求。但是getTodolist涉及請求,因此需要在jest.mock方法裡為其配置一下,否則將會報錯:

jest.mock('axios', () => ({
  get: jest.fn()
        // for test 1
        .mockImplementationOnce(() => Promise.resolve({
          status: 200,
          data: {
            result: []
          }
        }))
}))複製程式碼

上面說到的getTodolistgetUserInfo就是在測試中需要注意的隱式事件,它們並不受你測試的控制就在元件裡觸發了。

接下來開始進行鍵盤事件測試。其實跟滑鼠事件類似,鍵盤事件的觸發也是以事件名來命名的。不過對於一些常見的事件,vue-test-utils裡給出了一些別名比如:

enter, tab, delete, esc, space, up, down, left, right。你在書寫測試的時候可以直接這樣:

const input = wrapper.find('.el-input')
input.trigger('keyup.enter')複製程式碼

當然如果你需要指定某個鍵也是可以的,只需要提供keyCode就行:

const input = wrapper.find('.el-input')
input.trigger('keyup', {
  which: 13 // enter的keyCode為13
})複製程式碼

於是我們把這個測試完善一下,這個測試是測試當我在輸入框啟用的情況下按下Enter鍵能否觸發addTodos這個事件:

test('Should trigger addTodos when typing the enter key', () => {
  const stub = jest.fn()
  wrapper.setMethods({
    addTodos: stub
  })
  const input = wrapper.find('.el-input')
  input.trigger('keyup.enter')
  expect(stub).toBeCalled()
})複製程式碼

沒有問題,一次通過。

注意到我們在實際開發時,在元件上呼叫原生事件是需要加.native修飾符的:

<el-input placeholder="請輸入待辦事項" v-model="todos" @keyup.enter.native="addTodos"></el-input>複製程式碼

但是在vue-test-utils裡你是可以直接通過原生的keyup.enger來觸發的。

wrapper.update()的使用

很多時候我們要跟非同步打交道。尤其是非同步取值,非同步賦值,頁面非同步更新。而對於使用Vue來做的實際開發來說,非同步的情況簡直太多了。

還記得nextTick麼?很多時候,我們要獲取一個變更的資料結果,不能直接通過this.xxx獲取,相應的我們需要在this.$nextTick()裡獲取。在測試裡我們也會遇到很多需要非同步獲取的情況,但是我們不需要nextTick這個辦法,相應的我們可以通過async await配合wrapper.update()來實現元件更新。例如下面這個測試新增todo成功的例子:

test('Should add a todo if handle in the right way', async () => {
  wrapper.setData({
    todos: 'Test',
    stauts: '0',
    id: 1
  })

  await wrapper.vm.addTodos()
  await wrapper.update()
  expect(wrapper.vm.list).toEqual([
    {
      status: '0',
      content: 'Test',
      id: 1
    }
  ])
})複製程式碼

在本例中,從進頁面到新增一個todo並顯示出來需要如下步驟:

  1. getUserInfo -> getTodolist
  2. 輸入todo並敲擊回車
  3. addTodos -> getTodolist
  4. 顯示新增的todo

可以看到總共有3個ajax請求。其中第一步不在我們test()的範圍內,2、3、4都是我們能控制的。而addTodos和getTodolist這兩個ajax請求帶來的就是非同步的操作。雖然我們mock方法,但是本質上是返回了Promise物件。所以還是需要用await來等待。

注意你在jest.mock()裡要加上相應的mockImplementationOnce的get和post請求。

所以第一步await wrapper.vm.addTodos()就是等待addTodos()的返回。
第二步await wrapper.update()實際是在等待getTodolist的返回。

缺一不可。兩步等待之後我們就可以通過斷言資料list的方式測試我們是否拿到了返回的todo的資訊。

接下去的就是對todo的一些增刪改查的操作,採用的測試方法已經和前文所述相差無幾,不再贅述。至此所有的獨立測試用例的說明就說完了。看看這測試通過的成就感:

不過在測試中我還有關於除錯的一些經驗想分享一下,配合除錯能更好的判斷我們的測試的時候發生的不可預知的問題所在。

用VSCode來除錯測試

由於我自己是使用VSCode來做的開發和除錯,所以一些用其他IDE或者編輯器的朋友們可能會有所失望。不過沒關係,可以考慮加入VSCode陣營嘛!

本文撰寫的時候採用的nodejs版本為8.9.0,VSCode版本為1.18.0,所以所有的debug測試的配置僅保證適用於目前的環境。其他環境的可能需要自行測試一下,不再多說。

關於jest的除錯的配置如下:(注意配置路徑為VScode關於本專案的.vscode/launch.json

{
  // Use IntelliSense to learn about possible Node.js debug attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug Jest",
      "type": "node",
      "request": "launch",
      "program": "${workspaceRoot}/node_modules/jest-cli/bin/jest.js",
      "stopOnEntry": false,
      "args": [
        "--runInBand",
        "--forceExit"
      ],
      "cwd": "${workspaceRoot}",
      "preLaunchTask": null,
      "runtimeExecutable": null,
      "runtimeArgs": [
        "--nolazy"
      ],
      "env": {
        "NODE_ENV": "test"
      },
      "console": "integratedTerminal",
      "sourceMaps": true
    }
  ]
}複製程式碼

配置完上面的配置之後,你可以在DEBUG皮膚裡(不要跟我說你不知道什麼是DEBUG皮膚~)找到名為Debug Jest的選項:

然後你可以在你的測試檔案裡打斷點了:

然後執行debug模式,按那個綠色啟動按鈕,就能進入DEBUG模式,當執行到斷點處就會停下:

於是你可以在左側皮膚的LocalClosure裡找到當前作用域下你所需要的變數值、變數型別等等。充分運用VSCode的debug模式,開發的時候查錯和除錯的效率都會大大加大。

總結

本文用了很大的篇幅描述瞭如何搭建一個Jest測試環境,並在測試過程中不斷完善我們的測試環境。講述了Koa後端測試的方法和測試覆蓋率的提高,講述了Vue前端單元測試環境的搭建以及許多相應的測試例項,以及在測試過程中不停地遇到問題並解決問題。能夠看到此處的都不是一般有耐心的人,為你們鼓掌~也希望你們通過這篇文章能過對本文在開頭提出的幾個重點在心中有所體會和感悟:

可以瞭解到測試的意義,Jest測試框架的搭建,前後端測試的異同點,如何寫測試用例,如何檢視測試結果並提升我們的測試覆蓋率,100%測試覆蓋率是否是必須,以及在搭建測試環境、以及測試本身過程中遇到的各種疑難雜症。

本文所有的測試用例以及整體專案例項你都可以在我的vue-koa-demo的github專案中找到原始碼。如果你喜歡我的文章以及專案,歡迎點個star~如果你對我的文章和專案有任何建議或者意見,歡迎在文末評論或者在本專案的issues跟我探討!

本文首發於我的部落格,歡迎踩點~

參考連結

Koa相關

Supertest搭配koa報錯

測試完自動退出

Async testing Koa with Jest

How to use Jest to test Express middleware or a funciton which consumes a callback?

A clear and concise introduction to testing Koa with Jest and Supertest

Debug jest with vscode

Test port question
Coverage bug

Eaddrinuse bug

Istanbul ignore

Vue相關

vue-test-utils

Test Methods and Mock Dependencies in Vue.js with Jest

Storage problem

相關文章