Тестування RESTful API на NodeJS з Mocha і Chai


Переклад керівництва Samuele Zaza. Текст оригінальної статті можна знайти тут: https://scotch.io/tutorials/test-a-node-restful-api-with-mocha-and-chai*
Я до сих пір пам'ятаю захват від можливості нарешті писати бекэнд великого проекту на node і я впевнений, що багато хто розділяє мої почуття.
А що далі? Ми повинні бути впевнені, що наш додаток поводиться так, як ми того очікуємо. Один з найпоширеніших способів досягти цього — тести. Тестування — це дуже корисна річ, коли ми додаємо нову фічу в додаток: наявність вже встановленого і налаштованого тестового оточення, яке може бути запущено однією командою, допомагає зрозуміти, в якому місці нова фіга породить нові баги.
Раніше ми обговорювали розробку RESTful Node API і аутентифікацію Node API. У цьому керівництві ми напишемо простий RESTful API і використовуємо Mocha і Chai для його тестування. Ми будемо тестувати CRUD для програми "книгосховище".
Як завжди, ви можете все робити по кроках, читаючи керівництво, або завантажити вихідний код github.
Mocha: Тестове оточення
Mocha — це javascript фреймворк для Node.js, який дозволяє проводити асинхронне тестування. Скажімо так: він створює оточення, в якому ми можемо використовувати свої улюблені assert бібліотеки.

Mocha поставляється з величезною кількістю можливостей. На сайті їх величезний список. Найбільше мені подобається наступне:
  • проста підтримка асинхронності, включаючи Promise
  • підтримка таймаутів асинхронного виконання
  • before
    ,
    after
    ,
    before each
    ,
    after each
    хуки (дуже корисно для очищення оточення перед тестами)
  • використання будь assertion бібліотеки, яку ви заходите (в нашому випадку Chai)
Chai: assertion бібліотека
Отже, з Mocha у нас з'явилося оточення для виконання наших тестів, але як ми будемо тестувати HTTP запити, наприклад? Більш того, як перевірити, що GET запит повернув очікуваний JSON у відповідь, залежно від переданих параметрів? Нам потрібна assertion бібліотека, тому що mocha явно недостатньо.
Для цього керівництва я вибрав Chai:

Chai дає нам зводу вибору інтерфейсу: "should", "expect", "assert". Особисто використовую should, але ви можете вибрати будь-яку. До того ж у Chai є плагін Chai HTTP, який дозволяє без труднощів тестувати HTTP запити.
PREREQUISITES
  • Node.js: базове розуміння node.js і рекомендується базове розуміння RESTful API (я не буду сильно заглиблюватися в деталі реалізації).
  • POSTMAN для виконання запитів до API.
  • Синтакс ES6: я вирішив використовувати останню версію Node (6..), в якій добре реалізована інтеграція ES6 features для кращої читання коду. Якщо ви не дуже дружите з ES6, ви можете почитати відмінні статті (Pt.1, Pt.2 and Pt.3). Але не турбуйтеся, я буду давати пояснення коли зустрінеться який-небудь особливі синтаксис.
Настав час настроїти наш книгосховище.
Налаштування проекту
Структура папок
Структура проекту буде мати наступний вигляд:
-- controllers 
---- models
------ book.js
---- routes
------ book.js
-- config
---- default.json
---- dev.json
---- test.json
-- test
---- book.js
package.json
server.json

Зверніть увагу, що папка
/config
містить 3 JSON файлу: як видно з назви, вони містять налаштування для різного оточення.
У цьому посібнику ми будемо перемикатися між двома базами даних — одна для розробки, інша для тестування. Такі чином, файли будуть містити mongodb URI в форматі JSON:
dev.json
та
default.json
:
{
"DBHost": "YOUR_DB_URI"
}

test.json
:
{
"DBHost": "YOUR_TEST_DB_URI"
}

Більше про файлах конфігурації (папка config, порядок файлів, формат файлів) можна почитати [тут] https://github.com/lorenwest/node-config/wiki/Configuration-Files).
Зверніть увагу на файл
/test/book.js
, в якому будуть всі наші тести.
package.json
Створіть файл
package.json
і вставити наступне:
{
"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"
}
}

