12/08/2018, 15:36

Unit test cho Nodejs RESTful API với Mocha và Chai

Chúng ta có thể tìm thấy nhiều ví dụ khởi tạo một RESTful API bằng Nodejs. Các bước thường thông thường sẽ là : Định nghĩa các packages sẽ dùng, khởi chạy một server với Express(Framework phổ biến và có nhiều hỗ trợ), định nghĩa các model, khai báo các router sử dụng ExpressRouter, và cuối cúng là ...

Chúng ta có thể tìm thấy nhiều ví dụ khởi tạo một RESTful API bằng Nodejs. Các bước thường thông thường sẽ là : Định nghĩa các packages sẽ dùng, khởi chạy một server với Express(Framework phổ biến và có nhiều hỗ trợ), định nghĩa các model, khai báo các router sử dụng ExpressRouter, và cuối cúng là Test API. Trong đó việc thực hiện test các Api là công việc mất nhiều thời gian, nhất là khi chúng ta thay đổi một model, sẽ có nhiều Api phải test lại. Việc viết unit test cho Api trở nên cực kỳ cần thiết, nhất là khi chúng ta tích hợp việc deploy với các hệ thống CI/CD. Trong bài này mình sẽ hướng dẫn một cách viết unit test RESTful API cho project viết bằng Nodejs sử dụng Mocha và Chai.

Mocha: Là một javascript framework cho NodeJs cho phép thực hiện testing bất đồng bộ. Có thể nói đây là thư viện mà tôi thích nhất dùng để thực hiện viết test cho các dự án viết bằng Nodejs. Mocha có rất nhiều tính năng tuyệt vời, có thể tóm tắt những thứ mà tôi thích nhất của thư viện này :

  • Hỗ trợ bất đồng bộ đơn giản, bao gồm cả Promise.
  • Hỗ trợ nhiều hooks before, after, before each, after each (Rất tiện lợi cho bạn thiết lập và "làm sạch" môi trường test).
  • Có rất nhiều thư viện hỗ trợ việc xác định giá trị cần test (assertion). Chai là một thư viện tôi sử dụng trong bài viết này Chai: Assertion library. Trong bài viết này chúng ta phải test những Api có các phương thức GET, POST..., và phải kiểm tra đối tượng json mà Api trả về, đó là lý do ta phải dùng thêm Chai. Chai cung cấp nhiều tùy chọn Assertion cho việc thực hiện kiểm tra đối tượng: "should", "expect", "assert" Trong bài viết này chúng ta thêm addon "Chai HTTP" để thực hiện các HTTP requests và trả về giá trị của Api.
  • Nodejs: Chúng ta cần có môi trường lập trình Nodej và hiểu biết cơ bản đủ có thể xấy dựng một RESTfull Api bằng nodejs
  • POSTMAN: Cho việc tạo http request tới Api
  • Cú pháp ES6: Việc này yêu cầu phiên bản của Nodejs phải từ 6.x.x trở lên

Chúng ta xây dựng một RESTful API đơn giản: Petstore

Cấu trúc file và thư mục

Chuẩn bị thư mục dự án

$ mkdir petstore
$ cd petstore
$ npm init -y

Cấu trúc dự án

-- controllers 
---- models
------ pet.js
---- routes
------ pet.js
-- test
---- pet.js
package.json
server.json

package.json

{
  "name": "petstore",
  "version": "1.0.0",
  "description": "A petstore API",
  "main": "server.js",
  "author": "hoangdv",
  "license": "ISC",
  "dependencies": {
    "body-parser": "^1.15.1",
    "express": "^4.13.4",
    "morgan": "^1.7.0"
  },
  "devDependencies": {
    "chai": "^3.5.0",
    "chai-http": "^2.0.1",
    "mocha": "^2.4.5"
  },
  "scripts": {
    "start": "node server.js",
    "test": "mocha --timeout 10000"
  }
}

Cài đặt các thư viện được định nghĩa trong file package.json

$ npm install

Server

server.js

let express = require('express');
let app = express();
let morgan = require('morgan');
let bodyParser = require('body-parser');
let port = process.env.PORT || 8080;
let pet = require('./routes/pet');

//don't show the log when it is test
if(process.env.NODE_ENV !== 'test') {
    //use morgan to log at command line
    app.use(morgan('combined')); //'combined' outputs the Apache style LOGs
}

