Pull to refresh

Перевод статьи Хенрика Книберга «ATDD from Trenches» (ATDD с передовой)

Reading time 13 min
Views 17K
Оригинал: www.infoq.com/articles/atdd-from-the-trenches

ATDD с передовой


Разработка через приемочное тестирование для начинающих

image

Если вы когда-нибудь бывали в такой ситуации:

Тогда эта статья для вас — конкретный пример того, как начать разработку через приемочные тесты (Acceptance-test driven development) в действующих проектах с легаси кодом. В ней описан один из способов решения проблемы технического долга.
Это пример из реального проекта, со всеми изъянами и недостатками, а не отполированное упражнение из книги. Так что надевайте свои берцы. Я буду использовать Java и JUnit, без всяких модных сторонних библиотек (которыми, как правило, злоупотребляют).
Предупреждение: Я не утверждаю, что это единственный Правильный Путь, существует много других “стилей” ATDD. Так же в этой статье не так много чего-то нового и инновационного, здесь просто описаны хорошо себя зарекомендовавшие подходы и опыт из первых рук.

Что я хотел сделать

Несколько дней назад я начал делать защиту паролем для webwhiteboard.com (моего проекта — хобби). Пользователи уже давно просят добавить возможность защитить паролем виртуальные доски, так что настало время это сделать.
На словах звучит просто, но на самом деле нужно сделать довольно много изменений в дизайне. Пока что предполагалось, что webwhiteboards.com используется анонимными пользователями, без всяких логинов и паролей. Кто должен иметь возможность защитить доску паролем? Кто сможет получить к ней доступ? Что если я забуду пароль? Как реализовать это простым, но в то же время достаточно надежным способом?
Код webwhiteboard хорошо покрыт юнит тестами и интеграционными тестами.
Но приемочные тесты, то есть тесты, проходящие через все слои с точки зрения конечного пользователя, полностью отсутствуют.

Рассмотрим дизайн

Главная цель дизайна webwhiteboard — простота: минимизировать необходимость ввода пароля, не создавать учетные записи, поменьше прочих раздражителей. Так что я установил два ограничения на доску, защищенную паролем:
  • Анонимный пользователь не может защитить доску паролем. Но он может открыть уже защищенную доску. Ему не нужно будет входить в систему, а нужно только ввести пароль защищенной доски.
  • Управлять логинами и паролями будет внешний OpenId/Oauth компонент, первоначально предполагался Google. Таким образом, пользователю не придется создавать еще одну учетную запись.


Подход к реализации

Здесь много неопределенности. Я не знал, как это должно работать, не говоря уже о том, как это реализовать. Вот что я решил сделать (собственно ATDD):
  • Шаг 1. Задокументировать предпологаемый процесс
  • Шаг 2. Превратить его в запускаемый приемочный тест
  • Шаг 3. Запустить приемочный тест, убедиться что он упал
  • Шаг 4. Починить приемочный тест
  • Шаг 5. Почистить код

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

Шаг 1: Задокументировать предпологаемый процесс

Представим, что функционал Готов. Будто ангел спустился с небес и сделал все, пока я спал. Звучит слишком хорошо, чтобы быть правдой! Как мне проверить, что работа уже сделана? Какой сценарий проверить первым? Давайте этот:
  1. Я создаю новую доску
  2. Устанавливаю на нее пароль
  3. Джо пытается открыть мою доску, система спрашивает пароль
  4. Джо вводит неправильный пароль, доступ запрещен
  5. Джо пробует еще раз, вводит правильный пароль и получает доступ. (Надо понимать, что “Джо” — это я сам, просто из другого браузера).

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

Шаг 2: Превратить его в запускаемый приемочный тест

Это не так-то просто. Других приемочных тестов нет, так с чего же мне начать? Новая функциональность будет взаимодействовать с некоторой внешней компонентой, отвечающей за аутентификацию (сначала я решил использовать Janrain). Еще будет база данных и куча непростых веб штучек с всплывающими диалоговыми окнами, токенами, переходами между страницами и всякое такое. Уфф.
Пора сделать шаг назад. Прежде чем решать проблему “как мне написать приемочный тест”, мне нужно решить более простую проблему “как вообще писать приемочные тесты с существующим кодом”?
Чтобы ответить на этот вопрос, я сначала напишу тест на “самый простой сценарий” их тех, которые уже есть в системе.

Шаг 2.1 Написать самый простой автоматический приемочный тест

Вот сценарий, с которого я начал:
  1. Попытаться открыть несуществующую доску
  2. Проверить, что я не могу ее увидеть

Как написать такой тест? С помощью какого фреймворка? Каких инструментов? Следует ли мне тестировать через пользовательский интерфейс или нет? Следует ли мне включать в тестирование клиентский код или напрямую вызывать сервис?
Куча вопросов. Трюк: не отвечайте на них! Просто притворитесь что все уже магически сделано и просто напишите тест на псевдокоде. Например:
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 дня кодирования. Большую часть времени я провёл, возвращаясь к ранее написанному коду и дизайну, а вовсе не так линейно как это может показаться, читая эту статью.

Как насчет ручного тестирования?

Разумеется, я делал достаточно много ручного тестирования после того, как получил зеленые приемочные тесты. Но поскольку автоматические приемочные тесты покрывают как основную функциональность так и много специальных случаев, я мог сфокусироваться на более субъективном и исследовательском тестировании. Как насчет общего впечатления пользователя? Имеет ли смысл эта последовательность действий? Легко ли ее понять? Куда лучше добавить поясняющий текст? Хорош ли дизайн с эстетической точки зрения? Я не собираюсь выигрывать никаких наград по дизайну, но я и не хочу чего-то монументально уродливого.
Мощный набор автоматических приемочных тестов избавляет от скучного монотонного ручного тестирования (известного как “обезьянье тестирование”), и освобождает время для более интересного и значимого типа ручного тестирования.
В идеале мне бы следовало начать с автоматических приемочных тестов с самого начала, так что отчасти я вернул немного технического долга.

Ключевые моменты

Надеюсь, этот пример был вам полезен! Он демонстрирует довольно типичную ситуацию — “Я хочу добавить новую фичу, и было бы круто написать на нее автоматический приемочный тест, но в проекте пока нет ни одного приемочного теста, и я не знаю какой фреймворк использовать и с чего стоит начать”.
Я очень люблю этот шаблон, он позволял мне сдвинуться с мертвой точки много раз. В итоге:
  1. Притворитесь, что у вас уже есть превосходный фреймворк, инкапсулированный в действительно удобный вспомогательный класс (в моем случае AcceptanceTestClient).
  2. Напишите очень простой приемочный тест для того, что уже работает сегодня (например простое открытие вашего приложения). Используйте этот тест для того, чтобы написать классы вроде AcceptanceTestClient и связанную с ними обвязку теста (такую, как подмена настоящей базы данных или других внешних сервисов).
  3. Напишите приемочный тест для вашей новой функциональности. Добейтесь чтобы он выполнялся, но падал.
  4. Сделайте тест зеленым. По мере написания кода, пишите юнит тесты для любого более-менее сложного кода.
  5. Рефакторьте. И, может быть, напишите еще несколько юнит тестов для того, чтобы улучшить метрику, или наоборот – удалите лишние тесты или код. Держите код чистым, как яйца у кота!

Как только вы это сделали, вы преодолели самый трудный барьер. Вы начали применять 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“.

Перевели Александр Андронов (@alex4Zero), Антон Бевзюк (@bevzuk) и Дмитрий Павлов
Smart Step Group.
Tags:
Hubs:
+15
Comments 6
Comments Comments 6

Articles