Экстремальное программирование. Разработка через тестирование

Бек Кент

Часть II

На примере xUnit

 

 

Какой подход использовать при создании инструмента для разработки через тестирование? Естественно, разработку через тестирование.

Архитектура xUnit хорошо реализуется на языке Python, поэтому во второй части книги я перейду на использование Python. Не беспокойтесь, для тех, кто никогда раньше не имел дела с Python, я добавлю в текст необходимые пояснения. Когда вы прочитаете вторую часть, вы, во-первых, освоите базовые навыки программирования на Python, во-вторых, узнаете, как самому разработать свою собственную инфраструктуру для автоматического тестирования, и, в-третьих, ознакомитесь с более сложным примером использования методики TDD – три по цене одного!

 

18. Первые шаги на пути к xUnit

Разработка инструмента тестирования с использованием самого этого инструмента для тестирования многим может показаться чем-то, напоминающим хирургическую операцию на своем собственном мозге. («Только не вздумай трогать центры моторики! О! Слишком поздно! Игра окончена».) Сначала эта идея может показаться жутковатой. Однако инфраструктура тестирования обладает более сложной внутренней логикой, если сравнивать с относительно несложным денежным примером, рассмотренным в первой части книги. Часть II можно рассматривать как шаг в сторону разработки «настоящего» программного обеспечения. Кроме того, вы можете рассматривать этот материал как упражнение в самодокументируемом программировании.

Прежде всего, у нас должна быть возможность создать тест и запустить тестовый метод. Например: TestCase("testMethod"). run(). Возникает проблема: мы собираемся написать тест для программного кода, который мы будем использовать для написания тестов. Так как у нас пока еще нет даже намека на инфраструктуру тестирования, мы вынуждены проверить правильность нашего самого первого шага вручную. К счастью, мы достаточно хорошо отдохнули, а значит, вероятность того, что мы допустим ошибку, относительно невелика. Однако чтобы сделать ее еще меньше, мы планируем двигаться маленькими-маленькими шажками, тщательно проверяя все, что мы делаем. Вот список задач, который приходит на ум, когда начинаешь размышлять о разработке собственной инфраструктуры тестирования:

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Конечно же, мы по-прежнему работаем в стиле «сначала тесты». Для нашего прототеста нам потребуется небольшая программа, которая должна отображать на экране значение «истина», если произошло обращение к тестовому методу, и значение «ложь» в противном случае. Теперь представим, что у нас есть тест, который устанавливает флаг внутри тестового метода, в этом случае мы могли бы после выполнения теста отобразить состояние флага на экране и самостоятельно убедиться в том, что флаг установлен правильно. Выполнив проверку вручную, мы сможем попробовать автоматизировать процесс.

Итак, у нас наметилась следующая стратегия. Мы создаем объект, который соответствует нашему тесту. В объекте содержится флаг. Перед выполнением тестового метода флаг должен быть установлен в состояние «ложь». Тестовый метод устанавливает флаг в состояние «истина». После выполнения тестового метода мы должны проверить состояние флага. Назовем наш тестовый класс именем WasRun, так как объект этого класса будет сигнализировать нам о том, был ли выполнен тестовый метод. Флаг внутри этого класса также будет называться wasRun (это несколько сбивает с толку, однако wasRun – такое подходящее имя). Собственно объект (экземпляр класса WasRun) будет называться просто test. То есть мы сможем написать инструкцию assert test.wasRun (assert – встроенная инструкция языка Python).

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

test = WasRun(«testMethod»)

print(test.wasRun)

test.testMethod()

print(test.wasRun)

Мы ожидаем, что эта миниатюрная программа напечатает None до выполнения тестового метода и 1 – после. (В языке Python значение None является аналогом null или nil и наряду с числом 0 соответствует значению «ложь».) Однако программа не делает того, что мы от нее ждем. И немудрено – мы еще не определили класс WasRun (сначала тесты!).

WasRun

class WasRun:

pass

(Ключевое слово pass используется в случае, если реализация класса или метода отсутствует.) Теперь интерпретатор сообщает нам, что в классе WasRun нет атрибута с именем wasRun. Создание атрибута происходит в момент создания объекта (экземпляра класса), то есть в процессе выполнения конструктора (для удобства конструктор любого класса называется __init__). Внутри конструктора мы присваиваем флагу wasRun значение None (ложь):

WasRun

class WasRun:

def __init__(self, name):

self.wasRun = None

Теперь программа действительно отображает на экране значение None, однако после этого интерпретатор сообщает нам, что мы должны определить в классе WasRun метод testMethod. (Было бы неплохо, если бы среда разработки автоматически реагировала на это: самостоятельно создавала бы функцию-заглушку и открывала редактор с курсором, установленным в теле этой функции. Не правда ли, это было бы просто здорово? Кстати, некоторые производители IDE уже додумались до этого.)

WasRun

def testMethod(self):

pass

