Книга: Экстремальное программирование. Разработка через тестирование
Назад: 28. Шаблоны зеленой полосы
Дальше: 30. Шаблоны проектирования

29. Шаблоны xUnit

В этой главе рассматриваются шаблоны, предназначенные для использования при работе с xUnit.
Проверка утверждений
Как убедиться в том, что тест работает правильно? Напишите логическое выражение, которое автоматически подтвердит ваше мнение о том, что код работает.
Если мы хотим сделать тесты полностью автоматическими, значит, абсолютно все предположения о работе тестируемого кода необходимо превратить в тесты, при этом результат выполнения этих тестов должен говорить нам, работает код корректно или нет. Проще говоря, мы должны обладать возможностью щелкнуть на кнопке и через короткое время узнать, работает код корректно или нет. Отсюда следует, что
• в результате выполнения теста должно получиться логическое значение: «истина» (True) указывает, что все в порядке, а «ложь» – что произошло нечто непредвиденное;
• проверка результата каждого теста выполняется компьютером автоматически при помощи какой-либо разновидности оператора assert().
Мне приходилось видеть выражения наподобие assertTrue(rectangle.area()!= 0). Чтобы тест выполнился успешно, метод area() должен вернуть любое ненулевое значение – это не очень полезный тест. Делайте тесты более конкретными. Если площадь прямоугольника должна быть равна 50, так и пишите: assertTrue(rectangle.area() == 50). Во многих реализациях xUnit присутствует специальное выражение assert() для тестирования равенства (эквивалентности). Отличительная его черта состоит в том, что вместо одного логического параметра выражение assertEquals() принимает два произвольных объекта и пытается определить, являются ли они эквивалентными. Преимущество состоит в том, что в случае неудачи выражение assertEquals() сгенерирует более информативное сообщение с указанием двух несовпадающих значений. Ожидаемое значение, как правило, указывается первым. Например, предыдущее выражение в среде JUnit можно переписать следующим образом: assertEquals(50, rectangle.area()).
Думать об объектах, как о черных ящиках, достаточно тяжело. Представим, что у нас есть объект Contract, состояние которого хранится в поле status и может быть экземпляром класса Offered или Running. В этом случае можно написать тест исходя из предполагаемой реализации:

 

Contract contract = new Contract(); // по умолчанию состояние Offered
contract.begin(); // состояние меняется на Running
assertEquals(Running.class, contract.status.class);

 

Этот тест слишком сильно зависит от текущей реализации объекта status. Однако тест должен завершаться успешно, даже если поле status станет логическим значением. Может быть, когда status меняется на Running, можно узнать дату начала работы над контрактом:

 

assertEquals(…, contract.startDate()); // генерирует исключение, если
// status является экземпляром Offered

 

