Viết test cho RESTful API bằng Mocha và Chai

Việc viết unit test đóng vai trò quan trọng trong việc đảm bảo chất lượng của API mà ta phát triển. Mocha và Chai sẽ giúp chúng ta thực hiện được công việc đó dễ dàng hơn. Phạm vi bài viết không bao phủ toàn bộ tính năng của Mocha và Chai, mà được trình bày theo case study là ta viết 1 RESTful API bằng nodejs, sau đó áp dụng Mocha và Chai để viết test cho API mà ta vừa viết, đồng thời giải thích ý nghĩa của những hàm hay phương thức mà ta vừa sử dụng. Để biết đầy đủ những gì Mocha và Chai hỗ trợ, bạn có thể link ở mục tài liệu tham khảo.

Mocha: Testing Environment

Mocha là một framework cho phép ta viết test cho code NodeJS không đồng bộ. Nó cung cấp môi trường mà trong đó ta có thể sử dụng các thư viện assertion mà ta ưu thích để viết test.

Mocha có rất nhiều tính năng tuyệt vời, bên dưới là 1 số tính năng mà ta thường dùng:

  • Hỗ trợ viết test cho code bất đồng bộ 1 cách đơn giản, bao gồm việc sử dụng promise.
  • Hỗ trợ viết test timeout cho cho code bất đồng bộ.
  • Sử dụng các thuật ngữ như before, after, before each, after each hooks: rất hữu dụng trong việc chuẩn bị môi trường test.
  • Cho phép sử dụng thư việc assertion bên ngoài (mà trong trường hợp này viết này, chúng ta sử dụng thư viện Chai).

Chai: Assertion Library

Với Mocha, chúng ta có môi trường để thực hiện các test của mình, nhưng làm cách nào để chúng ta test các HTTP call chẳng hạn? Hơn nữa, làm thế nào để chúng ta test xem liệu một GET request với input mà ra truyền vào có thực sự trả về JSON đúng như mong đợi của chúng hay không? Với nhu cầu đó, chúng ta cần một thư viện assertion.

Project setup

Cấu trúc project

Chú ý là thư mục config chứa 3 file JSON, chúng được đặt tên và config theo những mục đích nhất định. Trong bài viết này ta sẽ sử dụng 2 databases, 1 cho mục đích phát triển và database còn lại cho mục đích test. Việc dùng 2 databases giúp ta hạn chế lỗi phát sinh do dùng cùng 1 database cho cả mục đích phát triển và mục đích test. Lỗi này thường phát sinh khi ta chuẩn bị dữ liệu test, dẫn đến xóa hoặc thay đổi dữ liệu mà ta dùng cho mục đích phát triển.

  • default.json
{
	"DBHost" : "YOUR_DB_URI"
}
  • dev.json
{
	"DBHost": "YOUR_DB_URI"
}
  • test.json
{
	"DBHost": "YOUR_TEST_DB_URI"
}

Package.json

Ta tạo file package.json với nội dung như bên dưới

{
  "name": "bookstore",
  "version": "1.0.0",
  "description": "A bookstore API",
  "main": "server.js",
  "author": "Sam",
  "license": "ISC",
  "dependencies": {
    "body-parser": "^1.15.1",
    "config": "^1.20.1",
    "express": "^4.13.4",
    "mongoose": "^4.4.15",
    "morgan": "^1.7.0"
  },
  "devDependencies": {
    "chai": "^3.5.0",
    "chai-http": "^2.0.1",
    "mocha": "^2.4.5"
  },
  "scripts": {
    "start": "SET NODE_ENV=dev && node server.js",
    "test": "mocha --timeout 10000"
  }
}

mocha, chai, chai-http được lưu trong devDependencies (flag --save-dev từ command line)
Ta thêm flag --timeout 10000 vì dữ liệu fetch từ mongodb mặc định là 2 giây thì có thể không đủ. Bây giờ ta có thể tiến hành viết code cho server và viết test cho nó.

Phía server

Main

Ta tạo file server.js với nội dung như bên dưới

let express = require('express');
let app = express();
let mongoose = require('mongoose');
let morgan = require('morgan');
let bodyParser = require('body-parser');
let port = 8080;
let book = require('./app/routes/book');
let config = require('config'); //we load the db location from the JSON files
//db options
let options = { 
				server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, 
                replset: { socketOptions: { keepAlive: 1, connectTimeoutMS : 30000 } } 
              }; 

//db connection      
mongoose.connect(config.DBHost, options);
let db = mongoose.connection;
db.on('error', console.error.bind(console, 'connection error:'));

