В прошлый раз я писал о создании приложения на nodejs с использованием expressjs как фреймворка и jade как шаблонитизатора. В этот раз я хочу остановиться на тестирование серверной части.
Для тестов воспользуемся:
— Mocha — фреймворк позволяющий писать тесты и запускать легко и просто. Генерирует отчеты в различных вариантах, а так же умеет создавать документацию из тестов.
— Should — библиотека для тестов в стиле «утверждения» (Не нашел правильного названия)
— SuperTest — библиотека для тестирования HTTP серверов на nodejs
— jscoverage — для оценки покрытия кода тестами
Я не стал писать все с нуля, а решил обернуть в тесты приложение из предыдущей статьи, полностью скопировав его в папку app2.
Первым делом добавим необходимые нам модули в файл package.json. Нам понадобятся: mocha, should, supertest.
{
"name": "app2",
"version": "0.0.0",
"author": "Evgeny Reznichenko <kusakyky@gmaill.com>",
"dependencies": {
"express": "3",
"jade": "*",
"should": "*",
"mocha": "*",
"supertest": "*"
}
}
Выполним команду npm i, для установки всех требуемых модулей.
И установим jscoverage (под Ubuntu sudo apt-get install jscoverage).
Далее создадим директорию lib в корне проекта и скопируем туда наш app.js, это нужно что бы удобно покрыть все скрипты тестами на покрытие.
Отредактируем файл app.js, что бы он экспортировал наш сервер наружу, а в корне проекта создадим файл index.js который будет подключать сервер и вешать его на сокет. И не забудем подправить пути к views и public директориям.
Должно получиться так:
var app = require('./lib/app.js');
app.listen(3000);
var express = require('express'),
jade = require('jade'),
fs = require('fs'),
app = express(),
viewOptions = { compileDebug: false, self: true };
//data
var db = {
users: [
{ id: 0, name: 'Jo', age: 20, sex: 'm' },
{ id: 1, name: 'Bo', age: 19, sex: 'm' },
{ id: 2, name: 'Le', age: 18, sex: 'w' },
{ id: 10, name: 'NotFound', age: 18, sex: 'w' }
],
titles: {
'/users': 'Список пользователей',
'/users/profile': 'Профиль пользователя'
}
};
//utils
function merge(a, b) {
var key;
if (a && b) {
for (key in b) {
a[key] = b[key];
}
}
return a;
}
//App settings
app.set('views', __dirname + '/../views');
app.set('view engine', 'jade');
app.set('title', 'Мой сайт');
app.locals.compileDebug = viewOptions.compileDebug;
app.locals.self = viewOptions.self;
app.use(express.static(__dirname + '/../public'));
app.use(app.router);
app.use(function (req, res, next) {
next('not found');
});
//error
app.use(function (err, req, res, next) {
if (/not found/i.test(err)) {
res.locals.title = 'Не найдено :(';
res.render('/errors/notfound');
} else {
res.locals.title = 'Ошибка';
res.render('/errors/error');
}
});
app.use(express.errorHandler());
//routes
//Заменяем рендер
app.all('*', function replaceRender(req, res, next) {
var render = res.render,
view = req.path.length > 1 ? req.path.substr(1).split('/'): [];
res.render = function(v, o) {
var data,
title = res.locals.title;
res.render = render;
res.locals.title = app.get('title') + (title ? ' - ' + title: '');
//тут мы должны учесть что первым аргументом может придти
//имя шаблона
if ('string' === typeof v) {
if (/^/.+/.test(v)) {
view = v.substr(1).split('/');
} else {
view = view.concat(v.split('/'));
}
data = o;
} else {
data = v;
}
//в res.locals располагаются дополнительные данные для рендринга
//Например такие как заголовок страницы (res.locals.title)
data = merge(data || {}, res.locals);
if (req.xhr) {
//Если это аякс то отправляем json
res.json({ data: data, view: view.join('.') });
} else {
//Если это не аякс, то сохраняем текущее
//состояние (понадобиться для инициализации history api)
data.state = JSON.stringify({ data: data, view: view.join('.') });
//И добавляем префикс к шаблону. Далее я расскажу для чего он нужен.
view[view.length - 1] = '_' + view[view.length - 1];
//Собственно сам рендер
res.render(view.join('/'), data);
}
};
next();
});
//Загружаем заголовок страници
app.all('*', function loadPageTitle(req, res, next) {
res.locals.title = db.titles[req.path];
next();
});
app.get('/', function(req, res){
res.render('index');
});
app.get('/users', function(req, res){
var data = { users: db.users };
res.render('index', data);
});
app.get('/users/profile', function(req, res, next){
var user = db.users[req.query.id],
data = { user: user };
if (user) {
res.render(data);
} else {
next('Not found');
}
});
//
function loadTemplate(viewpath) {
var fpath = app.get('views') + viewpath,
str = fs.readFileSync(fpath, 'utf8');
viewOptions.filename = fpath;
viewOptions.client = true;
return jade.compile(str, viewOptions).toString();
}
app.get('/templates', function(req, res) {
var str = 'var views = { '
+ '"index": (function(){ return ' + loadTemplate('/index.jade') + ' }()),'
+ '"users.index": (function(){ return ' + loadTemplate('/users/index.jade') + ' }()),'
+ '"users.profile": (function(){ return ' + loadTemplate('/users/profile.jade') + ' }()),'
+ '"errors.error": (function(){ return ' + loadTemplate('/errors/error.jade') + ' }()),'
+ '"errors.notfound": (function(){ return ' + loadTemplate('/errors/notfound.jade') + ' }())'
+ '};'
res.set({ 'Content-type': 'text/javascript' }).send(str);
});
module.exports = app;
Взглянем на файл app.js, явно напрашивается разделение на три логические части:
— В первую части вынесем всю логику работы с моделями данных
— Во вторую часть все вспомогательные утилиты, сейчас у нас только функция merge
— В третей будет сам сервер
С желаниями определились, начнем писать тесты, только сначала напишем Makefile для удобства запуска тестов и разместим его в корне проекта.
MOCHA = ./node_modules/.bin/mocha
test:
@NODE_ENV=test $(MOCHA)
-r should
-R spec
.PHONY: test
— -r — указывает что Mocha должна подключить библиотеку should
— -R — указывает в каком виде мы хотим видеть отчеты тестирования. Там есть несколько видов отчетов, этот будет выглядеть примерно так:
Тестируем «тулзы»
По умолчанию Mocha запускает тесты из директории test, поэтому создадим такую директорию и напишем наш первый тест.
А тестировать мы начнем с наши «тулзов», составим небольшой план.
— В «тулзах» должна быть функция merge
— Функция merge должна сливать два объект в один
— Причем объект который передан первым должен расширяться, вторым объектом
— Функция не должна изменять второй объект
BDD тесты в Mocha начинаются с блока describe();, сами тесты пишутся в блоках it(); они должны располагаться внутри блока describe(). Допускается любая вложенность блоков describe() друг в друга. Так же доступны хуки: before(), after(), beforeEach(), и afterEach(). Хуки так же должны быть описаны внутри блока describe(). Подробнее о хуках я расскажу когда будем тестировать наши модели для работы с фейковой БД.
В директории test создадим файл tools.js и напишем тест для tools.merge.
var tools = require('../lib/tools/index.js');
describe('tools', function () {
//В "тулзах" должна быть функция merge
it('should be have #merge', function () {
tools.should.be.have.property('merge');
tools.merge.should.be.a('function');
});
describe('#merge', function () {
//Функция merge должна сливать два объект в один
it('should merged', function () {
var a = { foo: '1' },
b = { bar: '2' };
tools.merge(a, b).should.eql({ foo: '1', bar: '2' });
});
//Причем объект который передан первым должен расширяться, вторым объектом
it('should be extend', function () {
var a = { foo: '1' },
b = { bar: '2' };
tools.merge(a, b);
//строгое сравнение по ссылке, убеждаемся что это
//один и тот же объект
a.should.not.equal({ foo: '1', bar: '2' });
a.should.equal(a);
});
//Функция не должна изменять второй объект
it('should not be extended', function () {
var a = { foo: '1' },
b = { bar: '2' };
tools.merge(a, b);
b.should.not.eql({ foo: '1', bar: '2' });
});
});
});
Если мы сейчас запустим тест, то свалимся с ошибкой еще на этапе подключения модуля tools, что нормально у нас еще нет этого модуля. Создадим файл lib/tools/index.js и перенесем туда код функции merge из lib/app.js.
Запустим тест make test и увидим что все четыре теста завалены,
т.к. завален самый первый тест становиться понятно что из модуля tools не экспортируется функция merge. Добавим экспорт и запустим тесты повторно, теперь все должно проходить нормально.
Прежде чем переходить к дальнейшему тестированию остальных частей приложения, добавим тесты на покрытие.
Добавим запуск jscoverage с параметрами --encoding=utf8 и --no-highlight в качестве входящей директории указываем lib, а в качестве исходящей укажем lib-cov. Теперь добавим запуск Mocha для тестов покрытия, установим переменную окружения COVERAGE=1 в качестве репортера укажем html-cov, что бы получить красивую html страницу с результатами тестов покрытия.
MOCHA = ./node_modules/.bin/mocha
test:
@NODE_ENV=test $(MOCHA)
-r should
-R spec
test-cov: lib-cov
@COVERAGE=1 $(MOCHA)
-r should
-R html-cov > coverage.html
lib-cov: clear
@jscoverage --encoding=utf8 --no-highlight lib lib-cov
clear:
@rm -rf lib-cov coverage.html
.PHONY: test test-cov clear
Вернемся к нашему тесту и в самому верху заменим строчку:
var tools = require('../lib/tools/index.js');
на
var tools = process.env.COVERAGE
? require('../lib-cov/tools/index.js')
: require('../lib/tools/index.js');
Все. Теперь можем запустить make test-cov. В корне проекта появиться файл coverage.html, с результатами теста покрытия, файл самодостаточен и может тут же быть открыт в браузере.
Красным будут показаны строчки, в которых не было ни единого захода, а это значит что это место не покрыто тестами. Так же приводиться общая статистика покрытия тестов в процентах по каждому файлу.
Отлично, среда для тестирования настроена, осталось написать тесты для БД и сервера.
Тестируем работу с базой
Напишем код для тестирование наших моделей. Сначала определимся с функционалом.
1) У нас должно быть две модели User и UserList
2) Модель User должна иметь методы:
— find — функция возвращает список пользователей объектом типа UserList, даже если ничего нет
— findById — функция должна искать пользователя по Ид и возвращать результат в виде объекта типа User, либо ничего, если пользователя с таким ид нет
— save — функция должна сохранять пользователя, возвращает err в случае ошибки
— toJSON — функция возвращает приводит объект типа User к json
3) Модель UserList должна иметь только метод toJSON
var should = require('should'),
db = process.env.COVERAGE
? require('../lib-cov/models/db.js')
: require('../lib/models/db.js'),
models = process.env.COVERAGE
? require('../lib-cov/models/index.js')
: require('../lib/models/index.js'),
User = models.User,
UserList = models.UserList;
describe('models', function () {
//Эта функция будет вызвана один раз
//внутри этого блока "describe('models')"
before(function () {
db.regen();
});
//Мы должны иметь модель User
it('should be have User', function () {
models.should.be.have.property('User');
models.User.should.be.a('function');
});
//Мы должны иметь модель UserList
it('should be have UserList', function () {
models.should.be.have.property('UserList');
models.UserList.should.be.a('function');
});
//Тестируем модель User
describe('User', function () {
//модель User должна иметь метод find
it('should be have #find', function () {
User.should.be.have.property('find');
User.find.should.be.a('function');
});
//модель User должна иметь метод findById
it('should be have #findById', function () {
User.should.be.have.property('findById');
User.findById.should.be.a('function');
});
//модель User должна иметь метод save
it('should be have #save', function () {
User.prototype.should.be.have.property('save');
User.prototype.save.should.be.a('function');
});
//модель User должна иметь метод toJSON
it('should be have #toJSON', function () {
User.prototype.should.be.have.property('toJSON');
User.prototype.toJSON.should.be.a('function');
});
describe('#find', function () {
//find должен возвращать UserList
it('should be instanceof UserList', function (done) {
User.find(function (err, list) {
if (err) return done(err);
list.should.be.an.instanceOf(UserList);
done();
});
});
//find должен возвращать UserList, даже если ничего нет
it('should not be exist', function (done) {
//Дропаем БД
db.drop();
User.find(function (err, list) {
//Восстанавливаем БД
db.generate();
if (err) return done(err);
list.should.be.an.instanceOf(UserList);
done();
});
});
});
describe('#findById', function () {
//findById должен возвращать объект типа User
it('should be instanceof User', function (done) {
User.findById(0, function (err, user) {
if (err) return done(err);
user.should.be.an.instanceOf(User);
done();
});
});
//findById должен возвращать ничего, если пользователь не найдено
it('should not be exists', function (done) {
User.findById(100, function (err, user) {
if (err) return done(err);
should.not.exist(user);
done();
});
});
});
describe('#save', function () {
//save должен выбрасывать ошибку, если указать неправильный возраст
it('should not be saved', function (done) {
var user = new User({ name: 'New user', age: 0, sex: 'w' });
user.save(function (err) {
err.should.eql('Invalid age');
done();
});
});
//Если все хорошо, то должен быть создан новый пользователь
it('should be saved', function (done) {
var newuser = new User({ name: 'New user', age: 2, sex: 'w' });
newuser.save(function (err) {
if (err) return done(err);
User.findById(newuser.id, function (err, user) {
if (err) return done(err);
user.should.eql(newuser);
done();
});
});
});
});
describe('#toJSON', function () {
//toJSON должен возвращать json представление модели
it('should be return json', function (done) {
User.findById(0, function (err, user) {
if (err) return done(err);
user.toJSON().should.be.eql({ id: 0, name: 'Jo', age: 20, sex: 'm' });
done();
});
});
});
});
describe('UserList', function () {
//UserList должен иметь метод toJSON
it('should be have #toJSON', function () {
UserList.prototype.should.be.have.property('toJSON');
UserList.prototype.toJSON.should.be.a('function');
});
});
});
Код снабжен комментариями, поэтому остановлюсь только на отдельных моментах.
before(function () {
db.regen();
});
Этот код будет вызван единожды при начале тестирования. Тут можно подключиться к базе и заполнить тестовыми данными, у нас нет реальной БД, поэтому вызываем только метод regen, который инициализирует нашу БД тестовыми данными.
Стоит обратить внимание на то что, работа с бд осуществляется в асинхронном стиле, при тестировании асинхронных методов мы должны вызывать метод done() по завершению тестирования блока, в случае ошибки ошибку следует передать в нее же. Кусок кода для наглядности:
...
//find должен возвращать UserList
it('should be instanceof UserList', function (done) {
User.find(function (err, list) {
if (err) return done(err);
list.should.be.an.instanceOf(UserList);
done();
});
});
...
Теперь приступим к реализации. Создадим в директории lib, директорию models, где будет реализован наш функционал работы с моделями:
/*
* Фэйковый файл бд
*/
//Начальные данные
var users = [];
exports.users = users;
//Сбрасываем состояние БД в начало
exports.regen = function () {
exports.drop();
exports.generate();
};
exports.drop = function () {
//Что бы не потерять указатель,
//мы опустошаем текущий массив таким способом
users.splice(0, users.length);
};
exports.generate = function () {
//Заполняем массив
users.push({ id: 0, name: 'Jo', age: 20, sex: 'm' });
users.push({ id: 1, name: 'Bo', age: 19, sex: 'm' });
users.push({ id: 2, name: 'Le', age: 18, sex: 'w' });
users.push({ id: 10, name: 'NotFound', age: 18, sex: 'w' });
};
//Генерируем начальные данные
exports.generate();
var util = require('util'),
db = require('./db.js'),
UserList = require('./userlist.js'),
users = db.users;
/*
* Модель пользователей
*/
var User = module.exports = function User(opt) {
this.id = users.length;
this.name = opt.name;
this.age = opt.age;
this.sex = opt.sex;
this.isNew = true;
}
/*
* Вызываем когда нужно инициализировать объект из базы
*/
function loadFromObj(obj) {
var user = new User(obj);
user.id = obj.id;
user.isNew = false;
return user;
}
/*
* Ищем всех пользователей и возвращяем массив
*/
User.find = function (fn) {
var i,
l = users.length,
list;
if (l) {
list = new UserList();
for (i = 0, l; l > i; i += 1) {
list.push(loadFromObj(users[i]));
}
}
fn(null, list);
};
/*
* Ищем пользователя по id
*/
User.findById = function (id, fn) {
var obj = users[id],
user;
if (obj) {
user = loadFromObj(obj);
}
fn(null, user);
};
/*
* Сохраняем
*/
User.prototype.save = function (fn) {
var err;
//Проверяем возраст на валидность
if (Number.isFinite(this.age) && this.age > 0 && this.age < 150) {
if (this.isNew) {
users.push(this.toJSON());
this.isNew = false;
} else {
users[this.id] = this.toJSON();
}
} else {
err = 'Invalid age';
}
fn(err);
};
User.prototype.toJSON = function () {
var json = {
id: this.id,
name: this.name,
age: this.age,
sex: this.sex
};
return json;
};
var util = require('util');
/*
* UserList - Список пользователя, наследуем от Array
*/
var UserList = module.exports = function UserList() {
Array.apply(this)
}
util.inherits(UserList, Array);
UserList.prototype.toJSON = function () {
var i,
l = this.length,
arr = new Array(l);
for (i = 0; l > i; i += 1) {
arr[i] = this[i].toJSON();
}
return arr;
};
exports.User = require('./user.js');
exports.UserList = require('./userlist.js');
Подправим код lib/app.js добавив в него подключение модели User и всю работу с пользователям будем осуществлять через нее.
var
...
User = require('./models/index.js').User,
...
...
app.get('/users', function(req, res, next){
User.find(function (err, users) {
if (err) {
next(err);
} else {
res.render('index', { users: users.toJSON() });
}
});
});
app.get('/users/profile', function(req, res, next){
var id = req.query.id;
User.findById(id, function(err, user) {
if (user) {
res.render({ user: user.toJSON() });
} else {
next('Not found');
}
});
});
...
Тестируем приложение
Осталась последняя не покрытая тестами часть. Это непосредственно http сервер. Честно признаюсь, что тут я решил схалявить и протестировать всего четыре ситуации:
1) Ответ должен приходить в html если это обычный запрос
2) Ответ должен приходить в json если это аякс
3) GET запрос в корень сайта должен возвращать страницу/объект, где title содержит значение 'Мой сайт'
Благодаря библиотеки supertest, писать подобные тесты легко и просто:
var request = require('supertest'),
app = process.env.COVERAGE
? require('../lib-cov/app.js')
: require('../lib/app.js');
describe('Response html or json', function () {
//Если это обычный запрос, должны получить
//ответ в виде html
it('should be responded as html', function (done) {
request(app)
.get('/')
.expect('Content-Type', /text/html/)
.expect(200, done);
});
//Если это аякс, должны получать json
it('should be responded as json', function (done) {
request(app)
.get('/')
.set('X-Requested-With', 'XMLHttpRequest')
.expect('Content-Type', /application/json/)
.expect(200, done);
});
});
describe('GET /', function () {
//Должен быть title === Мой сайт
it('should be included title', function (done) {
request(app)
.get('/')
.end(function (err, res) {
if (err) return done(err);
res.text.should.include('<title>Мой сайт</title>');
done();
});
});
//Должен быть title === Мой сайт
it('should be included title', function (done) {
request(app)
.get('/')
.set('X-Requested-With', 'XMLHttpRequest')
.end(function (err, res) {
if (err) return done(err);
res.body.should.have.property('data');
res.body.data.should.have.property('title', 'Мой сайт');
done();
});
});
});
В request() мы должны передать экземпляр http.Server либо функцию которая выполняет запрос. SuperTest использует SuperAgent для взаимодействия с сервером, поэтому можно использовать все его возможности для формирования запросов к серверу. Проверку ответов можно осуществить в функциях expect() либо непосредственно в результате запроса, передав функцию обработчик в end().
Заключение
Вот так просто можно (и даже нужно) писать тесты для наших приложений. Даже в моем маленьком примере кода для тестов получилось больше чем самого тестируемого кода, но даже эти тесты не полные и например тестами не покрыта ошибка, когда мы создадим одновременно двух пользователей и сохраним их. Хотя тесты покрытия, показывают что модель User покрыта тестами в том месте где происходит сохранение нового пользователя.
Поэтому сами тесты это не панацея, тесты должны быть грамотно написаны и нужно понимать мелочи, и тестировать именно те места которые могут вызывать проблемы.
Код доступен на github'e
Автор: zxcabs