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(); }); }); }); });
- 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...)
- 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 }); ...
- describe định nghĩ một block các test case cho cùng một loại.
- 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à:
- http status là 200
- body trả về là một array
- độ 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