//don't show the log when it is test
if(config.util.getEnv('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 Bookstore!"}));

app.route("/book")
	.get(book.getBooks)
	.post(book.postBook);
app.route("/book/:id")
	.get(book.getBook)
	.delete(book.deleteBook)
	.put(book.updateBook);


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

module.exports = app; // for testing

Lưu ý:

  • Như đã đề cập bên trên, tùy vào môi trường ta đang sử dụng là gì, ta sẽ sử dụng database tương ứng được quy định trong file mà ta đã config. Khi viết test ta sẽ sử dụng "datase test" từ đó giữ "real database" được clean.
  • Dòng cuối cùng, ta export server ra để sử dụng.

Model và Routes

Trong như mục /app/model. Ta tạo file book.js với nội dung như bên dưới

let mongoose = require('mongoose');
let Schema = mongoose.Schema;

//book schema definition
let BookSchema = new Schema(
  {
    title: { type: String, required: true },
    author: { type: String, required: true },
    year: { type: Number, required: true },
    pages: { type: Number, required: true, min: 1 },
    createdAt: { type: Date, default: Date.now },    
  }, 
  { 
    versionKey: false 
  }
);

// Sets the createdAt parameter equal to the current time
BookSchema.pre('save', next => {
  now = new Date();
  if(!this.createdAt) {
    this.createdAt = now;
  }
  next();
});

//Exports the BookSchema for use elsewhere.
module.exports = mongoose.model('book', BookSchema);

Trong như mục /app/routes. Ta tạo file book.js với nội dung như bên dưới

let mongoose = require('mongoose');
let Book = require('../models/book');

/*
 * GET /book route to retrieve all the books.
 */
function getBooks(req, res) {
	//Query the DB and if no errors, send all the books
	let query = Book.find({});
	query.exec((err, books) => {
		if(err) res.send(err);
		//If no errors, send them back to the client
		res.json(books);
	});
}

/*
 * POST /book to save a new book.
 */
function postBook(req, res) {
	//Creates a new book
	var newBook = new Book(req.body);
	//Save it into the DB.
	newBook.save((err,book) => {
		if(err) {
			res.send(err);
		}
		else { //If no errors, send it back to the client
			res.json({message: "Book successfully added!", book });
		}
	});
}

/*
 * GET /book/:id route to retrieve a book given its id.
 */
function getBook(req, res) {
	Book.findById(req.params.id, (err, book) => {
		if(err) res.send(err);
		//If no errors, send it back to the client
		res.json(book);
	});		
}

/*
 * DELETE /book/:id to delete a book given its id.
 */
function deleteBook(req, res) {
	Book.remove({_id : req.params.id}, (err, result) => {
		res.json({ message: "Book successfully deleted!", result });
	});
}

/*
 * PUT /book/:id to updatea a book given its id
 */
function updateBook(req, res) {
	Book.findById({_id: req.params.id}, (err, book) => {
		if(err) res.send(err);
		Object.assign(book, req.body).save((err, book) => {
			if(err) res.send(err);
			res.json({ message: 'Book updated!', book });
		});	
	});
}

//export all the functions
module.exports = { getBooks, postBook, getBook, deleteBook, updateBook };

Sau đây là 1 số khái niệm cơ bản:

  • Route sẽ phụ trách điều hướng để thực hiện các thao tác GET, POST, DELETE, PUT.
  • Trong hàm updateBook (), chúng ta sử dụng Object.assign, một hàm mới được giới thiệu trong ES6, trong trường hợp này, ghi đè các thuộc tính chung của sách với req.body.
  • Cuối cùng, chúng ta export đối tượng bằng cách sử dụng cú pháp nhanh với cặp khóa và giá trị để tránh lặp lại vô ích.

Tiến hành viết test

Trong đường dẫn /test, ta tạo file book.js vào dán vào nội dung như bên dưới

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

let mongoose = require("mongoose");
let Book = require('../app/models/book');

//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('Books', () => {
    beforeEach((done) => { //Before each test we empty the database
        Book.remove({}, (err) => { 
           done();         
        });     
    });
/*
  * Test the /GET route
  */
  describe('/GET book', () => {
      it('it should GET all the books', (done) => {
        chai.request(server)
            .get('/book')
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('array');
                res.body.length.should.be.eql(0);
              done();
            });
      });
  });

});