Я признаю, что пытаюсь плыть против течения, когда настаиваю на том, что все тесты должны быть написаны только с использованием общедоступного (public) протокола. Существует специальный пакет JXUnit, который является расширением JUnit и позволяет тестировать значения переменных, даже тех, которые объявлены как закрытые.
Желание протестировать объект в рамках концепции белого ящика – это не проблема тестирования, это проблема проектирования. Каждый раз, когда у меня возникает желание протестировать значение переменной-члена, чтобы убедиться в работоспособности кода, я получаю возможность улучшить дизайн системы. Если я забываю о своих опасениях и просто проверяю значение переменной, я теряю такую возможность. Иначе говоря, если идея об улучшении дизайна не приходит мне в голову, ничего не поделаешь. Я проверяю значение переменной, смахиваю непрошеную слезу, вношу соответствующую отметку в список задач и продолжаю двигаться вперед, надеясь, что наступит день, когда смогу найти подходящее решение.
Самая первая версия xUnit для Smalltalk (под названием SUnit) обладала очень простыми выражениями assert. Если одно из выражений терпело неудачу, автоматически открывалось окно отладчика, вы исправляли код и продолжали работу. Среда разработки Java не настолько совершенна, к тому же построение приложений на Java часто выполняется в пакетном режиме, поэтому имеет смысл добавлять в выражение assert() дополнительную информацию о проверяемом условии. Чтобы в случае неудачи выражения assert() можно было вывести на экран дополнительную информацию.
В JUnit это реализуется при помощи необязательного первого параметра. Например, если вы напишете assertTrue(«Должно быть True», false) и тест не сработает, то вы увидите на экране приблизительно следующее сообщение: Assertion failed: Должно быть True. Обычно подобного сообщения достаточно, чтобы направить вас напрямую к источнику ошибки в коде. В некоторых группах разработчиков действует жесткое правило, что все выражения assert() должны снабжаться подобными информационными сообщениями. Попробуйте оба варианта и самостоятельно определите, окупаются ли для вас затраты, связанные с информационными сообщениями.
Фикстура (Fixture)
Как создаются общие объекты, которые используются в нескольких тестах? Конвертируйте локальные переменные из тестов в переменные-члены класса TestCase. Переопределите метод setUp() и инициализируйте в нем эти переменные (то есть выполните создание всех необходимых объектов).
Если мы привыкли удалять дублирование из функционального (тестируемого) кода, должны ли мы удалять его из тестирующего кода? Может быть.
Существует проблема: зачастую вам приходится писать больше кода для того, чтобы установить объекты, используемые тестируемым методом, в интересующее вас состояние. Код, инициализирующий объекты, часто оказывается одинаковым для нескольких тестов. Такие объекты называются фикстурой теста (используется также английский термин scaffolding – строительные леса, подмостки). Дублирование подобного кода – это плохо. Вот две основные причины:
• написание подобного кода требует дополнительного времени, даже если мы просто копируем блоки текста через буфер обмена. Но наша задача – добиться того, чтобы написание тестов занимало как можно меньше времени;
• если приходится вручную менять интерфейс, перед нами встает необходимость изменять его в нескольких разных тестах (именно этого всегда следует ожидать от дублирования).
Однако дублирование кода инициализации объектов обладает также некоторыми преимуществами. Если код инициализации располагается непосредственно рядом с тестирующими выражениями assert(), весь код теста можно прочитать от начала и до конца. Если мы выделили код инициализации в отдельный метод, нам приходится помнить о том, что этот метод вызывается, нам приходится вспоминать, как именно выглядят объекты, и только вспомнив все это, мы можем написать остальную часть теста.
Среда xUnit поддерживает оба стиля написания тестов. Если вы думаете, что читателям будет сложно вспомнить объекты фикстуры, вы можете разместить код создания фикстуры непосредственно в теле теста. Однако вы также можете переместить этот код в метод с названием setUp(). В этом методе вы можете создать все объекты, которые будут использоваться в тестовых методах.
Далее приводится пример, который слишком прост, чтобы мотивировать выделение общего кода фикстуры, но зато достаточно короток, чтобы поместиться в данной книге. Мы можем написать:

 

EmptyRectangleTest
public void testEmpty() {
Rectangle empty = new Rectangle(0,0,0,0);
assertTrue(empty.isEmpty());
}

 

public void testWidth() {
Rectangle empty = new Rectangle(0,0,0,0);
assertEquals(0.0, empty.getWidth(), 0.0);
}

 

(Помимо прочего здесь также демонстрируется версия assertEquals() для чисел с плавающей точкой, которая принимает третий параметр – точность сравнения.) Мы можем избавиться от дублирования, написав:

 

EmptyRectangleTest
private Rectangle empty;

 

public void setUp() {
empty = new Rectangle(0,0,0,0);
}

 

public void testEmpty() {
assertTrue(empty.isEmpty());
}
public void testWidth() {
assertEquals(0.0, empty.getWidth(), 0.0);
}

 

Общий код выделен в виде отдельного метода. Среда xUnit гарантирует, что метод setUp() объекта TestCase будет обязательно вызван перед обращением к любому тестовому методу этого объекта. Теперь тестовые методы выглядят проще, однако, прежде чем понять их смысл, мы должны вспомнить о существовании метода setUp() и уточнить, что происходит внутри этого метода.
Какой из этих двух стилей предпочтительней? Попробуйте использовать каждый из них. Я фактически всегда выделяю общий код фикстуры и перемещаю его в метод setUp(), однако у меня хорошая память. Те, кто читает мои тесты, часто жалуются, что им приходится вспоминать слишком о многом. Значит, возможно, мне следует выделять меньшей объем кода, чтобы сделать тесты более понятными.
Взаимоотношения между подклассами класса TestCase и экземплярами этих подклассов являются наиболее запутанной стороной инфраструктуры xUnit. Каждый новый тип фикстуры требует создания нового подкласса класса TestCase. Каждая новая фикстура создается внутри экземпляра подкласса, используется один раз, а затем уничтожается.
В предыдущем примере, если мы хотим написать тесты для непустого прямоугольника (Rectangle), нам придется создать новый класс, который можно назвать, например, NormalRectangleTest. У этого класса будет свой собственный метод setUp(), в котором будет создан новый экземпляр Rectangle, необходимый ему для тестирования. Этот экземпляр Rectangle будет соответствовать непустому прямоугольнику. В общем случае, если я хочу использовать несколько отличающуюся фикстуру, я создаю новый подкласс класса TestCase.
Это означает, что не существует прямого простого соответствия между классами тестов и функциональными (тестируемыми) классами. Иногда одна фикстура используется для тестирования нескольких классов (подобное случается нечасто). Иногда для тестирования единственного функционального класса требуется создать две или три фикстуры. На практике в большинстве случаев получается, что количество классов тестов приблизительно совпадает с количеством функциональных классов. Однако это происходит вовсе не потому, что для каждого функционального класса вы создаете один-единственный класс теста.
Внешняя фикстура (External Fixture)
Как осуществляется освобождение внешних ресурсов в фикстуре? Переопределите метод tearDown() и освободите в нем ресурсы, выделенные в ходе создания фикстуры.
Помните, что каждый тест должен оставить рабочую среду в том же состоянии, в котором она была до того, как тест начал работу. Например, если внутри теста вы открываете файл, вы должны позаботиться о том, чтобы закрыть его перед тем, как тест завершит работу. Вы можете написать:

 