Знову-таки, нічого нового для того, хто написав хоча б один сервер на node.js. Пакети
mocha
,
chai
,
chai-http
, необхідні для тестування, встановлюються в блок
dev-dependencies
(прапор
--save-dev
з командного рядка).
Блок
scripts
містить два способи запуску сервера.
Для mocha я додав прапор
--timeout 10000
, тому що я забираю дані з бази, розташованої на mongolab і відпущені двох секунд за замовчуванням може не вистачати.
Ура! Ми закінчили нудну частину керівництва і настав час написати сервер і протестувати його.

Давайте створимо файл
server.js
і вставимо наступний код:
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'); // завантажуємо адресу бази з конфіги
//налаштування бази
let options = { 
server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, 
replset: { socketOptions: { keepAlive: 1, connectTimeoutMS : 30000 } } 
}; 

//з'єднання з базою 
mongoose.connect(config.DBHost, options);
let db = mongoose.connection;
db.on('error', console.error.bind(console, 'connection error:'));

//не показувати логи в тестовому оточенні
if(config.util.getEnv('NODE_ENV') !== 'test') {
//morgan для виведення логів в консоль
app.use(morgan('combined')); //'combined' виводить логи в стилі apache
}

//парсинг application/json 
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.слухати(port);
console.log("Listening on port " + port);

module.exports = app; // для тестування

Основні моменти:
  • Нам потрібен модуль
    config
    для доступу до файлу конфігурації відповідно до змінної оточення NODE_ENV. З нього ми отримуємо mongo db URI для з'єднання з базою даних. Це дозволить нам містити основну базу чистою, а проводити тести на окремій бази, прихованої від користувачів.
  • Змінна оточення NODE_ENV перевіряється на значення "test", щоб відключити логування morgan в командному рядку, інакше вони з'являться у висновку при запуску тестів.
  • Остання рядок експортує сервер для тестів.
  • Зверніть увагу на оголошення змінних через
    let
    . Воно робить змінну видимою тільки в рамках останнього блоку або глобально, якщо вона поза блоку.
В інше нічого нового: ми просто підключаємо потрібні модулі, визначаємо налаштування для взаємодії з сервером, створюємо точки входу і запускаємо сервер на визначеному порту.
Моделі і роутинг
Настав час для описати модель книги. Створимо файл
book.js
в папці
/app/model/
з наступним вмістом:
let mongoose = require('mongoose');
let Schema = mongoose.Schema;

//визначення схеми книги
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
}
);

// встановити параметр createdAt рівним поточного часу
BookSchema.pre('save', next => {
now = new Date();
if(!this.createdAt) {
this.createdAt = now;
}
next();
});

//Експорт моделі для подальшого використання.
module.exports = mongoose.model('book', BookSchema);

У нашої книги є назва, автор, кількість сторінок, рік публікації і дата створення в базі. Я встановив опції
versionKey
на
false
, так як вона не потрібна в даному керівництві.
Незвичайний callback в .pre() — це функція стрілка, функція з більш коротким синтаксисом. Згідно з визначенням MDN: "прив'язується до поточного значення
this
(не має власного
this
,
arguments
,
super
, or
new.target
). Функції-стрілки завжди анонімні".
Чудово, тепер ми знаємо все що потрібно про моделі і переходимо до роутам.
В папці
/app/routes/
створимо файл
book.js
наступного змісту:
let mongoose = require('mongoose');
let Book = require('../models/book');

/*
* GET /book маршрут для отримання списку всіх книг.
*/
function getBooks(req, res) {
//Зробити запит у базу і, якщо не помилок, віддати весь список книг
let query = Book.find({});
query.exec((err, books) => {
if(err) res.send(err);
//якщо немає помилок, надіслати клієнту
res.json(books);
});
}

/*
* POST /book для створення нової книги.
*/
function postBook(req, res) {
//Створити нову книгу
var newBook = new Book(req.body);
//Зберегти в базу.
newBook.save((err,book) => {
if(err) {
res.send(err);
}
else { //Якщо немає помилок, надіслати відповідь клієнту
res.json({message: "Book successfully added!", book });
}
});
}

/*
* GET /book/:id маршрут для отримання книги по ID.
*/
function getBook(req, res) {
Book.findById(req.params.id (err, book) => {
if(err) res.send(err);
//Якщо немає помилок, надіслати відповідь клієнту
res.json(book);
}); 
}

/*
* DELETE /book/:id маршрут для видалення книги по ID.
*/
function deleteBook(req, res) {
Book.remove({_id : req.params.id}, (err, result) => {
res.json({ message: "Book successfully deleted!", result });
});
}