Ở đây có 1 số thứ mới ta cần chú ý:

  • Ta set giá trị cho NODE_ENV là test. Khi đó server sẽ connect vào test database.
  • Ta định nghĩa should bằng việc set giá trị cho nó bằng chai.should(). should ở đây có nghĩ là "sẽ", dùng để ta so sánh giá trị thực và giá trị mà ta mong muốn. Ví dụ dòng 'res.should.have.status(200);' có nghĩ là ta mong muốn res sẽ có status bằng giá trị mà ta mong muốn là 200.
  • describe sẽ giúp ta mô ta là ta đang test cái gì. Ví dụ ở đoạn code phía trên, ta đang test link '/GET book'
  • beforeEach là block code sẽ được chạy trước mỗi describe block ở cùng level.

Test phần GET

Chai sẽ thực hiện một yêu cầu GET đến máy chủ và các xác nhận trên biến res trong it block có thỏa mãn các giá trị mà ta mong đợi hay không. Ở phần describe ta sẽ mô tả là ta đang viết test cho đường link '/GET book'. Trong đó mỗi it là case của thao tác test đường link '/GET book'. Có thể có 1 hoặc nhiều it tương ứng với có bao nhiêu case. Những gì mà ta mong đợi sẽ là:

  • Trạng thái 200.
  • Kết quả phải là một mảng.
  • Vì cửa hàng không có sách nào nên ta cho length = 0.

Bây giờ, từ command line, ta chạy lệnh

npm test

Và đây là output:

Test phần POST

Giả sử chúng ta đang cố gắng thêm một cuốn sách có field bị thiếu đến máy chủ. Khi đó ta sẽ phải viết các assertion sau:

  • Trạng thái là 200.
  • response body là một đối tượng.
    Một trong những properties của body là errors.
  • Errors có thuộc tính là các trang trường bị thiếu.
  • pages có propertiy là kind và kind = required.
process.env.NODE_ENV = 'test';

let mongoose = require("mongoose");
let Book = require('../app/models/book');

let chai = require('chai');
let chaiHttp = require('chai-http');
let server = require('../server');
let should = chai.should();

chai.use(chaiHttp);

describe('Books', () => {
    beforeEach((done) => {
        Book.remove({}, (err) => { 
           done();         
        });     
    });
  describe('/GET book', () => {
      it('it should GET all the books', (done) => {
        chai.request(server)
            .get('/book')
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('array');
                res.body.length.should.be.eql(0);
              done();
            });
      });
  });
  /*
  * Test the /POST route
  */
  describe('/POST book', () => {
      it('it should not POST a book without pages field', (done) => {
        let book = {
            title: "The Lord of the Rings",
            author: "J.R.R. Tolkien",
            year: 1954
        }
        chai.request(server)
            .post('/book')
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('errors');
                res.body.errors.should.have.property('pages');
                res.body.errors.pages.should.have.property('kind').eql('required');
              done();
            });
      });

  });
});

Run command line và ta có được output như sau:

Bây giờ ta viết test cho POST request với nội dung đầy đủ các field

process.env.NODE_ENV = 'test';

let mongoose = require("mongoose");
let Book = require('../app/models/book');

let chai = require('chai');
let chaiHttp = require('chai-http');
let server = require('../server');
let should = chai.should();

chai.use(chaiHttp);

describe('Books', () => {
    beforeEach((done) => {
        Book.remove({}, (err) => { 
           done();         
        });     
    });
  describe('/GET book', () => {
      it('it should GET all the books', (done) => {
        chai.request(server)
            .get('/book')
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('array');
                res.body.length.should.be.eql(0);
              done();
            });
      });
  });
  /*
  * Test the /POST route
  */
  describe('/POST book', () => {
      it('it should not POST a book without pages field', (done) => {
        let book = {
            title: "The Lord of the Rings",
            author: "J.R.R. Tolkien",
            year: 1954
        }
        chai.request(server)
            .post('/book')
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('errors');
                res.body.errors.should.have.property('pages');
                res.body.errors.pages.should.have.property('kind').eql('required');
              done();
            });
      });
      it('it should POST a book ', (done) => {
        let book = {
            title: "The Lord of the Rings",
            author: "J.R.R. Tolkien",
            year: 1954,
            pages: 1170
        }
        chai.request(server)
            .post('/book')
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('message').eql('Book successfully added!');
                res.body.book.should.have.property('title');
                res.body.book.should.have.property('author');
                res.body.book.should.have.property('pages');
                res.body.book.should.have.property('year');
              done();
            });
      });
  });
});

Lần này ta mọng đợi message trả về với nội dung ta đã thành công trong việc add quyển sách.

Test phần /GET/:id

Ta tạo 1 quyển sách, lưu nó lại. Sau đó get quyển sách vừa lưu bằng id.