Запускаем файл и видим на экране два значения: None и None. Нам хотелось бы видеть None и 1. Чтобы получить желаемый результат, в теле метода testMethod присвоим флагу wasRun желаемое значение:

WasRun

def testMethod(self):

self.wasRun = 1

Запускаем программу – то, что нужно! Мы получили желаемый результат. Зеленая полоса – ур-р-ра! Нам предстоит сложный рефакторинг, однако если мы видим перед собой зеленую полосу, значит, мы добились прогресса.

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

test= WasRun(«testMethod»)

print(test.wasRun)

test.run()

print(test.wasRun)

Чтобы заставить тест работать, достаточно воспользоваться следующей несложной реализацией:

WasRun

def run(self):

self.testMethod()

Наша тестовая программа снова печатает на экране то, что нам нужно. Зачастую во время рефакторинга возникает ощущение, что необходимо разделить код, с которым вы работаете, на две части, чтобы работать с ними по отдельности. Если в конце работы они снова сольются воедино, – замечательно. Если нет, значит, вы можете оставить их отдельно друг от друга. В данном случае со временем мы планируем создать класс TestCase, однако вначале мы должны обособить части нашего примера.

Следующий этап – динамический вызов метода testMethod. Одной из приятных отличительных характеристик языка Python является возможность использования имен классов и методов в качестве функций (см. создание экземпляра класса WasRun). Получив атрибут, соответствующий имени теста, мы можем обратиться к нему, как к функции. В результате будет выполнено обращение к методу с соответствующим именем.

WasRun

class WasRun:

def __init__(self, name):

self.wasRun = None

self.name = name

def run(self):

method = getattr(self, self.name)

method()

Это еще один шаблон рефакторинга: разработать код, который работает с некоторым конкретным экземпляром, и обобщить его, чтобы он мог работать со всеми остальными экземплярами, для этого константы заменяются переменными. В данном случае роль константы играет не некоторое значение, а фиксированный код (имя конкретного метода). Однако принцип остается тем же. В рамках TDD эта проблема решается очень легко: методика TDD снабжает вас конкретными работающими примерами, исходя из которых можно выполнить обобщение. Это значительно проще, чем выполнять обобщение исходя только из собственных умозаключений.

Теперь наш маленький класс WasRun занят решением двух разных задач: во-первых, он следит за тем, был ли выполнен метод; во-вторых, он динамически вызывает метод. Пришло время разделить полномочия (разделить нашу работу на две разные части). Прежде всего, создадим пустой суперкласс TestCase и сделаем класс WasRun производным классом:

TestCase

class TestCase:

pass

WasRun

class WasRun(TestCase):.

Теперь переместим атрибут name из подкласса в суперкласс:

TestCase

def __init__(self, name):

self.name = name

WasRun

def __init__(self, name):

self.wasRun = None

TestCase.__init__(self, name)

Наконец, замечаем, что метод run() использует только атрибуты суперкласса, значит, скорее всего, он должен располагаться в суперклассе. (Я всегда стараюсь размещать операции рядом с данными.)

TestCase

def __init__(self, name):

self.name= name

def run(self):

method = getattr(self, self.name)

method()

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

Нам надоело смотреть на то, как наша программа каждый раз печатает одно и то же: None и 1. Использовав разработанный механизм, мы можем теперь написать:

TestCaseTest

class TestCaseTest(TestCase):

def testRunning(self):

test = WasRun("testMethod")

assert(not test.wasRun)

test.run()

assert(test.wasRun)

TestCaseTest("testRunning"). run()

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Сердцем этого теста являются операторы print, превращенные в выражения assert, таким образом, вы можете видеть, что выполненная нами процедура – это усложненный шаблон рефакторинга «Выделение метода» (Extract Method).

Я открою вам маленький секрет. Шажки, которыми мы двигались от теста к тесту в данной главе, выглядят смехотворно маленькими. Однако до того, как получить представленный результат, я пытался выполнить разработку более крупными шагами и потратил на это около шести часов (конечно, мне пришлось тратить дополнительное время на изучение тонкостей языка Python). Я два раза начинал с нуля и каждый раз думал, что мой код сработает, однако этого не происходило. Я понял, что попытка пропустить самый начальный, самый примитивный этап разработки, – это грубейшее нарушение принципов TDD.

Конечно же, вы не обязаны постоянно перемещаться такими лилипутскими шажками. После того как вы освоите TDD, вы сможете двигаться вперед более уверенно, реализуя между тестами значительно больший объем функциональности. Однако чтобы в совершенстве освоить TDD, вы должны научиться перемещаться маленькими шажками тогда, когда это необходимо.

Далее мы планируем перейти к решению задачи обращения к методу setUp(). Однако вначале подведем итог.

В данной главе мы

• поняли, как начать работу со смехотворно малюсенького шага;

• реализовали функциональность путем создания фиксированного кода, а затем обобщения этого кода путем замены констант на переменные;