testMethod(self):
file = File("foobar"). open()
try:
…âûïîëíèòü òåñò…
finally:
file.close()

 

Если файл используется в нескольких тестах, вы можете сделать его частью общей фикстуры:

 

setUp(self):
self.file = File("foobar"). open()
testMethod(self):
try:
…выполнить тест…
finally:
self.file.close()

 

Во-первых, возникает неприятное дублирование выражений finally – это означает, что мы упустили что-то в дизайне. Во-вторых, при написании подобного метода можно легко допустить ошибку, например забыть добавить ключевое слово finally или вообще забыть о необходимости закрытия файла. Наконец, в этом тесте существует три сбивающих с толку строки – try, finally и сама команда close, – эти выражения не относятся непосредственно к процедуре тестирования.
Инфраструктура xUnit гарантирует вызов метода под названием tearDown() после выполнения тестового метода. Метод tearDown() будет вызван вне зависимости от того, что случится внутри тестового метода (однако следует иметь в виду, что, если сбой произойдет в ходе выполнения метода setUp(), метод tearDown() вызываться не будет). Мы можем преобразовать предыдущий тест следующим образом:

 

setUp(self):
self.file = File("foobar"). open()
testMethod(self):
…выполнить тест…
tearDown(self):
self.file.close()
Тестовый метод (Test Method)
Что такое единичный тест? Это метод, имя которого начинается с префикса test.
В процессе разработки вам придется иметь дело с сотнями, а может быть, и тысячами тестов, как можно уследить за всеми этими тестами?
Языки объектно-ориентированного программирования обеспечивают трехуровневую организацию исходного кода:
• модуль (в языке Java – пакет, по-английски, package);
• класс;
• метод.
Если мы пишем тесты как обычный исходный код, мы должны найти способ организации тестов с использованием элементов этой структуры. Если мы используем классы для представления фикстур, значит, методы этих классов являются естественным местом размещения тестирующего кода. Все тесты, использующие некоторую фикстуру, должны быть методами одного класса. Тесты, работающие с другой фикстурой, должны располагаться в другом классе.
В xUnit используется соглашение, в соответствии с которым имя тестового метода должно начинаться с префикса test. Специальные инструменты могут автоматически производить поиск таких методов и создавать из них наборы тестов (TestSuite). Остальная часть имени теста должна информировать будущего, ни о чем не ведающего читателя, зачем написан данный тест. Например, в наборе тестов, созданных при разработке инфраструктуры JUnit, можно обнаружить тест с именем testAssertPosInfinityNotEqualsNeglnfinity. Я не помню, чтобы я писал этот тест, однако, исходя из имени, могу предположить, что в какой то момент разработки было обнаружено, что код метода assert() инфраструктуры JUnit для чисел с плавающей точкой не делал различия между положительной и отрицательной бесконечностью. Использовав тест, я могу быстро найти код JUnit, осуществляющий сравнение чисел с плавающей точкой, и посмотреть, как осуществляется обработка положительной и отрицательной бесконечности. (На самом деле код выглядит не идеально – для поддержки бесконечности используется условный оператор).
Код тестового метода должен легко читаться и быть максимально прямолинейным. Если вы разрабатываете тест и видите, что его код становится слишком длинным, попробуйте поиграть в «детские шажки». Цель игры – написать самый маленький тестовый метод, который представляет собой реальный прогресс в направлении вашей конечной цели. Размер в три строки, судя по всему, является минимальным размером (если, конечно, вы не хотите делать тест намеренно бессмысленным). И постоянно помните о том, что вы пишете тесты для людей, а не только для компьютера и себя самого.
Патрик Логан (Patrick Logan) рассказал об идее, с которой я намерен поэкспериментировать. Эта идея также описана Макконнеллом (McConnell), а также Кэйном (Caine) и Гордоном (Gordon):
В последнее время я фактически постоянно применяю методику «основных тезисов» в любой моей работе. Тестирование не является исключением. Когда я пишу тесты, я прежде всего записываю план из нескольких пунктов – тезисов, – которые я хотел бы реализовать в этом тесте. Например:

 