process.env.NODE_ENV = 'test';

let mongoose = require("mongoose");
let Book = require('../app/models/book');

let chai = require('chai');
let chaiHttp = require('chai-http');
let server = require('../server');
let should = chai.should();

chai.use(chaiHttp);

describe('Books', () => {
    beforeEach((done) => {
        Book.remove({}, (err) => { 
           done();         
        });     
    });
  describe('/GET book', () => {
      it('it should GET all the books', (done) => {
            chai.request(server)
            .get('/book')
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('array');
                res.body.length.should.be.eql(0);
              done();
            });
      });
  });
  describe('/POST book', () => {
      it('it should not POST a book without pages field', (done) => {
        let book = {
            title: "The Lord of the Rings",
            author: "J.R.R. Tolkien",
            year: 1954
        }
            chai.request(server)
            .post('/book')
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('errors');
                res.body.errors.should.have.property('pages');
                res.body.errors.pages.should.have.property('kind').eql('required');
              done();
            });
      });
      it('it should POST a book ', (done) => {
        let book = {
            title: "The Lord of the Rings",
            author: "J.R.R. Tolkien",
            year: 1954,
            pages: 1170
        }
            chai.request(server)
            .post('/book')
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('message').eql('Book successfully added!');
                res.body.book.should.have.property('title');
                res.body.book.should.have.property('author');
                res.body.book.should.have.property('pages');
                res.body.book.should.have.property('year');
              done();
            });
      });
  });
 /*
  * Test the /GET/:id route
  */
  describe('/GET/:id book', () => {
      it('it should GET a book by the given id', (done) => {
        let book = new Book({ title: "The Lord of the Rings", author: "J.R.R. Tolkien", year: 1954, pages: 1170 });
        book.save((err, book) => {
            chai.request(server)
            .get('/book/' + book.id)
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('title');
                res.body.should.have.property('author');
                res.body.should.have.property('pages');
                res.body.should.have.property('year');
                res.body.should.have.property('_id').eql(book.id);
              done();
            });
        });

      });
  });
});

Lần này ta assert status là 200, body là object và có chứa đầy đủ các thuộc tính.

Run command line và ta được output như hình bên dưới.

Test phần /PUT/:id Route

Giờ là lúc để update. Đầu tiên ta save book, sau đó update năm xuất bản dựa trên id.

process.env.NODE_ENV = 'test';

let mongoose = require("mongoose");
let Book = require('../app/models/book');

let chai = require('chai');
let chaiHttp = require('chai-http');
let server = require('../server');
let should = chai.should();

chai.use(chaiHttp);

describe('Books', () => {
    beforeEach((done) => {
        Book.remove({}, (err) => { 
           done();         
        });     
    });
  describe('/GET book', () => {
      it('it should GET all the books', (done) => {
            chai.request(server)
            .get('/book')
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('array');
                res.body.length.should.be.eql(0);
              done();
            });
      });
  });
  describe('/POST book', () => {
      it('it should not POST a book without pages field', (done) => {
        let book = {
            title: "The Lord of the Rings",
            author: "J.R.R. Tolkien",
            year: 1954
        }
            chai.request(server)
            .post('/book')
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('errors');
                res.body.errors.should.have.property('pages');
                res.body.errors.pages.should.have.property('kind').eql('required');
              done();
            });
      });
      it('it should POST a book ', (done) => {
        let book = {
            title: "The Lord of the Rings",
            author: "J.R.R. Tolkien",
            year: 1954,
            pages: 1170
        }
            chai.request(server)
            .post('/book')
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('message').eql('Book successfully added!');
                res.body.book.should.have.property('title');
                res.body.book.should.have.property('author');
                res.body.book.should.have.property('pages');
                res.body.book.should.have.property('year');
              done();
            });
      });
  });
  describe('/GET/:id book', () => {
      it('it should GET a book by the given id', (done) => {
        let book = new Book({ title: "The Lord of the Rings", author: "J.R.R. Tolkien", year: 1954, pages: 1170 });
        book.save((err, book) => {
            chai.request(server)
            .get('/book/' + book.id)
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('title');
                res.body.should.have.property('author');
                res.body.should.have.property('pages');
                res.body.should.have.property('year');
                res.body.should.have.property('_id').eql(book.id);
              done();
            });
        });

      });
  });
 /*
  * Test the /PUT/:id route
  */
  describe('/PUT/:id book', () => {
      it('it should UPDATE a book given the id', (done) => {
        let book = new Book({title: "The Chronicles of Narnia", author: "C.S. Lewis", year: 1948, pages: 778})
        book.save((err, book) => {
                chai.request(server)
                .put('/book/' + book.id)
                .send({title: "The Chronicles of Narnia", author: "C.S. Lewis", year: 1950, pages: 778})
                .end((err, res) => {
                    res.should.have.status(200);
                    res.body.should.be.a('object');
                    res.body.should.have.property('message').eql('Book updated!');
                    res.body.book.should.have.property('year').eql(1950);
                  done();
                });
          });
      });
  });
});

