Хочу порекомендовать JsTestDriver - великолепный фрэймворк для модульного тестирования (unit testing) javascript кода от ребят из Google.
Он позволяет в считанные доли секунды протестировать javascript-код одновременно на любом (ну, почти любом) количестве любых браузеров
с любых компьютеров и платформ. Впечатляет, не правда ли?
Идея JsTestDriver проста, как всё гениальное (картинка взята с оф. сайта):
Запускается небольшой веб-сервер, причем не обязательно его запускать на локальной машине.
К нему, как и к обычному веб-серверу, используя определённый URL (например, http://localhost:4224/capture), коннектятся необходимые для тестирования браузеры,
которые могут находится на любом компьютере (и, соответственно, платформе), с которого доступен сервер.
Таким образом, можно будет одновременно тестировать код на Safari под Mac OS, Internet Explorer под Windows и, например, Firefox под Linux.
Далее происходит основная магия.
Браузеры (slaves в терминологии JsTestDriver) начинают периодически опрашивать сервер (при помощи AJAX) в ожидании новых задач (тестов).
И, как только задание будет получено, браузеры загрузят необходимые скрипты, выполнят тестовые функции (test cases) и вернут на сервер результаты выполнения.
Управление же сервером происходит при помощи клиентского приложения, которое легко интегрируется как с IDE разработчика, так и с системами автоматической сборки проектов (Ant, Team City, Phing и т.п.).
Однако, разработчику важна не столько вся эта кухня, сколько возможность иметь под рукой удобный инструмент для лёгкого и быстрого запуска модульных тестов и отслеживания результатов.
В этом нам поможет JsTestDriver плагин для Eclipse (аналогичный плагин есть так же и для IntelliJ IDEA).
Не вдаваясь в подробности опишу лишь основные фичи этого плагина:
Минимум настроек: только номер порта для сервера и пути к файлам браузеров (Firefox, Chrome, Safari, Internet Explorer и Opera) на локальном компьютере
Запуск сервера и браузеров для тестирования прямо из IDE
Простейший конфигурационный файл для запуска тестов (прописывается только URL тестового сервера и список необходимых javascript-файлов)
Ручной запуск модульных тестов через меню Run
Автоматический запуск модульных тестов при каждом изменении javascript-файлов
Наглядное отображение результатов тестирования прямо в IDE (на каком браузере под какой платформой в каком тест-кейсе произошла ошибка)
В общем, установка и настройка JsTestDriver не вызывают никаких особых проблем (всё очень подробно описано на оф. сайте), а уж использование приносит только сплошное удовольствие.
Правда, если быть честным, для этого пришлось немного (совсем чуть-чуть :) "поработать напильником"...
Капля дёгтя
Дело в том, что текущая версия (1.2 для сервера и 1.0.6 для плагина) немного недоработана (уверен, что это не надолго).
В частности, если в каком либо тестируемом js-файле будет синтаксическая ошибка (или иная ошибка, возникающая при исполнении этого файла), то в результатах тестов это никак не отражается.
Кроме того, наблюдается некоторая глючность Opera при исполнении тестов.
Лекарство
Для решения проблемы пришлось переписать один метод по загрузке скриптов и добавить "системный" тест-кейс,
который будет отражать ошибки, произошедшие до начала выполнения пользовательских тест-кейсов (см. runner-fix.js).
Ну и, в дополнение, мне показалось уместным добавить ещё две вещи:
Обновление тестовой страницы после выполнения тестов, чтобы исключить возможность влияния предыдущего выполнения тестов на последующие
assertFail( [expectedErrorMessage], caseWrapper ) - функция, которая позволяет протестировать случаи, когда должна происходить ошибка.
Пример использования:
function doSomething() {
thrownewError( 'Some error' );
}
...
// Test caseassertFail( 'Some error', function(){ doSomething() } );
// true
Чтобы внедрить эти изменения в JsTestDriver нужно выполнить следующие шаги:
Загружаем архив (содержит три файла: runner-fix.js, Runnerquirks.html, Runnerstrict.html)
Находим JsTestDriver.jar из которого запускается сервер. Для плагина к Eclipse он находится где-то в папке configuration.
В частности у меня, вот тут: c:\Program Files\Eclipse\Galileo\configuration\org.eclipse.osgi\bundles\929\1\.cp\lib\
Открываем JsTestDriver.jar как обычный zip-архив
Заходим в папку JsTestDriver.jar\com\google\jstestdriver\javascript\
Копируем туда файлы из runner-fix.zip
Запускаем JsTestDriver (вручную или через Eclipse плагин) и наслаждаемся :)
Подробнее о том, как собственно устанавливать, настраивать и использовать сервер или плагин, или писать тест-кейсы, смотрите по ссылкам, приведенным ниже.
Там же можно найти общую информацию о тестировании вообще и о модульном тестировании в частности.
На последок хочется отметить, что разработчики JsTestDriver надеятся, что javascript сообщество объединится
в поддержку этого фрэймворка, как единого движка для запуска тестов (наподобие JUnit в Java).
Что, совместно с использование различных расширений
(например, для YUI Test или QUnit),
сделает процесс модульного тестирования javascript максимально продуктивным.
Spket IDE — отличная среда для современной сложной JavaScript-разработки
(подробнее по ссылкам ниже [^]), однако, по непонятным мне причинам,
при работе на кастомном профайле (в настройках Эклипса Spket/JavaScript Profiles)
в code completion отсутствуют некоторые свойства стандартных объектов (например, методы match, replace, search и split для String).
Причем, добавить их через подключение js-файла в кастомный JavaScript-профайл тоже не получается.
Решить проблему можно следующим образом (на примере плагина под Эклипс):
Выходим из Эклипса (если он запущен)
Заходим в папку plugins Эклипса
При помощи любого файлового менеджера (FAR, Total Commander) открываем архивный файл com.spket.js_Х.Х.Х.jar (где X.X.X — ваша версия плагина Spket IDE)
Редактируем файл config/core.js, добавляя недостающие свойства
Синтаксис там очень простой:
...
class Number {
static var MAX_VALUE:Number;
static var MIN_VALUE:Number;
...
}
class String {
var length:Number;
static function fromCharCode(... chars):String;
function String(string:String);
function charAt(index:Number):String;
...function match(searchValue:RegExp_or_String):Array;
function replace(searchValue:RegExp_or_String, replaceValue:String_or_Function):String;
function search(searchValue:RegExp_or_String):Number;
function split(separator:String, limit:Number):Array;
}
class RegExp {
function RegExp(pattern:String, flags:String);
...
}
...
так что проблем особых возникнуть не должно. После перезаргузки Эклипса новые свойства (методы) станут доступны для code completion.
Как известно, Internet Explorer версий 5.5 и 6.0 содержит ошибку (aka "z-index issue"), которая заключается в том, что элементы SELECT "пробивают" любые другие элементы (например, позиционированные DIV-ы), размещённые над ними.
Единственный элемент, с которым этого не происходит — IFRAME. Именно на использовании его в качестве "прокладки" и основываются все методы устранения описанной выше ошибки IE [^]. Как правило, в них применяется один из двух вариантов. Либо, IFRAME вставляется внутрь (первым потомком) необходимого элемента, делается прозрачным, и стиль z-index устанавливается в -1. Либо, IFRAME добавляется в DOM, позиционируется непосредственно под необходимым элементом и делается равным ему по размеру.
При кажущейся простоте, у первого варианта два серьёзных недостатка. Во-первых, изменяются "внутренности" элемента, что может привести к конфликтам при динамической обработке контента javascript-ом. Во-вторых, "прикрыть", например, элементы с overflow:auto или overflow:scroll весьма проблематично. Второй вариант не имеет данных недостатков, т.к. IFRAME ни коим образом не связан с элементом, который он "прикрывает". Однако, существует проблема изменения позиции или размера элемента, которым IFRAME постоянно должен соответствовать.
В описываемом ниже решении для борьбы с «просвечивающими» SELECT-ами используется второй вариант размещения IFRAME.
"IFRAME-подложка"
Теперь собственно о главном. Предлагаемое решение основано на применении javascript-expressions в CSS стилях. Используется оно следующим образом:
Подключаем его на страницу при помощи тэга LINK или в любой CSS-файл при помощи @import
Прямо в этом же файле переписываем селекторы для элементов, которым необходима "подложка" (обязательно с * html).
Например: * html div.some-layer, * html #someLayer {...}
Всё.
Протестировать можно вот на этих страницах: тест 1, тест 2, тест 3.
К сведению: протестированный для сравнения bgIframe (плагин для jQuery) часть приведённых выше тестов пройти не смог.
Некоторые ограничения и особенности:
"Прикрываемый" элемент не должен использовать стиль behavior
У каждого "прикрываемого" элемента создаётся и используется свойство "_i" (element._i)
Для "IFRAME-подложки" используется CSS-класс "ie-substrate" (iframe.ie-substrate)
Как это работает (в версии 1.0.0):
/**
* Селектор слоёв (позиционированных элементов),
* которым нужно создать "IFRAME-подложку".
*/* html div.layer { /* Скрываем от IE 7+ в "standards-compliant" режиме */behavior: expression(/* Если документ загружен и распаршен */
document.readyState=='complete' ? (
/* и браузер не IE 7+ (в "quirks" режиме) */
parseFloat(navigator.appVersion.split('MSIE')[1])<7/* и "IFRAME-подложка" ещё не создана */
&& this.iframe==null ? (
/* создаём IFRAME и вставляем его прямо перед данным элементом (слоем) */this.iframe = this.parentNode.insertBefore(
document.createElement('iframe'), this
),
/* устанавливаем свойство "src" (важно для HTTPS соединения) */this.iframe.src = 'javascript:false',
/* устанавливаем свойство "id" (для функции обновления) */this.iframe.id = '_ie_sub' + newDate().valueOf(),
/* устанавливаем свойство "layer" для ссылки на данный слой */this.iframe.layer = this,
/* создаём функцию обновления (важно для IE 6.0) */this.iframe.func = newFunction(
"var iframe = document.getElementById('" + this.iframe.id +
"'); iframe.allowTransparency=true; iframe.allowTransparency=false;"
),
/* устанавливаем соответствующее имя класса */this.iframe.className = 'ie-substrate',
/* сбрасываем ссылку на IFRAME и устанавливаем флажок */this.iframe = true,
/* отключаем "behavior" для данного элемента */this.style.behavior = 'none'/* иначе - отключаем "behavior" для данного элемента */
) : this.style.behavior = 'none'/* иначе - ничего не делаем */
) : null);
}
/**
* Стили для "IFRAME-подложки"
*/iframe.ie-substrate {
position: absolute;
/* Делаем IFRAME прозрачным */filter: Mask();
/* Отслеживаем позицию и размеры "прикрываемого" слоя */behavior: expression(/* Если IFRAME в DOM-е (т.е. не удален) */this.parentNode ? (
/* Если "прикрываемый" слой в DOM-е (т.е. не удален) */this.layer.parentNode ? (
/* и IFRAME вставлен в DOM-е перед "прикрываемым" слоем */this.nextSibling==this.layer ? (
/* перехватываем свойства "прикрываемого" слоя */this.style.pixelWidth = this.layer.offsetWidth,
this.style.pixelHeight = this.layer.offsetHeight,
this.style.pixelLeft = this.layer.offsetLeft,
this.style.pixelTop = this.layer.offsetTop,
this.offsetTop>0 ? ( /* только если IFRAME виден */this.style.zIndex = this.layer.currentStyle.zIndex,
this.style.visibility = this.layer.currentStyle.visibility,
this.style.display = this.layer.currentStyle.display
) : null/* иначе */
) : (
/* вставляем IFRAME перед "прикрываемым" слоем в DOM-е */this.layer.parentNode.insertBefore(this, this.layer)
)
/* иначе */
) : (
/* удаляем IFRAME */this.removeNode(true)
)
/* иначе - ничего не делаем */
) : null);
/* Обновляем IFRAME-ы периодически, чтобы решить проблему в IE 6.0
* (SELECT-ы всё ещё могут перекрывать слои после скроллинга). */refresh: e\xpression(/* Скрываем от IE 5.5 *//* Если IFRAME виден */this.offsetTop>0 ? (
/* включить обновление */this.timer ? null : this.timer = window.setInterval(this.func, 300)
/* иначе */
) : (
/* выключить обновление */this.timer ? this.timer = window.clearInterval(this.timer) : null
)
);
}
Корректно работает с любыми элементами как в "standards-compliant", так и в "quirks" режимах, а также при любых динамических манипуляциях с "прикрытыми" элементами
Не требует внесения изменений в ваш CSS- или javascript-код
Небольшой объём библиотечного файла (всего 1 KB без комментариев)
Нет привязки к каким-либо фрэймворкам
Не оказывает никакого влияния на другие браузеры (в том числе IE 7+)
Легко отключается не оставляя следов (например, при отказе от поддержки IE 6 или от использования SELECT-ов)
Недостатки:
Снижение быстродействия при увеличении количества "прикрытых" элементов
Утечка памяти в IE 5.5 при большом количестве "прикрытых" элементов и при интенсивных манипуляциях с DOM-ом
Некорректное позиционирование "IFRAME-подложки", если "прикрываемый" элемент имеет относительное позиционирование и расположен внутри элемента TD со значением стиля vertical-align отличным от top
Насколько эти недостатки критичны — нужно рассматривать для каждого конкретного проекта отдельно.
Данная статья адресована, в первую очередь, программистам, пишущим на JavaScript и имеющим, как минимум, базовые знания в области объектов и наследования через прототипы. Поэтому, я не буду излагать азы (которые, при желании, можно найти по ссылкам в конце статьи [^]). Хочу просто показать, как можно реализовать классическое (классовое) наследование, используя стандартные возможности языка.
Поставим перед собой несколько целей:
Наследование должно быть полноценным, т.е. конструкция
object instanceofClass должна работать [^]
Реализовать простую возможность вызова конструктора [^] или любого метода базового класса [^]
Простота в использовании, стройность и лёгкость кода [^]
По возможности, облегчить задачу современным IDE (в частности Spket) для корректного функционирования code complete по созданным объектам [^]
Итак, создадим базовый класс:
// Пишем конструктор базового класса/**
* Некий абстрактный человек, имеющий имя и
* умеющий выполнять некоторые повседневные действия
* @constructor Person
* @param name {String} Имя человека
*/varPerson = function (name) {
// Инициализируем public свойство namethis.name = name;
// Локальные переменные определённые внутри конструктора
// можно использовать, при необходимости, как private члены класса
// но только через специальные public методы (getter и/или setter),
// определённые здесь же - в конструкторе
//
// var somePrivateVariable = {};
// this._getSomePrivateVariable = function() {
// return somePrivateVariable;
// }
}
// Описываем члены базового класса внутри wrapper-функции
// и сохраняем ссылку на неё в public static свойство $class
// для дальнейшего использования
// ПРИМЕЧАНИЕ:
// Естественно, wrapper-функция может быть и анонимной.
// В данном примере имя (совпадающее с именем класса) используется
// только для того, чтобы получить более дружественный code complete в IDE
/**
* @class Person
*/Person.$class = functionPerson() {
// Локальные переменные определённые в этой области
// будут что-то вроде private static членов классаvar $className = 'Person';
// Описываем public свойства класса/**
* Имя человека
* @type {String}
*/this.name = '';
// Описываем public методы класса/**
* Возвращает список ежедневных действий человека
* @returns {String}
*/this.performEverydayActions = function () {
return'- Спит, ест, развлекается [as a ' + $className + ']';
}
/**
* Возвращает имя человека
* @returns {String}
*/this.getName = function () {
return this.name;
}
this.toString = function () {
return this.name;
}
}
// "Компилируем" базовый класс, создавая прототип из wrapper-функцииPerson.prototype = newPerson.$class();
Далее, создадим производный класс:
// Пишем конструктор производного класса/**
* Некий абстрактный работник, имеющий имя (как и любой человек) и
* умеющий выполнять какую-либо работу
* @constructor Employee
* @extends Person
* @param name {String} Имя работника
* @param job {String} Функция работника (или работа, которую он умеет выполнять)
*/varEmployee = function (name, job) {
// Вызываем конструктор базового класса Person
// (через ссылку $super, которую установим ниже),
// чтобы проинициализировать имя при создании
// объекта производного класса (Employee)
arguments.callee.$super.call(this, name);
// Инициализируем public свойство jobthis.job = job;
}
// Сохраняем ссылку на базовый класс для дальнейшего использованияEmployee.$super = Person;
// Описываем члены производного класса внутри wrapper-функции
// и сохраняем ссылку на неё для дальнейшего использования/**
* @class Employee
* @extends Person
* @param $super {Object} Прототип базового класса
*/Employee.$class = functionEmployee($super) {
// Внутрь wrapper-функция производного класса будем передавать
// в качестве агрумента ссылку на прототип базового класса.
// Это существенно облегчит обращение к методам базового класса (см. ниже)var $className = 'Employee';
// Описываем public свойства класса/**
* Функция работника
* @type {String}
*/this.job = '';
// Описываем public методы класса/**
* Возвращает функцию работника
* @returns {String}
*/this.getJob = function () {
return this.job;
}
// Переопределяем метод базового класса/**
* Возвращает список ежедневных действий человека, включая его рабочие функции
* @returns {String}
*/this.performEverydayActions = function () {
// Вызываем метод базового классаreturn $super.performEverydayActions.call(this) +
'\n- ' + this.job + ' [as an ' + $className + ']';
}
}
// Устанавливаем наследование производного класса
// от базового класса через прототипыEmployee.$class.prototype = Employee.$super.prototype;
// "Компилируем" класс, создавая прототип из wrapper-функции
// и передавая в качестве аргумента прототип базового классаEmployee.prototype = newEmployee.$class(Employee.$super.prototype);
Теперь, создадим класс производный от производного (именно на этом шаге многие попытки реализовать классовое наследование терпели неудачу):
// Пишем конструктор ещё одного производного класса/**
* Программист, который может писать код
* @constructor Developer
* @extends Employee
* @param {String} name Имя
*/varDeveloper = function (name) {
// Вызываем конструктор базового класса (Employee)
arguments.callee.$super.call(this, name, 'Пишет код');
}
// Т.к. класс Developer не имеет собственных членов, то wrapper - пустая функция/**
* @class Developer
* @extends Employee
*/Developer.$class = function () {};
// Сохраняем ссылку на базовый классDeveloper.$super = Employee;
// Устанавливаем наследованиеDeveloper.$class.prototype = Developer.$super.prototype;
// "Компилируем" классDeveloper.prototype = newDeveloper.$class(Developer.$super.prototype);
Ещё немного усложним - создадим третий производный класс:
// Пишем конструктор последнего производного класса/**
* Ведущий инженер-программист, который ещё и командой разработчиков управляет
* @constructor LeadDeveloper
* @extends Developer
* @param {String} name Имя
* @param {Developer[]} team Команда программистов
*/varLeadDeveloper = function(name, team) {
// Вызываем конструктор базового класса (Developer)
arguments.callee.$super.call(this, name);
// Инициализируем public свойство teamthis.team = team;
}
// Сохраняем ссылку на базовый классLeadDeveloper.$super = Developer;
// Описываем члены класса/**
* @class LeadDeveloper
* @extends Developer
* @param $super {Object} Прототип базового класса
*/LeadDeveloper.$class = functionLeadDeveloper($super) {
var $className = 'LeadDeveloper';
// Описываем public свойства класса/**
* Команда программистов, которой руководит данный Lead
* @type {Developer[]}
*/this.team = [];
// Описываем public методы класса/**
* Возвращает подчиненных данного Lead-а
* @returns {Developer[]}
*/this.getTeam = function () {
return this.team;
}
// Переопределяем метод базового класса/**
* Возвращает список ежедневных действий человека, включая его рабочие функции
* @returns {String}
*/this.performEverydayActions = function () {
// Вызываем метод базового классаreturn $super.performEverydayActions.call(this) +
'\n- Управляет командой: [as a ' +
$className + ']\n ' + this.team.join('\n ');
}
}
// Устанавливаем наследованиеLeadDeveloper.$class.prototype = LeadDeveloper.$super.prototype;
// "Компилируем" классLeadDeveloper.prototype = newLeadDeveloper.$class(LeadDeveloper.$super.prototype);
Теперь проверим, что получилось:
var ivanov = newPerson('Иванов Василий');
alert(ivanov.getName() + ':\n' + ivanov.performEverydayActions());
// Иванов Василий:
// - Спит, ест, развлекается [as a Person]var petrova = newEmployee('Петрова Мария','Доит коров');
alert(petrova.getName() + ':\n' + petrova.performEverydayActions());
// Петрова Мария:
// - Спит, ест, развлекается [as a Person]
// - Доит коров [as an Employee]var sidorov = newDeveloper('Сидоров Алексей');
var pupkin = newDeveloper('Пупкин Иван');
var shishkin = newDeveloper('Шишкин Пётр');
alert(sidorov.getName() + ':\n' + sidorov.performEverydayActions());
// Сидоров Алексей:
// - Спит, ест, развлекается [as a Person]
// - Пишет код [as an Employee]var myshkin = newLeadDeveloper('Мышкин Фёдор', [sidorov, pupkin, shishkin]);
alert(myshkin.getName() + ':\n' + myshkin.performEverydayActions());
// Мышкин Фёдор:
// - Спит, ест, развлекается [as a Person]
// - Пишет код [as an Employee]
// - Управляет командой: [as a LeadDeveloper]
// Сидоров Алексей
// Пупкин Иван
// Шишкин Пётр alert(myshkin instanceofPerson);// true
alert(myshkin instanceofEmployee);
// true
alert(myshkin instanceofDeveloper);
// true
alert(myshkin instanceofLeadDeveloper);
// true
Всё отлично работает. Code complete в Spket без проблем выдаёт список свойств и методов объекта myshkin, причем по каждому из них указан класс, который их содержит:
Осталось сделать красивую "обёртку":
Function.prototype.$extends = function ($super) {
this.$super = $super;
return this;
}
Function.prototype.$class = function ($class) {
if ($class == null) {
$class = function () {};
}
this.$class = $class;
if (this.$super != null) this.$class.prototype = this.$super.prototype;
this.prototype = new $class(this.$super!=null ? this.$super.prototype : null);
return this;
}
И "упаковать" (для примера, только два последних класса, чтобы не повторять опять гору кода):
К сожалению, данная конструкция уже не по зубам IDE и code complete в финальной варианте всё-таки работать не будет. Однако, есть некоторые хитрости, которые позволят извлечь максимум пользы из всего вышеизложенного на различных фазах девелопмента.