/*
* PUT /book/:id маршрут для редагування книги по 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 });
}); 
});
}

//експортуємо всі функції
module.exports = { getBooks, postBook, getBook, deleteBook, updateBook };

Основні моменти:
  • Всі маршрути стандартні GET, POST, DELETE, PUT для виконання CRUD.
  • функції updatedBook() ми використовуємо
    Object.assign
    , нову функцію ES6, яка перезаписує загальні властивості
    book
    та
    req.body
    і залишає.інші недоторканими
  • наприкінці ми експортуємо об'єкт з використанням синтаксису "короткий властивість" (російською можна почитати тут, прим. перекладача) щоб не робити повторень.
Ми закінчили цю частину і отримали готове додаток!
Наївне тестування
Давайте запустимо наш додаток, відкриємо POSTMAN для відправки HTTP запитів до сервера і перевіримо що все працює, як очікувалося.
У командному рядку выпоним
npm start

GET /BOOK
У POSTMAN виконаємо GET запит і, якщо припустити що в базі є книги, отримаємо відповідь:
:
Сервер без помилок повернув книги з бази.
POST /BOOK
Давайте додамо нову книгу:

Схоже, що книга додалася. Сервер повернув книгу і повідомлення, яке підтверджує, що вона була додана. Чи це Так? Виконаємо ще один GET запит і подивимося на результат:

Працює!
PUT /BOOK/:ID
Давайте поміняємо кількість сторінок у книзі і подивимося на результат:

Відмінно! PUT теж працює, так що можна виконати ще один GET запит для перевірки

Все працює...
GET /BOOK/:ID
Тепер отримаємо одну книгу по ID GET запиті і потім видалимо її:

Отримали правильну відповідь і тепер видалимо цю книгу:
DELETE /BOOK/:ID
Подивимося на результат видалення:

Навіть останній запит працює як і задумано і нам навіть не потрібно робити ще один GET запит для перевірки, так як ми відправили клієнту відповідь від mongo (властивість result), яке показує, що книга дійсно пішла.
При виконанні тестом через POSTMAN додаток поводиться як і очікується, вірно? Значить, його можна використовувати на клієнта?
Давайте я вам відповім: НЕМАЄ!!
Наші дії я називаю наївним тестуванням, тому що ми виконали лише кілька операцій без врахування спірних випадків: POST запит без очікуваних даних, ВИДАЛИТИ з невірним id або зовсім без id.
Очевидно це просте додаток і, якщо нам пощастило, ми не наробили помилок, але як щодо реальних додатків? Більше того, ми витратили час на запуск в POSTMAN деяких тестових HTTP запитів. А що станеться, якщо одного разу ми вирішимо змінити код одного з них? Знову все перевіряти в POSTMAN?
Це лише кілька ситуацій, з якими ви можете зіткнутися або вже зіткнулися як розробник. На щастя, у нас є інструменти, що дозволяють створити тести, які завжди доступні, їх можна запустити однієї командної консолі.
Давайте зробимо щось краще, щоб перевірити наш додаток.
Хороше тестування
По-перше, давайте створимо файл
books.js
в папці
/test
:
//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');

//Підключаємо dev-dependencies
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(); 
}); 
});
/*
* Тест для /GET 
*/
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();
});
});
});

});

Як багато нових штук! Давай розберемося:
  • Обов'язково зверніть увагу на змінну NODE_ENV якої ми присвоїли значення test. Це дозволить сервера завантажити конфіг для тестової бази та не виводити в консоль логи
    morgan
    .
  • Ми підключили dev-dependencies і власне сервер (ми його експортували через module.exports).
  • Ми підключили
    chaiHttp
    на
    chai
    .
Все починається з блоку
describe
, який використовується для поліпшення структуризації наших тверджень. Це відіб'ється на виводі, як ми побачимо пізніше.
beforeEach
— це блок, який виконується для кожного блоку описаного в цьому
describe
блоці. Для чого ми це робимо? Ми видаляємо всі книги з бази, щоб база була порожня на початку кожного тесті.
Тестуємо /GET
Отже, у нас є перший тест. Chai виконує GET запит і перевіряє, що змінна
res
відповідає першому параметру (затвердження) блоку
it
"it should GET all the books". А саме, для цього порожнього книгосховища відповідь має бути наступним:
  • Статус 200.
  • Результат повинен бути масивом.
  • Так як база порожня, ми очікуємо що розмір масиву буде дорівнює 0.