• использовали шаблон «Встраиваемый переключатель» (Pluggable Selector) и дали себе обещание не использовать его вновь в течение как минимум четырех месяцев, так как он существенно усложняет анализ кода;

• начали работу над инфраструктурой тестирования маленькими шажками.

 

19. Сервируем стол (метод setUp)

Начав писать тесты, вы обнаружите, что действуете в рамках некоторой общей последовательности (Билл Уэйк (Bill Wake) придумал сокращение 3A – Arrange, Act, Assert):

• вначале вы создаете некоторые тестовые объекты – Arrange;

• затем заставляете эти объекты действовать – Act;

• потом проверяете результаты их работы – Assert.

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Первый этап – Arrange – зачастую совпадает для нескольких разных тестов, в то время как второй и третий этапы для разных тестов различаются. У меня есть два числа: 7 и 9. Если я сложу их, я должен получить 16; если я вычту второе из первого, я ожидаю получить –2; наконец, если я перемножу их, я полагаю, должно получиться 63. Операции и ожидаемые результаты различаются, однако исходные данные одни и те же – два числа: 7 и 9.

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

 Производительность. Мы хотим, чтобы тесты срабатывали как можно быстрее. Отсюда следует, что если одни и те же объекты используются в нескольких тестах, желательно, чтобы создание этих объектов выполнялось всего один раз.

 Изоляция. Успех или неудача одного теста никак не должны влиять на работу других тестов. Если несколько тестов используют одни и те же объекты и если один из тестов меняет внутреннее состояние какого-либо объекта, результаты работы остальных тестов, скорее всего, изменятся.

Взаимозависимость между тестами приводит к одному весьма неприятному эффекту: если один тест перестает работать, остальные десять тестов тоже перестают работать, несмотря на то, что тестируемый ими код выполняется правильно. Характерен также и другой, еще более неприятный эффект, когда порядок выполнения тестов имеет значение: если я запускаю тест A перед тестом Б, оба теста выполняются успешно, если я запускаю тест Б перед тестом А, тест А не выполняется. Или, еще хуже, код, проверяемый тестом Б, действует неправильно, однако из-за того, что тест А запускается перед тестом Б, тест Б выполняется успешно.

Итак, мы хотим избежать взаимозависимости между тестами. Предположим, что мы можем сделать процедуру создания объектов достаточно быстрой. В этом случае мы могли бы создавать объекты для теста каждый раз перед выполнением очередного теста. Этот подход в замаскированном виде уже использовался нами в классе WasRun, в котором требовалось, чтобы перед запуском теста флаг wasRun сбрасывался в состояние «ложь». Напишем тест:

TestCaseTest

def testSetUp(self):

test= WasRun("testMethod")

test.run()

assert(test.wasSetUp)

Чтобы запустить этот код, необходимо добавить в конец нашего файла строку TestCaseTest(«testSetUp»). run(). Интерпретатор вежливо сообщает нам, что атрибут с именем wasSetUp отсутствует. И немудрено, ведь мы пока еще не определили значение этого атрибута. Вот необходимый для этого код:

WasRun

def setUp(self):

self.wasSetUp= 1

Однако метод setUp() должен быть откуда-то вызван. Обращение к методу setUp() – это работа класса TestCase. Добавим соответствующий код:

TestCase

def setUp(self):

pass

def run(self):

self.setUp()

method = getattr(self, self.name)

method()

Чтобы заставить тест работать, мы сделали целых два шага – это слишком много, особенно если учесть, что мы движемся почти вслепую. Проверим, работает ли тест? Да, работает. Однако если вы хотите чему-то научиться, попробуйте придумать, как мы можем заставить тест работать, изменяя лишь по одному методу за один шаг.

Немедленно воспользуемся новым механизмом, чтобы сократить длину наших тестов. Прежде всего упростим класс WasRun, для этого перенесем процедуру установки флага wasRun в метод setUp():

WasRun

def setUp(self):

self.wasRun = None

self.wasSetUp = 1

Теперь можно упростить метод testRunning() – освободить его от

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

TestCaseTest

def testRunning(self):

test = WasRun("testMethod")

test.run()

assert(test.wasRun)

Мы также можем упростить сами тесты. В обоих случаях мы создаем экземпляр класса WasRun, а ведь задача создания тестовых объектов возлагается на подготовительный этап – именно об этом мы с вами говорили. Стало быть, мы можем создать объект WasRun в методе setUp(), а затем использовать его в тестовых методах. Каждый тестовый метод выполняется в отдельном экземпляре класса TestCaseTest, поэтому два разных теста не могут быть взаимозависимы. (Мы исходим из того, что объект не будет взаимодействовать с внешним миром некоторым непредусмотренным уродливым способом, например путем изменения значений глобальных переменных.)

TestCaseTest

def setUp(self):

self.test = WasRun("testMethod")