//parse application/json and look for raw text
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.text());
app.use(bodyParser.json({ type: 'application/json'}));

app.get("/", (req, res) => res.json({message: "Welcome to our Petstore!"}));

app.route("/pets")
    .get(pet.getPets)
    .post(pet.postPet);
app.route("/pets/:id")
    .get(pet.getPet)
    .delete(pet.deletePet)
    .put(pet.updatePet);

app.listen(port);
console.log("Listening on port " + port);

module.exports = app; // for testing

Model and Routes

Để thực hiện ví dụ cho bài viết, mình tạo mock một model pet ./model/pet.js

let ListData = [
    {id: 1, name: 'Kitty01', status: 'available'},
    {id: 2, name: 'Kitty02', status: 'available'},
    {id: 3, name: 'Kitty03', status: 'available'},
    {id: 4, name: 'Kitty04', status: 'available'},
    {id: 5, name: 'Kitty05', status: 'available'},
    {id: 6, name: 'Kitty06', status: 'available'},
    {id: 7, name: 'Kitty07', status: 'available'},
    {id: 8, name: 'Kitty08', status: 'available'},
    {id: 9, name: 'Kitty09', status: 'available'}
];
module.exports.find = (callback) => {
    callback(null, ListData);
};
module.exports.findById = (id, callback) => {
    callback(null, ListData.find(item => item.id == id)); // typeof id === "string"
};
module.exports.save = (pet, callback) => {
    let {name, status} = pet;
    if (!name && !status) {
        callback("Pet is invalid");
        return;
    }
    pet = {
        id: Date.now(),
        name,
        status
    };
    ListData.push(pet);
    callback(null, pet);
};
module.exports.delete = (id, callback) => {
    let roweffected = ListData.length;
    ListData = ListData.filter(item => item.id != id);
    roweffected = roweffected - ListData.length;
    callback(null, {roweffected})
};
module.exports.update = (id, pet, callback) => {
    let oldPet = ListData.find(item => item.id == id);
    if (!oldPet) {
        callback("Pet not found!");
        return;
    }
    let index = ListData.indexOf(oldPet);
    Object.assign(oldPet, pet);
    ListData.fill(oldPet, index, ++index);
    callback(null, oldPet);
};

TIếp theo là route cho server ./routes/pet.js

let Pet = require("../model/pet");

/*
 * GET /pets route to retrieve all the pets.
 */
let getPets = (req, res) => {
    Pet.find((err, pets) => {
        if (err) {
            res.send(err); // :D
            return;
        }
        res.send(pets);
    });
};

/*
 * POST /pets to save a new pet.
 */
let postPet = (req, res) => {
    let pet = req.body;
    Pet.save(pet, (err, newPet) => {
        if(err) {
            res.send(err);
            return;
        }
        res.send({
            message: "Pet successfully added!",
            pet: newPet
        });
    });
};

/*
 * GET /pets/:id route to retrieve a pet given its id.
 */
let getPet = (req, res) => {
    Pet.findById(req.params.id, (err, pet) => {
        if(err) {
            res.send(err);
            return;
        }
        res.send({
            pet
        });
    })
};

/*
 * DELETE /pets/:id to delete a pet given its id.
 */
let deletePet = (req, res) => {
    Pet.delete(req.params.id, (err, result) => {
        res.json({
            message: "Pet successfully deleted!",
            result
        });
    })
};

/*
 * PUT /pets/:id to update a pet given its id
 */
let updatePet = (req, res) => {
    Pet.update(req.params.id, req.body, (err, pet) => {
        if(err) {
            res.send(err);
            return;
        }
        res.send({
            message: "Pet updated!",
            pet
        });
    })
};

//export all the functions
module.exports = {
    getPets,
    postPet,
    getPet,
    deletePet,
    updatePet
};

Native test

Chúng ta sử dụng POSTMAN để test các routes của server đã hoạt động như mong muốn chưa. Khởi chạy server:

$ npm start

GET /pets

POST /pets

GET /pets/:id

PUT /pets/:id

DELETE /pets/:id