Зверніть увагу, що синтаксис should інтуїтивний і дуже схожий на розмовну мову.
Терерь в командному рядку выпоним:
npm test

і отримаємо:

Тест пройшов і висновок віддзеркалює структуру, яку ми описали за допомогою блоків
describe
.
Тестуємо /POST
Тепер перевіримо наскільки гарний наш API. Припустимо ми намагаємося додати книгу без поля `pages: сервер не повинен повернути відповідну помилку.
Додамо цей код в кінець блоку
describe('Books')
:
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();
});
});

});

Тут ми додали тест на неповний /POST запит. Подивимося на перевірки:
  • Статус повинен бути 200.
  • Тіло відповіді повинно бути об'єктом.
  • Одним із властивостей тіла відповіді повинно бути
    errors
    .
  • У поля
    errors
    має бути пропущене у запиті властивість
    pages
    .
  • pages
    має мати властивість
    kind
    рівне
    required
    щоб показати причину чому ми отримали негативну відповідь від сервера.
Зверніть увагу, що ми відправили дані про книгу за допомогою методу .send().
Давайте виконаємо команду ще раз подивимося на висновок:

Тест працює!!
Перед тим, як писати наступний тест, уточнимо пару речей:
  • По-перше, чому відповідь від сервера має таку структуру? Якщо ви читали
    callback
    для маршруту /POST, то ви побачили що в разі помилки сервер відправляє у відповідь помилку від
    mongoose
    . Спробуйте зробити це через POSTMAN і подивіться на відповідь.
  • У разі помилки ми все одно відповідаємо з кодом 200. Це зроблено для простоти, так як ми тільки вчимося тестувати наш API.
Проте я б запропонував віддавати у відповідь статус 206 Partial Content instead
Давайте відправимо правильний запит. Вставте наступний код в кінець блоку
describe("/POST book")
:
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();
});
});

На цей раз ми очікуємо об'єкт, що говорить нам, що книга додалася успішно і власне книгу. Ви вже повинні бути добре знайомі з перевірками, так що немає потреби вдаватися в деталі.
Знову запустимо команду і отримаємо:

Тестуємо /GET/:ID
Тепер створимо книгу, збережемо її в базу і використовуємо id для виконання GET запиту. Додамо наступний блок:
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();
});
});

});
});

Через asserts ми переконалися, що сервер повернув усі поля і потрібну книгу (id відповіді від півночі збігається з замовленим):

Ви помітили, що в тестуванням окремих маршрутів всередині незалежних блоків ми отримали дуже чистий висновок? До того ж це ефективно: ми написали кілька тестів, які можна повторити з допомогою однієї команди
Тестуємо /PUT/:ID
Настав час перевірити редагування однієї з наших книг. Спочатку ми збережемо книгу в базу, а потім выпоним запит, щоб поміняти рік її публікації.
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();
});
});
});
});

Ми хочемо переконатися, що поле
message
дорівнює
Book updated!
і поле
year
дійсно змінилося.

Ми майже закінчили.
Тестуємо /DELETE/:ID.
Шаблон дуже схожий на попередній тест: спочатку створюємо книгу, потім видаляємо її з допомогою запиту та перевіряємо відповідь:
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();
});
});
});
});

Знову сервер поверне нам відповідь від
mongoose
, який ми і перевіряємо. В консолі буде наступне:

Чудово! Наші тести проходять і у нас є чудова база для тестування нашого API за допомогою більш вишуканих перевірок.
Висновок
У цьому уроці ми зіткнулися з проблемою тестування наших маршрутів, щоб надати нашим користувачам стабільний API.
Ми пройшли через всі етапи створення RESTful API, роблячи наївні тести з POSTMAN, а потім запропонували кращий спосіб тестування, було нашою основною метою.
Написання тестів є хорошою звичкою для забезпечення стабільності роботи сервера. На жаль, часто це недооцінюється.
Бонус: Mockgoose
Завжди знайдеться хтось, хто скаже що дві бази — це не найкраще рішення, але іншого не дано. І що ж робити? Альтернатива є: Mockgoose.
По суті Mockgoose створює обгортку для Mongoose, яка перехоплює звернення до бази і замість цього використовує in memory сховище. До того ж він легко інтегрується з mocha
Примітка: Mockgoose вимагає щоб на машині, де запускаються тести була встановлена mongodb
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.