def testRunning(self):

self.test.run()

assert(self.test.wasRun)

def testSetUp(self):

self.test.run()

assert(self.test.wasSetUp)

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Теперь сделаем так, чтобы после выполнения тестового метода обязательно выполнялся метод tearDown().

В данной главе мы

• решили, что на текущий момент тестов важнее простота, чем их производительность;

• написали тест для метода setUp() и реализовали этот метод;

• использовали метод setUp(), чтобы упростить тестируемый объект-контейнер теста;

• использовали метод setUp(), чтобы упростить тесты, проверяющие созданный нами тестовый объект (я же говорил, что временами это напоминает нейрохирургическую операцию на собственном мозге).

 

20. Убираем со стола (метод tearDown)

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Иногда для выполнения теста требуется выделить некоторые внешние ресурсы. Очевидно, что связанные с этим операции должны выполняться в теле метода setUp(). Если мы хотим, чтобы тесты были независимыми друг от друга, мы должны позаботиться об освобождении этих ресурсов. Для выполнения связанных с этим операций предлагаю использовать специальный метод tearDown(), который будет автоматически выполняться после завершения теста.

Как можно протестировать выполнение метода tearDown()? Проще всего – использовать еще один флаг. Однако все эти флаги начинают сбивать меня с толку. Если мы будем использовать флаги, мы упустим один очень важный аспект: метод setUp() должен быть выполнен непосредственно перед обращением к тестовому методу, а метод tearDown() – непосредственно после обращения к тестовому методу. Чтобы убедиться в этом, я намерен изменить стратегию тестирования. Предлагаю создать миниатюрный журнал, в котором будет отмечаться последовательность выполнения методов. Каждый метод будет добавлять в конец журнала соответствующую запись. Таким образом, просмотрев журнал, мы сможем установить порядок выполнения методов.

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Строка журнала в классе WasRun

WasRun

def setUp(self):

self.wasRun = None

self.wasSetUp = 1

self.log = "setUp "

Теперь можно изменить метод testSetUp(), чтобы вместо флага он проверял содержимое журнала:

TestCaseTest

def testSetUp(self):

self.test.run()

assert("setUp " == self.test.log)

После этого мы можем удалить флаг wasSetUp. Мы также можем добавить в журнал запись о выполнении метода:

WasRun

def testMethod(self):

self.wasRun = 1

self.log = self.log + "testMethod "

В результате нарушается работа теста testSetUp(), так как в момент выполнения этого метода журнал содержит строку «setUp testMethod». Изменяем ожидаемое значение:

TestCaseTest

def testSetUp(self):

self.test.run()

assert("setUp testMethod " == self.test.log)

Теперь этот тест выполняет работу обоих тестов, поэтому можно удалить testRunning и переименовать testSetUp:

TestCaseTest

def setUp(self):

self.test = WasRun("testMethod")

def testTemplateMethod(self):

self.test.run()

assert("setUp testMethod " == self.test.log)

Мы используем экземпляр класса WasRun всего в одном месте, поэтому необходимо отменить добавленный ранее хитрый трюк, связанный с setUp():

TestCaseTest

def testTemplateMethod(self):

test = WasRun("testMethod")

test.run()

assert("setUp testMethod " == test.log)

Нет ничего страшного в том, что мы сделали рефакторинг, исходя из нескольких ранних соображений, а чуть позже отменили его, – подобная ситуация складывается достаточно часто. Некоторые предпочитают подождать, пока у них накопится достаточное количество оснований для рефакторинга, иными словами, они оттягивают выполнение рефакторинга, чтобы быть полностью уверенными в его необходимости. Они поступают так потому, что не любят аннулировать результаты проделанной работы. Однако я предпочитаю не отвлекаться на рассуждения о том, не придется ли в будущем отменять то или иное исправление, необходимое мне в настоящем. Вместо этого я предпочитаю сосредоточиться на дизайне. По этой причине я рефлекторно делаю рефакторинг тогда, когда считаю нужным, ни капли не опасаясь, что сразу после этого мне, возможно, придется отменить его.

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Строка журнала в классе WasRun

Теперь мы готовы к реализации метода tearDown(). Ага! Опять я вас поймал! Теперь мы готовы к тестированию метода tearDown():

TestCaseTest

def testTemplateMethod(self):

test = WasRun("testMethod")

test.run()

assert("setUp testMethod tearDown " == test.log)

Он потерпел неудачу. Чтобы заставить его работать, выполняем несложные добавления:

TestCase

def run(self, result):

result.testStarted()

self.setUp()

exec "self." + self.name + "()"

self.tearDown()

WasRun

def setUp(self):

self.log = "setUp "

def testMethod(self):

self.log = self.log + "testMethod "

def tearDown(self):

self.log = self.log + "tearDown "

Неожиданно мы получаем ошибку не в классе WasRun, а в классе TestCaseTest. У нас нет «пустой» реализации метода teardown() в классе TestCase:

