TDD. Базовые приемы для начинающих

Предисловие

В освоении любой технологии очень важно хорошо ухватиться за саму суть. Ведь неверная интерпретация основополагающих техник вредит вашим проектам, например, сказываясь на скорости разработки и удобстве последующей поддержки кода. Многие разработчики любят разбрасываться красноречивыми лозунгами, хотя на деле все у них не так радужно. Так в свое время было с ООП, так сейчас происходит с TDD. Сколько органичного и ажурного объектноориентированного кода вы видели? Через мои руки проходят сотни тысяч строк чужого кода. При этом я могу пересчитать разработчиков, для которых ООП плотно интегрировано в их способ изложения мыслей в коде, по пальцам одной руки. При взгляде же на код остальных почему-то на ум приходит только расхожая поговорка про танцора. Такое впечатление, что целью было уйти от объектов либо, наоборот, создать бесструктурное их нагромождение. Если вы не замечаете технологии, если для вас это стало настолько естественным, что вы уже давно забыли, что такое Strategy, а что такое Chain Of Responsibility, но паттерны продолжают рождаться сами по себе из-под вашего пера, вы на правильном пути. Чем хороша практика применения TDD — так это тем, что она позволяет более глубоко вникнуть в суть все того же ООП и взяться за чтение бессмертной GoF. Неоднократно я слышал изречения новичков в программировании, что они не понимают, зачем нужны паттерны проектирования. Полезность оных действительно сложно уловить без сколь-либо серьезного опыта в программировании. И TDD даст вам хороший пинок для этого. Разумеется, если вы отбросите гордыню и позволите себе беспристрастно подойти к изучению этой технологии. В процессе изложения я буду рассчитывать на то, что вы приучены активно пользоваться интернетом, чтобы отвечать на возникающие у вас вопросы. Это позволит мне
сконцентрироваться на раскрытии сабжа, а не описывании при этом еще и кучи мелких нюансов.

Среда

Первое, что нужно сделать — это обустроить свое рабочее пространство. Примеры я буду приводить на базе ОС Windows XP и таких сред разработки, как Visual Studio 2008 и NetBeans 6.0.

Клиент SVN

Этот момент достаточно важен. Без системы контроля версий процесс разработки не может быть полноценным. И дело даже не в TDD или работе в команде. Система контроля версий позволяет себя чувствовать более уверенно и весьма полезна даже если вы работаете в одиночку. Как много раз вы видели некий закомментированный код, который лежит мертвым грузом? Представьте себе свежую буханку хлеба, которую вы решили оставить про запас. Со временем вы получите либо сухари, либо плесень. В зависимости от условий хранения. Но не свежую и теплую буханку хлеба. Если чувствуете, что пора бы произвести изменения, режьте по живому и не занимайтесь такими "бэкапами". В любом случае у вас будет шанс откатиться обратно. Так вы сможете всегда поддерживать свой код "свежим" и "теплым", и это очень важно для качественной разработки. Я использую систему контроля версий для того, чтобы была возможность доступа к предыдущим ревизиям, а также для того, чтобы можно было держать под рукой релиз и в то же время беспрепятственно продолжать развитие проекта. Все просто с популярным клиентом для Windows — Tortoise SVN. Вы можете пользоваться и интегрированными в IDE средствами, но, на мой взгляд, отдельно стоящий клиент куда удобнее. Впрочем, это дело каждого. По Tortoise SVN есть русский перевод документации, легко доступный через интернет. Также я рекомендую поискать в сети руководство по организации структуры репозитория. На всякий случай упомяну, что репозиторий, как и код, тоже стоит содержать в чистоте. Есть смысл подписывать только то, что потребуется для билда. Все, что может быть сгенерировано на основе исходников и сопутствующих библиотек, включать в репозиторий не нужно. Tortoise SVN позволяет настраивать фильтры как раз для этих целей. У меня фильтр выглядит следующим образом:

bin doc obj *.tasklist *.dll *.pdb Bin *.trx TestResults build dist deploy

Это все то, что я не хочу видеть в репозитории, т.к. это никак не сказывается на процессе компиляции проекта и лишь раздувает репозиторий и экспортируемый код. Годится такой фильтр для Visual Studio и NetBeans с учетом их пристрастий к генерации тех или иных каталогов и сопутствующих файлов. Сейчас я вам распишу на примере Tortoise SVN те шаги, которые необходимо произвести перед тем, как делать упор на написание кода. Это именно то, что никто почему-то не описывает в руководствах по Subversion. Создаете новый проект на диске и выходите из IDE. Далее щелкаете правой кнопкой мыши по каталогу проекта и выбираете Tortoise SVN > Import. Импортируете проект в репозиторий. Теперь удаляете все исходники из каталога, снова щелкаете по нему правой кнопкой мыши и выбираете SVN Check-out. Теперь каталог синхронизирован с репозиторием. Запускаете IDE, которая автоматически подхватит проект, ничего при этом не заподозрив.

*Unit Framework

NetBeans 6.0 поставляется вместе с JUnit 4.1 (который поддерживает annotations — по образу и подобию атрибутов в .NET), а в Visual Studio 2008 Professional встроен свой тестовый фреймуорк, очень похожий на NUnit. Подробнее я расписываю то, как перейти с NUnit на MSTest, у себя в блоге ( сайт ) в теме записки: "Тестируем события в Visual Studio 2008 Beta 2 (Orcas) Professional". К сожалению, русской документации по тестовым фреймуоркам крайне мало. А переводить существующие руководства с английского у меня нет особого желания. Впрочем, я надеюсь, что мои примеры помогут вам быстро освоиться. Тем более, что JUnit, NUnit и MSTest — легковесные фреймуорки, за что я их и люблю.

Практика

Я не сторонник выписывать в столбик список "заповедей" с восклицательными знаками и незыблемыми утверждениями, которые американцы любят называть "TODO Practices". Мне не хотелось бы, чтобы вы бездумно следовали какой-либо очередной идее-завлекалочке для неоперившихся юнцов. Любому "лозунгу" должно быть практическое обоснование. Именно поэтому все, что я говорю, проверяйте на практике и делайте свои выводы. Очень хорошо, если вы прочитали книгу Кента Бека "Разработка через тестирование". Она доступна в русском переводе и в сети. Чтение этой книги позволит вам избежать многих ошибок в работе с технологией. А повторяться мне бы не хотелось. Я буду описывать свой опыт применения TDD и ответы на те вопросы, которые возникают чаще всего. Также перед тем, как продолжить, рекомендую ознакомиться со статьей "Ошибки начинающих TDD-практиков" ( сайт ).

Как тестировать private-методы

Не нужно их тестировать. TDD подразумевает, что сначала идет тест, а уже потом реализация. Т.е. вы сосредотачиваетесь на внешнем интерфейсе, на том, как ваш класс будете использовать вы сами или другие разработчики. Сам же по себе класс — это черный ящик, и каким образом вы достигаете выполнения своих тестов внутри этого ящика, не суть важно. Вы, разумеется, вправе заниматься рефакторингом, но на выработанном внешнем интерфейсе это уже никак не отразится. С другой стороны, если тестировать лишь "большие" классы, страдает покрытие кода тестами, а значит, и качество вашего кода остается под вопросом. Любая реализация должна хорошо просматриваться и легко тестироваться. Т.е. приватные методы в идеале должны быть нагружены лишь вызовами методов уже оттестированных вами классов. Суть TDD в том, что нужно разбить задачу на простые как в тестировании, так и в реализации блоки. Отсюда, кстати, и название — блочное тестирование. Это в итоге приводит к тому, что вы используете только то, в чем уверены. Рассмотрим на примере. Допустим, в вашу задачу входит реализация апплета для загрузки изображений на сервер с предварительной обработкой — скажем, с поворотом изображений, переводом в градации серого или чего-то вроде этого. Какие нас могут поджидать тут проблемы? Ну, во-первых, серверная часть может быть еще не написана. Так было в моем случае. Да и загружать массу изображений на удаленный сервер при тестировании — расточительно по времени хотя бы. К тому же, тяжело будет контролировать результат и выявлять причины сбоев. Что я делаю в таком случае? Вывожу весь процесс наружу и делаю очевидным. Т.е. не прячу процесс отсылки изображений в приватные методы, а тестирую с использованием заглушек.

Что такое заглушки, и как их использовать?

Заглушка — это такой класс, который позволяет вам посмотреть, что происходит внутри вашего класса. Эта идея настолько хорошо ложится на TDD, что специально были придуманы библиотеки автоматической генерации заглушек. Для .NET такой библиотекой может быть, например, DotNetMock. Как с ним работать, я расскажу чуть ниже, а пока сосредоточимся на создании своих собственных заглушек. Итак, нам нужно загрузить изображения на сервер. Swing очень хорошо приспособлен к реализации паттерна Model-View-Controller, поэтому нашей фронтальной задачей будет реализация Model. На этом пути у нас буду возникать параллельные задачи, на которые не стоит отвлекаться. В нашем случае такой параллельной задачей является реализация обращения к веб-сервису для загрузки изображений. Чтобы не отвлекаться от текущей задачи, создадим себе интерфейс, определяющий класс, который будет специализироваться на загрузке изображений.

interface IPhotoProcessor
{
void cancelUpload();
void uploadPhotos(List<KeyValuePair<File>> fileList);
}

Это все, что нужно знать классу Model для загрузки изображений. Впоследствии мы можем поменять алгоритм загрузки. Класс Model переписывать не придется. Загрузка инициируется публичным методом uploadSelected, объявленным в классе Model. Этот метод загружает только отмеченные изображения. При этом управление передается экземпляру класса, реализующего интерфейс IPhotoProcessor. И вот тут наша заглушка и пригодится. Будем мониторить тот список файлов, который передается в метод uploadPhotos — т.е. именно те файлы, которые бы начали загружаться на сервер, будь у нас "настоящая" реализация. В таком случае тест будет выглядеть так:

import org.junit.*;
import static org.junit.Assert.*;



@Test
public void uploadSelected()
{
Model model = new Model(
new IPhotoProcessor()
{
public void cancelUpload()
{
throw new UnsupportedOperationException("Not supported yet.");
}

public void uploadPhotos(List<KeyValuePair<File>> fileList)
{
/* Проверяем содержимое fileList при помощи методов assert*. */
}

});

/* Приводим model в необходимое состояние — например, выделяем конкретные изображения. */
model.uploadSelected();
}

N.B. Сейчас JUnit уже поддерживает метаданные, поэтому тестовые методы не обязательно должны начинаться с test*. Помечаете его вместо этого атрибутом @Test.

Как видите, ничего у нас не прячется от нашего взгляда, и любые действия внутри класса легко контролируемы. Это называется "writing code for testability", т.е., если класс легко поддается тестированию, вы на правильном пути. Если вам приходится дописывать какие-то несуразные костыли, то это может послужить хорошим признаком того, что пора бы заняться рефакторингом прежде, чем продолжать. Тесты должны быть как можно проще, чтобы любые ваши ошибки были легко уловимы. Нередко бывает так, что ошибки вкрапляются и в код тестов. Но т.к. тесты достаточно простые, ошибки практически сразу бросаются в глаза. В C# нету такой вкусной фишки в виде анонимных классов, как в Java. Точнее, анонимные классы появились в C# 3.0, но предназначены они для любителей функционального программирования, а именно тем, кто обрабатывает коллекции объектов. В C# стабы придется создавать как внутренние классы для тестового класса, а затем создавать их экземпляры в тестовых методах. Тот прием, который мы только что использовали для того, чтобы дать понять классу Model, какой класс использовать для загрузки изображений, называется injection и основан на применении паттерна Inversion of Control.

Что такое инжектирование?

Доходчивая статья на примере PHP, что не мешает ей оставаться полезной и для разработчиков, пишущих на других языках: сайт . Помните, что всюду хороша мера. И если вы не преследуете четкой цели в применении того или иного подхода, а делаете так, потому что вам вдруг очень понравился этот паттерн, и захотелось всю реализацию разнести по мелким классам — пора останавливаться. Вы встали на извилистую тропинку заложников чужих идей.

Как тестировать protected-методы?

Наследование — это клей, который делает архитектуру приложения слегка монолитной. Конечно, у нас есть мощный в умелых руках полиморфизм. Но если вы ловите себя на мысли, что стали слишком часто тестировать protected-методы, пора задуматься над рефакторингом и привести все к примитивным (и не более) тестам. Таким образом, я обычно проверяю лишь те параметры, которые передают в protected-методы абстрактные классы. Итак, для того, чтобы протестировать protected-метод, необходимо отнаследоваться от класса, в котором этот protected-метод объявлен, и расставить assert'ы. Класс может быть и внутренним для тестового класса. Затем из тестового метода создаете экземпляр такого псевдонаследника и приводите его в такое состояние, чтобы был вызван интересующий protected-метод. Благодаря полиморфизму будет вызван именно метод с assert'ами, и мы сможем убедиться в верности наших предположений либо, наоборот, прийти к выводу, что что-то пошло наперекосяк.

Как поступить с данными для тестов

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

private static final Map<String, String> _getExtensionTestData =
new HashMap<String, String>();

static
{
_getExtensionTestData.put("C:\\image.jpg", "jpg");
_getExtensionTestData.put("C:\\image", "");
_getExtensionTestData.put(".", "");
_getExtensionTestData.put("", "");
}

@Test
public void getExtension()
{
for (final String key : _getExtensionTestData.keySet())
{
assertEquals(_getExtensionTestData.get(key), Argument.getExtension(key));
}
}

В данном случае я тестирую метод getExtension класса Argument. Чем хорош такой подход — так это тем, что входные и выходные данные всегда на виду, и их легко модифицировать при случае, при этом наблюдая за тем, чтобы результат продолжал оставаться ожидаемым.

Как тестировать события

Т.к. все движение XP и TDD пошло от Java, где схема работы с событиями несколько иная, в этом месте разработчикам под .NET приходится идти своим путем. Вот то, как на данный момент работаю с событиями я сам (инструментарий NUnit + DotNetMock). Не хочу приводить полные листинги реальных тестируемых объектов, поэтому оставляю только ключевые необходимые для понимания моменты (надеюсь, этого будет достаточно, чтобы войти в курс дела). Итак, у нас есть класс примерно вот такого содержания:

public class SizeTracker
{
private static readonly object EventHeightChanged = new object();
public event EventHandler HeightChanged
{
...
}

private static readonly object EventWidthChanged = new object();
public event EventHandler WidthChanged
{
...
}
}
В задачи этого класса входит следить за изменениями ширины и высоты и докладывать об этом слушателям (сама бизнес-логика сейчас абсолютно не важна). Для тестирования событий этого класса был написан вот такой mock-object:
class SizeTrackerEventSink : MockObject
{
private ExpectationValue expectedWidth = new ExpectationValue("expectedWidth");
private ExpectationValue expectedHeight = new ExpectationValue("expectedHeight");
private SizeTracker sizeTracker = null;
public int ExpectedHeight
{
set
{
this.expectedHeight.Expected = value;
}
}
public int ExpectedWidth
{
set
{
this.expectedWidth.Expected = value;
}
}
public SizeTrackerEventSink(SizeTracker sizeTracker)
{
if (sizeTracker == null)
{
throw new ArgumentNullException("sizeTracker");
}
this.sizeTracker = sizeTracker;
this.sizeTracker.HeightChanged += delegate
{
this.expectedHeight.Actual = this.sizeTracker.Height;
};
this.sizeTracker.WidthChanged += delegate
{
this.expectedWidth.Actual = this.sizeTracker.Width;
};
}
}

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

SizeTracker sizeTracker = new SizeTracker(new Size(800, 600));
SizeTrackerEventSink eventSink = new SizeTrackerEventSink(sizeTracker);
eventSink.ExpectedWidth = 640;
eventSink.ExpectedHeight = 480;
sizeTracker.Width = 640;
eventSink.Verify();

Что тут происходит? При изменении ширины в пятой строчке у нас генерируется два события: во-первых, WidthChanged, а во-вторых, HeightChanged (потому что она должна быть пропорциональна ширине — пропорция задается исходным размером в конструкторе). Если это не так, то тест проваливается (failed), если события вызываются, но параметры не те, то то же самое. Использование такого подхода нахожу довольно удобным (во всяком случае, пока), т.к. можно очень гибко "набрать" тестовые условия, в отличие от black-box'ов готовых framework'ов. А вот тут можно скачать мой фреймуорк, построенный на базе DotNetMock, но адаптированный для VS 2008 (Orcas): сайт . Вся методология целиком и полностью построена на применении заглушек, которые мы уже рассмотрели. Поэтому с пониманием сути проблем возникнуть не должно.

Алексей Нестеров, eisernWolf@tut.by


Компьютерная газета. Статья была опубликована в номере 05 за 2008 год в рубрике программирование

©1997-2024 Компьютерная газета