Test-Driven Development: как полюбить модульное тестирование

Как и многие, я, как мог, сопротивлялся Test-Driven Development. Я не понимал, почему тесты нужно писать перед реализацией. Почему мы должны выворачивать разработку наизнанку и переворачивать естественный, как мне тогда казалось, процесс, с ног на голову.

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

И об этом мы поговорим в статье ниже.

Мы разберём страхи, останавливающие разработчика перед тем, чтобы начать, наконец, писать тесты. Выявим очевидные преимущества. Рассмотрим основные правила разработки через тестирование. И подкрепим всё это реальными примерами.

Какой ты разработчик? ツ

Какой ты разработчик? ツ

С чего начать?

Лично мой путь погружения в разработку через тестирование начался с книг Роберта Мартина. Первые упоминания о TDD появились в »Идеальном программисте», где Роберт Мартин упомянул такой подход вскользь. Ну, а уж в »Идеальной работе» он отыгрался по полной, посвятив этому подходу чуть ли не половину книги.

Ещё более весомый вклад в продвижение разработки через тестирование внесла книга Кента Бека «Экстремальное программирование. Разработка через тестирование». Именно благодаря ей Кент Бек по праву считается основоположником этого подхода.

Начните с этих книг. Чёткий слог изложения Кента Бека и позитивный настрой Роберта Мартина воодушевят вас попробовать этот подход.

Начинать страшно

Страх первый. Придётся переучиваться программированию.

Да, разработка через тестирование существенно меняет саму парадигму программирования, по сути, переворачивая его с ног на голову. Но на самом деле (внезапно) разработка через тестирование делает написание кода ещё более естественным, чем при обычном подходе.

Смотрите.

У нас есть техническое задание. Аналитик описал его в Jira, а может быть, в Confluence. Так или иначе, техническое задание написано человеческим языком и адресовано непосредственно разработчику. Но что, если бы мы могли адресовать техническое задание напрямую исполняемому коду? Чтобы техническое задание влияло напрямую на код и, к тому же, постоянно его валидировало?

Таким техническим заданием являются тесты.

Тесты описывают, как именно должна работать та часть кода, которую они тестируют. Тесты контролируют выполнение кода именно таким образом, как им нужно. Если мы способны подробно описать поведение приложения при помощи тестов — будьте уверены — выполнение кода будет соответствовать техническому заданию.

Тесты валидируют исполнение технического задания

Тесты валидируют исполнение технического задания

Не бойтесь того, что вам придётся писать код якобы неестественным путём. Бойтесь того, что ваш код не будет соответствовать техническому заданию.

Страх второй. Придётся постоянно писать и поддерживать кучу тестов.

С этим сложно поспорить, и это действительно будет так. В проектах, покрытых тестами, доля тестов обычно занимает десятки процентов кодовой базы. И их действительно придётся постоянно дописывать и поддерживать. Это долго, нудно и тяжело (на самом деле, нет).

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

Представим себе типичный проект, не покрытый тестами. В работоспособности некоторых функций мы уверены. Работоспособность некоторых нам не ведома. Некоторые функции не работоспособны, на них уже заведены баги.

Когда приложение не покрыто тестами, ты не знаешь, насколько оно рабочее.

Когда приложение не покрыто тестами, ты не знаешь, насколько оно рабочее.

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

Та самая армия тестов

Та самая армия тестов

Не бойтесь того, что вам придётся поддерживать армию тестов. Бойтесь того, что ваш код не будет защищён.

Страх третий. Посвятив себя написанию тестов, можно не уложиться в спринт.

Действительно, может показаться, что написание тестов занимает кучу времени. Но давайте посчитаем.

Написание одного теста лично у меня занимает в среднем 5 минут. Соответственно, написание десяти тестов займёт около 50 минут.

Предположим, что только один тест из десяти написанных был написан не зря и вовремя поймал баг, который вполне мог оказаться на тестовом стенде. Итого, на предотвращение одного бага мы потратили 50 минут. Не многовато ли?

50 минут на один баг. Не много ли?

50 минут на один баг. Не много ли?

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

По какому алгоритму будет происходить лечение этого бага?