TestCase

def tearDown(self):

pass

Мы начинаем получать пользу от разрабатываемой инфраструктуры. Замечательно! Никакого рефакторинга не требуется. Очевидная реализация, созданная нами после обнаружения ошибки, сработала, и код получился чистым.

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Строка журнала в классе WasRun

Далее мы перейдем к формированию отчета о результатах выполнения тестов. Вместо использования встроенного в Python механизма обработки ошибок мы планируем реализовать и использовать собственный механизм наблюдения за работой тестов.

В данной главе мы

• перешли от использования флагов к использованию журнала;

• создали тесты для метода tearDown() и реализовали этот метод с использованием нового механизма журналирования;

• обнаружили проблему и вместо того чтобы возвращаться назад, смело исправили ошибку.

 

21. Учет и контроль

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Строка журнала в классе WasRun

Метод tearDown() должен выполняться, даже когда в процессе выполнения теста возникло исключение. Однако чтобы добиться этого, мы должны перехватывать исключения. (Честно говоря, я уже пытался реализовать это, но у меня не получилось, и я вернул все на свои места.) Если при реализации перехвата исключений мы допустим ошибку, мы можем не заметить этого, так как стандартный механизм доклада об исключениях не будет функционировать.

Работая в стиле TDD, важно понимать, что особое значение имеет порядок, в котором вы реализуете тесты. Выбирая тест, над которым я буду работать дальше, я стараюсь выбрать тот, который, во-первых, послужит для меня источником новых знаний, а во-вторых, достаточно прост, чтобы я был уверен в том, что могу заставить его работать. Если я добиваюсь успешного выполнения этого теста, но захожу в тупик при реализации следующего, я вполне могу выполнить откат назад на два шага. Было бы неплохо, если бы среда разработки оказывала мне в этом помощь. Например, было бы неплохо, если бы в момент срабатывания всех тестов автоматически создавалась резервная копия всего исходного кода, с которым я работаю.

После выполнения всех тестов желательно получить информацию о том, как они выполнились, например: «запущено 5, неудачных 2: TestCaseTest.testFooBar – ZeroDivideException, MoneyTest.testNegation – AssertionError». Если тесты перестают выполняться или результаты перестают отображаться на экране мы, по крайней мере, сможем обнаружить ошибку. Однако наша инфраструктура не обязана знать обо всех разработанных тестах.

Пусть метод TestCase.run() возвращает объект класса TestResult с результатами выполнения теста (вначале тест будет только один, однако позже мы усовершенствуем этот объект).

TestCaseTest

def testResult(self):

test = WasRun("testMethod")

result = test.run()

assert("1 run, 0 failed" == result.summary())

Начнем с поддельной реализации:

TestResult

class TestResult:

def summary(self):

return "1 run, 0 failed"

Теперь сделаем так, чтобы в результате выполнения метода TestCase.run() возвращался объект класса TestResult:

TestCase

def run(self):

self.setUp()

method = getattr(self, self.name)

method()

self.tearDown()

return TestResult()

Теперь, когда все тесты выполнились успешно, можно сделать реализацию метода summary() реальной. Как и раньше, будем двигаться маленькими шажками. Для начала заменим количество выполненных тестов константой:

TestResult

def __init__(self):

self.runCount = 1

def summary(self):

return "%d run, 0 failed" % self.runCount

(Оператор % в языке Python является аналогом функции sprintf в языке C.) Однако runCount не может быть константой, это должна быть переменная, значение которой вычисляется исходя из количества выполненных тестов. Мы можем инициализировать эту переменную значением 0, а затем увеличивать ее на единицу при выполнении очередного теста.

TestResult

def __init__(self):

self.runCount = 0

def testStarted(self):

self.runCount = self.runCount + 1

def summary(self):

return "%d run, 0 failed" % self.runCount

Теперь мы должны позаботиться о вызове этого нового метода:

TestCase

def run(self):

result = TestResult()

result.testStarted()

self.setUp()

method = getattr(self, self.name)

method()

self.tearDown()

return result

Мы точно так же могли бы преобразовать константу «0», обозначающую количество тестов, потерпевших неудачу, в переменную, как сделали это с переменной runCount, однако существующие тесты этого не требуют. Поэтому напишем новый тест:

TestCaseTest

def testFailedResult(self):

test = WasRun("testBrokenMethod")

result = test.run()

assert("1 run, 1 failed", result.summary)

Здесь:

WasRun

def testBrokenMethod(self):

raise Exception

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Строка журнала в классе WasRun

Отчет о неудачных тестах

Мы немедленно замечаем, что исключение, генерируемое в методе WasRun.testBrokenMethod(), не перехватывается. Нам хотелось бы перехватить это исключение и в отчете о результатах тестирования отметить, что тест потерпел неудачу. Добавим соответствующий пункт в список задач.

Подведем итог. Мы

