Юнит-тесты рулят
Сегодня убедился, что юнит-тесты и в AS2 - вещь весьма полезная. Написал класс, к которому параллельно писал тесты. Не по методике TDD, но все же... И все тесты работают, а в приложении - нет. И тогда стал я писать тест, который показал бы мне таки красную полосу. И мне это удалось! После этого баг был локализован и выловлен. Баг оказался пустяковым - подумаешь по невнимательности в условной конструкции вместо == написал = :) Уж такого я на себя подумать не мог, да и со стороны все выглядело прилично. А потому стал думать а плясках с бубном, о собственной профнепригодности, о влиянии сложности приложения на нестабильно работающий класс, породившей баг, отладить который ввиду сложности приложения не представляется возможным. Не отчаивайтесь в моей ситуации - пишите тест. На кошках вылавливается легче.
Ну и под это дело как раз и хочу продемонстрировать пример. Но сначала, как известно, рабочая среда. Первым делом вам нужна библиотека AsUnit. Вам нужно скачать Framework. zip-архив содержит в себе три папки: as2, as25 и as3. Смысл примерно таков, что первая папка содержит все изначально написанную под AS2 библиотеку модульного тестирования. Пришло время AS3 и авторы переписали библиотеку путем портирования JUnit'а. А следом появилась бибилиотека AsUnit 2.5 - это портированная на AS2 библиотека версии AS3 :) В результате рекомендуется использовать 2.5. По сравнению с уже давно существующей AsUnit 2.0 там есть ряд преимуществ: в качестве тестов (единицы тестирования) считаются сами тестовые методы, а не assert'ы в методах, добавлены также улучшения по запуску тестов.
Так вот AsUnit 2.5 мы и используем в нашем проекте. Также понадобится компилятор mtasc, Apache Ant (о нем мы уже писали, а посему по вопросам установки отсылаю к соотвествующей статье) и задача Ant as2ant (на данный момент мною используется версия 1.5, просто копируем as2anr.jar в папку lib каталога с установкой Ant). После указания переменной окружения Path к компилятору mtasc можно считать рабочую среду подготовленной.
Приступим к самой теме. В качестве нашего предмета я выбрал некий абстрактный органайзер. Пускай у нас есть время, событие и некий флаг, показывающий, высок ли приоритет события или низок. Пример к жизни особого отношения не имеет - просто пример. Поэтому я позволи себе всякие вольности в его формулировке. Например, мы будем считать, что если мы планируем на одно и то же время сделать две вещи, то превращаем это в одно событие, а описания их просто конкатенируем. Но если они имеют разный приоритет, то и события разные. То есть предположим, на 14.00 я запланировал событие "Попить", "Поесть" и "Позвонить жене". И считаю, что приоритет первых двух невысок, а последнего - высок. Соотвественно я первые два объединяю в одно "Попить - поесть" с низким приоритетом, а последнее так и идет отдельно. То есть не факт, что я все это выполню, но если я посмторю, что у меня запланировано на 14.00, то увижу, что это два события: "Попить - поесть" и "Позвонит жене". Соотвественно хранит это дело у меня класс Organizer. В процессе разработки (через тестирование; это тема будущих статей и поэтому динамику разработки не показываю - только результат) у меня появился дополнительный класс Event для описания события. Его я тестировать не стал (а зря!) взяв на себя смелость утверждать, что он простой и тестирования класса Organizer вполне достаточно. Прежде чем привести результат, вкратце расскажу об устройстве модульных тестов. Модульный тест состоит, собственно, из теста. Тест - это некий метод некого тестового класса, тестирующий один из аспектов тестируемого класса. В нашем случае класс OrganizerTest будет содержать тесты (тестовые методы) для тестирования класса Organizer.
Класс OrganizerTest представляет из себя тестовый случай (Test Case) и потому расширяет класс TestCase. Класс TestCase устроен просто: он содержит в себе тесты (методы, начинающиеся со слова test), которые выполняются независимо. Перед выполнением каждого теста вызывается метод setUp, выполняющий некие начальные необходимые установки (создание экземпляра тестируемого класса, например). После вызова тестового метода вызывается метод tearDown (удаляет последствия). Результат каждого теста складывается из утверждений (assertion), которые либо выполняются, либо нет, а также исключений, которые могут выбрасываться тестируемым классом.
Несколько Test Cases объединяются в тестовый набор (Test Suite), для которых есть специальный класс (TestSuite). В нашем случае это AllTests, который содержит всего один тестовый случай. В тестовые наборы объединяют затем чтобы прогонять множество тестов сразу. При прогоне тестового набора автоматически прогоняются все тестовые набры, в него входящие (по шаблону Composite), а также все тестовые методы входящих Test Cases (определяются автоматически по началу на test).
Также в нашем проекте присутствует TestsRunner, запускающий тесты. Он выдает в качестве результата либо зеленую полосу (все тесты прошли удачно), либо красную - не сработал хотя бы один тест (утверждение, либо было выкинуто исключение). Красная полоса - это не плохо. Красная полоса - наш друг. Зеленая полоса зачастую заставляет задуматься, как будет показано дальше. Поэтому цель написания тестов - добиться красной полосы. Если полоса зеленая, то либо вы протестировали все, либо чего-то не протестировали :)
Также упомянем про наш build.xml. Этот ant-файл содержит две публичных цели: build и build.and.run. Цель build - цель по умолчанию. Она запускается при наборе команды ant без параметров в каталоге с билд-файлом. Она просто формирует билд нашего теста в виде swf-файла tests.swf в папке build. Цель build.and.run формирует билд и запускает его (надо указать правильный путь к flash-плееру в файле local.properties). Запускается просто: ant build.and.run.
Итак, наш класс Organizer:
/**
* @author Constantiner (constantiner at gmail dot com)
* @version Mar 27, 2006
*/
import constantiner.Event;
class constantiner.Organizer
{
private var events:Array;
/**
* Конструктор.
*/
public function Organizer ()
{
events = new Array ();
}
public function addEvent (eventDate : Date, eventDescription : String, isHiPriority:Boolean) : Void
{
var anEvent:Event = new Event (eventDate, eventDescription, isHiPriority);
var sameEvent:Event = getTheSame (anEvent);
if (sameEvent == null)
{
events.push (anEvent);
}
else
{
sameEvent.concat (anEvent);
}
}
public function getEventsNumber() : Number
{
return events.length;
}
public function getDescription (eventDate:Date, isHiPriority:Boolean):String
{
var anEvent:Event = new Event (eventDate, null, isHiPriority);
var sameEvent:Event = getTheSame (anEvent);
return sameEvent.getDescription ();
}
private function getTheSame (targetEvent:Event):Event
{
for (var i:Number = 0; i < events.length; i ++)
{
var tempEvent:Event = Event (events [i]);
if (targetEvent.equals (tempEvent))
{
return tempEvent;
}
}
return null;
}
}
Вспомогательный класс Event:
/**
* @author Constantiner (constantiner@gmail.com)
* @version Mar 27, 2006
*/
class constantiner.Event
{
private var eventDateMs:Number;
private var eventDescription:String;
private var isHiPriority : Boolean;
/**
* Конструктор.
*/
public function Event (eventDate:Date, eventDescription:String, isHiPriority:Boolean)
{
eventDateMs = eventDate.getTime ();
this.eventDescription = eventDescription;
this.isHiPriority = isHiPriority;
}
public function equals(targetEvent : Event) : Boolean
{
return Boolean ((targetEvent.isHiPriority == isHiPriority) && (targetEvent.eventDateMs = eventDateMs));
}
/**
* Метод, возвращающий строковую репрезентацию данного экземпляра (как правило, в целях отладки).
* @return Текстовая репрезентация данного экземпляра.
*/
public function toString() : String
{
var formattedDate:Date = new Date (eventDateMs);
return "constantiner.Event <date: " + formattedDate.toString() + ", isHiPriority: " + isHiPriority + ", eventDescription: " + eventDescription + ">";
}
public function concat(targetEvent:Event) : Void
{
eventDescription += " --- " + targetEvent.eventDescription;
}
/**
* @param
* @return
*/
public function getDescription ():String
{
return eventDescription;
}
}
Теперь наш тест:
/**
* @author Constantiner (constantiner@gmail.com)
* @version Mar 27, 2006
*/
import asunit.framework.TestCase;
import constantiner.Organizer;
class constantiner.tests.OrganizerTest extends TestCase
{
private var className:String = "constantiner.tests.OrganizerTest";
private var instance:Organizer;
public function setUp():Void
{
instance = new Organizer ();
}
public function tearDown():Void
{
delete instance;
}
public function testInstantiated():Void
{
assertTrue("target instantiated", instance instanceof Organizer);
}
public function testAddDate ():Void
{
instance.addEvent (new Date (1999, 5), "Hello event", true);
var eventsNumber:Number = instance.getEventsNumber ();
assertEquals ("Должно быть одно событие, а реально их " + eventsNumber, eventsNumber, 1);
instance.addEvent (new Date (2000, 5), "Next year hello", false);
eventsNumber = instance.getEventsNumber ();
assertEquals ("Должно быть два события, а реально их " + eventsNumber, eventsNumber, 2);
}
public function testAddTheSameDate ():Void
{
var firstDate:Date = new Date (2001, 5);
var secondDate:Date = new Date (firstDate.getTime());
instance.addEvent (firstDate, "Hello event", true);
instance.addEvent (secondDate, "And hello again", true);
var eventsNumber:Number = instance.getEventsNumber ();
assertEquals ("Должно быть одно событие, собранное из двух, а реально их " + eventsNumber, eventsNumber, 1);
}
public function testConcatDescription ():Void
{
var firstDate:Date = new Date (2001, 5);
var secondDate:Date = new Date (firstDate.getTime());
var firstDescription:String = "Hello event";
var secondDescription:String = "And hello again";
instance.addEvent (firstDate, firstDescription, true);
instance.addEvent (secondDate, secondDescription, true);
var newDescription:String = instance.getDescription (firstDate, true);
assertTrue ("Новое описание события должно быть длиннее их суммы. Новое описание: " + newDescription, (newDescription.length > (firstDescription.length + secondDescription.length)));
}
}
Запускаем наш Test Runner и видим зеленую полосу:
AsUnit 2.5 by Luke Bayes and Ali Mills
....
Time: 0.026
OK (4 tests)
Ура, говорим мы, наш класс работает! Внедряем его в проект - и находим ошибки (а вы видите ошибку в классах?). Но такого не может быть! Мы же все оттестировали! Обманчива эта иллюзия. Можно подумать, что окружающий код влияет пагубно, ошибку обнаружить нельзя итд. Но на самом деле хватит мумить! Только не мумить! Нам надо сломать тест! Красная полоса - наше спасение! Приступим!
Собственно я написал в итоге темт, дающий красную полосу. Вот он:
public function testMultiplyAdd ():Void
{
var count:Number = 10;
for (var i:Number = 0; i < count; i ++)
{
var firstDate:Date = new Date (2001 + i, 5);
var secondDate:Date = new Date (firstDate.getTime());
var firstDescription:String = "Hello event";
var secondDescription:String = "And hello again";
instance.addEvent (firstDate, firstDescription, true);
instance.addEvent (secondDate, secondDescription, false);
}
var eventsNumber:Number = instance.getEventsNumber ();
assertEquals ("Должно быть " + count * 2 + " событий, а реально их " + eventsNumber, count * 2, eventsNumber);
}
}
Запускаем и видим:
AsUnit 2.5 by Luke Bayes and Ali Mills
.....F
Time: 0.028
There was 1 failure:
0) constantiner.tests.OrganizerTest.testMultiplyAdd()
assertEquals.message: Должно быть 20 событий, а реально их 2
FAILURES!!!
Tests run: 5, Failures: 1, Errors: 0
Красная полоса! Ура! Дальше можно написать тесты еще, можно написать тесты для класса Event. Но в таком простом и изолированном случае, каким является наш тест, мне проще оказалось найти методом пошаговой отладки тупой баг по невнимательности в методе equals класса Event. Сравнение - это == , а не =. Будьте внимательны!
Ну вот, собственно, мой первый урок использования модульного тестирования в AS2-разработке. Скажу, что статья далеко не полная и не охватывает важного аспекта модульного тестирования - методики TDD (разработка через тестирование). Но все еще впереди!
Ну а материалы к статье можно скачать тут. В архиве все исходники, билд-файл, а также библиотека AsUnit 2.5. То есть все готово к запуску теста и экспериментам. А в качестве решения проблем при скачивании файла могу посоветовать указать ссылку на этот файл в качестве реферера.
Удачных открытий!