Тестировщику потребуется какое-то время, чтобы обработать баг. Ему нужно удостовериться, что это баг. Проверить, что он точно воспроизводится. Уточнить что-то у аналитика или разработчика. Оформить баг, приложив все данные. На это всё у тестировщика уйдёт минимум полчаса (а обычно — больше).

Далее, баг берёт в работу разработчик. Разработчику необходимо воспроизвести баг локально. Если потребуется, уточнить что-то у аналитика. Возможно, получить дополнительную информацию от тестировщика. Понять причину бага. Исправить его. Задеплоиться на стенд. На всё это у разработчик потратить минимум два часа. Прибавьте к этому возможный повторный пулл-реквест, код-ревью и мёрдж в develop.

После этого, тестировщик берёт задачу в работу. Убеждается, что баг исправлен, и закрывает его. Ещё полчаса.

3 часа на баг, который вырвался на стенд

3 часа на баг, который вырвался на стенд

Итого, при самых скромных подсчётах, лечение одного бага занимает около 3 часов. А написание 10 тестов заняло бы 50 минут.

Не спрашивайте, где взять время. Возьмите его у себя. Напишите тесты.

Но каковы преимущества такого подхода?

Любое улучшение нужно делать ради конкретной выгоды. И если со страхами всё более-менее понятно, то выгода от перехода к разработке через тестирование всё ещё кажется весьма сомнительной.

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

Разберём основные преимущества разработки через тестирование.

Смелость

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

Живая документация

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

Архитектура

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

Снижение плотности дефектов

Результатом первых трёх преимуществ является преимущество четвёртое: множество компаний, самых разных, сообщают о снижении количества дефектов в 2, 5, 10 и более раз. Мы, как профессионалы, не можем игнорировать такие показатели.

Три закона Test-Driven Development

Итак, что же такое Test-Driven Development? Подход опирается на три закона, которые и определяют шаги разработки.

Закон первый. Сначала тест, потом функциональность.

Главный закон разработки через тестирование заключается в том, что мы не пишем основной код в пакете main, пока не напишем модульный тест, который его проверяет. Вы спросите:, но как же мы тогда напишем тест? Ведь он будет вызывать функцию, которая не существует.

Решение этой проблемы лежит на поверхности. Если вы читали мою статью «Приложение от проекта до релиза: этапы реализации», вы знаете ответ. Если вдруг не читали, рекомендую к прочтению. Вызывать функцию, которая ещё не реализована, нам помогают интерфейсы.

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

Закон второй. Один тест проверяет только одну функцию.

Каждая функция должна быть протестирована отдельно от остальных. Различные сценарии в рамках одной функции должны быть протестированы отдельно друг от друга. На каждый такой сценарий пишется свой собственный тест.

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

Закон третий. Функция должна обеспечить только прохождение своего теста.

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

Иными словами, вы пишете код только тогда, когда у вас есть тест, который не проходит. Как только тест начинает проходить, вы не пишете код. Для того, чтобы писать код дальше, вам придётся написать тест, который не проходит.

Всё просто.

Перейдём к практике.

Для того, чтобы вышеописанные тезисы не пропали даром, их необходимо закрепить практикой. Что мы и сделаем. Мы создадим приложение и реализуем функции одного из сервисов при помощи Test-Driven Development.

Подготовка проекта.

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

  1. Проектирование предметной области (бизнес-сущности).

  2. Реализация логики приложения через сервисы.

  3. Подключение к внешним интерфейсам через слой DAO.

Проектирование предметной области

Что ж, давайте спроектируем нашу предметную область. Для простоты примера, пусть это будет одна-единственная бизнес-сущность, и называться она будет Process.

data class Process(
    val id: UUID? = null,
    val state: ProcessState,
    val createdAt: OffsetDateTime? = null,
    val updatedAt: OffsetDateTime? = null
)

У Process будет минимальное количество полей: три служебных (id, createdAt, updatedAt) и одно бизнесовое (state). Служебные поля, поскольку они являются атрибутами хранилища данных, будут заполняться в репозитории. Бизнес-поле будет заполняться в приложении.

Проектирование сервисного слоя