• разработали поддельную реализацию и начали поэтапно делать ее реальной путем замены констант переменными;

• написали еще один тест;

• когда тест потерпел неудачу, написали еще один тест меньшего масштаба, чтобы обеспечить выполнение неудачного теста.

 

22. Обработка неудачного теста

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Строка журнала в классе WasRun

Отчет о неудачных тестах

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

TestCaseTest

def testFailedResultFormatting(self):

result = TestResult()

result.testStarted()

result.testFailed()

assert("1 run, 1 failed" == result.summary())

В данном тесте фигурируют два новых метода: testStarted() и testFailed(). Первый из них должен вызываться в начале работы теста, а второй – в случае, если тест не срабатывает. Тестовый метод testFailedResultFormatting() предполагает, что, если эти два метода вызываются в указанном порядке, отчет о результате тестирования должен выглядеть корректно. Если мы заставим этот тестовый метод работать, наша проблема сведется к тому, чтобы обеспечить вызов метода testStarted() в начале выполнения теста и вызов testFailed() в случае, если тест потерпел неудачу.

Чтобы реализовать функциональность методов testStarted() и testFailed(), воспользуемся двумя счетчиками: счетчиком запущенных тестов и счетчиком неудачных тестов:

TestResult

def __init__(self):

self.runCount = 0

self.errorCount = 0

def testFailed(self):

self.errorCount = self.errorCount + 1

Если счетчик будет работать корректно (мы должны были бы это протестировать, однако кофе, похоже, ударил мне в голову), отчет будет напечатан корректно:

TestResult

def summary(self):

return "%d run, %d failed" % (self.runCount, self.failureCount)

Теперь можно предположить, что, если обращение к методу testFailed() будет выполнено корректно, мы получим на экране желаемый результат. Но где следует вызвать этот метод? В обработчике исключения, перехваченного в процессе выполнения тестового метода:

TestCase

def run(self):

result = TestResult()

result.testStarted()

self.setUp()

try:

method = getattr(self, self.name)

method()

except:

result.testFailed()

self.tearDown()

return result

В этом коде присутствует одно тонкое место: если исключение возникает во время выполнения метода setUp(), оно не будет перехвачено. Это означает, что в определенной степени тесты зависят друг от друга. Однако прежде, чем менять код, мы должны написать еще один тест. (Я научил мою старшую дочку Бетани программировать в стиле TDD, когда ей было 12 лет. Это самый первый стиль программирования, с которым она познакомилась. Она не умеет программировать иначе и абсолютно уверена, что добавлять в программу новый функциональный код можно только в случае, если существует тест, который терпит неудачу. Однако более опытные программисты, как правило, вынуждены напоминать себе о том, что сначала необходимо писать тесты, а потом – код, заставляющий их выполняться.) Я оставляю этот следующий тест и его реализацию вам в качестве самостоятельного упражнения (мои пальцы опять устали):

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Строка журнала в классе WasRun

Отчет о неудачных тестах

Перехват и отчет об ошибках setUp

Далее мы сделаем так, чтобы наша инфраструктура смогла запустить несколько тестов подряд.

В данной главе мы

• обеспечили успешное выполнение нашего теста меньшего масштаба;

• заново приступили к реализации более крупного теста;

• обеспечили успешное выполнение крупного теста, воспользовавшись механизмом, реализованным для маленького теста;

• обратили внимание на потенциальную проблему, но вместо того, чтобы немедленно браться за ее решение, добавили соответствующую пометку в список задач.

 

23. Оформляем тесты в набор

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Строка журнала в классе WasRun

Отчет о неудачных тестах

Перехват и отчет об ошибках setUp

Мы не можем оставить работу над xUnit, не реализовав класс TestSuite, представляющий собой набор тестов. Конец нашего файла, где мы запускаем все наши тесты, выглядит весьма неопрятно:

print(TestCaseTest(«testTemplateMethod»). run(). summary())

print(TestCaseTest("testResult"). run(). summary())

print(TestCaseTest("testFailedResultFormatting"). run(). summary())

print(TestCaseTest("testFailedResult"). run(). summary())

Дублирование – это всегда плохо, за исключением случаев, когда вы используете его в качестве мотивации для поиска недостающего элемента дизайна. В данном случае нам хотелось бы сгруппировать тесты и запустить их при помощи одной команды. (Мы приложили массу усилий для изоляции тестов, однако все эти усилия не окупят себя, если мы будем запускать тесты по одному.) Еще одна хорошая причина, по которой было бы неплохо реализовать TestSuite, заключается в том, что этот класс хорошо демонстрирует использование шаблона «Компоновщик» (Composite), – мы хотим, чтобы набор тестов вел себя в точности так же, как единичный тест.

Мы хотим обладать возможностью создать набор тестов (объект TestSuite), добавить в него несколько тестов и получить общий результат выполнения всех этих тестов:

TestCaseTest

def testSuite(self):

