測試根據是否涉及軟件功能,分爲 功能性測試 與 非功能性測試,前者包括單元測試、集成測試、系統測試、接口測試、迴歸測試、驗收測試,後者包括文檔測試、安裝測試、性能測試、可靠性測試、安全性測試。功能性測試驗證了功能邏輯自己是否正確,非功能性測試驗證的是除功能以外的邏輯。測試是軟件用戶信心的重要來源,而自動化測試就是創建這種信心的最高效手段,結合 CI 能夠在代碼發生變動的每一個時刻自動執行測試保障工程的高質量。html
本章將基於上一章已完成的工程 host1-tech/nodejs-server-examples - 13-debugging-and-profiling 使用 jest、benchmark 爲店鋪管理加上關鍵的功能性與非功能性自動化測試,在工程根目錄執行相關模塊安裝命令:node
$ yarn add -D jest supertest execa benchmark beautify-benchmark # 本地安裝 jest、supertest、benchmark、beautify-benchmark、execa # ... info Direct dependencies ├─ beautify-benchmark@0.2.4 ├─ benchmark@2.1.4 ├─ execa@4.0.3 ├─ jest@26.4.0 └─ supertest@4.0.2 # ...
如今就店鋪管理的關鍵用例編寫功能測試:git
$ mkdir tests # 新建 tests 存放測試配置腳本 $ tree src -L 1 # 展現當前目錄內容結構 . ├── Dockerfile ├── database ├── node_modules ├── package.json ├── public ├── scripts ├── src ├── tests └── yarn.lock
// tests/globalSetup.js const { commandSync } = require('execa'); module.exports = () => { commandSync('yarn sequelize db:migrate'); };
// jest.config.js module.exports = { globalSetup: '<rootDir>/tests/globalSetup.js', };
// src/config/index.js // ... const config = { // ... // 測試配置 test: { db: { logging: false, + storage: 'database/test.db', }, }, // ... }; // ...
// package.json { "name": "13-debugging-and-profiling", "version": "1.0.0", "scripts": { "start": "node -r ./scripts/env src/server.js", "start:inspect": "cross-env CLUSTERING='' node --inspect-brk -r ./scripts/env src/server.js", "start:profile": "cross-env CLUSTERING='' 0x -- node -r ./scripts/env src/server.js", "start:prod": "cross-env NODE_ENV=production node -r ./scripts/env src/server.js", + "test": "jest", "sequelize": "sequelize", "sequelize:prod": "cross-env NODE_ENV=production sequelize", "build:yup": "rollup node_modules/yup -o src/moulds/yup.js -p @rollup/plugin-node-resolve,@rollup/plugin-commonjs,rollup-plugin-terser -f umd -n 'yup'" }, // ... }
// src/controllers/shop.test.js const supertest = require('supertest'); const express = require('express'); const { commandSync } = require('execa'); const shopController = require('./shop'); const { Shop } = require('../models'); describe('controllers/shop', () => { const seed = '20200725050230-first-shop.js'; let server; beforeAll(async () => { commandSync(`yarn sequelize db:seed --seed ${seed}`); server = express().use(await shopController()); }); afterAll(() => commandSync(`yarn sequelize db:seed:undo --seed ${seed}`)); describe('GET /', () => { it('should get shop list', async () => { const pageIndex = 0; const pageSize = 10; const shopCount = await Shop.count({ offset: pageIndex * pageSize }); const res = await supertest(server).get('/'); expect(res.status).toBe(200); const { success, data } = res.body; expect(success).toBe(true); expect(data).toHaveLength(Math.min(shopCount, pageSize)); }); }); describe('GET /:shopId', () => { it('should get shop info', async () => { const shop = await Shop.findOne(); const res = await supertest(server).get(`/${shop.id}`); expect(res.status).toBe(200); const { success, data } = res.body; expect(success).toBe(true); expect(data.name).toBe(shop.name); }); }); describe('PUT /:shopId', () => { it('should update if proper shop info give', async () => { const shop = await Shop.findOne(); const shopName = '美珍香'; const res = await supertest(server).put( `/${shop.id}?name=${encodeURIComponent(shopName)}` ); expect(res.status).toBe(200); const { success, data } = res.body; expect(success).toBe(true); expect(data.name).toBe(shopName); }); it('should not update if shop info not valid', async () => { const shop = await Shop.findOne(); const shopName = ''; const res = await supertest(server).put( `/${shop.id}?name=${encodeURIComponent(shopName)}` ); expect(res.status).toBe(400); const { success, data } = res.body; expect(success).toBe(false); expect(data).toBeFalsy(); }); }); describe('POST /', () => { it('should create if proper shop info given', async () => { const oldShopCount = await Shop.count(); const shopName = '美珍香'; const res = await supertest(server).post('/').send(`name=${shopName}`); expect(res.status).toBe(200); const { success, data } = res.body; expect(success).toBe(true); expect(data.name).toBe(shopName); const newShopCount = await Shop.count(); expect(newShopCount - oldShopCount).toBe(1); }); it('should not create if shop info not valid', async () => { const shopName = ''; const res = await supertest(server).post('/').send(`name=${shopName}`); expect(res.status).toBe(400); const { success, data } = res.body; expect(success).toBe(false); expect(data).toBeFalsy(); }); }); describe('DELETE /:shopid', () => { it('should delete shop info', async () => { const oldShopCount = await Shop.count(); const shop = await Shop.findOne(); const res = await supertest(server).delete(`/${shop.id}`); expect(res.status).toBe(200); const { success } = res.body; expect(success).toBe(true); const newShopCount = await Shop.count(); expect(newShopCount - oldShopCount).toBe(-1); }); }); });
執行測試:github
$ yarn test src/controllers # 執行 src/controllers 目錄下的功能測試 # ... _FAIL_ src/controllers/shop.test.js controllers/shop GET / ✓ should get shop list (37 ms) GET /:shopId ✓ should get shop info (8 ms) PUT /:shopId ✓ should update if proper shop info give (21 ms) ✓ should not update if shop info not valid (11 ms) POST / ✓ should create if proper shop info given (20 ms) ✓ should not create if shop info not valid (4 ms) DELETE /:shopid ✕ should delete shop info (13 ms) ● controllers/shop › DELETE /:shopid › should delete shop info expect(received).toBe(expected) // Object.is equality Expected: true Received: {"createdAt": "2020-08-16T08:00:05.063Z", "id": 470, "name": "美珍香", "updatedAt": "2020-08-16T08:00:05.154Z"} 111 | 112 | const { success } = res.body; > 113 | expect(success).toBe(true); | ^ 114 | 115 | const newShopCount = await Shop.count(); 116 | expect(newShopCount - oldShopCount).toBe(-1); at Object.<anonymous> (src/controllers/shop.test.js:113:23) Test Suites: 1 failed, 1 total Tests: 1 failed, 6 passed, 7 total Snapshots: 0 total Time: 3.06 s Ran all test suites matching /src\/controllers/i. # ...
發現 controllers/shop › DELETE /:shopid › should delete shop info
執行失敗,根據提示優化邏輯,而後再次執行測試(也可使用 jest 的 --watch
參數自動從新執行):數據庫
// src/services/shop.js // ... class ShopService { // ... async remove({ id, logging }) { const target = await Shop.findByPk(id); if (!target) { return false; } - return target.destroy({ logging }); + return Boolean(target.destroy({ logging })); } // ... } // ...
$ yarn test src/controllers # 執行 src/controllers 目錄下的功能測試 # ... _PASS_ src/controllers/shop.test.js controllers/shop GET / ✓ should get shop list (39 ms) GET /:shopId ✓ should get shop info (9 ms) PUT /:shopId ✓ should update if proper shop info give (18 ms) ✓ should not update if shop info not valid (6 ms) POST / ✓ should create if proper shop info given (20 ms) ✓ should not create if shop info not valid (3 ms) DELETE /:shopid ✓ should delete shop info (9 ms) Test Suites: 1 passed, 1 total Tests: 7 passed, 7 total Snapshots: 0 total Time: 3.311 s Ran all test suites matching /src\/controllers/i. # ...
這樣就有了對店鋪管理功能最基本的自動化測試,考慮到 escapeHtmlInObject
方法的高頻使用,須要對此方法編寫功能測試用例:express
// src/utils/escape-html-in-object.test.js const escapeHtml = require('escape-html'); const escapeHtmlInObject = require('./escape-html-in-object'); describe('utils/escape-html-in-object', () => { it('should escape a string', () => { const input = `"'$<>`; expect(escapeHtmlInObject(input)).toEqual(escapeHtml(`"'$<>`)); }); it('should escape strings in object', () => { const input = { a: `"'$<>`, b: `<>$"'`, c: { d: `'"$><`, }, }; expect(escapeHtmlInObject(input)).toEqual({ a: escapeHtml(`"'$<>`), b: escapeHtml(`<>$"'`), c: { d: escapeHtml(`'"$><`), }, }); }); it('should escape strings in array', () => { const input = [`"'$<>`, `<>&"'`, [`'"$><`]]; expect(escapeHtmlInObject(input)).toEqual([ escapeHtml(`"'$<>`), escapeHtml(`<>&"'`), [escapeHtml(`'"$><`)], ]); }); it('should escape strings in object and array', () => { const input1 = { a: `"'$<>`, b: `<>$"'`, c: [`'"$><`, { d: `><&'"` }], }; expect(escapeHtmlInObject(input1)).toEqual({ a: escapeHtml(`"'$<>`), b: escapeHtml(`<>$"'`), c: [escapeHtml(`'"$><`), { d: escapeHtml(`><&'"`) }], }); const input2 = [`"'$<>`, `<>&"'`, { a: `'"$><`, b: [`><&'"`] }]; expect(escapeHtmlInObject(input2)).toEqual([ escapeHtml(`"'$<>`), escapeHtml(`<>&"'`), { a: escapeHtml(`'"$><`), b: [escapeHtml(`><&'"`)] }, ]); }); it('should keep none-string fields in object or array', () => { const input1 = { a: `"'$<>`, b: 1, c: null, d: true, e: undefined, }; expect(escapeHtmlInObject(input1)).toEqual({ a: escapeHtml(`"'$<>`), b: 1, c: null, d: true, e: undefined, }); const input2 = [`"'$<>`, 1, null, true, undefined]; expect(escapeHtmlInObject(input2)).toEqual([ escapeHtml(`"'$<>`), 1, null, true, undefined, ]); }); it('should convert sequelize model instance as plain object', () => { const input = { toJSON: () => ({ a: `"'$<>`, b: `<>$"'` }), }; expect(escapeHtmlInObject(input)).toEqual({ a: escapeHtml(`"'$<>`), b: escapeHtml(`<>$"'`), }); }); });
$ yarn test src/utils # 執行 src/utils 目錄下的功能測試 # ... _FAIL_ src/utils/escape-html-in-object.test.js utils/escape-html-in-object ✓ should escape a string (2 ms) ✓ should escape strings in object (1 ms) ✓ should escape strings in array ✓ should escape strings in object and array (1 ms) ✕ should keep none-string fields in object or array (3 ms) ✓ should convert sequelize model instance as plain object (1 ms) ● utils/escape-html-in-object › should keep none-string fields in object or array TypeError: Cannot convert undefined or null to object at Function.keys (<anonymous>) 16 | // } else if (input && typeof input == 'object') { 17 | const output = {}; > 18 | Object.keys(input).forEach((k) => { | ^ 19 | output[k] = escapeHtmlInObject(input[k]); 20 | }); 21 | return output; at escapeHtmlInObject (src/utils/escape-html-in-object.js:18:12) at forEach (src/utils/escape-html-in-object.js:19:19) at Array.forEach (<anonymous>) at escapeHtmlInObject (src/utils/escape-html-in-object.js:18:24) at Object.<anonymous> (src/utils/escape-html-in-object.test.js:64:12) Test Suites: 1 failed, 1 total Tests: 1 failed, 5 passed, 6 total Snapshots: 0 total Time: 1.027 s Ran all test suites matching /src\/utils/i. # ...
發現 utils/escape-html-in-object › should keep none-string fields in object or array
執行失敗,根據提示優化邏輯,而後再次執行測試:json
// src/utils/escape-html-in-object.js const escapeHtml = require('escape-html'); module.exports = function escapeHtmlInObject(input) { // 嘗試將 ORM 對象轉化爲普通對象 try { input = input.toJSON(); } catch {} // 對類型爲 string 的值轉義處理 if (Array.isArray(input)) { return input.map(escapeHtmlInObject); - } else if (typeof input == 'object') { + } else if (input && typeof input == 'object') { const output = {}; Object.keys(input).forEach((k) => { output[k] = escapeHtmlInObject(input[k]); }); return output; } else if (typeof input == 'string') { return escapeHtml(input); } else { return input; } };
$ yarn test src/utils # 執行 src/utils 目錄下的功能測試 # ... _PASS_ src/utils/escape-html-in-object.test.js utils/escape-html-in-object ✓ should escape a string (2 ms) ✓ should escape strings in object (1 ms) ✓ should escape strings in array ✓ should escape strings in object and array (1 ms) ✓ should keep none-string fields in object or array (1 ms) ✓ should convert sequelize model instance as plain object Test Suites: 1 passed, 1 total Tests: 6 passed, 6 total Snapshots: 0 total Time: 1.021 s Ran all test suites matching /src\/utils/i. # ...
目前 escapeHtmlInObject
功能已經正確執行,再次考慮到此方法的高頻使用,對此方法進一步作性能測試:segmentfault
// src/utils/escape-html-in-object.perf.js const { Suite } = require('benchmark'); const benchmarks = require('beautify-benchmark'); const escapeHtmlInObject = require('./escape-html-in-object'); const suite = new Suite(); suite.add('sparse special chars', () => { escapeHtmlInObject(' & '); }); suite.add('sparse special chars in object', () => { escapeHtmlInObject({ _: ' & ' }); }); suite.add('sparse special chars in array', () => { escapeHtmlInObject([' & ']); }); suite.add('dense special chars', () => { escapeHtmlInObject(`"'&<>"'&<>""''&&<<>>`); }); suite.add('dense special chars in object', () => { escapeHtmlInObject({ _: `"'&<>"'&<>""''&&<<>>` }); }); suite.add('dense special chars in object', () => { escapeHtmlInObject([`"'&<>"'&<>""''&&<<>>`]); }); suite.on('cycle', (e) => benchmarks.add(e.target)); suite.on('complete', () => benchmarks.log()); suite.run({ async: false });
執行測試:安全
$ node src/utils/escape-html-in-object.perf.js # 執行 escape-html-in-object.perf.js 6 tests completed. sparse special chars x 39,268 ops/sec ±1.39% (73 runs sampled) sparse special chars in object x 15,887 ops/sec ±1.11% (70 runs sampled) sparse special chars in array x 19,084 ops/sec ±1.24% (75 runs sampled) dense special chars x 39,504 ops/sec ±1.07% (89 runs sampled) dense special chars in object x 16,127 ops/sec ±1.04% (87 runs sampled) dense special chars in object x 20,288 ops/sec ±0.90% (94 runs sampled)
發現執行指標比底層模塊 escape-html 的低了若干數量級,走查代碼懷疑 try-catch
語句引發內存分配與釋放致使性能變差,所以嘗試使用 if
語句進行替換:性能優化
// src/utils/escape-html-in-object.js const escapeHtml = require('escape-html'); module.exports = function escapeHtmlInObject(input) { // 嘗試將 ORM 對象轉化爲普通對象 - try { - input = input.toJSON(); - } catch {} + if (input && typeof input == 'object' && typeof input.toJSON == 'function') { + input = input.toJSON(); + } // 對類型爲 string 的值轉義處理 if (Array.isArray(input)) { return input.map(escapeHtmlInObject); } else if (input && typeof input == 'object') { const output = {}; Object.keys(input).forEach((k) => { output[k] = escapeHtmlInObject(input[k]); }); return output; } else if (typeof input == 'string') { return escapeHtml(input); } else { return input; } };
而後再次執行測試:
$ node src/utils/escape-html-in-object.perf.js # 執行 escape-html-in-object.perf.js sparse special chars x 6,480,336 ops/sec ±1.19% (89 runs sampled) sparse special chars in object x 4,597,185 ops/sec ±1.12% (85 runs sampled) sparse special chars in array x 4,131,352 ops/sec ±0.73% (87 runs sampled) dense special chars x 3,512,408 ops/sec ±0.42% (89 runs sampled) dense special chars in object x 3,073,066 ops/sec ±0.45% (90 runs sampled) dense special chars in object x 3,153,604 ops/sec ±0.42% (95 runs sampled)
發現性能指標與 escape-html 相近,代表推斷正確。
執行相關功能測試進行迴歸測試:
$ yarn test src/utils # 執行 src/utils 目錄下的功能測試 # ... _PASS_ src/utils/escape-html-in-object.test.js utils/escape-html-in-object ✓ should escape a string (2 ms) ✓ should escape strings in object ✓ should escape strings in array (1 ms) ✓ should escape strings in object and array ✓ should keep none-string fields in object or array (1 ms) ✓ should convert sequelize model instance as plain object Test Suites: 1 passed, 1 total Tests: 6 passed, 6 total Snapshots: 0 total Time: 1.049 s Ran all test suites matching /src\/utils/i. # ...
因爲功能測試執行經過,代表功能保持良好,本次性能優化對原有功能不產生影響。
host1-tech/nodejs-server-examples - 14-testing
從零搭建 Node.js 企業級 Web 服務器(零):靜態服務
從零搭建 Node.js 企業級 Web 服務器(一):接口與分層
從零搭建 Node.js 企業級 Web 服務器(二):校驗
從零搭建 Node.js 企業級 Web 服務器(三):中間件
從零搭建 Node.js 企業級 Web 服務器(四):異常處理
從零搭建 Node.js 企業級 Web 服務器(五):數據庫訪問
從零搭建 Node.js 企業級 Web 服務器(六):會話
從零搭建 Node.js 企業級 Web 服務器(七):認證登陸
從零搭建 Node.js 企業級 Web 服務器(八):網絡安全
從零搭建 Node.js 企業級 Web 服務器(九):配置項
從零搭建 Node.js 企業級 Web 服務器(十):日誌
從零搭建 Node.js 企業級 Web 服務器(十一):定時任務
從零搭建 Node.js 企業級 Web 服務器(十二):遠程調用
從零搭建 Node.js 企業級 Web 服務器(十三):斷點調試與性能分析從零搭建 Node.js 企業級 Web 服務器(十四):自動化測試