Наш сервис будет содержать в себе стандартный набор CRUD-операций. Конечно же, на данном этапе это будет интерфейс.

interface ProcessService {

    fun save(process: Process): Process

    fun update(process: Process)

    fun get(id: UUID): Process

    fun delete(id: UUID)
}

Мы можем сразу создать ProcessServiceImpl, но и это не обязательно:

@Service
class ProcessServiceImpl(
    private val repository: ProcessRepository
) : ProcessService {

    override fun save(process: Process): Process {
        TODO("Not yet implemented")
    }

    override fun update(process: Process) {
        TODO("Not yet implemented")
    }

    override fun get(id: UUID): Process {
        TODO("Not yet implemented")
    }

    override fun delete(id: UUID) {
        TODO("Not yet implemented")
    }
}

Этого достаточно, чтобы приступить к разработке через тестирование.

В нашем примере присутствует всего одна сущность, но вот вам совет: всегда начинайте реализовывать логику самой независимой сущности.

Если бы в нашем проекте была вторая сущность, предположим, ProcessVersion, которая была бы связана с сущностью Process связью один-ко-многим и имела бы на неё обратную ссылку, мы бы всё равно начали разрабатывать логику с сущности Process, поскольку она полностью независима, и мы можем создавать её экземпляры без оглядки на ProcessVersion.

Если бы мы начали с ProcessVersion, то для создания этой сущности нам бы пришлось предварительно создать Process, и мы всё равно пришли бы к реализации Process.

Этапы реализации созависимых сервисов тоже описаны в моей статье.

Мы начнём разработку с теста на функцию save. После прохождения этого теста, у нас будет реализована функциональность, позволяющая сохранять наш процесс где бы то ни было. Это позволит нам написать тесты на функции get, update и delete, требующие уже сохранённый в базе объект.

Жизненный цикл исполнения теста.

Любой тест, явно или неявно, должен содержать в себе 4 этапа:

  1. Подготовка окружения.

  2. Вызов тестируемой функции.

  3. Проверка результата.

  4. Очистка данных.

Этап первый. Подготовка окружения.

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

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

Генератор пригодится везде, где только можно

Генератор пригодится везде, где только можно

А поскольку сущность является ключевой, генерировать её мы будем постоянно.

@Component
class ProcessGenerator {

    fun generate(): Process = Process(
        state = ProcessState.entries.random()
    )
}

В нашем примере, при генерации процесса нам потребуется проинициализировать поле state случайным значением.

По итогам подготовки, наш тест будет выглядеть так:

@Test
fun save() {
    //prepare
    val process = processGenerator.generate()
}

Перейдём ко второму этапу.

Этап второй. Вызов тестируемой функции.

Здесь мы просто вызываем тестируемую функцию:

@Test
fun save() {
    //prepare
    val process = processGenerator.generate()
    //when
    processService.save(process)
}

Этап 3. Проверка результата.

На этом этапе, нам важно проверить результат на соответствие ожидаемому. Это ключевой момент в жизни любого теста. Мы проверим, что сохранённому процессу присвоены идентификатор и время сохранения, а состояние совпадает со сгенерированным на этапе 1:

@Test
fun save() {
    //prepare
    val process = processGenerator.generate()
    //when
    processService.save(process)
        .also { saved ->
            //then
            assertNotNull(saved.id)
            assertNotNull(saved.createdAt)
            assertEquals(process.state, saved.state)
        }
}

Этап 4 мы реализуем централизованно чуть позже.

Итак, мы написали тест. Теперь, когда он будет запущен, он ожидаемо упадёт. И в этом нет ничего аномального. Ведь любой тест должен соответствовать принципу «Красный — Зелёный — Рефакторинг».

Принцип «Красный — Зелёный — Рефакторинг».

Как и любой продукт (даже цифровой), тест проходит свои этапы созревания. Этих этапов три. И каждый из них — обязателен (за некоторыми исключениями).

4d636d3179c522aea7b19c3c725626cf.png

Этап «Красный».

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

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

Этап «Зелёный».

После того, как наш тест упал, мы переходим в пакет main и пишем реализацию. После этого наш тест перестаёт падать и становится «зелёным».