suite = TestSuite()

suite.add(WasRun("testMethod"))

suite.add(WasRun("testBrokenMethod"))

result = suite.run()

assert("2 run, 1 failed" == result.summary())

Для успешного выполнения теста создадим в объекте TestSuite список тестов и реализуем метод add(), который просто добавляет тест, переданный в качестве аргумента, в список:

TestSuite

class TestSuite:

def __init__(self):

self.tests = []

def add(self, test):

self.tests.append(test)

(В языке Python оператор [] создает пустую коллекцию.)

Однако с реализацией метода run() возникают проблемы. Мы хотим, чтобы результаты срабатывания всех тестов накапливались в едином объекте класса TestResult. Таким образом, мы можем написать следующий код:

TestSuite

def run(self):

result = TestResult()

for test in tests:

test.run(result)

return result

Здесь оператор цикла «for test in tests» выполняет итерации по всем элементам последовательности tests, присваивает их по одному переменной цикла test и запускает соответствующий тест. Однако шаблон «Компоновщик» (Composite) подразумевает, что набор объектов должен обладать точно таким же интерфейсом, каким обладает отдельный объект. Если мы передаем параметр методу TestCase.run(), значит, мы должны передавать точно такой же параметр методу TestSuite.run(). Можно использовать одну из трех альтернатив.

• Воспользоваться встроенным в язык Python механизмом параметров со значениями по умолчанию. К сожалению, значение параметра по умолчанию вычисляется во время компиляции, но не во время выполнения, а мы не хотим повторно использовать один и тот же объект TestResult.

• Разделить метод на две части – одна создает объект TestResult, а вторая выполняет тест, используя переданный ей объект TestResult. Я не могу придумать хороших имен для двух частей метода, а это означает, что данная стратегия не является самой лучшей.

• Создавать объекты TestResult в вызывающем коде.

Мы будем создавать объекты TestResult в вызывающем коде. Этот шаблон называется «Накапливающий параметр» (Collecting Parameter).

TestCaseTest

def testSuite(self):

suite = TestSuite()

suite.add(WasRun("testMethod"))

suite.add(WasRun("testBrokenMethod"))

result = TestResult()

suite.run(result)

assert("2 run, 1 failed" == result.summary())

При использовании данного подхода метод run() не возвращает никакого явного значения:

TestSuite

def run(self, result):

for test in tests:

test.run(result)

TestCase

def run(self, result):

result.testStarted()

self.setUp()

try:

method = getattr(self, self.name)

method()

except:

result.testFailed()

self.tearDown()

Теперь мы можем облагородить обращение к тестовым методам в конце файла:

suite = TestSuite()

suite.add(TestCaseTest("testTemplateMethod"))

suite.add(TestCaseTest("testResult"))

suite.add(TestCaseTest("testFailedResultFormatting"))

suite.add(TestCaseTest("testFailedResult"))

suite.add(TestCaseTest("testSuite"))

result = TestResult()

suite.run(result)

print(result.summary())

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Строка журнала в классе WasRun

Отчет о неудачных тестах

Перехват и отчет об ошибках setUp

Создать объект TestSuite автоматически на основе класса TestCase

Здесь слишком много повторяющегося кода, от которого можно избавиться, если обеспечить способ конструирования набора тестов автоматически, исходя из предоставленного класса TestCase.

Однако вначале восстановим корректное выполнение четырех не неудачных тестов (эти тесты используют старый интерфейс функции run() без аргументов):

TestCaseTest

def testTemplateMethod(self):

test = WasRun("testMethod")

result = TestResult()

test.run(result)

assert("setUp testMethod tearDown " == test.log)

def testResult(self):

test = WasRun("testMethod")

result = TestResult()

test.run(result)

assert("1 run, 0 failed" == result.summary())

def testFailedResult(self):

test = WasRun("testBrokenMethod")

result = TestResult()

test.run(result)

assert("1 run, 1 failed" == result.summary())

def testFailedResultFormatting(self):

result = TestResult()

result.testStarted()

result.testFailed()

assert("1 run, 1 failed" == result.summary())

Обратите внимание, что каждый из тестов создает экземпляр класса TestResult – эту операцию можно выполнить однократно внутри метода setUp(). Благодаря реализации этой идеи мы упростим тесты, однако сделаем их несколько более сложными в прочтении:

TestCaseTest

def setUp(self):

self.result = TestResult()

def testTemplateMethod(self):

test = WasRun("testMethod")

test.run(self.result)

assert("setUp testMethod tearDown " == test.log)

def testResult(self):

test = WasRun("testMethod")

test.run(self.result)

assert("1 run, 0 failed" == self.result.summary())

def testFailedResult(self):

test = WasRun("testBrokenMethod")

test.run(self.result)

assert("1 run, 1 failed" == self.result.summary())

def testFailedResultFormatting(self):

self.result.testStarted()

self.result.testFailed()