Chúng ta may mắn mọi thứ hoạt động tốt, nó đã chạy mà không có lỗi nào. Nhưng điều này thì thật khó để đảm bảo cho một project thực tế, với lượng api lớn hơn rất nhiều và nghiệp vụ phức tạp, chúng ta sẽ mất rất nhiều thời gian để thực hiện hết các test với POSTMAN, chúng ta cần một các tiếp cận khác nhanh nhẹn hơn.

Unit test với Mocha và Chai

Tạo một file trong thư mục ./test với tên pet.js

//During the test the env variable is set to test
process.env.NODE_ENV = 'test';

//Require the dev-dependencies
let chai = require('chai');
let chaiHttp = require('chai-http');
let server = require('../server');
let should = chai.should();

chai.use(chaiHttp);
//Our parent block
describe('Pets', () => {
    beforeEach((done) => {
        //Before each test we empty the database in your case
        done();
    });
    /*
     * Test the /GET route
     */
    describe('/GET pets', () => {
        it('it should GET all the pets', (done) => {
            chai.request(server)
                .get('/pets')
                .end((err, res) => {
                    res.should.have.status(200);
                    res.body.should.be.a('array');
                    res.body.length.should.be.eql(9); // fixme :)
                    done();
                });
        });
    });
});
  1. Ghi đè biến môi trường NODE_ENV=test, phục vụ cho việc những project chúng ta cấu hình môi trường test và prod khác nhau (database, api key...)
  2. Chúng ta định nghĩa should bằng cách khởi chạy chai.should();, để thực hiện ghi đè thuộc tính của Object cho việc thực hiện test. Mã nguồn thư viện:
...
Object.defineProperty(Object.prototype, 'should', {
      set: shouldSetter
      , get: shouldGetter
      , configurable: true
    });
...
  1. describe định nghĩ một block các test case cho cùng một loại.
  2. beforeEach là một hook được khởi chạy trước khi thực hiện các test được định nghĩa. Hook này giúp khởi tạo môi trường test dễ dàng(clear database, run init settup...)

Test /GET route

Test được định nghĩa trong block it should GET all the pets Kết quả mong muốn của API này sẽ là:

  1. http status là 200
  2. body trả về là một array
  3. độ dài của array là 9

Cú pháp kiểm tra khá gần với ngôn ngữ tự nhiên!

Run test

$ npm run test

Chúng ta có kết quả:

Test /POST route

describe('/POST pets', () => {
        it('it should POST a pet', (done) => {
            let pet = {
                name: "Bug",
                status: "detected"
            };
            chai.request(server)
                .post('/pets')
                .send(pet)
                .end((err, res) => {
                    res.should.have.status(200);
                    res.body.should.be.a('object');
                    res.body.should.have.property('message').eql('Pet successfully added!');
                    res.body.pet.should.have.property('id');
                    res.body.pet.should.have.property('name').eql(pet.name);
                    res.body.pet.should.have.property('status').eql(pet.status);
                    done();
                });
        });
        it('it should not POST a book without status field', (done) => {
            let pet = {
                name: "Bug"
            };
            chai.request(server)
                .post('/pets')
                .send(pet)
                .end((err, res) => {
                    res.should.have.status(200);
                    res.body.should.be.a('object');
                    res.body.should.have.property('message').eql("Pet is invalid!");
                    done();
                });
        });
    });

Test /GET/:id Route

    describe('/GET/:id pets', () => {
        it('it should GET a pet by the given id', (done) => {
            // TODO add a model to db then get that *id* to take this test
            let id = 1;
            chai.request(server)
                .get('/pets/' + id)
                .end((err, res) => {
                    res.should.have.status(200);
                    res.body.should.be.a('object');
                    res.body.should.have.property('pet');
                    res.body.pet.should.have.property('id').eql(id);
                    res.body.pet.should.have.property('name');
                    res.body.pet.should.have.property('status');
                    done();
                });
        });
    });

Test the /PUT/:id Route

describe('/PUT/:id pets', () => {
        it('it should UPDATE a pet given the id', (done) => {
            // TODO add a model to db then get that id to take this test
            let id = 1;
            chai.request(server)
                .put('/pets/' + id)
                .send({
                    name: "Bug",
                    status: "fixed"
                })
                .end((err, res) => {
                    res.should.have.status(200);
                    res.body.should.be.a('object');
                    res.body.should.have.property('pet');
                    res.body
            
            
            
         
0