PhantomJS — это сборка движка WebKit без графического интерфейса, позволяющая в режиме консоли загружать веб-страницу, выполнять JavaScript, полноценно работать с DOM, Canvas и SVG. Одним из главных заявленных применений PhantomJS является автоматизированное функциональное тестирование пользовательского интерфейса. PhantomJS имеет интеграцию с различными фреймворками для тестирования JavaScript и веб-страниц. Посмотрим, что можно сделать на базе стандартного функционала PhantomJS, чтобы протестировать отдельный компонент и целое приложение, написанное на ExtJS/Sencha. В этой статье я приведу некоторую простейшую заготовку для тестировочного фреймворка, иллюстрирующую подход к тестированию кода, основанного на сторонней JavaScript-библиотеке. Весь код, представленный в статье, доступен на GitHub.
Рассмотрим простой компонент, расширяющий стандартный выпадающий список Ext.form.ComboBox. Он должен содержать список месяцев, настраиваемый с помощью конфигурационного параметра months — массива, содержащего номера отображаемых месяцев, начиная с нуля. Например, если массив состоит из чисел 1, 3, и 5, то выпадающий список должен отображать месяцы февраль, апрель и июнь.
Создадим HTML-страницу ExtjsTesterPage.html, в рамках которой будет тестироваться компонент. Для включения в страницу стилей и скриптов ExtJS необходимо как минимум импортировать файлы ext-all.css и ext-all.js. Для этой цели воспользуемся версией ExtJS 4.0.2а, которая размещена на CacheFly.
Подготовим небольшой типовой фреймворк для тестирования. Это файл ExtjsTester.js, который будет передаваться на исполнение PhantomJS и принимать в качестве аргумента имя скрипта с тестом. В начале его поместим проверку наличия аргумента командной строки:
На примере этого фрагмента кода можно проиллюстрировать два важных элемента PhantomJS API. Во-первых, передача параметров в скрипт: phantom.args — это массив аргументов командной строки. Второй элемент API — это вызов метода phantom.exit(), который вызывает завершение PhantomJS и возвращает указанный код ошибки.
Подключим модуль работы с файловой системой, который потребуется нам для загрузки скриптов в режиме выполнения. Модуль fs имеет достаточно обширную функциональность, позволяет искать, читать и записывать файлы.
Для простоты примера я не стал подключать какие-либо существующие библиотеки для функционального тестирования JavaScript, хотя интеграция с ними у PhantomJS имеется. Объявим типовой класс assert-исключения:
Теперь для целей тестирования создадим обёртку над стандартным классом PhantomJS — WebPage.
Параметр конструктора scriptUnderTest — это имя файла со скриптом, подлежащим тестированию, например, с кодом компонента ExtJS. С помощью импортированной ранее зависимости fs мы убеждаемся в существовании этого файла, а с помощью функции WebPage#injectJs — инъектируем его в нашу тестовую страницу, которую мы загрузили из ранее созданного файла ExtjsTesterPage.html.
Стоит обратить внимание на метод WebPage#onConsoleMessage. Если не задать его, то весь консольный вывод, произошедший на странице, потеряется, так как страница выполняется в песочнице.
Параметр testFun — это наша будущая функция, выполняющая тестирование. Для того, чтобы обеспечить корректное тестирование компонента ExtJS, нужно убедиться, что фреймворк ExtJS полностью загружен и инициализирован. Для этого предназначен метод Ext.onReady(), но в нашем случае его использование затрудняется тем, что страница выполняется в песочнице, и передача какого-либо кода внутрь страницы возможна только с помощью метода WebPage#evaluate. Поэтому снабдим класс TestPage методом, ожидающим загрузки ExtJS:
Когда ExtJS будет полностью загружен, выполнится метод doTest следующего содержания:
Здесь реализован вызов тестовой функции и завершение PhantomJS с кодом возврата, соответствующим результатам работы тестовой функции.
В завершение добавим к классу TestPage несколько конвиниенс-методов для ассертов и выполнения кода в контексте страницы:
Метод evaluate делегирует исполнение переданной функции обёрнутому объекту WebPage. Метод assert выполняет типовое диагностическое утверждение с выбросом AssertionError, если это утверждение не выполнится. Метод evaluateAndAssertEquals выполняет сравнение результата выполнения функции в контексте страницы с ожидаемым значением.
Последней строчкой файла ExtjsTester станет загрузка самого тестового скрипта, который мы будем передавать через параметр командной строки. Воспользуемся для этого модулем для работы с файловой системой — загрузим скрипт в виде строки и исполним его через eval:
А вот и сам тестовый скрипт — файл MonthComboBoxTest.js:
Здесь мы создаём наш объект-обёртку TestPage, передавая ему скрипт тестируемого компонента и функцию для тестирования. В ней мы сначала создаём компонент на странице с такой конфигурацией, чтобы он содержал только три месяца, а затем проверяем, что наш замысел соответствует действительности, компонент действительно присутствует на странице, а его хранилище содержит ровно три элемента.
Для запуска теста воспользуемся командой:
Прежде всего, чтобы протестировать приложение, нам понадобится немного доработать наш фреймворк. Тестовая функция должна выполняться по завершении запуска приложения. Одним из способов определить это является проверка наличия компонента Viewport на странице. Добавим к классу TestPage следующий метод, в целом аналогичный методу TestPage#waitForExtReady:
Добавим также в конструктор объекта TestPage параметр waitForViewport, значение которого true будет означать, что перед выполнением тестовой функции необходимо дождаться появления Viewport. Немного модифицируем фрагмент, в котором вызывается тестовая функция:
В целях краткости я не буду приводить здесь весь код тестируемого приложения — на него можно взглянуть на GitHub. Поясню лишь, что приложение состоит из главной формы с кнопкой и окна, которое появляется при щелчке по этой кнопке. Главная форма и окно обслуживаются различными контроллерами, связь между которыми организована посредством сообщений уровня приложения (Ext.app.Application#fireEvent). Вот тестовый код, проверяющий, что при щелчке по кнопке действительно появляется всплывающее окно:
Компонентный запрос 'mainform > button[action=popup]' соответвует кнопке на главной форме приложения. Запрос 'popupwindow' соответствует всплывающему окну. Третий параметр конструктора TestPage (значение true) означает, что перед выполнением тестовой функции необходимо дождаться появления Viewport. Запускается тестовая функция аналогично:
В статье проиллюстрирован подход к тестированию компонентов и приложений на ExtJS/Sencha на базе движка PhantomJS и разработана примерная заготовка для тестировочного фреймворка, которая с равным успехом может быть использована для тестирования и других JavaScript-библиотек. В заключение хочется отметить, что подобное функциональное тестирование ограничено браузерами, работающими на движке WebKit. Однако, учитывая высокие показатели соответствия этого движка современным Web-стандартам, а также приемлемый уровень абстракции и кроссбраузерности библиотеки ExtJS (Sencha), можно сделать вывод, что для тестирования логики работы приложения или функционирования компонента PhantomJS может оказаться достаточным.
Код тестового компонента и приложения, а также фреймворка для тестирования, представленного в статье, доступен на GitHub.
Тестирование компонента
Рассмотрим простой компонент, расширяющий стандартный выпадающий список Ext.form.ComboBox. Он должен содержать список месяцев, настраиваемый с помощью конфигурационного параметра months — массива, содержащего номера отображаемых месяцев, начиная с нуля. Например, если массив состоит из чисел 1, 3, и 5, то выпадающий список должен отображать месяцы февраль, апрель и июнь.
Ext.define('MonthComboBox', {
extend : 'Ext.form.ComboBox',
alias : 'widget.monthcombo',
store : Ext.create('Ext.data.Store', {
fields : [ 'num', 'name' ]
}),
queryMode : 'local',
displayField: 'name',
valueField : 'num',
allMonths : ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
],
months: [],
initComponent: function() {
/**
* заполняем хранилище только теми месяцами, номера которых присутствуют в массиве {@link MonthComboBox#months}
*/
for (var i = 0; i < this.months.length; i++) {
this.store.add({
num : this.months[i],
name: this.allMonths[this.months[i]]
});
}
this.callParent(arguments);
}
});
Создадим HTML-страницу ExtjsTesterPage.html, в рамках которой будет тестироваться компонент. Для включения в страницу стилей и скриптов ExtJS необходимо как минимум импортировать файлы ext-all.css и ext-all.js. Для этой цели воспользуемся версией ExtJS 4.0.2а, которая размещена на CacheFly.
<!DOCTYPE html>
<html>
<head>
<title>ExtJS Test Page</title>
<link rel="stylesheet" type="text/css" href="http://extjs.cachefly.net/ext-4.0.2a/resources/css/ext-all.css">
<script type="text/javascript" src="http://extjs.cachefly.net/ext-4.0.2a/ext-all.js"></script>
</head>
<body></body>
</html>
Подготовим небольшой типовой фреймворк для тестирования. Это файл ExtjsTester.js, который будет передаваться на исполнение PhantomJS и принимать в качестве аргумента имя скрипта с тестом. В начале его поместим проверку наличия аргумента командной строки:
if (phantom.args.length == 0) {
console.log('Требуется указать имя файла с тестовым скриптом');
phantom.exit(1);
}
На примере этого фрагмента кода можно проиллюстрировать два важных элемента PhantomJS API. Во-первых, передача параметров в скрипт: phantom.args — это массив аргументов командной строки. Второй элемент API — это вызов метода phantom.exit(), который вызывает завершение PhantomJS и возвращает указанный код ошибки.
Подключим модуль работы с файловой системой, который потребуется нам для загрузки скриптов в режиме выполнения. Модуль fs имеет достаточно обширную функциональность, позволяет искать, читать и записывать файлы.
var fs = require('fs');
Для простоты примера я не стал подключать какие-либо существующие библиотеки для функционального тестирования JavaScript, хотя интеграция с ними у PhantomJS имеется. Объявим типовой класс assert-исключения:
function AssertionError(message) {
this.message = message;
}
AssertionError.prototype.toString = function() {
return 'AssertionError: ' + this.message;
};
Теперь для целей тестирования создадим обёртку над стандартным классом PhantomJS — WebPage.
function TestPage(scriptUnderTest, testFun) {
// проверяем существование файла со скриптом
if (!fs.exists(scriptUnderTest)) {
console.log("File " + scriptUnderTest + " not found");
phantom.exit(1);
}
var me = this;
// создаём страницу
this.page = require('webpage').create();
// перенаправляем весь консольный вывод страницы в нашу консоль верхнего уровня
this.page.onConsoleMessage = function (msg) {
console.log(msg);
};
// открываем тестовую страницу
this.page.open("ExtjsTesterPage.html", function() {
// инъектируем тестовый скрипт
me.page.injectJs(scriptUnderTest);
// ждём готовности ExtJS и выполняем тестовый скрипт
me.waitForExtReady(function() {
me.doTest(testFun);
});
});
}
Параметр конструктора scriptUnderTest — это имя файла со скриптом, подлежащим тестированию, например, с кодом компонента ExtJS. С помощью импортированной ранее зависимости fs мы убеждаемся в существовании этого файла, а с помощью функции WebPage#injectJs — инъектируем его в нашу тестовую страницу, которую мы загрузили из ранее созданного файла ExtjsTesterPage.html.
Стоит обратить внимание на метод WebPage#onConsoleMessage. Если не задать его, то весь консольный вывод, произошедший на странице, потеряется, так как страница выполняется в песочнице.
Параметр testFun — это наша будущая функция, выполняющая тестирование. Для того, чтобы обеспечить корректное тестирование компонента ExtJS, нужно убедиться, что фреймворк ExtJS полностью загружен и инициализирован. Для этого предназначен метод Ext.onReady(), но в нашем случае его использование затрудняется тем, что страница выполняется в песочнице, и передача какого-либо кода внутрь страницы возможна только с помощью метода WebPage#evaluate. Поэтому снабдим класс TestPage методом, ожидающим загрузки ExtJS:
TestPage.prototype.waitForExtReady = function(fun) {
var me = this;
console.log('Загрузка ExtJS...');
var readyChecker = window.setInterval(function() {
var isReady = me.page.evaluate(function() {
return Ext.isReady;
});
if (isReady) {
console.log('ExtJS загружен.');
window.clearInterval(readyChecker);
fun.call(me);
}
}, 100);
};
Когда ExtJS будет полностью загружен, выполнится метод doTest следующего содержания:
TestPage.prototype.doTest = function(testFun) {
try {
// Вызов тестовой функции
testFun.call(this);
phantom.exit(0);
} catch (e) {
console.log(e);
phantom.exit(1);
}
};
Здесь реализован вызов тестовой функции и завершение PhantomJS с кодом возврата, соответствующим результатам работы тестовой функции.
В завершение добавим к классу TestPage несколько конвиниенс-методов для ассертов и выполнения кода в контексте страницы:
TestPage.prototype.evaluate = function(fun) {
return this.page.evaluate(fun);
};
TestPage.prototype.assert = function assert(test, message) {
if (!test) {
throw new AssertionError(message);
}
};
TestPage.prototype.evaluateAndAssertEquals = function(expectedValue, actualFun, message) {
this.assert(expectedValue === this.evaluate(actualFun), message);
};
Метод evaluate делегирует исполнение переданной функции обёрнутому объекту WebPage. Метод assert выполняет типовое диагностическое утверждение с выбросом AssertionError, если это утверждение не выполнится. Метод evaluateAndAssertEquals выполняет сравнение результата выполнения функции в контексте страницы с ожидаемым значением.
Последней строчкой файла ExtjsTester станет загрузка самого тестового скрипта, который мы будем передавать через параметр командной строки. Воспользуемся для этого модулем для работы с файловой системой — загрузим скрипт в виде строки и исполним его через eval:
eval(fs.read(phantom.args[0]));
А вот и сам тестовый скрипт — файл MonthComboBoxTest.js:
new TestPage("MonthComboBox.js", function() {
// Создание компонента MonthComboBox и отображение его на странице
this.evaluate(function() {
Ext.widget('monthcombo', {
months : [1, 2, 5],
renderTo: Ext.getBody()
});
});
// Проверяем, что в хранилище компонента содержится три элемента
this.evaluateAndAssertEquals(3, function() {
return Ext.ComponentQuery.query('monthcombo')[0].store.getCount();
}, "Wrong element count");
});
Здесь мы создаём наш объект-обёртку TestPage, передавая ему скрипт тестируемого компонента и функцию для тестирования. В ней мы сначала создаём компонент на странице с такой конфигурацией, чтобы он содержал только три месяца, а затем проверяем, что наш замысел соответствует действительности, компонент действительно присутствует на странице, а его хранилище содержит ровно три элемента.
Для запуска теста воспользуемся командой:
phantomjs ExtjsTester.js MonthComboBoxTest.js
Тестирование приложения
Прежде всего, чтобы протестировать приложение, нам понадобится немного доработать наш фреймворк. Тестовая функция должна выполняться по завершении запуска приложения. Одним из способов определить это является проверка наличия компонента Viewport на странице. Добавим к классу TestPage следующий метод, в целом аналогичный методу TestPage#waitForExtReady:
TestPage.prototype.waitForViewport = function(fun) {
var me = this;
console.log('Ждём появления Viewport...');
var launchedChecker = window.setInterval(function() {
var isLaunched = me.page.evaluate(function() {
return typeof Ext.ComponentQuery.query('viewport')[0] !== 'undefined';
});
if (isLaunched) {
console.log('Viewport готов.');
window.clearInterval(launchedChecker);
fun.call(me);
}
}, 100);
};
Добавим также в конструктор объекта TestPage параметр waitForViewport, значение которого true будет означать, что перед выполнением тестовой функции необходимо дождаться появления Viewport. Немного модифицируем фрагмент, в котором вызывается тестовая функция:
me.waitForExtReady(function() {
if (waitForViewport) {
me.waitForViewport(function() {
me.doTest(testFun);
})
} else {
me.doTest(testFun);
}
});
В целях краткости я не буду приводить здесь весь код тестируемого приложения — на него можно взглянуть на GitHub. Поясню лишь, что приложение состоит из главной формы с кнопкой и окна, которое появляется при щелчке по этой кнопке. Главная форма и окно обслуживаются различными контроллерами, связь между которыми организована посредством сообщений уровня приложения (Ext.app.Application#fireEvent). Вот тестовый код, проверяющий, что при щелчке по кнопке действительно появляется всплывающее окно:
new TestPage("app.js", function() {
// программно щёлкаем по кнопке
this.evaluate(function() {
Ext.ComponentQuery.query('mainform > button[action=popup]')[0].btnEl.dom.click();
});
// проверяем, что окно появилось на экране
this.evaluateAndAssertTrue(function() {
return typeof Ext.ComponentQuery.query('popupwindow')[0] !== 'undefined';
}, "Popup window not opened");
}, true);
Компонентный запрос 'mainform > button[action=popup]' соответвует кнопке на главной форме приложения. Запрос 'popupwindow' соответствует всплывающему окну. Третий параметр конструктора TestPage (значение true) означает, что перед выполнением тестовой функции необходимо дождаться появления Viewport. Запускается тестовая функция аналогично:
phantomjs ExtjsTester.js AppTest.js
В статье проиллюстрирован подход к тестированию компонентов и приложений на ExtJS/Sencha на базе движка PhantomJS и разработана примерная заготовка для тестировочного фреймворка, которая с равным успехом может быть использована для тестирования и других JavaScript-библиотек. В заключение хочется отметить, что подобное функциональное тестирование ограничено браузерами, работающими на движке WebKit. Однако, учитывая высокие показатели соответствия этого движка современным Web-стандартам, а также приемлемый уровень абстракции и кроссбраузерности библиотеки ExtJS (Sencha), можно сделать вывод, что для тестирования логики работы приложения или функционирования компонента PhantomJS может оказаться достаточным.
Код тестового компонента и приложения, а также фреймворка для тестирования, представленного в статье, доступен на GitHub.