assert("1 run, 1 failed" == self.result.summary())

def testSuite(self):

suite = TestSuite()

suite.add(WasRun("testMethod"))

suite.add(WasRun("testBrokenMethod"))

suite.run(self.result)

assert("2 run, 1 failed" == self.result.summary())

Вызов тестового метода

Вызов метода setUp перед обращением к методу

Вызов метода tearDown после обращения к методу

Метод tearDown должен вызываться даже в случае неудачи теста

Выполнение нескольких тестов

Отчет о результатах

Строка журнала в классе WasRun

Отчет о неудачных тестах

Перехват и отчет об ошибках setUp

Создать объект TestSuite автоматически на основе класса TestCase

Все эти бесчисленные ссылки self выглядят ужасно, однако без этого в языке Python никак не обойтись. Если бы этот язык изначально был объектно-ориентированным, наверное, в этих ссылках не было бы надобности, а ссылки на глобальные переменные требовали бы квалификации. Однако язык Python изначально является интерпретируемым языком с добавленной в него поддержкой объектов (надо отметить, что поддержка объектов в этом языке реализована великолепно). В результате по умолчанию переменные считаются глобальными, а явные ссылки на self – необходимыми.

Я оставляю реализацию оставшихся пунктов вам в качестве упражнения. Надеюсь, обретенные навыки работы в стиле TDD помогут вам.

Чтобы подвести итог, напомню, что в данной главе мы

• написали тест для класса TestSuite;

• написали часть реализации, однако не добились успешного выполнения тестов – это нарушение правил (я уверен, что существует простая поддельная реализация, которая заставила бы тесты работать, благодаря чему мы могли бы выполнять рефакторинг, имея перед глазами зеленую полоску, однако сейчас я не хочу думать на эту тему);

• изменили интерфейс метода run(), благодаря чему набор тестов можно использовать точно так же, как и отдельный тест, – в результате тесты наконец выполнились успешно;

• выполнили рефакторинг имеющихся тестов – переместили общий код создания объекта результатов в метод setUp().

 

24. Ретроспектива xUnit

Если перед вами встала задача разработки своей собственной инфраструктуры тестирования, методика, описанная в части II данной книги, послужит вам руководством. Не следует слишком много внимания уделять деталям реализации – значительно больший интерес представляют тесты. Если вы напишете код, обеспечивающий успешное выполнение представленных здесь тестов, в вашем распоряжении окажется минимальная инфраструктура тестирования, пригодная для запуска тестов в условиях изоляции и обеспечивающая композицию тестов. Вы сможете приступить к разработке программного кода в стиле TDD.

На момент написания данной книги инфраструктура тестирования xUnit адаптирована для более чем 30 языков программирования. Язык, на котором вы программируете, скорее всего, уже обладает своей собственной реализацией xUnit. Однако, даже если кто-то уже сделал это до вас, возможно, будет лучше, если вы попробуете разработать свою собственную новую версию xUnit самостоятельно. На то есть две важные причины:

 Контроль над реализацией. Основополагающая характеристика xUnit – это простота. Мартин Фаулер (Martin Fowler) сказал: «Никогда в истории программной индустрии еще не было случая, чтобы столь многие разработчики были обязаны столь немногому количеству строк программного кода». На мой взгляд, некоторые реализации xUnit к настоящему времени стали слишком большими и сложными. Если вы разработаете собственную версию xUnit, то получите инструмент, который вы будете контролировать в полном объеме.

 Обучение. Когда я сталкиваюсь с необходимостью изучить новый язык программирования, я приступаю к реализации xUnit. Когда я добиваюсь срабатывания первых восьми-десяти тестов, я овладеваю навыками работы с основными конструкциями и возможностями нового для меня языка.

Когда вы начнете работать с xUnit, вы обнаружите, что существует значительная разница между выражениями assert, потерпевшими неудачу, и ошибками других типов, возникающими в процессе выполнения тестов. В отличие от остальных ошибок выражения assert требуют больше времени для отладки. Из-за этого большинство реализаций xUnit отличает сбои операторов assert от всех остальных ошибок: в рамках GUI зачастую информация об ошибках отображается в начале списка.

Инфраструктура JUnit объявляет простой интерфейс Test, который реализуется классами TestCase и TestSuite. Если вы хотите создать тестовый класс, который мог бы взаимодействовать со стандартными средствами тестирования, встроенными в JUnit, вы можете реализовать функции интерфейса Test самостоятельно:

public interface Test {

public abstract int countTestCases();

public abstract void run(TestResult result);

}

В языках с оптимистическим (динамическим) приведением типов можно даже не объявлять о поддержке этого интерфейса – достаточно реализовать входящие в его состав операции. При использовании языка сценариев Сценарий может ограничивать реализацию countTestCases() возвратом единицы и выполнять проверку TestResult на отказ, а вы можете выполнять ваши сценарии с обычными объектами TestCase.