Hiện nay việc viết unit test là một phần không thể thiếu trong mỗi dự án. Điều đó cũng cần thiết khi chúng ta thực hiện deploy với hệ thống CI/CD. Hôm nay mình sẽ giới thiệu qua việc viết test api với cho Nodejs project với mocha, chai, supertestsinonjs.

Getting started

Trước tiên cần chuẩn bị một project Nodejs với một số modules cần thiết sau:

  • mocha: Javascript test framework (ngoài ra chúng ta có thể dùng Jest)
  • chai: BDD/TDD assertion library
  • sinon: Standalone test spies, stubs and mocks for JavaScript
  • supertest: HTTP assertion

Ok giờ chạy npm init để tạo một project, mình init một project với file package.json (đây là file đủ sau khi mình thêm scripts vào rồi) như sau:

{
  "name": "unittest",
  "version": "1.0.0",
  "description": "nodejs unit test",
  "main": "index.js",
  "scripts": {
    "compile": "./node_modules/.bin/babel src --out-dir dist --ignore '**/*.test.js'",
    "test": "mocha src/test/*.test.js --compilers js:babel-core/register",
    "coverage": "nyc --reporter=html npm test"
  },
  "author": "kominam",
  "license": "MIT",
  "dependencies": {
    "babel-polyfill": "^6.26.0",
    "body-parser": "^1.18.2",
    "express": "^4.15.4",
    "mongoose": "^4.11.12",
    "nyc": "^11.2.1"
  },
  "devDependencies": {
    "babel-cli": "^6.26.0",
    "babel-core": "^6.26.0",
    "babel-preset-env": "^1.6.0",
    "chai": "^4.1.2",
    "mocha": "^3.5.3",
    "sinon": "^3.3.0",
    "supertest": "^3.0.0"
  }
}

Vì mình viết dưới dạng es6 nên có dùng thêm babel để compile js file và nyc để xem code coverage.
Mình làm việc với MongoDB nên dùng thằng mongoose(Mongoose) để thao tác dễ dàng hơn. Đầu tiên tạo 1 model với schema có tên là Todo như sau:

import mongoose, { Schema } from 'mongoose';

const todoSchema = new Schema({
  content: String,
  isComplete: {
    type: Boolean,
    default: false
  }
});

export default mongoose.model('Todo', todoSchema);

Tiếp theo tạo controller với 2 methods indexstore:

require('babel-polyfill');
import Todo from '../models/Todo';

let index = async function (req, res) {
  try {
    let todos = await Todo.find({});
    res.status(200).json({
      data: todos
    })
  } catch(err) {
    res.status(500).json({
      err
    });
  }
}

let store = async function (req,res) {
  try {
    let newTodo = await Todo.create({
      content: req.body.content
    });
    res.status(200).json({
      data: newTodo
    })
  } catch(err) {
    res.status(500).json({
      err
    });
  }
}

export { index, store };

Định nghĩa router với express.Router():

import express from 'express';
import { index, store } from '../controller/TodoController';

const router = express.Router();

router.get('/api/v1/todos', index);
router.post('/api/v1/todos', store);

export default router;

Tạo server server.js như sau:

import express from 'express';
import chalk from 'chalk';
import mongoose from 'mongoose';
import bodyParser from 'body-parser';
import router from './routes/web';

const PORT = process.env.PORT || 8000;
const MONGODB_URI = 'mongodb://localhost:27017/todos-ex'
mongoose.connect(
  MONGODB_URI, {
    useMongoClient: true
  }
);

const app = express();

app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// router
app.use('/', router);

app.listen(8000, () => {
  console.log('%s I am ready to serve\n',
              chalk.green('✓'));
  console.log('-> Press CTRL-C to stop\n');
})

export default app;

Unit test with sinon

Tiếp theo là viết unit test cho TodoController sử dụng stub của sinon:

  • supertest : HTTP assertions

Nói qua một chút về mock, stubspy:

  • spy: hiểu đơn giản thì nó là 1 function ghi lại các tham số, giá trị trả về... liên quan đến lần gọi của function.
  • stub: cũng giống như 1 spy, nhưng nó sẽ thay đổi hoạt động của function bằng cách return lại giá trị mình muốn.
  • mock: là fake methods (như spy) cùng với pre-programmed behavior (giống như stub) và cả pre-programmed expectations.
import { assert, expect } from 'chai';
import { stub } from 'sinon';
import request from 'supertest';
import server from '../server';
import Todo from '../models/Todo';

describe('TodoController Unit Test', () => {
  it('should get todos', () => {
    const findAllTodoStub = stub(Todo, 'find').yields(undefined, [
      {
        _id: '59c60a137c02831d3f45f771',
        content: 'Nodejs Learning Curve ',
        __v: 0,
        isComplete: false
      }
    ]);
    findAllTodoStub.withArgs({});

    request(server)
      .get('/api/v1/todos')
      .expect(200, {
        data: [
          {
            _id: '59c60a137c02831d3f45f771',
            content: 'Nodejs Learning Curve ',
            __v: 0,
            isComplete: false
          }
        ]
      });
      findAllTodoStub.restore();
  });
  it('can not get all because something went wrong with our database' ,() => {
    const findAllTodoStub = stub(Todo, 'find').yields(new Error('Request to DB timeout'), undefined);
    findAllTodoStub.withArgs({});

    request(server)
      .get('/api/v1/todos')
      .expect(500, {
        err: 'Request to DB timeout'
      });
      findAllTodoStub.restore();
  })
});
  • yields: gần giống như callsArg. Nó sẽ invoke callback thông qua argument truyền vào. Nếu stub không được gọi với function của argument đó thì yields sẽ trả về lỗi. Ở ví dụ này khi gọi method Todo.find() thành công nó sẽ trả về bản 1 bản ghi Todo. Ở trường hợp thứ 2 mình giả định rằng kết nối tới database hết hạn nên yields sẽ trả về lỗi.
  • restore: là một trong số utilities của sinon. Nó sẽ restore lại tất cả fake method đc cung cấp bởi 1 object truyền vào. ở ví dụ này nó sẽ restore lại findAllTodoStub trước khi request tới server.
  • withArgs: chỉ stub method cho những argument cụ thể nào đó. Ở ví dụ này mình không truyền vào argument nào cả.

Compile and run test

chạy script npm run compile để compile source code và sau đó chạy npm test để chạy test ta có kết quả như sau:

> unittest@1.0.0 test /home/do.van.nam/Desktop/Workspaces/nodejs-unitTest
> mocha src/test/*.test.js --compilers js:babel-core/register



✓ I am ready to serve

-> Press CTRL-C to stop

  TodoController Unit Test
    ✓ should get todos
    ✓ can not get all because something went wrong with our database


  2 passing (20ms)

Hoặc mọi người có thể xem coverage với nyc (người yêu cũ :D) bằng việc chạy scripts npm run coverage. Nó sẽ generate ra 1 folder coverage ở ngay thư mục gốc của project. Mọi người mở file index.html trong đó và kết quả sẽ như thế này:
Coverage

Happy coding !

References

Sinonjs
Supertest
Source code