- PVSM.RU - https://www.pvsm.ru -
Перевод руководства Samuele Zaza [1]. Текст оригинальной статьи можно найти здесь: https://scotch.io/tutorials/test-a-node-restful-api-with-mocha-and-chai [2]*
Я до сих пор помню восторг от возможности наконец-то писать бекэнд большого проекта на node и я уверен, что многие разделяют мои чувства.
А что дальше? Мы должны быть уверены, что наше приложение ведет себя так, как мы того ожидаем. Один из самых распространенных способов достичь этого — тесты. Тестирование — это безумно полезная вещь, когда мы добавляем новую фичу в приложение: наличие уже установленного и настроенного тестового окружения, которое может быть запущено одной командой, помогает понять, в каком месте новая фига породит новые баги.
Ранее мы обсуждали разработку RESTful Node API [3] и аутентификацию Node API [4]. В этом руководстве мы напишем простой RESTful API и используем Mocha [5] и Chai [6] для его тестирования. Мы будем тестировать CRUD для приложения "книгохранилище".
Как всегда, вы можете все делать по шагам, читая руководство, или скачать исходный код на github [7].
Mocha — это javascript фреймворк для Node.js, который позволяет проводить асинхронное тестирование. Скажем так: он создает окружение, в котором мы можем использовать свои любимые assert библиотеки.
Mocha поставляется с огромным количеством возможностей. На сайте их огромный список. Больше всего мне нравится следующее:
before
, after
, before each
, after each
хуки (очень полезно для очистки окружения перед тестами)Итак, с Mocha у нас появилось окружение для выполнения наших тестов, но как мы будем тестировать HTTP запросы, например? Более того, как проверить, что GET запрос вернул ожидаемый JSON в ответ, в зависимости от переданных параметров? Нам нужна assertion библиотека, потому что mocha явно недостаточно.
Для этого руководства я выбрал Chai:
Chai дает нам своду выбора интерфейса: "should", "expect", "assert". Лично использую should, но вы можете выбрать любую. К тому же у Chai есть плагин Chai HTTP, который позволяет без затруднений тестировать HTTP запросы.
Настало время настроить наше книгохранилище.
Структура проекта будет иметь следующий вид:
-- 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 [11]).
Обратите внимание на файл /test/book.js
, в котором будут все наши тесты.
Создайте файл 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 для соединения с базой данных. Это позволит нам содержать основную базу чистой, а тесты проводить на отдельной базы, скрытой от пользователей.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 [13]: "привязывается к текущему значению 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 };
Основные моменты:
Object.assign
, новую функцию ES6, которая перезаписывает общие свойства book
и req.body
и оставляет.остальные нетронутымиМы закончили эту часть и получили готовое приложение!
Давайте запустим наше приложение, откроем POSTMAN для отправки HTTP запросов к серверу и проверим что все работает как ожидалось.
В командной строке выпоним
npm start
В POSTMAN выполним GET запрос и, если предположить что в базе есть книги, получим ответ:
:
Сервер без ошибок вернул книги из базы.
Давайте добавим новую книгу:
Похоже, что книга добавилась. Сервер вернул книгу и сообщение, подтверждающее, что она была добавлена. Так ли это? Выполним еще один GET запрос и посмотрим на результат:
Работает!
Давайте поменяем количество страниц в книге и посмотрим на результат:
Отлично! PUT тоже работает, так что можно выполнить еще один GET запрос для проверки
Все работает...
Теперь получим одну книгу по ID в GET запросе и потом удалим ее:
Получили правильный ответ и теперь удалим эту книгу:
Посмотрим на результат удаления:
Даже последний запрос работает как и задумано и нам даже не нужно делать еще один 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();
});
});
});
});
Как много новых штук! Давай разберемся:
morgan
.chaiHttp
к chai
.Все начинается с блока describe
, который используется для улучшения структуризации наших утверждений. Это отразится на выводе, как мы увидим позже.
beforeEach
— это блок, который выполнится для каждого блока описанного в этом describe
блоке. Для чего мы это делаем? Мы удаляем все книги из базы, чтобы база была пуста в начале каждого тесте.
Итак, у нас есть первый тест. Chai выполняет GET запрос и проверяет, что переменная res
удовлетворяет первому параметру (утверждение) блока it
"it should GET all the books". А именно, для данного пустого книгохранилища ответ должен быть следующим:
Обратите внимание, что синтаксис should интуитивен и очень похож на разговорный язык.
Терерь в командной строке выпоним:
npm test
и получим:
Тест прошел и вывод отражает структуру, которую мы описали с помощью блоков describe
.
Теперь проверим насколько хорош наш 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 запрос. Посмотрим на проверки:
errors
.errors
должно быть пропущенное в запросе свойство pages
.pages
должно иметь свойство kind
равное required
чтобы показать причину почему мы получили негативный ответ от сервера.Обратите внимание, что мы отправили данные о книге с помощью метода .send().
Давайте выполним команду еще раз и посмотрим на вывод:
Тест работает!!
Перед тем, как писать следующий тест, уточним пару вещей:
callback
для маршрута /POST, то вы увидели что в случае ошибки сервер отправляет в ответ ошибку от mongoose
. Попробуйте сделать это через POSTMAN и посмотрите на ответ.Однако я бы предложил отдавать в ответ статус 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();
});
});
На этот раз мы ожидаем объект, говорящий нам, что книга добавилась успешно и собственно книгу. Вы уже должны быть хорошо знакомы с проверками, так что нет нужды вдаваться в детали.
Снова запустим команду и получим:
Теперь создадим книгу, сохраним ее в базу и используем 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 в ответе от севера совпадает с запрошенным):
Вы заметили, что в тестированием отдельных маршрутов внутри независимых блоков мы получили очень чистый вывод? К томе же это эффективно: мы написали несколько тестов, которые можно повторить с помощью одной команды
Настало время проверить редактирование одной из наших книг. Сначала мы сохраним книгу в базу, а потом выпоним запрос, чтобы поменять год ее публикации.
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 [15] создает обертку для Mongoose, которая перехватывает обращения к базе и вместо этого использует in memory хранилище. К тому же он легко интегрируется с mocha
Примечание: Mockgoose требует чтобы на машине, где запускаются тесты была установлена mongodb
Автор: alQlagin
Источник [16]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/testirovanie/177790
Ссылки в тексте:
[1] Samuele Zaza: https://pub.scotch.io/@samuxyz
[2] https://scotch.io/tutorials/test-a-node-restful-api-with-mocha-and-chai: https://scotch.io/tutorials/test-a-node-restful-api-with-mocha-and-chai
[3] RESTful Node API: https://scotch.io/tutorials/build-a-restful-api-using-node-and-express-4
[4] аутентификацию Node API: https://scotch.io/tutorials/authenticate-a-node-js-api-with-json-web-tokens
[5] Mocha: https://mochajs.org/
[6] Chai: http://chaijs.com/
[7] github: https://github.com/samuxyz/bookstore
[8] Pt.1: https://scotch.io/tutorials/better-node-with-es6-pt-i
[9] Pt.2: https://scotch.io/tutorials/better-javascript-with-es6-pt-ii-a-deep-dive-into-classes
[10] Pt.3: https://scotch.io/tutorials/better-javascript-with-es6-pt-iii-cool-collections-slicker-strings
[11] https://github.com/lorenwest/node-config/wiki/Configuration-Files: https://github.com/lorenwest/node-config/wiki/Configuration-Files
[12] morgan: https://www.npmjs.com/package/morgan
[13] MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions
[14] тут: http://learn.javascript.ru/es-object
[15] Mockgoose: https://github.com/mccormicka/Mockgoose
[16] Источник: https://habrahabr.ru/post/308352/?utm_source=habrahabr&utm_medium=rss&utm_campaign=sandbox
Нажмите здесь для печати.