Введение

Разработчики применяют браузерный javascript для:

  • обработки событий на странице;
  • формирования HTML-контента;
  • работы с AJAX.

Используя вместе QUnit и Sinon.JS, можно написать юнит-тесты для любого из этих применений.

QUnit — framework для юнит-тестирования javascript, созданный и используемый разработчиками jQuery, предоставляет базовый функционал для написания тестов, их группировки и проверки утверждений.

Sinon.JS — mock-библиотека для javascript, которую можно использовать с любым тестовым фреймворком. Их совместное использование будет продемонстрировано на небольшом примере текстового чата. Скачать пример можно из репозитория на GitHub.

Быстрый старт

Предполагается, что тестируемое API хранится в отдельном файле chat.js, код юнит-тестов будем писать в отдельном файле chat.tests.js, а для запуска тестов создадим страницу tests.html, в которой пропишем ссылки на тестируемое API, код тестов и необходимые библиотеки:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Chat unit tests</title>
	<!-- Необходимо подключить стили QUnit -->
    <link rel="stylesheet" href="resources/qunit-1.10.0.css">
</head>
<body>
    <div id="qunit">
    </div>
	<!-- В этот div тесты могут добавлять разметку, она будет удалена после запуска теста -->
    <div id="qunit-fixture">
    </div>
	<!-- Подключаем библиотеки для тестов -->
    <script src="resources/qunit-1.10.0.js" type="text/javascript"></script>
    <script src="resources/sinon-1.5.0.js" type="text/javascript"></script>
    <script src="resources/sinon-qunit-1.0.0.js" type="text/javascript"></script>
	<!-- jQuery нам тоже понадобится -->
    <script src="../script/jquery-1.8.2.min.js" type="text/javascript"></script>
	<!-- Наше тестируемое API -->
	<script src="../script/chat.js" type="text/javascript"></script>
	<!-- Наконец, сами unit-тесты -->
    <script src="chat.tests.js" type="text/javascript"></script>
</body>
</html>

Для запуска тестов надо открыть страницу tests.html в браузере, здесь мы увидим такой результат:
qunit_quick_start_1

Опция “Check for globals” проверяет наличие глобальных переменных в API и тестах, что очень полезно для предотвращения конфликтов.

Если включена опция “No try-catch”, то тесты будут выполняться до первого exception, что не позволит прогнать весь набор тестов, но даст доступ к низкоуровневой информации об исключительной ситуации, что удобно для отладки в старых браузерах.

Asserts

