從零搭建 Node.js 企業級 Web 服務器(十四):自動化測試

測試類型

測試根據是否涉及軟件功能,分爲 功能性測試非功能性測試,前者包括單元測試、集成測試、系統測試、接口測試、迴歸測試、驗收測試,後者包括文檔測試、安裝測試、性能測試、可靠性測試、安全性測試。功能性測試驗證了功能邏輯自己是否正確,非功能性測試驗證的是除功能以外的邏輯。測試是軟件用戶信心的重要來源,而自動化測試就是創建這種信心的最高效手段,結合 CI 能夠在代碼發生變動的每一個時刻自動執行測試保障工程的高質量。html

c52e37ca29696e960789d7fda2af4105c373113d.jpg

寫入自動化測試

本章將基於上一章已完成的工程 host1-tech/nodejs-server-examples - 13-debugging-and-profiling 使用 jestbenchmark 爲店鋪管理加上關鍵的功能性與非功能性自動化測試,在工程根目錄執行相關模塊安裝命令: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 服務器(十四):自動化測試

相關文章
相關標籤/搜索