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

Giới thiệu

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 & 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.

Chuẩn bị

  • 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ài đặt Project

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); // 😀
            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
};

Test

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.pet.should.have.property('name').eql("Bug");
                    res.body.pet.should.have.property('status').eql("fixed");
                    done();
                });
        });
    });

Test the /DELETE/:id Route

describe('/DELETE/:id pets', () => {
        it('it should DELETE 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)
                .delete('/pets/' + id)
                .end((err, res) => {
                    res.should.have.status(200);
                    res.body.should.be.a('object');
                    res.body.should.have.property('message').eql('Pet successfully deleted!');
                    res.body.should.have.property('result');
                    res.body.result.should.have.property('roweffected').eql(1);
                    done();
                });
        });
    });

Hoàn thành viết file test, chúng ta thực hiện test

$ npm test

Kết quả:

Như vậy chúng ta đã hoàn thành việc viết unit test RESTfull API server viết bằng Nodejs. Với những trường ngôn ngữ
server side khác chúng ta cũng có thể dùng Mocha và Chai để thực hiện test RESTfull API

chai.request('http://localhost:8080')
  .get('/')

Tổng kết

  • Các bước tạo ra một Server RESTfull API bằng Nodejs
  • Thực hiện triểm tra đơn giản các API bằng POSTMAN
  • Thực hiện viết unit test nhằm thực hiện tự động test Bài viết với hy vọng tạo ra một thói quen tốt khi phát triền
    phần mềm và tiết kiệm thời gian cho việc phát triển, vận hành dự án. Mục đích cuối cùng vẫn luôn là cung cấp cho
    người dùng cuối một trải nghiệm ổn định.

Source code project: Github

Bài viết được đăng tại Viblo bởi cùng tác giả.

Từ khóa: , , ,