[Из песочницы] Тестирование 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.listen(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
Давайте добавим новую книгу:
![](https://habrastorage.org/files/116/5bb/06a/1165bb06ab8640c9b1ccd52602958c05.png» alt=«image)
Похоже, что книга добавилась. Сервер вернул книгу и сообщение, подтверждающее, что она была добавлена. Так ли это? Выполним еще один GET запрос и посмотрим на результат:
![](https://cdn.scotch.io/144/DMfg6tELQ3uqMRC4cbuD_getbook2.png» alt=«image)
Работает!
PUT /BOOK/: ID
Давайте поменяем количество страниц в книге и посмотрим на результат:
![](https://habrastorage.org/files/51e/d23/ccd/51ed23ccdcb247489b3a997cec4c4dc8.png» alt=«image)
Отлично! PUT тоже работает, так что можно выполнить еще один GET запрос для проверки
![](https://habrastorage.org/files/6b9/3e4/29c/6b93e429c3be4c71bcc91723bca3ccc1.png» alt=«image)
Все работает…
GET /BOOK/: ID
Теперь получим одну книгу по ID в GET запросе и потом удалим ее:
![](https://habrastorage.org/files/b03/2e0/da6/b032e0da69814e8b874ba6666facf93e.png» alt=«image)
Получили правильный ответ и теперь удалим эту книгу:
DELETE /BOOK/: ID
Посмотрим на результат удаления:
![](https://habrastorage.org/files/192/9b7/ae0/1929b7ae07674926966fd8f474630507.png» alt=«image)
Даже последний запрос работает как и задумано и нам даже не нужно делать еще один GET запрос для проверки, так как мы отправили клиенту ответ от mongo (свойство result), которое показывает, что книга действительно удалилась.
При выполнении тестом через POSTMAN приложение ведет себя как и ожидается, верно? Значит, его можно можно использовать на клиенте?
Давайте я вам отвечу: НЕТ!
Наши действия я называю наивным тестированием, потому что мы выполнили только несколько операций без учета спорных случаев: POST запрос без ожидаемых данных, DELETE с неверным 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
и получим:
![](https://habrastorage.org/files/5c7/981/382/5c7981382cd348418f260fb55d4a4edb.png» alt=«image)
Тест прошел и вывод отражает структуру, которую мы описали с помощью блоков 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 ().
Давайте выполним команду еще раз и посмотрим на вывод:
![](https://habrastorage.org/files/0ee/989/0b9/0ee9890b9fcf45f989f58ebc77b32a1d.png» alt=«image)
Тест работает!
Перед тем, как писать следующий тест, уточним пару вещей:
- Во-первых, почему ответ от сервера имеет такую структуру? Если вы читали
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();
});
});
На этот раз мы ожидаем объект, говорящий нам, что книга добавилась успешно и собственно книгу. Вы уже должны быть хорошо знакомы с проверками, так что нет нужды вдаваться в детали.
Снова запустим команду и получим:
![](https://habrastorage.org/files/633/26b/7f1/63326b7f1df949998ef4ed700e8545be.png» alt=«image)
Тестируем /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 в ответе от севера совпадает с запрошенным):
![](https://habrastorage.org/files/28b/2ca/0ac/28b2ca0acd664da392bdbff7cfd78605.png» alt=«image)
Вы заметили, что в тестированием отдельных маршрутов внутри независимых блоков мы получили очень чистый вывод? К томе же это эффективно: мы написали несколько тестов, которые можно повторить с помощью одной команды
Тестируем /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
действительно изменилось.
![](https://habrastorage.org/files/14d/e4c/c44/14de4cc44f0a4b0594d25dd5be70f9be.png» alt=«image)
Мы почти закончили.
Тестируем /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
, который мы и проверяем. В консоли будет следующее:
![](https://habrastorage.org/files/d36/f42/da4/d36f42da44854bb184eacfb1130f9abd.png» alt=«image)
Восхитительно! Наши тесты проходят и у нас есть отличная база для тестирования нашего API с помощью более изысканных проверок.
Заключение
В этом уроке мы столкнулись с проблемой тестирования наших маршрутов, чтобы предоставить нашим пользователям стабильный API.
Мы прошли через все этапы создания RESTful API, делая наивные тесты с POSTMAN, а затем предложили лучший способ тестирования, являлось нашей основной целью.
Написание тестов является хорошей привычкой для обеспечения стабильности работы сервера. К сожалению часто это недооценивается.
Бонус: Mockgoose
Всегда найдется кто-то, кто скажет что две базы — это не лучшее решение, но другого не дано. И что же делать? Альтернатива есть: Mockgoose.
По сути Mockgoose создает обертку для Mongoose, которая перехватывает обращения к базе и вместо этого использует in memory хранилище. К тому же он легко интегрируется с mocha
Примечание: Mockgoose требует чтобы на машине, где запускаются тесты была установлена mongodb