Для проверки утверждений в юнит-тестах QUnit предоставляет несколько функций. Обратите внимание, что в отличие от других xUnit-фреймворков актуальное значение указывается первым аргументом, а ожидаемое — вторым (кроме функции ok(), в которой проверяется первый аргумент). Последним аргументом может быть строка с пояснением, что именно проверяется утверждением.

  • ok() — ожидает истинность первого аргумента
  • equal() / notEqual() — для сравнения используется оператор ==
  • deepEqual() / notDeepEqual() — для сравнения используется оператор ===
  • strictEqual() / notStrictEqual() — для сравнения используется оператор (===
  • throws() — ожидает, что будет сгенерирована исключительная ситуация.

Простой пример

Для создания юнит-теста QUnit предоставляет функцию test(), первым аргументом которой является строка с названием теста, вторым — функция, содержащая код теста. В этом примере проверяется, что вызов метода из тестируемого API вернет объект ожидаемой структуры.

 test('test createMessage()', function() {
	// Метод createMessage() создает объект сообщения чата,
	// протестируем, что сообщение формируется в правильном формате
	var userName = 'user 1';
	var text = 'Hello!';
	// Результат вызова метода
	var actualMessage = chat.createMessage(userName, text);
	// Эталонное сообщение
	var expectedMessage = {userName: userName, text: text};
	// Сравниваем эталонный объект и результат 
	deepEqual(actualMessage, expectedMessage, 'Неверный формат сообщения')
});

Пример посложнее с использованием верстки

Специальный div #qunit-fixture удобно использовать для тестирования генерируемой верстки, так как QUnit очищает его после каждого теста, что обеспечивает атомарность.

test('test quoteTool.insertQuote()', function() {
	// Метод quoteTool.insertQuote() вставляет в textarea выделенный
	// текст, форматируя его как цитату
	
	// В #qunit-fixture добавим верстку с сообщениями чата
	var fixture = $('#qunit-fixture');
	fixture.append('<div id="id1">123456</div>');
	// В этой переменной текст, который будет выделен как цитата
	var expectedText = 'qwerty';
	fixture.append('<div id="id2">' + expectedText + '</div>');
	fixture.append('<div id="id3">йцукен</div>');
	fixture.append('<textarea id="message_text"></textarea>');
	// Выделяем требуемый текст на странице
	var selection = window.getSelection();
	var range = document.createRange();
	range.selectNodeContents(document.getElementById('id2'));
	selection.removeAllRanges();
	selection.addRange(range);
	// Создаем тестируемый объект
	var textArea = $('textarea#message_text');
	var quoteTool = chat.quoteTool(textArea);
	quoteTool.insertQuote();
	// Проверяем, что после вызова метода в textarea вставлен 
	// отформатированный выбранный текст
	deepEqual(textArea.val(), '>> ' + expectedText + 'n', 'Неверный текст цитаты');
});

Модули, конфигурация

QUnit позволяет объединять тесты в модули, это удобно для группировки тестов сходного функционала. Модуль может иметь переменные, видимые в каждом тесте, а также функции setup() и teardown(), которые выполняются перед и после каждого теста соответственно, и в которых можно инициализировать и очищать общие данные. Модуль начинается с функции module(), после которой может идти n-ное количество функций test(), все n тестов будут принадлежать этому модулю. Модуль кончится тогда, когда начнется следующий модуль.

module('chatControl tests', {
	setup: function() {
		// Инициализируем верстку перед тестами
		var fixture = $('#qunit-fixture');
		fixture.append('<div id="chat_window"></div>');
		// Переменные chatDiv, server и chatControl будут видны
		// во всех тестах модуля
		this.chatDiv = $('#chat_window');
		this.server = chat.server({saveUrl: 'http://test.com/save', loadUrl: 'http://test.com/load'});
		var config = {
			chatDiv: this.chatDiv,
			server: this.server
		};
		this.chatControl = chat.chatControl(config);
	}
});

В тесте используем инициализированные в setup() переменные:

test('test addMessage() with empty args', function() {
	// Проверяем, что в чат нельзя добавить пустое сообщение.
	// Переменные chatDiv и chatControl инициализированы в 
	// методе setup() модуля.
	deepEqual(this.chatDiv.html(), '', 'Содержимое не пусто №1');
	this.chatControl.addMessage({userName: 'userName', text: ''});
	deepEqual(this.chatDiv.html(), '', 'Содержимое не пусто №2');
	this.chatControl.addMessage({userName: '', text: 'test'});
	deepEqual(this.chatDiv.html(), '', 'Содержимое не пусто №3');
});

Что дает Sinon.JS

Библиотека Sinon.JS предоставляет функции для эмуляции и проверки требуемого поведения в javascript. Sinon создает обертку поверх функции или объекта. Обертки могут быть 3-х видов — spy, stub и mock — они предоставляют интерфейс для проверки правильности доступа к эмулируемому ресурсу.

Spy

Самая простая обертка, выполняет слежение за вызовом. Исходный объект не меняется, и в него предается управление. Удобна для проверки того, какая функция с какими параметрами была вызвана. Некоторые члены интерфейса:

  • calledOnce() — возвращает true, если функция была вызвана ровно один раз
  • callCount — счетчик вызовов функции
  • getCall(m).args[n] — возвращает n-ный аргумент m-ного вызова
  • getCall(m).calledWith(args) — проверяет, что m-ный вызов произошел с указанными аргументами.

Stub

Расширяет интерфейс spy, при этом оригинальный объект замещается и не вызывается. Можно настраивать требуемое поведение эмулируемого объекта при вызове, например возвращаемое значение.

Пример использования spy и stub:

test('test updateMessages()', function() {
	// Метод chatControl.updateMessages() запрашивает новые сообщения
	// у сервера и добавляет их в историю чата.
	
	// message1, message2 - новые сообщения, которые должен вернуть сервер
	var message1 = chat.createMessage('userName1', 'text 1');
	var message2 = chat.createMessage('userName2', 'text 2');
	var messages = [message1, message2];
	// Заменяем реализацию server.loadMessages(), новая реализация
	// вернет предопределенные сообщения без обращения к реальному серверу
	this.stub(this.server, 'loadMessages', function(userName, callback) {
		callback(messages);
	});
	// Инициируем слежение за методом chatControl.addMessage(),
	// который добавлет сообщения в историю чата
	this.spy(this.chatControl, 'addMessage');
	
	this.chatControl.updateMessages();
	// Проверяем, что метод server.loadMessages() был вызван ровно один раз
	ok(this.server.loadMessages.calledOnce, 'server.loadMessages() не вызван');
	// Проверяем, что количество вызовов метода добавления сообщения
	// равно количеству сообщений, пришедших с сервера
	deepEqual(this.chatControl.addMessage.callCount, messages.length, 'Неправильно число вызовов chatControl.addMessage()');
	// Проверяем правильность аргументов каждого вызова
	ok(this.chatControl.addMessage.getCall(0).calledWith(message1), 'Неправильный аргумент 1-го вызова chatControl.addMessage()');
	ok(this.chatControl.addMessage.getCall(1).calledWith(message2), 'Неправильный аргумент 2-го вызова chatControl.addMessage()');
});

Эмуляция сервера

Sinon.JS позволяет эмулировать ответ сервера при ajax-запросах:

test('test loadMessages()', function() {
	// Метод server.loadMessages() обращается к серверу чата
	// для получения новых сообщений для пользователя. В метод
	// должна быть передана функция обратного вызова для обработки
	// этих сообщений.

	// Создадим новые сообщения, которые должен вернуть реальный сервер чата
	var message1 = chat.createMessage(this.userName, 'text 1');
	var message2 = chat.createMessage(this.userName, 'text 2');
	var message3 = chat.createMessage(this.userName, 'text 3');
	var expectedMessages = [message1, message2, message3];
	
	// Будем использовать "поддельный" сервер от Sinon.JS
	var fakeServer = this.sandbox.useFakeServer();
	// Адрес, по которому нужно получить сообщения, должен
	// содержать имя пользователя
	var expectedUrl = this.config.loadUrl + '/' + this.userName;
	// Настраиваем ответ сервера
	fakeServer.respondWith(
		"POST",
		expectedUrl,
        [
			200,
			{ "Content-Type": "application/json" },
			JSON.stringify(expectedMessages)
		]
	);
	// Создаем заглушку для функции обратного вызова
	var callbackStub = this.stub();
	
	// Вызываем тестируемый метод
	this.server.loadMessages(this.userName, callbackStub);
	// и инициируем ответ "поддельного" сервера
	fakeServer.respond();
	
	// Проверяем правильность работы с функцией обратного вызова
	ok(callbackStub.calledOnce, 'callback вызван не один раз');
	ok(callbackStub.getCall(0).calledWith(expectedMessages), 'Неправильный аргумент вызова callback');
});

Mock

Моки — дальнейшее развитие идеи заглушек, они дают возможность тонко настроить ожидаемое поведение объекта и затем проверить, что работа с эмулируемым объектом проходила должным образом.

test('test messageTool.addMessage()', function() {
	// Метод messageTool.addMessage() создает из текста в textarea сообщение,
	// добавляет его в историю чата и отправляет на сервер. После добавления
	// сообщения textarea очищается.
	
	// Создаем textarea с текстом сообщения
	var userName = 'user name';
	var text = 'Expected message text';
	var fixture = $('#qunit-fixture');
	fixture.append('<textarea id="message_text">' + text + '</textarea>');
	
	var chatControl = chat.chatControl({});
	// mock для объекта chatControl
	var chatControlMock = this.mock(chatControl);
	var expectedMessage = chat.createMessage(userName, text);
	// Настраиваем требование - метод chatControl.addMessage() должен быть
	// вызван ровно один раз с нужным параметром
	chatControlMock.expects('addMessage').once().withExactArgs(expectedMessage);
	var server = chat.server({});
	// mock для объекта server
	var serverMock = this.mock(server);
	// Требуем, что server.saveMessage() будет вызван один раз с нужным параметром
	serverMock.expects('saveMessage').once().withExactArgs(expectedMessage);
	
	var textArea = $('textarea#message_text');
	var messageTool = chat.messageTool({
		textArea: textArea,
		chatControl: chatControl,
		server: server,
		userName: userName
	});
	messageTool.addMessage();
	// После вызова тестируемого метода проверяем, что работа с объектами
	// прошла по ожидаемому сценарию
	chatControlMock.verify();
	serverMock.verify();
	// Проверяем очистку textArea
	deepEqual(textArea.val(), '', 'textarea не очищена');
});

Интеграция с ant

Запустить юнит-тесты во время автоматической сборки (и остановить сборку в случае падения теста) можно при помощи PhantomJS (это запускаемый в консоли WebKit) и специального раннера. После установки PhantomJS создайте переменную окружения PHANTOMJS_HOME, указывающую на папку PhantomJS.

Ниже приведен пример ant-проекта, который выполняет QUnit-тесты во время сборки:

<project name="qunit-example" default="run-qunit" basedir=".">
    <description>Пример интеграции QUnit и ant</description>

	<property environment="env"/>

	<target name="run-qunit" description="Запуск тестов QUnit с помощью PhantomJS">
		<exec executable="${env.PHANTOMJS_HOME}/phantomjs" failonerror="true">
			<arg value="${basedir}/test/tools/runner.js" />
			<arg value="file:///${basedir}/test/tests.html?noglobals=true" />
		</exec>
	</target>

</project>