Сегодня мы немного поразбираемся в библиотеке CasperJS. Что такое CasperJS? Аннотация на сайте говорит нам, что это navigation scripting & testing utility, то есть инструмент для написания сценариев навигации и для тестирования. Работает CasperJS с помощью безбашенного headless-браузера PhantomJS (Webkit), либо с помощью почти-headless-браузера SlimerJS (Gecko).
Так что же с помощью CasperJS можно сделать? Например, можно написать вот такой скрипт:
casper.test.begin('Testing my website', function suite(test) {
casper.start();
casper.thenOpen('http://mytestsite.ru', function() {
test.assertHttpStatus(200, 'Homepage was loaded successfully.');
test.assertExists('div#logo', 'Header link to the homepage is present.');
test.assertExists('form#user-login-form', 'Login form is present.');
});
casper.run(function() {
test.done();
});
});
Этот скрипт автоматически зайдет на главную страницу заданного сайта и проверит, что http-response равен 200, а затем проверит, что на странице присутствует div с логотипом и форма логина.
То есть, с помощью CasperJS мы можем описывать на обычном языке JavaScript сценарии последовательной навигации по сайту, с переходами по ссылкам, заполнением и отправкой форм, и конечно с тестовыми проверками (см. assert) на соответствие того, что мы видим, тем или иным условиям. Иными словами, CasperJS — это библиотека для front-end-тестирования.
Зачем все это нужно? Вопрос не праздный. Зачем вообще нужно тестировать программное обеспечение в целом и сайты в частности? Очевидно, чтобы было не стыдно за конечный результат, чтобы получить проект, который в целом работает, а не вроде бы иногда работает. Однако автоматизированное, последовательное тестирование веб-сайта нередко заменяется занудным и относительно долгим процессом «я сейчас нажму сюда, создам материал, потом нажму сюда, и зарегистрирую пользователя, а потом сюда, и посмотрю, что будет». На проектах малой и средней сложности такой подход может обеспечивать достаточное качество выходного результата. При должной степени ответственности и сообразительности тестирующего, конечно.
Но чем сложнее задача, тем выше будет вероятность пропустить исключительный случай, о чем-то забыть или получить регрессию. Да и цепочка действий, которую нужно воспроизвести для проверки того или иного сценария, тоже может стать слишком длинной. Например, чтобы проверить оформление заказа в интернет-магазине в определенных условиях, надо — накидать товаров в корзину на нужную сумму, пройти в оформление заказа, заполнить форму данных о себе (возможно, достаточно длинную), провести тестовый платеж, проверить результаты оформления с позиции пользователя и с позиции администратора. Вполне очевидно, что этот процесс было бы неплохо автоматизировать, а не повторять из раза в раз.
В этом, на наш взгляд, и заключается основное назначение библиотеки:
- в описании поведенческих паттернов, таких как «оформить заказ», «заполнить информацию в профиле», «создать материал»;
- в автоматизированном запуске скриптов, исполняющих эти действия;
- и в проверке результатов запуска на соответствие заданным условиям.
Даже если не проверять результаты в тесте, польза от автоматизированного запуска очевидна: гораздо проще отладить почтовое уведомление о новом заказе, запуская скрипт оформления автоматически, нежели каждый раз оформлять заказ вручную.
Кстати, CasperJS пригоден еще и для веб-скрейпинга, но это уже другая история.
На этом закончим затянувшееся вступление, и посмотрим на практике, как можно работать с библиотекой CasperJS в Друпале.
Установка CasperJS и PhantomJS
Установить CasperJS на сервер довольно просто:
- Нужно проверить наличие питона версии не менее 2.6 (и установить, если его нет):
python --version
- Затем установить CasperJS:
cd /usr/share
sudo git clone git://github.com/n1k0/casperjs.git
cd casperjs
git checkout 1.1-beta3
sudo ln -sf `pwd`/bin/casperjs /usr/local/bin/casperjs
- Затем нужно скачать последнюю версию PhantomJS с сайта, распаковать в /usr/share и выполнить:
cd /usr/share/phantomjs-1.9.8-linux-i686/bin
sudo ln -sf `pwd`/phantomjs /usr/local/bin/phantomjs
- В результате по запуску casperjs должна выводиться примерно такая надпись:
CasperJS version 1.1.0-beta3 at /usr/share/casperjs, using phantomjs version 1.9.8
Как выглядит скрипт CasperJS
Посмотрим на простенький скрипт с двумя тестами (что загрузилась главная страница и страница /user/login):
casper.test.begin('Testing my website', function suite(test) {
casper.start();
casper.thenOpen('http://mytestsite.ru', function() {
test.assertHttpStatus(200, 'Homepage was loaded successfully.');
});
casper.thenOpen('http://mytestsite.ru/user/login', function() {
test.assertHttpStatus(200, 'Login page was loaded successfully.');
});
casper.run(function() {
test.done();
});
});
Что мы видим?
casper.start()
конфигурирует и запускает процесс, без этого нельзя.
casper.thenOpen()
добавляет очередной шаг в цепочку навигационных действий (об этом ниже).
casper.run()
запускает настроенную цепочку, а по окончании цепочки (в коллбэке, определенном в casper.run()
) — происходит завершение тестирования test.done()
.
Такова структура одного теста на CasperJS: старт → определение цепочки навигационных действий → запуск этой цепочки → окончание теста (успешное или нет).
Теперь если мы запустим скрипт простой командой
casperjs test <путь и имя файла>
то увидим результат:
# Testing mysamplesite.ru
PASS Homepage was loaded successfully.
PASS Login page was loaded successfully.
PASS 2 tests executed in 1.423s, 2 passed, 0 failed, 0 dubious, 0 skipped.
Что важно знать о навигационных действиях
Здесь и далее, цепочкой навигационных действий мы будем называть последовательность действий в браузере, заданную методами casper.then*()
и casper.waitFor*()
. Этими действиями могут быть переходы по ссылкам, ожидание загрузки очередной страницы, или ресурса и т.п. Главное что каждый шаг в навигационной цепочке как правило сопряжен с событием загрузки чего-либо. И что именно в коллбэках, выполняемых по окончании загрузки этого чего-либо мы вызываем методы test.assert*
для вычисления результатов теста.
Чтобы не сломать себе мозг в процессе отладки тестового скрипта на CasperJS, следует сразу понять важную вещь о навигационных действиях. А именно — то, что создание (инициализация) цепочки действий then-методами происходит до непосредственного запуска. Запуск заранее определенной цепочки действий происходит позже по вызову метода casper.run()
. Очевидно, коллбэки, указанные для каждого шага цепочки, выполнятся асинхронно когда-нибудь потом, уже после инициализации.
То есть вот такой кусочек кода:
//...
var nextUrl;
function getMyUrl() {
return 'http://google.com';
}
casper.thenOpen('http://yandex.ru', function () {
nextUrl = getMyUrl();
});
casper.thenOpen(nextUrl, function () {
casper.echo('Ну и ну!');
});
//...
не приведет к загрузке google.com, т.к. на момент инициализации навигационной цепочки переменная nextUrl еще не определена.
Чтобы можно было добавлять шаги в навигационную цепочку динамически — нужно вызывать casper.then*
-методы прямо из коллбэков к другим casper.then*
-методам.
Пара слов о evaluate()
Важно заметить, что суть скриптов на CasperJS — это отправка указаний браузеру, который послушно выполняет эти указания над открытым в нем документом и отдает результаты, которые нам нужны для тестирования. Непосредственно самого документа (то есть открытой веб-страницы) в окружении CasperJS не видно.
Однако, зачастую нам нужно вычислить что-то непосредственно над DOM самой страницы, чтобы потом использовать в тестах. Для этого используется метод casper.evaluate()
. По сути этот метод — мост между окружением CasperJS+PhantomJS и DOM открытой страницы. С помощью evaluate() мы можем выполнить какой-либо скрипт так, будто мы его выполняем из консоли браузера, а результат выполнения будет возвращен в окружение CasperJS для тестов. В скрипте checkout.js
в конце заметки будет пример использования evaluate()
.
Для справки: если порыться в реализации assert*-методов типа
assertExists()
, нетрудно обнаружить, что их связь с DOM открытой страницы тоже реализована черезevaluate()
.
То есть общее правило написания тестов на CasperJS: все манипуляции с DOM открытой страницы должны проходить через evaluate()
.
CasperJS для Drupal
Стараниями компании Lullabot создан фундамент для разработки тестов на CasperJS под Drupal. Фундамент существует в виде одноименного проекта и может быть установлен простой командой drush dl casperjs
. Под фундаментом подразумевается не модуль, а команда casperjs для Drush, которую можно исполнять из любой директории сайта вот так:
drush casperjs
Помимо реализации команды для drush, в поставке фундамента есть несколько файлов с функциями-хелперами, а также пара стандартных тестов. Посмотрим на содержимое фундамента:
casperjs.drush.inc
— реализация команды для drush;includes/common.js
— набор служебных функций, включая хелперы для сохранения скриншота, переключения вьюпортов и обертку надcasper.run()
; здесь же живет настройка таймаута на выход из зависания (равная по умолчанию двум минутам);includes/session.js
— функции, помогающие логиниться под тем или иным аккаунтом и обертка для casper.start(), позволяющая сразу начать тест с той или иной авторизации; здесь же можно в массиве casper.drupalUsers указать список пользователей и пароли для них, чтобы исполнять тесты с этих учетных записей;includes/pre-test.js
— скрипт, запускаемый перед всеми остальными тестами, в нем создаются сессии для всех пользователей из массиваcasper.drupalUsers
;
Важно: если вы хотите пользоваться командой
drush casperjs
и функциями-хелперами, нужно обязательно задать в массивеcasper.drupalUsers
файлаincludes/session.js
хотя бы один актуальный логин-пароль, либо очистить массив. В скриптеpre-test.js
проводится обязательная авторизация и она будет основательно подвисать, если что не так.includes/post-test.js
— скрипт, запускаемый после всех тестов, в нем принудительно закрываются все сессии для пользователей из массива casper.drupalUsers;README.txt
— в файле дана исчерпывающая и простая инструкция по установке CasperJS и PhantomJS на сервере, а также краткие сведения по функционалу и запуску тестов.
Еще в директории tests есть парочка базовых тестов — create_content.js
и homepage.js
. На них можно и нужно смотреть при написании своих скриптов. Однако запускать их особого смысла нет: они приведены для примера и без ошибок, зависаний и правок пройдут только на «ванильной» установке Drupal 7. Малейшее расхождение (настройка pathauto, тема, отличная от Бартика) — и тест не пройдет.
В целом заложенный луллаботами фундамент достаточно полезен. Разве что не очень нравится, что тесты надо держать в ~/.drush/casperjs/
. Мало того что так тесты для разных проектов смешаются в кашу, так еще и запуск drush casperjs
без аргументов будет норовить запустить для данного проекта всё что сможет найти в .drush/casperjs/tests/
. Поэтому нам больше подошло выпилить из ~/.drush/casperjs/
все инклудники и подключать их отдельно в той структуре директорий с тестами, в которой удобно нам.
Тест оформления заказа checkout.js
Чтобы закрепить полученную информацию о CasperJS практикой — рассмотрим исполнение сценария оформления заказа в магазине на базе Drupal Commerce. В этом тесте мы возьмем случайный товар с главной страницы магазина, положим его в корзину, проверим, что он туда попал, а затем пройдем все стадии оформления заказа и найдем заказанный товар в профиле пользователя.
casper.test.begin('Проверяем оформление заказа', function suite(test) {
var products = [];
var cart = [];
//Функция для получения массива объектов с информацией о товарах на заданной странице
function getProductsFromGrid() {
var prods = [];
jQuery('form.commerce-add-to-cart').each(function () {
var product = {};
var $productBlock = jQuery(this).parents('div.project-desc');
product.title = $productBlock.find('h3 a').text();
product.link = $productBlock.find('h3 a').attr('href');
product.price = $productBlock.find('div.price div.field-content').text();
product.formId = jQuery(this).attr('id');
prods.push(product);
});
return prods;
} //getProductsFromGrid
//функция кладет заданный товар в корзину
function putProductInCart(product) {
jQuery('#' + product.formId).submit();
}
casper.drupalStartAs('test_user');
casper.thenOpen('/', function() {
//получаем товары на главной странице
products = products.concat(this.evaluate(getProductsFromGrid));
//проверяем, что товары найдены
test.assertNotEquals(products.length, 0, 'Найдено ' + products.length + ' товаров на странице');
//положим в корзину случайный товар
var randomProduct = Math.floor(Math.random()*products.length + 0);
cart.push(products[randomProduct]);
//здесь и выше функции, использующие jQuery вызываются через casper.evaluate()
//обратите внимание, как передаются аргументы
this.evaluate(putProductInCart, cart[0]);
});
//проверим, что в корзине появился товар
casper.thenOpen('/cart', function () {
test.assertSelectorHasText('#dc-cart-ajax-form-wrapper table td.views-field-line-item-title div a', cart[0].title, 'Товар найден в корзине');
});
//проверим оформление заказа
casper.thenClick('#edit-checkout');
casper.waitForUrl(/checkout\/[0-9]+/, function () {
test.assertExists('#commerce-checkout-form-shipping', 'Этап доставки на месте');
//выберем "самовывоз" и отправим форму
casper.fill('form#commerce-checkout-form-shipping',
{'commerce_shipping[shipping_service]': 'takeaway'},
true);
});
//проверим order review
casper.waitForUrl(/checkout\/[0-9]+\/review/, function () {
test.assertSelectorHasText('div.view-commerce-cart-summary table td.views-field-line-item-title', cart[0].title, 'В таблице проверки заказа найден выбранный ранее товар');
casper.fill('form#commerce-checkout-form-review', {}, true);
});
//проверим форму заполнения информации о пользователе
casper.waitForUrl(/checkout\/[0-9]+\/checkout/, function () {
test.assertExists('#commerce-checkout-form-checkout', 'Форма оформления на месте');
casper.fill('form#commerce-checkout-form-checkout',
{
'customer_profile_billing[field_first_name][und][0][value]': 'Test',
'customer_profile_billing[field_last_name][und][0][value]': 'User',
'customer_profile_billing[field_phone][und][0][value]': '+7 (903) 123-45-67',
'customer_profile_billing[field_call_time][und][0][value]': '9 - 18',
'customer_profile_billing[field_postcode][und][0][value]': '124482',
'customer_profile_billing[field_area][und][0][value]': 'Москва',
'customer_profile_billing[field_city][und][0][value]': 'Зеленоград',
'customer_profile_billing[field_city_address][und][0][value]': 'Савелкинский проезд д. 4',
'customer_profile_billing[field_more_info][und][0][value]': 'Тестовый заказ: не звонить, не писать, проверять ошибки!'
},
true);
});
//проверим форму оплаты
casper.waitForUrl(/checkout\/[0-9]+\/online_payment/, function () {
test.assertExists('form#commerce-checkout-form-online-payment', 'Форма оплаты на месте');
casper.fill('form#commerce-checkout-form-online-payment',
{
'commerce_payment[payment_method]': 'cash|commerce_payment_cash'
},
true);
});
//проверим завершение заказа
casper.waitForUrl(/checkout\/[0-9]+\/complete/, function () {
test.assertExists('form#commerce-checkout-form-complete', 'Заказ оформлен');
//ссылка на оформленный заказ
var orderLink = this.evaluate(function () {
return jQuery('div.checkout-completion-message a').attr('href');
});
casper.echo(orderLink);
//зайдем на страницу заказа в профиле пользователя
casper.thenOpen(orderLink, function () {
test.assertSelectorHasText('div.entity-commerce-order table td.views-field-line-item-title', cart[0].title, 'Выбранный товар найден в оформленном заказе');
});
});
casper.drupalRun();
});
Без особого труда можно доработать этот тест, например, до набора в корзину нескольких товаров и проверки системы скидок (которая в Drupal Commerce как раз не без извращений), или до проверки, что введенная информация сохраняется в профиле пользователя.
Недостатки
Не то чтобы были налицо какие-то страшные изъяны, но пару особенностей стоит отметить.
Во-первых, в процессе написания тестов сталкиваешься с регулярными подвисаниями при запуске, порой даже если явной ошибки нет. Можно например взять пустой js-файл, запустить — и тест зависнет. Раздражает не сильно, но немного неприятно.
И во-вторых, поскольку это фронд-энд тестирование, при написании тестов неизбежно (и довольно быстро) упираешься в зависимость от конкретной верстки. Чтобы извлечь с тестируемой страницы данные, рано или поздно придется написать селектор, который в одной теме оформления сработает, а в другой — нет. Так что имеет смысл заранее это предусмотреть, и по возможности темо-зависимые функции собирать в пакете тестов отдельно, чтобы удобнее было переписывать, когда потребуется переносимость.
Заключение
Что дальше можно делать с тестами на CasperJS? Можно подробнее описать несколько часто возникающих шаблонов действий (создание материалов, регистрация, поиск товара, вычисление скидки и т.п.), увеличить, так сказать, покрытие. Можно добавить небольшое API для извлечения данных из бэкэнда в целях тестирования. Можно написать тест, выполняющий рандомные действия, или последовательно обходящий url-ы из карты сайта в поиске ошибок и warning-ов — благо их легко поймать обычным селектором.
В целом, написание тестовых сценариев на CasperJS представляется интересным и полезным. Во всяком случае, для основных поведенческих паттернов, особенно для чувствительных к изменениям в самых разных частях сайта. Конечно, это совсем не то же самое, что юнит-тесты на бэкэнде, и сценарии будут исполняться медленнее. Но описать некоторые модели поведения со стороны пользователя может оказаться не лишним в поиске регрессий. Во всяком случае нам ряд применений в своих проектах уже очевиден. Так что мы продолжим использовать CasperJS. Не исключено, что и новые заметки еще появятся.