Тестируем приложение nodejs

от автора

В прошлый раз я писал о создании приложения на nodejs с использованием expressjs как фреймворка и jade как шаблонитизатора. В этот раз я хочу остановиться на тестирование серверной части.

Для тестов воспользуемся:
Mocha — фреймворк позволяющий писать тесты и запускать легко и просто. Генерирует отчеты в различных вариантах, а так же умеет создавать документацию из тестов.
Should — библиотека для тестов в стиле «утверждения» (Не нашел правильного названия)
SuperTest — библиотека для тестирования HTTP серверов на nodejs
jscoverage — для оценки покрытия кода тестами

Я не стал писать все с нуля, а решил обернуть в тесты приложение из предыдущей статьи, полностью скопировав его в папку app2.

Первым делом добавим необходимые нам модули в файл package.json. Нам понадобятся: mocha, should, supertest.

package.json

{ 	"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 директориям.
Должно получиться так:

index.js

var app = require('./lib/app.js'); app.listen(3000); 

lib/app.js

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 для удобства запуска тестов и разместим его в корне проекта.

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.

test/tools.js

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 страницу с результатами тестов покрытия.

Makefile

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, где будет реализован наш функционал работы с моделями:

models/db.js

/*  * Фэйковый файл бд  */   //Начальные данные 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(); 

models/user.js

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; }; 

models/userlist.js

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; }; 

models/index.js

exports.User = require('./user.js'); exports.UserList = require('./userlist.js'); 

Подправим код lib/app.js добавив в него подключение модели User и всю работу с пользователям будем осуществлять через нее.

lib/app.js

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, писать подобные тесты легко и просто:

test/app.js

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

ссылка на оригинал статьи http://habrahabr.ru/post/162555/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *