单元测试


    • 你的代码质量如何度量?
    • 你是如何保证代码质量?
    • 你敢随时重构代码吗?
    • 你是如何确保重构的代码依然保持正确性?
    • 你是否有足够信心在没有测试的情况下随时发布你的代码?

    如果答案都比较犹豫,那么就证明我们非常需要单元测试。

    它能带给我们很多保障:

    • 代码质量持续有保障
    • 重构正确性保障
    • 增强自信心
    • 自动化运行

    Web 应用中的单元测试更加重要,在 Web 产品快速迭代的时期,每个测试用例都给应用的稳定性提供了一层保障。 API 升级,测试用例可以很好地检查代码是否向下兼容。 对于各种可能的输入,一旦测试覆盖,都能明确它的输出。 代码改动后,可以通过测试结果判断代码的改动是否影响已确定的结果。

    所以,应用的 Controller、Service、Helper、Extend 等代码,都必须有对应的单元测试保证代码质量。 当然,框架和插件的每个功能改动和重构都需要有相应的单元测试,并且要求尽量做到修改的代码能被 100% 覆盖到。

    测试框架

    从 , 我们会发现有大量测试框架存在,每个测试框架都有它的独特之处。

    我们选择和推荐大家使用 Mocha,功能非常丰富,支持运行在 Node.js 和浏览器中, 对异步测试支持非常友好。

    AVA

    为什么没有选择最近比较火的 AVA,它看起来会跑得很快。 经过我们几个真实项目实践下来,AVA 真的只是看起来很美,但是实际会让测试代码越来越难写,成本越来越高。

    的评价:

    @fool2fish 的评价:

    断言库

    同样,测试断言库也是百花齐放的时代, 我们经历过 ,到 should 和 ,还是不断地在尝试更好的断言库。

    直到我们发现 power-assert, 因为, 最终我们重新回归原始的 assert 作为默认的断言库。

    简单地说,它的优点是:

    • 没有 API 就是最好的 API,不需要任何记忆,只需 assert 即可。
    • 强大的错误信息反馈
    • 强大的错误信息反馈
    • 强大的错误信息反馈

    报错信息实在太美太详细,让人有种想看错误报告的欲望:

    测试约定

    为了让我们更多地关注测试用例本身如何编写,而不是耗费时间在如何运行测试脚本等辅助工作上, 框架对单元测试做了一些基本约定。

    测试目录结构

    我们约定 目录为存放所有测试脚本的目录,测试所使用到的 fixtures 和相关辅助脚本都应该放在此目录下。

    测试脚本文件统一按 ${filename}.test.js 命名,必须以 .test.js 作为文件后缀。

    一个应用的测试目录示例:

    测试运行工具

    统一使用 , 自动将内置的 Mocha、、power-assert, 等模块组合引入到测试脚本中, 让我们聚焦精力在编写测试代码上,而不是纠结选择那些测试周边工具和模块。

    只需要在 package.json 上配置好 scripts.test 即可。

    1. {
    2. "scripts": {
    3. "test": "egg-bin test"
    4. }
    5. }

    然后就可以按标准的 npm test 来运行测试了。

    1. npm test
    2. > unittest-example@ test /Users/mk2/git/github.com/eggjs/examples/unittest
    3. > egg-bin test
    4. test/hello.test.js
    5. should work
    6. 1 passing (10ms)

    本文主要介绍如何编写应用的单元测试,关于框架和插件的单元测试请查看框架开发和相关章节。

    mock

    正常来说,如果要完整手写一个 app 创建和启动代码,还是需要写一段初始化脚本的, 并且还需要在测试跑完之后做一些清理工作,如删除临时文件,销毁 app。

    常常还有模拟各种网络异常,服务访问异常等特殊情况。

    所以我们单独为框架抽取了一个测试 mock 辅助模块:, 有了它我们就可以非常快速地编写一个 app 的单元测试,并且还能快速创建一个 ctx 来测试它的属性、方法和 Service 等。

    在测试运行之前,我们首先要创建应用的一个 app 实例, 通过它来访问需要被测试的 Controller、Middleware、Service 等应用层代码。

    1. // test/controller/home.test.js
    2. const assert = require('assert');
    3. const mock = require('egg-mock');
    4. describe('test/controller/home.test.js', () => {
    5. let app;
    6. before(() => {
    7. // 创建当前应用的 app 实例
    8. app = mock.app();
    9. // 等待 app 启动成功,才能执行测试用例
    10. return app.ready();
    11. });
    12. });

    这样我们就拿到了一个 app 的引用,接下来所有测试用例都会基于这个 app 进行。 更多关于创建 app 的信息请查看 mock.app(options) 文档。

    每一个测试文件都需要这样创建一个 app 实例非常冗余,因此 egg-mock 提供了一个 bootstrap 文件,可以直接从它上面拿到我们所常用的实例:

    1. // test/controller/home.test.js
    2. const { app, mock, assert } = require('egg-mock/bootstrap');
    3. describe('test/controller/home.test.js', () => {
    4. // test cases
    5. });

    ctx

    我们除了 app,还需要一种方式便捷地拿到 ctx,方便我们进行 Extend、Service、Helper 等测试。 而我们已经通过上面的方式拿到了一个 app,结合 egg-mock 提供的 app.mockContext(options) 方法来快速创建一个 ctx 实例。

    1. it('should get a ctx', () => {
    2. const ctx = app.mockContext();
    3. assert(ctx.method === 'GET');
    4. assert(ctx.url === '/');
    5. });

    如果我们想模拟 ctx.user 这个数据,也可以通过给 mockContext 传递 data 参数实现:

    1. it('should mock ctx.user', () => {
    2. const ctx = app.mockContext({
    3. user: {
    4. name: 'fengmk2',
    5. },
    6. });
    7. assert(ctx.user);
    8. assert(ctx.user.name === 'fengmk2');
    9. });

    现在我们拿到了 app,也知道如何创建一个 ctx 了,那么就可以进行更多代码的单元测试了。

    测试执行顺序

    特别需要注意的是执行顺序,尽量保证在执行某个用例的时候执行相关代码。

    常见的错误写法

    1. // Bad
    2. const { app } = require('egg-mock/bootstrap');
    3. describe('bad test', () => {
    4. doSomethingBefore();
    5. it('should redirect', () => {
    6. return app.httpRequest()
    7. .get('/')
    8. .expect(302);
    9. });
    10. });

    Mocha 刚开始运行的时候会载入所有用例,这时 describe 方法就会被调用,那 doSomethingBefore 就会启动。 如果希望使用 only 的方式只执行某个用例那段代码还是会被执行,这是非预期的。

    正确的做法是将其放到 before 中,只有运行这个套件中某个用例才会执行。

    1. // Good
    2. const { app } = require('egg-mock/bootstrap');
    3. describe('good test', () => {
    4. before(() => doSomethingBefore());
    5. it('should redirect', () => {
    6. return app.httpRequest()
    7. .get('/')
    8. .expect(302);
    9. });
    10. });

    Mocha 使用 before/after/beforeEach/afterEach 来处理前置后置任务,基本能处理所有问题。 每个用例会按 before -> beforeEach -> it -> afterEach -> after 的顺序执行,而且可以定义多个。

    1. describe('egg test', () => {
    2. before(() => console.log('order 1'));
    3. before(() => console.log('order 2'));
    4. after(() => console.log('order 6'));
    5. beforeEach(() => console.log('order 3'));
    6. afterEach(() => console.log('order 5'));
    7. it('should worker', () => console.log('order 4'));
    8. });

    异步测试

    egg-bin 支持测试异步调用,它支持多种写法:

    1. // 使用返回 Promise 的方式
    2. it('should redirect', () => {
    3. return app.httpRequest()
    4. .get('/')
    5. .expect(302);
    6. // 使用 callback 的方式
    7. it('should redirect', done => {
    8. app.httpRequest()
    9. .get('/')
    10. .expect(302, done);
    11. });
    12. // 使用 async
    13. it('should redirect', async () => {
    14. await app.httpRequest()
    15. .get('/')
    16. .expect(302);
    17. });

    使用哪种写法取决于不同应用场景,如果遇到多个异步可以使用 async function,也可以拆分成多个测试用例。

    Controller 测试

    Controller 在整个应用代码里面属于比较难测试的部分了,因为它跟 router 配置紧密相关, 我们需要利用 app.httpRequest() SuperTest 发起一个真实请求, 来将 Router 和 Controller 连接起来,并且可以帮助我们发送各种满足边界条件的请求数据, 以测试 Controller 的参数校验完整性。 app.httpRequest() 是 封装的 SuperTest 请求实例。

    例如我们要给 app/controller/home.js

    写一个完整的单元测试,它的测试代码 test/controller/home.test.js 如下:

    1. const { app, mock, assert } = require('egg-mock/bootstrap');
    2. describe('test/controller/home.test.js', () => {
    3. describe('GET /', () => {
    4. it('should status 200 and get the body', () => {
    5. // 对 app 发起 `GET /` 请求
    6. return app.httpRequest()
    7. .get('/')
    8. .expect(200) // 期望返回 status 200
    9. .expect('hello world'); // 期望 body 是 hello world
    10. });
    11. it('should send multi requests', async () => {
    12. // 使用 generator function 方式写测试用例,可以在一个用例中串行发起多次请求
    13. await app.httpRequest()
    14. .get('/')
    15. .expect(200) // 期望返回 status 200
    16. // 再请求一次
    17. const result = await app.httpRequest()
    18. .get('/')
    19. .expect(200)
    20. .expect('hello world');
    21. // 也可以这样验证
    22. assert(result.status === 200);
    23. });
    24. });
    25. });

    通过基于 SuperTest 的 app.httpRequest() 可以轻松发起 GET、POST、PUT 等 HTTP 请求,并且它有非常丰富的请求数据构造接口, 例如以 POST 方式发送一个 JSON 请求:

    1. // app/controller/home.js
    2. class HomeController extends Controller {
    3. async post() {
    4. this.ctx.body = this.ctx.request.body;
    5. }
    6. }
    7. // test/controller/home.test.js
    8. it('should status 200 and get the request body', () => {
    9. // 模拟 CSRF token,下文会详细说明
    10. app.mockCsrf();
    11. return app.httpRequest()
    12. .post('/post')
    13. .type('form')
    14. .send({
    15. foo: 'bar',
    16. })
    17. .expect(200)
    18. .expect({
    19. foo: 'bar',
    20. });
    21. });

    更详细的 HTTP 请求构造方式,请查看 。

    mock CSRF

    框架的默认安全插件会自动开启 , 如果完整走 CSRF 校验逻辑,那么测试代码需要先请求一次页面,通过解析 HTML 拿到 CSRF token, 然后再使用此 token 发起 POST 请求。

    所以 egg-mock 对 app 增加了 app.mockCsrf() 方法来模拟取 CSRF token 的过程。 这样在使用 SuperTest 请求 app 就会自动通过 CSRF 校验。

    1. app.mockCsrf();
    2. return app.httpRequest()
    3. .post('/post')
    4. .type('form')
    5. .send({
    6. foo: 'bar',
    7. })
    8. .expect(200)
    9. .expect({
    10. foo: 'bar',
    11. });

    Service 相对于 Controller 来说,测试起来会更加简单, 我们只需要先创建一个 ctx,然后通过 ctx.service.${serviceName} 拿到 Service 实例, 然后调用 Service 方法即可。

    例如

    1. // app/service/user.js
    2. class UserService extends Service {
    3. async get(name) {
    4. return await userDatabase.get(name);
    5. }
    6. }

    编写单元测试:

    1. describe('get()', () => {
    2. it('should get exists user', async () => {
    3. // 创建 ctx
    4. const ctx = app.mockContext();
    5. // 通过 ctx 访问到 service.user
    6. const user = await ctx.service.user.get('fengmk2');
    7. assert(user);
    8. assert(user.name === 'fengmk2');
    9. });
    10. it('should get null when user not exists', async () => {
    11. const ctx = app.mockContext();
    12. const user = await ctx.service.user.get('fengmk1');
    13. assert(!user);
    14. });
    15. });

    当然,实际的 Service 代码不会像我们示例中那么简单,这里只是展示如何测试 Service 而已。

    Extend 测试

    应用可以对 Application、Request、Response、Context 和 Helper 进行扩展。 我们可以对扩展的方法或者属性针对性的编写单元测试。

    Application

    egg-mock 创建 app 的时候,已经将 Application 的扩展自动加载到 app 实例了, 直接使用这个 app 实例访问扩展的属性和方法即可进行测试。

    例如 app/extend/application.js,我们给 app 增加了一个基于 ylru 的缓存功能:

    1. const LRU = Symbol('Application#lru');
    2. const LRUCache = require('ylru');
    3. module.exports = {
    4. get lru() {
    5. if (!this[LRU]) {
    6. this[LRU] = new LRUCache(1000);
    7. }
    8. return this[LRU];
    9. },
    10. };

    对应的单元测试:

    1. describe('get lru', () => {
    2. it('should get a lru and it work', () => {
    3. // 设置缓存
    4. app.lru.set('foo', 'bar');
    5. // 读取缓存
    6. assert(app.lru.get('foo') === 'bar');
    7. });
    8. });

    可以看到,测试 Application 的扩展是最容易的。

    Context

    例如在 app/extend/context.js 中增加一个 isXHR 属性,判断是否通过 XMLHttpRequest 发起的请求:

    1. module.exports = {
    2. get isXHR() {
    3. return this.get('X-Requested-With') === 'XMLHttpRequest';
    4. },
    5. };

    对应的单元测试:

    1. describe('isXHR()', () => {
    2. it('should true', () => {
    3. const ctx = app.mockContext({
    4. headers: {
    5. 'X-Requested-With': 'XMLHttpRequest',
    6. },
    7. });
    8. assert(ctx.isXHR === true);
    9. it('should false', () => {
    10. const ctx = app.mockContext({
    11. headers: {
    12. 'X-Requested-With': 'SuperAgent',
    13. },
    14. });
    15. assert(ctx.isXHR === false);
    16. });
    17. });

    通过 ctx.request 来访问 Request 扩展的属性和方法,直接即可进行测试。

    例如在 app/extend/request.js 中增加一个 isChrome 属性,判断是否 Chrome 浏览器发起的请求:

    1. const IS_CHROME = Symbol('Request#isChrome');
    2. module.exports = {
    3. get isChrome() {
    4. if (!this[IS_CHROME]) {
    5. const ua = this.get('User-Agent').toLowerCase();
    6. this[IS_CHROME] = ua.includes('chrome/');
    7. }
    8. return this[IS_CHROME];
    9. },
    10. };

    对应的单元测试:

    Response

    Response 测试与 Request 完全一致。 通过 ctx.response 来访问 Response 扩展的属性和方法,直接即可进行测试。

    例如在 app/extend/response.js 中增加一个 属性,判断当前响应状态码是否 200:

    1. module.exports = {
    2. get isSuccess() {
    3. return this.status === 200;
    4. },
    5. };

    对应的单元测试:

    1. describe('isSuccess()', () => {
    2. it('should true', () => {
    3. const ctx = app.mockContext();
    4. ctx.status = 200;
    5. assert(ctx.response.isSuccess === true);
    6. });
    7. it('should false', () => {
    8. const ctx = app.mockContext();
    9. ctx.status = 404;
    10. assert(ctx.response.isSuccess === false);
    11. });
    12. });

    Helper

    Helper 测试方式与 Service 类似,也是通过 ctx 来访问到 Helper,然后调用 Helper 方法测试。

    例如 app/extend/helper.js

    1. module.exports = {
    2. money(val) {
    3. const lang = this.ctx.get('Accept-Language');
    4. if (lang.includes('zh-CN')) {
    5. return `¥ ${val}`;
    6. }
    7. return `$ ${val}`;
    8. },
    9. };

    对应的单元测试:

    1. describe('money()', () => {
    2. it('should RMB', () => {
    3. const ctx = app.mockContext({
    4. // 模拟 ctx 的 headers
    5. headers: {
    6. 'Accept-Language': 'zh-CN,zh;q=0.5',
    7. },
    8. });
    9. assert(ctx.helper.money(100) === '¥ 100');
    10. });
    11. it('should US Dolar', () => {
    12. const ctx = app.mockContext();
    13. assert(ctx.helper.money(100) === '$ 100');
    14. });
    15. });

    Mock 方法

    egg-mock 除了上面介绍过的 app.mockContext()app.mockCsrf() 方法外,还提供了非常多的 mock 方法帮助我们便捷地写单元测试。

    • 如我们不想在终端 console 输出任何日志,可以通过 mock.consoleLevel('NONE') 来模拟。
    • 又如我想模拟一次请求的 Session 数据,可以通过 app.mockSession(data) 来模拟。

      1. describe('GET /session', () => {
      2. it('should mock session work', () => {
      3. app.mockSession({
      4. foo: 'bar',
      5. uid: 123,
      6. });
      7. return app.httpRequest()
      8. .get('/session')
      9. .expect(200)
      10. .expect({
      11. session: {
      12. foo: 'bar',
      13. uid: 123,
      14. },
      15. });
      16. });
      17. });

    因为 mock 之后会一直生效,我们需要避免每个单元测试用例之间是不能相互 mock 污染的, 所以通常我们都会在 afterEach 钩子里面还原掉所有 mock。

    1. describe('some test', () => {
    2. // before hook
    3. afterEach(mock.restore);
    4. // it tests
    5. });

    引入 egg-mock/bootstrap 时,会自动在 afterEach 钩子中还原所有的 mock,不需要在测试文件中再次编写。

    下面会详细解释一下 egg-mock 的常见使用场景。

    Mock 属性和方法

    因为 egg-mock 是扩展自 mm 模块, 它包含了 mm 的所有功能,这样我们就可以非常方便地 mock 任意对象的属性和方法了。

    Mock 一个对象的属性

    mock app.config.baseDir 指向 /tmp/mockapp

    1. mock(app.config, 'baseDir', '/tmp/mockapp');
    2. assert(app.config.baseDir === '/tmp/mockapp');

    Mock 一个对象的方法

    mock fs.readFileSync 返回 hello world

    1. mock(fs, 'readFileSync', filename => {
    2. return 'hello world';
    3. });
    4. assert(fs.readFileSync('foo.txt') === 'hello world');

    还有 mock.data()mock.error() 等更多高级的 mock 方法, 详细使用说明请查看 。

    Mock Service

    Service 作为框架标准的内置对象,我们提供了便捷的 app.mockService(service, methodName, fn) 模拟 Service 方法返回值。

    例如,模拟 app/service/user 中的 get(name) 方法,让它返回一个本来不存在的用户数据。

    1. it('should mock fengmk1 exists', () => {
    2. app.mockService('user', 'get', () => {
    3. return {
    4. name: 'fengmk1',
    5. };
    6. });
    7. return app.httpRequest()
    8. .get('/user?name=fengmk1')
    9. .expect(200)
    10. // 返回了原本不存在的用户信息
    11. .expect({
    12. name: 'fengmk1',
    13. });
    14. });

    通过 app.mockServiceError(service, methodName, error) 可以模拟 Service 调用异常。

    例如,模拟 app/service/user 中的 get(name) 方法调用异常:

    1. it('should mock service error', () => {
    2. app.mockServiceError('user', 'get', 'mock user service error');
    3. return app.httpRequest()
    4. .get('/user?name=fengmk2')
    5. // service 异常,触发 500 响应
    6. .expect(500)
    7. .expect(/mock user service error/);
    8. });

    框架内置了 ,应用发起的对外 HTTP 请求基本都是通过它来处理。 我们可以通过 app.mockHttpclient(url, method, data) 来 mock 掉 app.curlctx.curl 方法, 从而实现各种网络异常情况。

    例如在 app/controller/home.js 中发起了一个 curl 请求

    需要 mock 它的返回值:

    1. describe('GET /httpclient', () => {
    2. it('should mock httpclient response', () => {
    3. app.mockHttpclient('https://eggjs.org', {
    4. // 模拟的参数,可以是 buffer / string / json,
    5. // 都会转换成 buffer
    6. // 按照请求时的 options.dataType 来做对应的转换
    7. data: 'mock eggjs.org response',
    8. });
    9. return app.httpRequest()
    10. .get('/httpclient')
    11. .expect('mock eggjs.org response');

    示例代码

    完整示例代码可以在 找到。