Ta assert phần message xem có đúng như mong đợi là "Book updated!" và năm xuất bản có được cập nhật đúng hay không.

Test phần /DELETE/:id Route

Quy trình cũng tương tự: save book, sau đó delete book và ta vừa save và check response.

process.env.NODE_ENV = 'test';

let mongoose = require("mongoose");
let Book = require('../app/models/book');

let chai = require('chai');
let chaiHttp = require('chai-http');
let server = require('../server');
let should = chai.should();

chai.use(chaiHttp);

describe('Books', () => {
    beforeEach((done) => {
        Book.remove({}, (err) => { 
           done();         
        });     
    });
  describe('/GET book', () => {
      it('it should GET all the books', (done) => {
            chai.request(server)
            .get('/book')
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('array');
                res.body.length.should.be.eql(0);
              done();
            });
      });
  });
  describe('/POST book', () => {
      it('it should not POST a book without pages field', (done) => {
        let book = {
            title: "The Lord of the Rings",
            author: "J.R.R. Tolkien",
            year: 1954
        }
            chai.request(server)
            .post('/book')
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('errors');
                res.body.errors.should.have.property('pages');
                res.body.errors.pages.should.have.property('kind').eql('required');
              done();
            });
      });
      it('it should POST a book ', (done) => {
        let book = {
            title: "The Lord of the Rings",
            author: "J.R.R. Tolkien",
            year: 1954,
            pages: 1170
        }
            chai.request(server)
            .post('/book')
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('message').eql('Book successfully added!');
                res.body.book.should.have.property('title');
                res.body.book.should.have.property('author');
                res.body.book.should.have.property('pages');
                res.body.book.should.have.property('year');
              done();
            });
      });
  });
  describe('/GET/:id book', () => {
      it('it should GET a book by the given id', (done) => {
        let book = new Book({ title: "The Lord of the Rings", author: "J.R.R. Tolkien", year: 1954, pages: 1170 });
        book.save((err, book) => {
            chai.request(server)
            .get('/book/' + book.id)
            .send(book)
            .end((err, res) => {
                res.should.have.status(200);
                res.body.should.be.a('object');
                res.body.should.have.property('title');
                res.body.should.have.property('author');
                res.body.should.have.property('pages');
                res.body.should.have.property('year');
                res.body.should.have.property('_id').eql(book.id);
              done();
            });
        });

      });
  });
  describe('/PUT/:id book', () => {
      it('it should UPDATE a book given the id', (done) => {
        let book = new Book({title: "The Chronicles of Narnia", author: "C.S. Lewis", year: 1948, pages: 778})
        book.save((err, book) => {
                chai.request(server)
                .put('/book/' + book.id)
                .send({title: "The Chronicles of Narnia", author: "C.S. Lewis", year: 1950, pages: 778})
                .end((err, res) => {
                    res.should.have.status(200);
                    res.body.should.be.a('object');
                    res.body.should.have.property('message').eql('Book updated!');
                    res.body.book.should.have.property('year').eql(1950);
                  done();
                });
          });
      });
  });
 /*
  * Test the /DELETE/:id route
  */
  describe('/DELETE/:id book', () => {
      it('it should DELETE a book given the id', (done) => {
        let book = new Book({title: "The Chronicles of Narnia", author: "C.S. Lewis", year: 1948, pages: 778})
        book.save((err, book) => {
                chai.request(server)
                .delete('/book/' + book.id)
                .end((err, res) => {
                    res.should.have.status(200);
                    res.body.should.be.a('object');
                    res.body.should.have.property('message').eql('Book successfully deleted!');
                    res.body.result.should.have.property('ok').eql(1);
                    res.body.result.should.have.property('n').eql(1);
                  done();
                });
          });
      });
  });
});

Trong phần assertion, ta cũng check giá trị của response xem có đúng các giá trị mà ta mong đợi hay không. Sau đó run và được kết quả như bên dưới:

Tài liệu tham khảo

https://mochajs.org/
http://www.chaijs.com/
https://scotch.io/tutorials/test-a-node-restful-api-with-mocha-and-chai