/* Добавить в пространство кортежей */
/* Извлечь из пространства кортежей */
/* Читать из пространства кортежей */

 

Это самые основные тезисы, однако я добавляю в каждую из этих категорий конкретные тесты. Когда я добавляю тесты, я добавляю в мой список тезисов еще один уровень комментариев:

 

/* Добавить в пространство кортежей */
/* Извлечь из пространства кортежей */
/** Извлечение несуществующего элемента **/
/** Извлечение существующего элемента **/
/** Извлечение нескольких элементов **/
/* Читать из пространства кортежей */

 

Как правило, мне хватает двух-трех уровней комментариев. Я не могу представить ситуацию, в которой мне могло бы потребоваться больше уровней. Список тезисов становится документацией контракта для тестируемого класса. Приведенные здесь примеры, конечно же, сокращены, однако в языках программирования, поддерживающих контракты, тезисы могли бы быть более конкретными. (Я не использую какие-либо добавления к Java, обеспечивающие автоматизацию в стиле Eiffel.)
Сразу же после самого низкого уровня комментариев располагается исходный код теста.
Тест исключения (Exception Test)
Как можно протестировать ожидаемое исключение? Перехватите исключение и игнорируйте его, тест должен терпеть неудачу только в случае, если исключение не сгенерировано.
Предположим, что мы пишем код, осуществляющий поиск значения. Если значение не обнаружено, мы хотим сгенерировать исключение. Тестирование механизма поиска выполняется относительно просто:

 

public void testRate() {
exchange.addRate("USD", "GBP", 2);
int rate = exchange.findRate("USD", "GBP");
assertEquals(2, rate);
}

 

Тестирование исключения может оказаться неочевидным. Вот как мы это делаем:

 

public void testMissingRate() {
try {
exchange.findRate("USD", "GBP");
fail();
} catch (IllegalArgumentException expected) {
}
}

 

Если метод findRate() не генерирует исключения, произойдет обращение к методу fail() – это метод xUnit, который докладывает о том, что тест потерпел неудачу. Обратите внимание, что мы перехватываем только то исключение, которое должно быть сгенерировано методом findRate(). Благодаря этому, если будет сгенерировано какое-либо другое (неожиданное для нас) исключение (включая сбой метода assert), мы узнаем об этом.
Все тесты (All Tests)
Как можно запустить все тесты вместе? Создайте тестовый набор, включающий в себя все имеющиеся тестовые наборы, – один для каждого пакета (package) и один, объединяющий в себе все тесты пакетов для всего приложения.
Предположим, вы добавили подкласс класса TestCase и в этот подкласс вы добавили тестовый метод. В следующий раз, когда будут выполняться все тесты, добавленный вами тестовый метод также должен быть выполнен. (Во мне опять проснулась привычка действовать в стиле TDD – должно быть, вы заметили, что предыдущее предложение – это эскиз теста, который я, наверное, написал бы, если бы не был занят работой над данной книгой.) К сожалению, в большинстве реализаций xUnit, равно как и в большинстве IDE, не поддерживается стандартный механизм запуска абсолютно всех тестов, поэтому в каждом пакете необходимо определить класс AllTests, который реализует статический метод suite(), возвращающий объект класса TestSuite. Вот класс AllTests для «денежного» примера:

 

public class AllTests {
public static void main(String[] args) {
junit.swingui.TestRunner.run(AllTests.class);
}
public static Test suite() {
TestSuite result = new TestSuite("TFD tests");
result.addTestSuite(MoneyTest.class);
result.addTestSuite(ExchangeTest.class);
result.addTestSuite(IdentityRateTest.class);
return result;
}
}

 

Вы также должны включить в класс AllTests() метод main(), благодаря чему класс можно будет запустить напрямую из IDE или из командной строки.
Назад: 28. Шаблоны зеленой полосы
Дальше: 30. Шаблоны проектирования