Этап «Рефакторинг».

Согласно Третьему Правилу TDD, мы должны перейти к написанию следующего теста. Но это рекомендуется делать не сразу. Возьмите паузу и порефачьте код. Сразу после реализации функции намного проще её причесать, чем потом.

Итак, наш тест упал. Перейдём в пакет main и посмотрим, что можно сделать, чтобы его починить.

Реализация тестируемой функции.

Мы переходим в ProcessServiceImpl и видим такую картину:

override fun save(process: Process): Process {
    TODO("Not yet implemented")
}

Для того, чтобы наш тест стал «зелёным», необходимо, чтобы функция save возвращала Process с заполненными id и createdAt. Для этого нужно передать process в репозиторий и получить оттуда сохранённый объект.

Давайте напишем такую реализацию:

override fun save(process: Process): Process = repository.insert(process)

Но есть проблема. У нас нет никакого репозитория, и уж тем более, базы данных, и мы даже не знаем, какими они будут. Ведь такие вещи Роберт Мартин рекомендует оставлять на потом. А нужны они нам уже сейчас. Как быть?

В такой ситуации уместно будет воспользоваться mock-репозиторием и mock-хранилищем. Репозиторий мы разместим сразу в пакете test, потому что в реальной разработке он нам никогда не потребуется. Хранилищем будет стандартная хэш-таблица, идентификатором в качестве ключа и объектом в качестве значения.

Mock-реализации спасут нас на этапе тестового покрытия сервисов

Mock-реализации спасут нас на этапе тестового покрытия сервисов

Выглядеть такой репозиторий будет так:

@Component
class ProcessRepositoryMock(
    private val processStorageMock: MutableMap
) : ProcessRepository {

    override fun insert(process: Process): Process =
        process.copy(id = UUID.randomUUID(), createdAt = OffsetDateTime.now())
            .also { processStorageMock[it.id!!] = it }
}

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

Запускаем наш тест. Тест проходит. Ура.

Третий шаг реализации теста — »Проверка результата» — происходит сам собой. Но мы не реализовали четвёртый шаг — »Очистку данных».

Этап 4. Очистка данных.

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

@Autowired
private lateinit var processStorageMock: MutableMap

@AfterEach
fun cleanUp() {
    processStorageMock.clear()
}

Теперь после каждого теста наша mock-база данных будет очищаться.

Что мы сделали?

Мы написали один маленький тест и сделали его «Зелёным». Мы написали очень важный для тестирования генератор данных, а также, mock-репозиторий.

Но произошло ещё кое-что действительно важное, на чём мы не акцентировали внимание. Попутно, мимоходом, мы написали реализацию функции ProcessService.save. И все остальные функции так же, попутно, будут реализовываться. Мы лишь будем писать тесты и делать их «Зелёными».

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

Это и есть разработка через тестирование.

Заключительные тезисы.

Mock-компоненты.

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

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

Генераторы.

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

Разрабатывая через тестирование, мы постоянно будем писать тесты. А значит, нам постоянно будут нужны генераторы данных. И если вы их не пишете, значит, вы не хотите писать тесты и втайне надеетесь, что напишете тесты один раз и забудете про них. Не обманывайте себя.

По традиции, выкладываю код с примерами:

https://github.com/promoscow/tdd-habr

Обратите внимание, что в git-логе есть 4 коммита, по количеству написанных тестов:

  • Шаг 1. Пишем тест на функцию save() + генератор + mock-репозиторий + mock-хранилище.

  • Шаг 2. Добавляем тест на функцию get().

  • Шаг 3. Добавляем тест на функцию update().

  • Шаг 4. Добавляем тест на функцию delete().

Откатиться на любой коммит можно через Reset Current Branch To Here... -> Hard.

Если некогда читать…

…у меня есть видео с конференции IT Community Day, которая прошла 12 октября 2024 года в Казани. Практически полный пересказ данной статьи с live-кодингом.

Запись доклада также есть и на YouTube.

На этом, пока всё. Пишите тесты, реализуйте прекрасные фичи и наслаждайтесь своей работой.

© Habrahabr.ru