本文將分享結合 京東智能設計平臺羚瓏 項目自身狀況搭建的測試工做流的實踐經驗,針對於 Node.js 服務端應用的工具方法和接口的單元測試、集成測試等。實踐經驗能給你帶來:javascript
文中涉及的基礎技術棧有(須要瞭解的知識):html
羚瓏 是京東旗下智能設計平臺,提供在線設計服務,主要包括大類如:前端
基於行業領先技術,爲商家、用戶提供豐富的設計能力,實現快速產出。java
先介紹下羚瓏項目的架構,方便後續的描述和理解。羚瓏項目採用先後端分離的機制,前端採用 React Family 的基礎架構,再加上 Next.js 服務端渲染以提供更好的用戶體驗及 SEO 排名。後端架構則以下圖所示,流程大概是 瀏覽器或第三方應用訪問項目 Nginx 集羣,Nginx 集羣再經過負載均衡轉發到羚瓏應用服務器,應用服務器再經過對接外部服務或內部服務等,或讀寫緩存、數據庫,邏輯處理後經過 HTTP 返回到前端正確的數據。node
接下來,根據項目所需咱們對比下當下 Node.js 端主流的測試框架。git
Jest | Mocha | AVA | Jasmine | |
---|---|---|---|---|
GitHub Stars | 28.5K | 18.7K | 17.1K | 14.6K |
GitHub Used by | 1.5M | 926K | 46.6K | 5.3K |
文檔友好 | 優秀 | 良好 | 良好 | 良好 |
模擬功能(Mock) | 支持 | 外置 | 外置 | 外置 |
快照功能(Snapshot) | 支持 | 外置 | 支持 | 外置 |
支持 TypeScript | ts-jest | ts-mocha | ts-node | jasmine-ts |
詳細的錯誤輸出 | 支持 | 支持 | 支持 | 未知 |
支持並行與串行 | 支持 | 外置 | 支持 | 外置 |
每一個測試進程隔離 | 支持 | 不支持 | 支持 | 未知 |
*文檔友好:文檔結構組織有序,API 闡述完整,以及示例豐富。github
分析:mongodb
綜上,咱們選擇了 Jest 做爲基礎測試框架。數據庫
接下來,咱們從 0 到 1 開始實踐,首先是搭建測試流,雖然 Jest 能夠達到開箱即用,然而項目架構不盡相同,大多時候須要根據實際狀況作些基礎配置工做。如下是根據羚瓏項目提取出來的簡化版項目目錄結構,以下。json
├─ dist # TS 編譯結果目錄 ├─ src # TS 源碼目錄 │ ├─ app.ts # 應用主文件,相似 Express 框架的 /app.js 文件 │ └─ index.ts # 應用啓動文件,相似 Express 框架的 /bin/www 文件 ├─ test # 測試文件目錄 │ ├─ @fixtures # 測試固定數據 │ ├─ @helpers # 測試工具方法 │ ├─ module1 # 模塊1的測試套件集合 │ │ └─ test-suite.ts # 測試套件,一類測試用例集合 │ └─ module2 # 模塊2的測試套件集合 ├─ package.json └─ yarn.lock
這裏有兩個小點:
@
開頭的目錄,咱們定義爲特殊文件目錄,用於提供些測試輔助工具方法、配置文件等,平級的其餘目錄則是測試用例所在的目錄,按業務模塊或功能劃分。以 @
開頭能夠清晰的顯示在同級目錄最上方,很容易開發定位,湊巧也方便了編寫正則匹配。test-suite.ts
是項目內最小測試文件單元,咱們稱之爲測試套件,表示同一類測試用例的集合,能夠是某個通用函數的多個測試用例集合,也能夠是一個系列的單元測試用例集合。首先安裝測試框架。
yarn add --dev jest ts-jest @types/jest
由於項目是用 TypeScript 編寫,因此這裏同時安裝 ts-jest @types/jest
。而後在根目錄新建 jest.config.js
配置文件,並作以下小許配置。
module.exports = { // preset: 'ts-jest', globals: { 'ts-jest': { tsConfig: 'tsconfig.test.json', }, }, testEnvironment: 'node', roots: ['<rootDir>/src/', '<rootDir>/test/'], testMatch: ['<rootDir>/test/**/*.ts'], testPathIgnorePatterns: ['<rootDir>/test/@.+/'], moduleNameMapper: { '^~/(.*)': '<rootDir>/src/$1', }, }
preset: 預設測試運行環境,多數狀況設置爲 ts-jest
便可,若是須要爲 ts-jest
指定些參數,如上面指定 TS 配置爲 tsconfig.test.json
,則須要像上面這樣的寫法,將 ts-jest
掛載到 globals
屬性上,更多配置能夠移步其官方文檔,這裏。
testEnvironment: 基於預設再設置測試環境,Node.js 須要設置爲 node
,由於默認值爲瀏覽器環境 jsdom
。
roots: 用於設定測試監聽的目錄,若是匹配到的目錄的文件有所改動,就會自動運行測試用例。<rootDir>
表示項目根目錄,即與 package.json
同級的目錄。這裏咱們監聽 src
和 test
兩個目錄。
testMatch: Glob
模式設置匹配的測試文件,固然也能夠是正則模式,這裏咱們匹配 test
目錄下的全部文件,匹配到的文件纔會當作測試用例執行。
testPathIgnorePatterns: 設置已經匹配到的但須要被忽略的文件,這裏咱們設置以 @
開頭的目錄及其全部文件都不當作測試用例。
moduleNameMapper: 這個與 TS paths
和 Webpack alias
雷同,用於設置目錄別名,能夠減小引用文件時的出錯率而且提升開發效率。這裏咱們設置以 ~
開頭的模塊名指向 src
目錄。
搭建好測試運行環境,因而即可着手編寫測試用例了,下面咱們編寫一個接口單元測試用例,比方說測試首頁輪播圖接口的正確性。咱們將測試用例放在 test/homepage/carousel.ts
文件內,代碼以下。
import { forEach, isArray } from 'lodash’ import { JFSRegex, URLRegex } from '~/utils/regex' import request from 'request-promise' const baseUrl = 'http://ling-dev.jd.com/server/api' // 聲明一個測試用例 test('輪播圖個數應該返回 5,而且數據正確', async () => { // 對接口發送 HTTP 請求 const res = await request.get(baseUrl + '/carousel/pictures') // 校驗返回狀態碼爲 200 expect(res.statusCode).toBe(200) // 校驗返回數據是數組而且長度爲 5 expect(isArray(res.body)).toBe(true) expect(res.body.length).toBe(5) // 校驗數據每一項都是包含正確的 url, href 屬性的對象 forEach(res.body, picture => { expect(picture).toMatchObject({ url: expect.stringMatching(JFSRegex), href: expect.stringMatching(URLRegex), }) }) })
編寫好測試用例後,第一步須要啓動應用服務器:
第二步運行測試,在命令行窗口輸入:npx jest
,以下圖能夠看到用例測試經過。
固然最佳實踐則是把命令封裝到 package.json
裏,以下:
{ "scripts": { "test": "jest", "test:watch": "jest --watch", } }
以後即可使用 yarn test
來運行測試,經過 yarn test:watch
來啓動監聽式測試服務。
雖然上面已經完成基本的測試流程開發,但很明顯的一個問題是每次運行測試,咱們須要先啓動應用服務,共啓動兩個進程,而且須要提早配置 ling-dev.jd.com
指向 127.0.0.1:3800
,這是一個繁瑣的過程。因此咱們引入了 SuperTest
,它能夠把應用服務集成到測試服務一塊兒啓動,而且不須要指定 HTTP 請求的主機地址。
咱們封裝一個公共的 request
方法,將它放在 @helpers/agent.ts
文件內,以下。
import http from 'http' import supertest from 'supertest' import app from '~/app' export const request = supertest(http.createServer(app.callback()))
解釋:
app.callback()
而不是 app.listen()
,是由於它能夠將同一個 app
同時做爲 HTTP 和 HTTPS 或多個地址。app.callback()
返回適用於 http.createServer()
方法的回調函數來處理請求。http.createServer()
建立一個未監聽的 HTTP 對象給 SuperTest,固然 SuperTest 內部也會調用 listen(0)
這樣的特殊端口,讓操做系統提供可用的隨機端口來啓動應用服務器。因此上面的測試用例咱們能夠改寫成這樣:
import { forEach, isArray } from 'lodash’ import { JFSRegex, URLRegex } from '~/utils/regex' // 引入公共的 request 方法 import { request } from '../@helpers/agent' test('輪播圖個數應該返回 5,而且數據正確', async () => { const res = await request.get('/api/carousel/pictures') expect(res.status).toBe(200) // 一樣的校驗... })
由於 SuperTest
內部已經幫咱們包裝好了主機地址並自動啓動應用服務,因此請求接口時只需書寫具體的接口,如 /api/carousel/pictures
,也只需運行一條命令 yarn test
,就能夠完成整個測試工做。
項目架構中能夠看到數據庫使用的是 MongoDB,在測試時,幾乎全部的接口都須要與數據庫鏈接。此時可經過環境變量區分並新建 test 數據庫,用於運行測試用例。有點很差的是測試套件執行完成後須要對 test 數據庫進行清空,以免髒數據影響下個測試套件,尤爲是在併發運行時,須要保持數據隔離。
使用 MongoDB Memory Server
是更好的選擇,它會啓動獨立的 MongoDB 實例(每一個實例大約佔用很是低的 7MB 內存),而測試套件將運行在這個獨立的實例裏。假如併發爲 3,那就建立 3 個實例分別運行 3 個測試套件,這樣能夠很好的保持數據隔離,而且數據都保存在內存中,這使得運行速度會很是快,當測試套件完成後則自動銷燬實例。
接下來咱們把 MongoDB Memory Server
引入實際測試中,最佳方式是把它寫進 Jest 環境配置裏,這樣只須要一次書寫,自動運行在每一個測試套件中。因此替換 jest.config.js
配置文件的 testEnvironment
爲自定義環境 <rootDir>/test/@helpers/jest-env.js
。
編寫自定義環境 @helpers/jest-env.js
:
const NodeEnvironment = require('jest-environment-node') const { MongoMemoryServer } = require('mongodb-memory-server') const child_process = require('child_process') // 繼承 Node 環境 class CustomEnvironment extends NodeEnvironment { // 在測試套件啓動前,獲取本地開發 MongoDB Uri 並注入 global 對象 async setup() { const uri = await getMongoUri() this.global.testConfig = { mongo: { uri }, } await super.setup() } } async function getMongoUri() { // 經過 which mongod 命令拿到本地 MongoDB 二進制文件路徑 const mongodPath = await new Promise((resolve, reject) => { child_process.exec( 'which mongod', { encoding: 'utf8' }, (err, stdout, stderr) => { if (err || stderr) { return reject( new Error('找不到系統的 mongod,請確保 `which mongod` 能夠指向 mongod') ) } resolve(stdout.trim()) } ) }) // 使用本地 MongoDB 二進制文件建立內存服務實例 const mongod = new MongoMemoryServer({ binary: { systemBinary: mongodPath }, }) // 獲得建立成功的實例 Uri 地址 const uri = await mongod.getConnectionString() return uri } // 導出自定義環境類 module.exports = CustomEnvironment
Mongoose 中即可以這樣鏈接:
await mongoose.connect((global as any).testConfig.mongo.uri, { useNewUrlParser: true, useUnifiedTopology: true, })
固然在 package.json
裏須要禁用 MongoDB Memory Server
去下載二進制包,由於上面已經使用了本地二進制包。
"config": { "mongodbMemoryServer": { "version": "4.0", // 禁止在 yarn install 時下載二進制包 "disablePostinstall": "1", "md5Check": "1" } }
大多時候接口是須要登陸後才能訪問的,因此咱們須要把整塊登陸功能抽離出來,封裝成通用方法,同時藉此初始化一些測試專用數據。
爲了使 API 易用,我但願登陸 API 長這樣:
import { login } from '../@helpers/login' // 調用登陸方法,根據傳遞的角色建立用戶,並返回該用戶登陸的 request 對象。 // 支持多參數,根據參數不一樣自動初始化測試數據。 const request = await login({ role: 'user', }) // 使用已登陸的 request 對象訪問須要登陸的用戶接口, // 應當是登陸態,並正確返回當前登陸的用戶信息。 const res = await request.get('/api/user/info')
開發登陸方法:
// @helpers/agent.ts // 新添加 makeAgent 方法 export function makeAgent() { // 使用 supertest.agent 支持 cookie 持久化 return supertest.agent(http.createServer(app.callback())) }
// @helpers/login.ts import { assign, cloneDeep, pick } from 'lodash' import { makeAgent } from './agent' export async function login(userData: UserDataType): Promise<RequestAgent> { userData = cloneDeep(userData) // 若是沒有用戶名,自動建立用戶名 if (!userData.username) { userData.username = chance.word({ length: 8 }) } // 若是沒有暱稱,自動建立暱稱 if (!userData.nickname) { userData.nickname = chance.word({ length: 8 }) } // 獲得支持 cookie 持久化的 request 對象 const request: any = makeAgent() // 發送登陸請求,這裏爲測試專門設計一個登陸接口 // 包含正常登陸功能,但還會根據傳參不一樣初始化測試專用數據 const res = await request.post('/api/login-test').send(userData) // 將登陸返回的數據賦值到 request 對象上 assign(request, pick(res.body, ['user', 'otherValidKey...'])) // 返回 request 對象 return request as RequestAgent }
實際用例中就像上面示例方式使用。
從項目架構中能夠看到項目也會調用較多外部服務。比方說建立文件夾的接口,內部代碼須要調用外部服務去鑑定文件夾名稱是否包含敏感詞,就像這樣:
import { detectText } from '~/utils/detect' // 調用外部服務檢測文件夾名稱是否包含敏感詞 const { ok, sensitiveWords } = await detectText(folderName) if (!ok) { throw new Error(`檢測到敏感詞: ${sensitiveWords}`) }
實際測試的時候並不須要全部測試用例運行時都調用外部服務,這樣會拖慢測試用例的響應時間以及不穩定性。咱們能夠創建個更好的機制,新建一個測試套件專門用於驗證 detectText
工具方法的正確性,而其餘測試套件運行時 detectText
方法直接返回 OK 便可,這樣既保證了 detectText
方法被驗證到,也保證了其餘測試套件獲得快速響應。
模擬功能(Mock)就是爲這樣的情景而誕生的。咱們只須要在 detectText
方法的路徑 utils/detect.ts
同級新建__mocks__/detect.ts
模擬文件便可,內容以下,直接返回結果:
export async function detectText( text: string ): Promise<{ ok: boolean; sensitive: boolean; sensitiveWords?: string }> { // 刪除全部代碼,直接返回 OK return { ok: true, sensitive: false } }
以後每一個須要模擬的測試套件頂部加上下面一句代碼便可。
jest.mock('~/utils/detect.ts')
在驗證 detectText
工具方法的測試套件裏,則只需 jest.unmock
便可恢復真實的方法。
jest.unmock('~/utils/detect.ts')
固然應該把 jest.mock
寫在 setupFiles
配置裏,由於須要模擬的測試套件佔絕大多數,寫在配置裏會讓它們在運行前自動加載該文件,這樣開發就沒必要每處測試套件都加上一段一樣的代碼,能夠有效提升開發效率。
// jest.config.js setupFiles: ['<rootDir>/test/@helpers/jest-setup.ts']
// @helpers/jest-setup.ts jest.mock('~/utils/detect.ts')
模擬功能還有方法模擬,定時器模擬等,能夠查閱其文檔瞭解更多示例。
快照功能(Snapshot)
能夠幫咱們測試大型對象,從而簡化測試用例。
舉個例子,項目的模板解析接口,該接口會將 PSD 模板文件進行解析,而後吐出一個較大的 JSON 數據,若是挨個校驗對象的屬性是否正確可能很不理想,因此可使用快照功能,就是第一次運行測試用例時,會把 JSON 數據存儲到本地文件,稱之爲快照文件,第二次運行時,就會將第二次返回的數據與快照文件進行比較,若是兩個快照匹配,則表示測試成功,反之測試失敗。
而使用方式很簡單:
// 請求模板解析接口 const res = await request.post('/api/secret/parser') // 斷言快照是否匹配 expect(res.body).toMatchSnapshot()
更新快照也是敏捷的,運行命令 jest --updateSnapshot
或在監聽模式輸入 u
來更新。
集成測試的概念是在單元測試的基礎上,將全部模塊按照必定要求或流程關係進行串聯測試。比方說,一些模塊雖然可以單獨工做,但並不能保證鏈接起來也能正常工做,一些局部反映不出來的問題,在全局上極可能暴露出來。
由於測試框架 Jest 對於每一個測試套件是並行運行的,而套件內的用例則是串行運行的,因此編寫集成測試很方便,下面咱們用文件夾的使用流程示例如何完成集成測試的編寫。
import { request } from '../@helpers/agent' import { login } from '../@helpers/login' const urlCreateFolder = '/api/secret/folder' // POST const urlFolderDetails = '/api/secret/folder' // GET const urlFetchFolders = '/api/secret/folders' // GET const urlDeleteFolder = '/api/secret/folder' // DELETE const urlRenameFolder = '/api/secret/folder/rename' // PUT const folders: ObjectAny[] = [] let globalReq: ObjectAny test('沒有權限建立文件夾應該返回 403 錯誤', async () => { const res = await request.post(urlCreateFolder).send({ name: '個人文件夾', }) expect(res.status).toBe(403) }) test('確保建立 3 個文件夾', async () => { // 登陸有權限建立文件夾的用戶,好比設計師 globalReq = await login({ role: 'designer' }) for (let i = 0; i < 3; i++) { const res = await globalReq.post(urlCreateFolder).send({ name: '個人文件夾' + i, }) // 將建立成功的文件夾置入 folders 常量裏 folders.push(res.body) expect(res.status).toBe(200) // 更多驗證規則... } }) test('重命名第 2 個文件夾', async () => { const res = await globalReq.put(urlRenameFolder).send({ id: folders[1].id, name: '新文件夾名稱', }) expect(res.status).toBe(200) }) test('第 2 個文件夾的名稱應該是【新文件夾名稱】', async () => { const res = await globalReq.get(urlFolderDetails).query({ id: folders[1].id, }) expect(res.status).toBe(200) expect(res.body.name).toBe('新文件夾名稱') // 更多驗證規則... }) test('獲取文件夾列表應該返回 3 條數據', async () => { // 與上雷同,鑑於代碼過多,先行省略... }) test('刪除最後一個文件夾', async () => { // 與上雷同,鑑於代碼過多,先行省略... }) test('再次獲取文件夾列表應該返回 2 條數據', async () => { // 與上雷同,鑑於代碼過多,先行省略... })
測試覆蓋率是對測試完成程度的評測,基於文件被測試的狀況來反饋測試的質量。
運行命令 jest --coverage
便可生成測試覆蓋率報告,打開生成的 coverage/lcov-report/index.html
文件,各項指標盡收眼底。由於 Jest 內部使用 Istanbul 生成覆蓋率報告,因此各項指標依然參考 Istanbul。
寫完這麼多測試用例以後,或者是開發完功能代碼後,咱們是否是但願每次將代碼推送到託管平臺,如 GitLab,託管平臺能自動幫咱們運行全部測試用例,若是測試失敗就郵件通知咱們修復,若是測試經過則把開發分支合併到主分支?
答案是必須的。這就與持續集成(Continuous Integration)不謀而合,通俗的講就是常常性地將代碼合併到主幹分支,每次合併前都須要運行自動化測試以驗證代碼的正確性。
因此咱們配置一些自動化測試任務,按順序執行安裝、編譯、測試等命令,測試命令則是運行編寫好的測試用例。一個 GitLab 的配置任務(.gitlab-ci.yml
)可能像下面這樣,僅做參考。
# 每一個 job 以前執行的命令 before_script: - echo "`whoami` ($0 $SHELL)" - echo "`which node` (`node -v`)" - echo $CI_PROJECT_DIR # 定義 job 所屬 test 階段及執行的命令等 test: stage: test except: - test cache: paths: - node_modules/ script: - yarn - yarn lint - yarn test # 定義 job 所屬 deploy 階段及執行的命令等 deploy-stage: stage: deploy only: - test script: - cd /app - make BRANCH=origin/${CI_COMMIT_REF_NAME} deploy-stage
持續集成的好處:
TDD 全稱測試驅動開發(Test-driven development),是敏捷開發中的一種設計方法論,強調先將需求轉換爲具體的測試用例,而後再開發代碼以使測試經過。
BDD 全稱行爲驅動開發(Behavior-driven development),也是一種敏捷開發設計方法論,它沒有強調具體的形式如何,而是強調【做爲何角色,想要什麼功能,以便收益什麼】這樣的用戶故事指定行爲的論點。
二者都是很好的開發模式,結合實際狀況,咱們的測試更像是 BDD,不過並無徹底摒棄 TDD,咱們的建議是若是以爲先寫測試能夠幫助更快的寫好代碼,那就先寫測試,若是以爲先寫代碼再寫測試,或一邊開發一邊測試更好,則採用本身的方式,而結果是編碼功能和測試用例都須要完成,而且運行經過,最後經過 Code Review 對代碼質量作進一步審查與把控。
筆者稱之爲【師夷長技,聚於自身】:結合項目自身的實際狀況,靈活變通,造成一套適合自身項目發展的模式驅動開發。
自動化測試提供了一種有保障的機制檢測整個系統,能夠頻繁地進行迴歸測試,有效提升系統穩定性。固然編寫與維護測試用例須要耗費必定的成本,須要考慮投入與產出效益之間的平衡。
歡迎關注凹凸實驗室博客:aotu.io
或者關注凹凸實驗室公衆號(AOTULabs),不定時推送文章: