Оригинал: www.infoq.com/articles/atdd-from-the-trenches
ATDD с передовой
Разработка через приемочное тестирование для начинающих
Если вы когда-нибудь бывали в такой ситуации:
Тогда эта статья для вас — конкретный пример того, как начать разработку через приемочные тесты (Acceptance-test driven development) в действующих проектах с легаси кодом. В ней описан один из способов решения проблемы технического долга.
Это пример из реального проекта, со всеми изъянами и недостатками, а не отполированное упражнение из книги. Так что надевайте свои берцы. Я буду использовать Java и JUnit, без всяких модных сторонних библиотек (которыми, как правило, злоупотребляют).
Предупреждение: Я не утверждаю, что это единственный Правильный Путь, существует много других “стилей” ATDD. Так же в этой статье не так много чего-то нового и инновационного, здесь просто описаны хорошо себя зарекомендовавшие подходы и опыт из первых рук.
Что я хотел сделать
Несколько дней назад я начал делать защиту паролем для webwhiteboard.com (моего проекта — хобби). Пользователи уже давно просят добавить возможность защитить паролем виртуальные доски, так что настало время это сделать.
На словах звучит просто, но на самом деле нужно сделать довольно много изменений в дизайне. Пока что предполагалось, что webwhiteboards.com используется анонимными пользователями, без всяких логинов и паролей. Кто должен иметь возможность защитить доску паролем? Кто сможет получить к ней доступ? Что если я забуду пароль? Как реализовать это простым, но в то же время достаточно надежным способом?
Код webwhiteboard хорошо покрыт юнит тестами и интеграционными тестами.
Но приемочные тесты, то есть тесты, проходящие через все слои с точки зрения конечного пользователя, полностью отсутствуют.
Рассмотрим дизайн
Главная цель дизайна webwhiteboard — простота: минимизировать необходимость ввода пароля, не создавать учетные записи, поменьше прочих раздражителей. Так что я установил два ограничения на доску, защищенную паролем:
- Анонимный пользователь не может защитить доску паролем. Но он может открыть уже защищенную доску. Ему не нужно будет входить в систему, а нужно только ввести пароль защищенной доски.
- Управлять логинами и паролями будет внешний OpenId/Oauth компонент, первоначально предполагался Google. Таким образом, пользователю не придется создавать еще одну учетную запись.
Подход к реализации
Здесь много неопределенности. Я не знал, как это должно работать, не говоря уже о том, как это реализовать. Вот что я решил сделать (собственно ATDD):
- Шаг 1. Задокументировать предпологаемый процесс
- Шаг 2. Превратить его в запускаемый приемочный тест
- Шаг 3. Запустить приемочный тест, убедиться что он упал
- Шаг 4. Починить приемочный тест
- Шаг 5. Почистить код
Эти шаги повторяются много раз. На каждом шаге мне может понадобиться вернуться назад и исправить предыдущий шаг (что я и делал довольно часто).
Шаг 1: Задокументировать предпологаемый процесс
Представим, что функционал Готов. Будто ангел спустился с небес и сделал все, пока я спал. Звучит слишком хорошо, чтобы быть правдой! Как мне проверить, что работа уже сделана? Какой сценарий проверить первым? Давайте этот:
- Я создаю новую доску
- Устанавливаю на нее пароль
- Джо пытается открыть мою доску, система спрашивает пароль
- Джо вводит неправильный пароль, доступ запрещен
- Джо пробует еще раз, вводит правильный пароль и получает доступ. (Надо понимать, что “Джо” — это я сам, просто из другого браузера).
Написав этот небольшой тестовый скрипт, я понял, что есть еще много альтернативных сценариев, которые нужно будет принять в расчет. Но это — основной сценарий и если я заставлю его работать, я сделаю большой шаг вперед.
Шаг 2: Превратить его в запускаемый приемочный тест
Это не так-то просто. Других приемочных тестов нет, так с чего же мне начать? Новая функциональность будет взаимодействовать с некоторой внешней компонентой, отвечающей за аутентификацию (сначала я решил использовать Janrain). Еще будет база данных и куча непростых веб штучек с всплывающими диалоговыми окнами, токенами, переходами между страницами и всякое такое. Уфф.
Пора сделать шаг назад. Прежде чем решать проблему “как мне написать приемочный тест”, мне нужно решить более простую проблему “как вообще писать приемочные тесты с существующим кодом”?
Чтобы ответить на этот вопрос, я сначала напишу тест на “самый простой сценарий” их тех, которые уже есть в системе.
Шаг 2.1 Написать самый простой автоматический приемочный тест
Вот сценарий, с которого я начал:
- Попытаться открыть несуществующую доску
- Проверить, что я не могу ее увидеть
Как написать такой тест? С помощью какого фреймворка? Каких инструментов? Следует ли мне тестировать через пользовательский интерфейс или нет? Следует ли мне включать в тестирование клиентский код или напрямую вызывать сервис?
Куча вопросов. Трюк: не отвечайте на них! Просто притворитесь что все уже магически сделано и просто напишите тест на псевдокоде. Например:
public class AcceptanceTest {
@Test
public void openWhiteboardThatDoesntExist() {
//1. Попытаться открыть несуществующую доску
//2. Проверить, что я не могу ее увидеть
}
}
Я его запустил и он прошел! Ура! Эм, но подождите, это же неправильно! Первый шаг в треугольнике TDD (“Красный — Зеленый — Рефакторинг”) это Красный. Так что мне нужно сначала сделать так, чтобы тест упал, чтобы доказать, что это требование еще не реализовано.
Пожалуй, я начну с написания некоторого настоящего кода. Но тем не менее, псевдокод помог мне сделать шаг в правильном направлении.
Шаг 2.2 Сделать самый простой автоматический приемочный тест Красным
Чтобы это сделать, я выдумал класс AcceptanceTestClient и притворился, что он магически решил все проблемы и предоставляет мне прекрасный высокоуровневый интерфейс для запуска моих приемочных тестов. Вот насколько просто его использовать:
client.openWhiteboard(«xyz»);
assertFalse(client.hasWhiteboard());
Как только я написал этот код, я фактически придумал интерфейс, который наиболее хорошо подходит для сценария моего теста. В тесте должно быть примерно столько же строк кода, сколько было в псевдокоде.
Дальше, используя горячие клавиши Eclipse, я автоматически сгенерировал пустой класс AcceptanceTestClient и методы, которые мне нужны:
public class AcceptanceTestClient {
public void openWhiteboard(String string) {
// TODO Auto-generated method stub
}
public boolean hasWhiteboard() {
// TODO Auto-generated method stub
return false;
}
}
Вот как выглядит тестовый класс полностью:
public class AcceptanceTest {
AcceptanceTestClient client;
@Test
public void openWhiteboardThatDoesntExist() {
//1. Попытаться открыть несуществующую доску
client.openWhiteboard("xyz");
//2. Проверить, что я не могу ее увидеть
assertFalse(client.hasWhiteboard());
}
}
Тест запускается, но падает (потому что client — null). Хорошо!
Чего я добился? Не сказать чтобы многого. Но это начало. Теперь у меня есть зародыш класса-помощника для приемочных тестов — AcceptanceTestClient.
Шаг 2.3. Сделать самый простой автоматический приемочный тест Зеленым
Следующий шаг — сделать приемочный тест зеленым.
Теперь мне нужно решить намного более простую проблему. Мне не нужно заботиться ни о аутентификации, ни о нескольких пользователях, ни о чем таком. Я смогу добавить тесты на эти сценарии позже.
Что касается AcceptanceTestClient, его реализация была довольно стандартной — поддельная (mock) база данных (у меня уже был код для этого) и запуск версии всей системы webwhiteboard в памяти.
Вот так выглядит настройка:
(Нажмите на картинку, чтобы увеличить)
Технические детали: Web Whiteboard использует GWT (Google Web Toolkit). Все написано на Java, но GWT автоматически переводит клиентский код в javascript, и магически вставляет вызовы RPC (Remote Procedure Calls) чтобы спрятать все низкоуровневые детали реализации асинхронного взаимодействия клиента с сервером.
Перед запуском приемочного теста, я “замыкаю” систему напрямую и вырезаю все фреймворки, внешние компоненты и сетевое взаимодействие.
(Нажмите на картинку, чтобы увеличить)
Так что я создаю AcceptanceTestClient, который разговаривает с сервисом webwhiteboard точно также, как это делал бы реальный клиентский код. Отличия спрятаны за занавесками:
- Реальный клиент общается с интерфейсом сервиса web whiteboard, который запускается в окружении GWT, которое автоматически превращает вызовы в RPC и отправляет их на сервер.
- Приемочный тест тоже общается с веб интерфейсом сервиса web whiteboard, но он напрямую вызывает реализацию сервиса, без RPC и, следовательно, GWT не используется во время запуска тестов.
Кроме того, AcceptanceTestClient в своей конфигурации подменяет реальную mongo базу данных (облачная NoSQL база данных) на фейк, хранящий данные в оперативной памяти.
Главная причина для подмены всех зависимостей — упростить окружение, ускорить выполнение тестов, и убедиться в том, что тесты покрывают бизнес логику в изоляции от всех компонент и сетевых соединений.
Может показаться, что вся эта настройка чересчур сложна, однако на самом деле это всего лишь один метод init, состоящий всего из 3х строчек кода.
public class AcceptanceTest {
AcceptanceTestClient client;
@Before
public void initClient() {
WhiteboardStorage fakeStorage = new FakeWhiteboardStorage();
WhiteboardService service = new WhiteboardServiceImpl(fakeStorage);
client = new AcceptanceTestClient(service);
}
@Test
public void openWhiteboardThatDoesntExist() {
client.openWhiteboard("xyz");
assertFalse(client.hasWhiteboard());
}
}
WhiteboardServiceImpl — это настоящая реализация сервиса webwhiteboard.
Обратите внимание, что конструктор AcceptanceTestClient теперь принимает экземпляр WhiteboardService (шаблон проектирования “инъекция зависимости”). Это дает нам дополнительный побочный эффект: он не заботится о конфигурации. Один и тот-же класс AcceptanceTestClient можно использовать и для тестирования настоящей системы, просто передав ему экземпляр WhiteboardService, настроенный на реальную базу.
public class AcceptanceTestClient {
private final WhiteboardService service;
private WhiteboardEnvelope envelope;
public AcceptanceTestClient(WhiteboardService service) {
this.service = service;
}
public void openWhiteboard(String whiteboardId) {
boolean createIfMissing = false;
this.envelope = service.getWhiteboard(whiteboardId, createIfMissing);
}
public boolean hasWhiteboard() {
return envelope != null;
}
}
Подводя итог, AcceptanceTestClient ведет себя так-же, как настоящий веб клиент webwhiteboard, в то же время предоставляя высокоуровневый интерфейс для приемочных тестов.
Вы можете спросить “зачем нам нужен AcceptanceTestClient, если у нас уже есть WhiteboardService, который мы можем вызвать напрямую?”. На это есть 2 причины:
- Интерфейс сервиса WhiteboardService более низкоуровневый. AcceptanceTestClient предоставляет именно те методы, которые нужны приемочным тестам, и именно в том виде, который позволит сделать тесты максимально понятными.
- AcceptanceTestClient скрывает всякие мелочи, которые не важны для теста — например, понятие WhiteboardEnvelope, булевое createIfMissing, и другие детали низкого уровня. На самом деле в нашем сценарии используются и другие сервисы, такие как UserService и WhiteboardSyncService.
Я не собираюсь вас больше утомлять деталями реализации AcceptanceTestClient, поскольку эта статья не про устройство webwhiteboard. Достаточно сказать, что AcceptanceTestClient связывает потребности приемочного теста и низкоуровневые детали реализации взаимодействия с интерфейсом сервиса. Написать его было легко, потому что настоящий код клиента служит подсказкой как-надо-взаимодействовать-с-сервисом.
В любом случае, теперь наш Самый Простой приемочный тест проходит!
@Test
public void openWhiteboardThatDoesntExist() {
myClient.openWhiteboard("xyz");
assertFalse(myClient.hasWhiteboard());
}
Следующий шаг — немного прибраться.
На самом деле я пока еще не написал ни строчки продуктового кода (поскольку эта функциональность уже присутствует и работает), это был только код тестового фрейморка. Тем не менее я потратил несколько минут чтобы его подчистить, убрать дубликацию, дать методам более понятные имена и т.д.
Напоследок я добавил еще один тест, просто ради полноты и еще потому что это было легко :o)
@Test
public void createNewWhiteboard() {
client.createNewWhiteboard();
assertTrue(client.hasWhiteboard());
}
Ура, у нас есть тестовый фреймворк! И без всяких модных сторонних библиотек. Только Java и Junit.
Шаг 2.4 Написать приемочный тест для Защиты Паролем
Теперь пришло время добавить тест на защиту паролем.
Я начну с того, что опишу “спецификацию” моего теста на псевдокоде:
@Test
public void passwordProtect() {
//1. Я создаю новую доску
//2. Я защищаю ее паролем
//3. Джо пытается открыть мою доску, его просят ввести пароль
//4. Джо вводит неправильный пароль и ему отказывают в доступе
//5. Джо пробует снова, вводит правильный пароль и получает доступ
}
И теперь, как и раньше, я пишу тестовый код, притворившись, что у класса AcceptanceTestClient уже есть все, что мне нужно. Эта методика чрезвычайно полезна.
@Test
public void passwordProtect() {
//1. Я создаю новую доску
myClient.createNewWhiteboard();
String whiteboardId = myClient.getCurrentWhiteboardId();
//2. Я устанавливаю на нее пароль
myClient.protectWhiteboard("bigsecret");
//3. Джо пытается открыть мою доску, его просят ввести пароль
try {
joesClient.openWhiteboard(whiteboardId);
fail("Expected WhiteboardProtectedException");
} catch (WhiteboardProtectedException err) {
// Хорошо
}
assertFalse(joesClient.hasWhiteboard());
//4. Джо вводит неправильный пароль и ему отказывают в доступе
try {
joesClient.openProtectedWhiteboard(whiteboardId, "wildguess");
fail("Expected WhiteboardProtectedException");
} catch (WhiteboardProtectedException err) {
// Хорошо
}
assertFalse(joesClient.hasWhiteboard());
//5. Джо пробует снова, вводит правильный пароль и получает доступ
joesClient.openProtectedWhiteboard(whiteboardId, "bigsecret");
assertTrue(joesClient.hasWhiteboard());
}
Я потратил всего несколько минут на то, чтобы написать этот код, потому что я просто придумывал то, что мне было нужно, по ходу написания. Почти ни одного из этих методов нет в классе AcceptanceTestClient (пока нет).
Пока я писал код, мне уже пришлось принять несколько решений. Не нужно думать слишком усердно, просто делайте то, что первое приходит в голову. Лучшее — враг хорошего, и сейчас все, чего я хочу — это получить достаточно хороший результат, то есть тест, который можно запустить и который упадет. Позже, когда тест станет зеленым, я отрефакторю свой код и подумаю более тщательно над тем, как улучшить его дизайн.
Есть большой соблазн начать причесывать код прямо сейчас, особенно отрефакторить эти ужасные операторы try/catch. Но один из законов TDD — сделать тест зеленым до начала рефакторинга, тесты будут защищать вас, когда вы будете рефакторить. Так что я решил повременить с причесыванием кода.
Шаг 3 – Добиться, чтобы приемочный тест запустился и упал
Следуя треугольнику тестирования, мой следующий шаг — добиться, чтобы мой тест запустился и упал.
Я снова пользуюсь горячими клавишами Eclipse, чтобы создать пустые методы. Отлично. Запускаем тест и вуаля, он Красный!
Шаг 4: Сделать приемочный тест зеленым
Теперь мне придется написать продуктовый код. Я добавляю несколько новых сущностей в систему. Иногда код, который я добавлял, был довольно нетривиальным, так что его нужно было покрыть юнит тестами. Я делал это при помощи TDD. Это тоже самое, что ATDD, но в меньшем масштабе.
Вот как ATDD и TDD работают вместе. Считайте, что ATDD — это внешний цикл:
Для каждого цикла написания приемочного теста (на уровне новой функциональности), мы делаем несколько циклов написания юнит тестов (на уровне классов и методов).
Так что, несмотря на то, что на высоком уровне я сфокусирован на том, чтобы сделать мой приемочный тест Зеленым (на что может потребоваться нескольких часов), на низком уровне я занят тем, что, например, делаю мой следующий юнит тест Красным (что обычно занимает несколько минут).
Это не совсем хардкорный “TDD с кожаной плеткой”. Это больше похоже на “по меньшей мере убедись, что юнит тесты и production код зачекинены вместе”. И такой чекин происходит несколько раз в час. Можете это называть “в духе TDD” :o).
Шаг 5 Почистить код
Как обычно, как только приемочный тест стал зеленым, время заняться уборкой. Никогда не экономьте на этом! Это примерно как мыть посуду после еды — лучше это сделать сразу.
Я чищу не только production код, но и код тестов. Например, я выделил грязноватый try-catch во вспомогательный метод, и получился чистый и опрятный тестовый метод:
@Test
public void passwordProtect() {
myClient.createNewWhiteboard();
String whiteboardId = myClient.getCurrentWhiteboardId();
myClient.protectWhiteboard("bigsecret");
assertCantOpenWhiteboard(joesClient, whiteboardId);
assertCantOpenWhiteboard(joesClient, whiteboardId, "wildguess");
joesClient.openProtectedWhiteboard(whiteboardId, "bigsecret");
assertTrue(joesClient.hasWhiteboard());
}
Моя цель — сделать приемочный тест настолько коротким, чистым и читабельным, что коментарии становятся излишними. Первоначальный псевдокод и комментарии выполняют только роль шаблона — “вот каким чистым должен быть код!”. Удаление комментариев дает ощущение победы, а в качестве бонуса делает метод еще короче!
Что дальше?
Повторяйте. Как только я получил первый работающий тест, я подумал о том, чего еще не хватает. Например, вначале я говорил, что только залогиненный пользователь может защитить доску паролем. Так что я добавил тест на это, сделал его красным, потом зеленым, а потом почистил код. И так далее.
Вот полный список тестов, которые я сделал для этой функциональности (пока что):
- passwordProtectionRequiresAuthentication
- protectWhiteboard
- passwordOwnerDoesntHaveToKnowThePassword
- changePassword
- removePassword
- whiteboardPasswordCanOnlyBeChangedByThePersonWhoSetIt
Наверняка я добавлю еще несколько тестов позже, если я обнаружу баги или придумаю новые сценарии использования.
В общем и целом, это заняло примерно 2 дня кодирования. Большую часть времени я провёл, возвращаясь к ранее написанному коду и дизайну, а вовсе не так линейно как это может показаться, читая эту статью.
Как насчет ручного тестирования?
Разумеется, я делал достаточно много ручного тестирования после того, как получил зеленые приемочные тесты. Но поскольку автоматические приемочные тесты покрывают как основную функциональность так и много специальных случаев, я мог сфокусироваться на более субъективном и исследовательском тестировании. Как насчет общего впечатления пользователя? Имеет ли смысл эта последовательность действий? Легко ли ее понять? Куда лучше добавить поясняющий текст? Хорош ли дизайн с эстетической точки зрения? Я не собираюсь выигрывать никаких наград по дизайну, но я и не хочу чего-то монументально уродливого.
Мощный набор автоматических приемочных тестов избавляет от скучного монотонного ручного тестирования (известного как “обезьянье тестирование”), и освобождает время для более интересного и значимого типа ручного тестирования.
В идеале мне бы следовало начать с автоматических приемочных тестов с самого начала, так что отчасти я вернул немного технического долга.
Ключевые моменты
Надеюсь, этот пример был вам полезен! Он демонстрирует довольно типичную ситуацию — “Я хочу добавить новую фичу, и было бы круто написать на нее автоматический приемочный тест, но в проекте пока нет ни одного приемочного теста, и я не знаю какой фреймворк использовать и с чего стоит начать”.
Я очень люблю этот шаблон, он позволял мне сдвинуться с мертвой точки много раз. В итоге:
- Притворитесь, что у вас уже есть превосходный фреймворк, инкапсулированный в действительно удобный вспомогательный класс (в моем случае AcceptanceTestClient).
- Напишите очень простой приемочный тест для того, что уже работает сегодня (например простое открытие вашего приложения). Используйте этот тест для того, чтобы написать классы вроде AcceptanceTestClient и связанную с ними обвязку теста (такую, как подмена настоящей базы данных или других внешних сервисов).
- Напишите приемочный тест для вашей новой функциональности. Добейтесь чтобы он выполнялся, но падал.
- Сделайте тест зеленым. По мере написания кода, пишите юнит тесты для любого более-менее сложного кода.
- Рефакторьте. И, может быть, напишите еще несколько юнит тестов для того, чтобы улучшить метрику, или наоборот – удалите лишние тесты или код. Держите код чистым, как яйца у кота!
Как только вы это сделали, вы преодолели самый трудный барьер. Вы начали применять ATDD!
Об авторе
Henrik Kniberg — Agile/Lean консультант из компании Crisp в Стокгольме, в основном работающий на Spotify. Он получает удовольствие от того, что помогает компаниям добиваться успеха как в технической, так и в человеческой сторонах разработки программного обеспечения, как описано в его популярных книгах “Scrum and XP from the Trenches”, “Kanban and Scrum, making the most of both” и “Lean from the Trenches“.
Перевели Александр Андронов, Антон Бевзюк и Дмитрий Павлов Smart Step Group.
Автор: bevzuk