从零搭建 Node.js 企业级 Web 服务器(十四):自动化测试

测试类型

测试根据是否涉及软件功能,分为 功能性测试非功能性测试,前者包括单元测试、集成测试、系统测试、接口测试、回归测试、验收测试,后者包括文档测试、安装测试、性能测试、可靠性测试、安全性测试。功能性测试验证了功能逻辑本身是否正确,非功能性测试验证的是除功能之外的逻辑。测试是软件用户信心的重要来源,而自动化测试就是建立这种信心的最高效手段,结合 CI 可以在代码发生变更的每个时刻自动执行测试保障工程的高质量。

c52e37ca29696e960789d7fda2af4105c373113d.jpg

写入自动化测试

本章将基于上一章已完成的工程 host1-tech/nodejs-server-examples - 13-debugging-and-profiling 使用 jest、benchmark 为店铺管理加上关键的功能性与非功能性自动化测试,在工程根目录执行相关模块安装命令:

$ 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

# ...

测试功能

现在就店铺管理的关键用例编写功能测试:

$ 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);

});

});

});

执行测试:

$ 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 方法的高频使用,需要对此方法编写功能测试用例:

// 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 执行失败,根据提示优化逻辑,然后再次执行测试:

// 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 功能已经正确执行,再次考虑到此方法的高频使用,对此方法进一步做性能测试:

// 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 服务器(十四):自动化测试

以上是 从零搭建 Node.js 企业级 Web 服务器(十四):自动化测试 的全部内容, 来源链接: utcz.com/a/38941.html

回到顶部