Правильное TDD

в 13:39, , рубрики: kotlin, tdd, тестирование, Тестирование IT-систем

Привет! На написание этого поста меня вдохновил другой пост TDD есть опиум для народа, где обсуждаются спорные моменты в подходе TDD и в принципе делается вывод о его несостоятельности (хотя и признается необходимость тестов в любом случае). С автором я был полностью согласен... раньше, пока не понимал действительную суть TDD. Поэтому, я счел своим долгом рассказать суть Test Driven Development от лица человека, который пробовал писать тесты до реализации, разочаровался из-за сложностей, и только через некоторое время, уловив основную мысль, увидевшего новые возможности в разработке через тестирование.

Замечание: я всего лишь junior, и опыта разработки у меня не так много. Но надеюсь, что мне удастся донести мысль до читателя, а ошибки помогут исправить в комментариях. Примеры будут на Kotlin, мне кажется, это не должно стать помехой, язык достаточно хорошо читаемый. Несмотря на довод о слишком упрощенных примерах (наподобие калькулятора) в оригинальной статье, здесь я также не иду в дебри реальных повседневных задач, ограничиваясь кустарным примером. Да простит меня читатель, я не хочу заставлять сильно вникать в код, хочу просто объяснить идею.

Unit тесты

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

"The adding of a new feature begins by writing a test that passes if and only if the feature's specifications are met. The developer can discover these specifications by asking about use cases and user stories."

Beck, Kent. Test-Driven Development by Example

На самом деле TDD тесно связано с Behaviour Driven Developmentс той лишь разницей, что BDD идет на шаг дальше, описывая требования бизнеса на формальном языке. Таким образом когда мы пишем тест до реализации условного "калькулятора", мы не проверяем, что класс Calculator возвращает 4 при вызове метода add(2, 2). Мы проверяем, что система возвращает 4 при аргументах 2 и 2. Модуль Calculator внутри может вообще делегировать сложение другому компоненту по http, или отправлять запрос в очередь и ждать ответа, или запросить результат хранящийся в таблице базы данных, нам это не важно. Если стоит требование реализовать сложение двух чисел, мы пишем тест по принципу черного ящика, проверяя только входные и выходные параметры.

Пример

Для иллюстрации стандартного цикла разработки через TDD, представим заказчика, которому нужно приветствовать новых пользователей. Требуется при вводе имени, чтобы сервис возвращал строку "Welcome, ${имя}!".

Итак, с чего начать? С архитектуры! Удобнее всего для TDD, мне кажется, подходит Ports and Adapters (Hexagonal architecture). Объяснение сути архитектуры выходят за рамки данной статьи, однако из картинки должно быть понятно:

Место Application займет модуль Greeting
Место Application займет модуль Greeting

Для тестирования будем использовать JUnit5 и Assertj. Напишем первый тест. Все согласно требованиям: на вход поступает имя, а на выходе проверяем результат. При этом тест даже не компилируется.

package com.habr.domain

import org.assertj.core.api.Assertions.assertThat
import kotlin.test.Test

internal class CreateGreetingByNameUseCaseTest {

    private val useCase: CreateGreetingByNameUseCase = GreetingService()

    @Test
    fun `given name Bob, when create greeting, then expect greeting with name Bob`() {
        assertThat(useCase.createGreeting("Bob")).isEqualTo("Welcome, Bob!")
    }
}

Подготовим структуру проекта. Пакет domain - здесь находится бизнес логика, port - точки входа и выхода (интерфейсы) в модуль. Для того, чтобы тест скомпилировался, нужно добавить интерфейс и его реализацию:

package com.habr.port

interface CreateGreetingByNameUseCase {

    fun createGreeting(name: String): String
}
package com.habr.domain

import com.habr.port.CreateGreetingByNameUseCase

class GreetingService : CreateGreetingByNameUseCase {

    override fun createGreeting(name: String) = ""
}

Следующий шаг: запуск тестов. Очевидно, что будет ошибка, так как вместо приветствия, сервис возвращает пустую строку. Теперь необходимо добавить реализацию:

package com.habr.domain

import com.habr.port.CreateGreetingByNameUseCase

class GreetingService : CreateGreetingByNameUseCase {

    override fun createGreeting(name: String) = "Welcome, ${name}!"
}

Отлично, модуль приветствий работает как надо! Однако появляется новое требование от заказчика: необходимо отправлять получившееся приветствие в другой микросервис (это всего лишь пример, опустим вопрос "Зачем так нужно делать?"). Добавляем новый тест на появившееся бизнес правило:

package com.habr.domain

import com.habr.port.CreateGreetingByNameUseCase
import org.assertj.core.api.Assertions.assertThat
import kotlin.test.Test

internal class CreateGreetingByNameUseCaseTest {

    private val httpResource = FakeHttpResource()
    private val useCase: CreateGreetingByNameUseCase = GreetingService()

    @Test
    fun `given name Bob, when create greeting, then expect greeting with name Bob`() {
        assertThat(useCase.createGreeting("Bob")).isEqualTo("Welcome, Bob!")
    }

    @Test
    fun `given some name, when create greeting, then send greeting to resource`() {
        useCase.createGreeting("Sam")

        assertThat(httpResource.received).contains("Welcome, Sam!")
    }
}

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

package com.habr.port

interface HttpResource {

    fun send(greeting: String)
}
package com.habr.stub

import com.habr.port.HttpResource

internal class FakeHttpResource() : HttpResource {

    val received: MutableCollection<String> = mutableListOf()

    override fun send(greeting: String) {
        received.add(greeting)
    }
}

Ожидаемо, после компиляции и запуска наш последний тест падает. Настало время непосредственной реализации отправки сформированного приветствия в другой ресурс. Изменим соответствующим образом класс GreetingService:

package com.habr.domain

import com.habr.port.CreateGreetingByNameUseCase
import com.habr.port.HttpResource

class GreetingService(private val httpResource: HttpResource) : CreateGreetingByNameUseCase {

    override fun createGreeting(name: String): String {
        val greeting = "Welcome, ${name}!"
        httpResource.send(greeting)
        return greeting
    }
}

После добавления новой зависимости на HttpResource в класс GreetingService снова перестали компилироваться тесты. Исправим создание в них GreetingService, добавив FakeHttpResource в конструктор:

package com.habr.domain

import com.habr.port.CreateGreetingByNameUseCase
import com.habr.stub.FakeHttpResource
import org.assertj.core.api.Assertions.assertThat
import kotlin.test.Test

internal class CreateGreetingByNameUseCaseTest {

    private val httpResource = FakeHttpResource()
    private val useCase: CreateGreetingByNameUseCase = GreetingService(httpResource)

    @Test
    fun `given name Bob, when create greeting, then expect greeting with name Bob`() {
        assertThat(useCase.createGreeting("Bob")).isEqualTo("Welcome, Bob!")
    }

    @Test
    fun `given some name, when create greeting, then send greeting to resource`() {
        useCase.createGreeting("Sam")

        assertThat(httpResource.received).contains("Welcome, Sam!")
    }
}

Отлично, еще один этап пройден, все тесты зеленые! Прилетает следующее требование заказчика (на этот раз последнее, обещаю): необходимо сохранять имя пользователя, чтобы можно было узнать дату и время его последнего приветствия. Согласно этому описанию пишем новый тест класс для нового Use Case:

package com.habr.domain

import com.habr.port.CreateGreetingByNameUseCase
import com.habr.stub.FakeHttpResource
import org.assertj.core.api.Assertions.assertThat
import java.time.Clock
import java.time.Instant
import java.time.ZoneId
import kotlin.test.Test

internal class GreetingLastTimestampUseCaseTest {

    private val clock = Clock.fixed(Instant.now(), ZoneId.systemDefault())
    private val createGreetingByNameUseCase: CreateGreetingByNameUseCase
    private val greetingLastTimestampUseCase: GreetingLastTimestampUseCase

    init {
        val greetingService = GreetingService(FakeHttpResource())
        createGreetingByNameUseCase = greetingService
        greetingLastTimestampUseCase = greetingService
    }

    @Test
    fun `given greeting was created, when last timestamp, then expected timestamp`() {
        createGreetingByNameUseCase.createGreeting("Alex")

        assertThat(greetingLastTimestampUseCase.lastTimestamp("Alex")).isEqualTo(clock.instant())
    }
}

Объявили GreetingLastTimestampUseCase для нового случая использования нашего модуля - получения даты и времени последнего приветствия. Также добавили часы, которые всегда возвращают одно и то же время, опять же - для удобства тестирования. Идем по протоптанной дорожке: пытаемся заставить тесты скомпилироваться, создав интерфейс GreetingLastTimestampUseCase и реализовать его в GreetingService:

package com.habr.domain

import com.habr.port.CreateGreetingByNameUseCase
import com.habr.port.GreetingLastTimestampUseCase
import com.habr.port.HttpResource
import java.time.Instant

class GreetingService(private val httpResource: HttpResource) : CreateGreetingByNameUseCase,
    GreetingLastTimestampUseCase {

    override fun createGreeting(name: String): String {
        val greeting = "Welcome, ${name}!"
        httpResource.send(greeting)
        return greeting
    }

    override fun lastTimestamp(name: String): Instant? = null
}

Тесты идут, но не проходят. Реализуем сохранение даты и времени приветствия через интерфейс репозитория GreetingTimestampRepository:

package com.habr.port

import java.time.Instant

interface GreetingTimestampRepository {

    fun save(name: String, timestamp: Instant)
    fun findByName(name: String): Instant?
}
package com.habr.domain

import com.habr.port.CreateGreetingByNameUseCase
import com.habr.port.GreetingLastTimestampUseCase
import com.habr.port.GreetingTimestampRepository
import com.habr.port.HttpResource
import java.time.Clock

class GreetingService(
    private val clock: Clock,
    private val httpResource: HttpResource,
    private val greetingTimestampRepository: GreetingTimestampRepository
) : CreateGreetingByNameUseCase,
    GreetingLastTimestampUseCase {

    override fun createGreeting(name: String): String {
        greetingTimestampRepository.save(name, clock.instant())
        val greeting = "Welcome, ${name}!"
        httpResource.send(greeting)
        return greeting
    }

    override fun lastTimestamp(name: String) = greetingTimestampRepository.findByName(name)
}

Реализация написана, но тесты запустить не можем, снова не компилируется. Нужно передать часы и репозиторий в конструктор GreetingService. Перед этим, по аналогии с HttpResource, создадим stub репозитория на основе hash map:

package com.habr.stub

import com.habr.port.GreetingTimestampRepository
import java.time.Instant

internal class FakeGreetingTimestampRepository : GreetingTimestampRepository {

    private val map = mutableMapOf<String, Instant>()

    override fun save(name: String, timestamp: Instant) {
        map[name] = timestamp
    }

    override fun findByName(name: String) = map[name]
}
package com.habr.domain

import com.habr.port.CreateGreetingByNameUseCase
import com.habr.port.GreetingLastTimestampUseCase
import com.habr.stub.FakeGreetingTimestampRepository
import com.habr.stub.FakeHttpResource
import org.assertj.core.api.Assertions.assertThat
import java.time.Clock
import java.time.Instant
import java.time.ZoneId
import kotlin.test.Test

internal class CreateGreetingByNameUseCaseTest {

    private val clock = Clock.fixed(Instant.now(), ZoneId.systemDefault())
    private val httpResource = FakeHttpResource()
    private val createGreetingByNameUseCase: CreateGreetingByNameUseCase
    private val greetingLastTimestampUseCase: GreetingLastTimestampUseCase

    init {
        val greetingService = GreetingService(clock, httpResource, FakeGreetingTimestampRepository())
        createGreetingByNameUseCase = greetingService
        greetingLastTimestampUseCase = greetingService
    }

    @Test
    fun `given name Bob, when create greeting, then expect greeting with name Bob`() {
        assertThat(createGreetingByNameUseCase.createGreeting("Bob")).isEqualTo("Welcome, Bob!")
    }

    @Test
    fun `given some name, when create greeting, then send greeting to resource`() {
        createGreetingByNameUseCase.createGreeting("Sam")

        assertThat(httpResource.received).contains("Welcome, Sam!")
    }

    @Test
    fun `given greeting was created, when last timestamp, then expected timestamp`() {
        createGreetingByNameUseCase.createGreeting("Alex")

        assertThat(greetingLastTimestampUseCase.lastTimestamp("Alex")).isEqualTo(clock.instant())
    }
}

Тесты проходят (за кадром осталось исправление класса CreateGreetingByNameUseCaseTest - просто добавить отсутствующие параметры в конструктор GreetingService), требуемый функционал реализован, заказчик доволен!

Итоги и выводы

В результате проделанной работы структура проекта стала выглядеть таким образом в Intellij IDEa:

Правильное TDD - 2

Чего сделано не было:

  • Не было написано некоторых "логичных" тестов. Например, что модуль вернет null при запросе даты и времени приветствия для имени, которому приветствие не формировалось вовсе. В реальности, не всегда все случаи очевидны, полная картинка взаимодействия вашего модуля с другими системами выстраивается постепенно, иногда решая возникающие баги.

  • Не было создано адаптеров, то есть компонентов, которые обращались бы к реальной базе данных или посылали http запрос. Это не совсем касается TDD в данном случае.

  • Нет интеграционных тестов. Опять же, по причине не соответствия теме и отсутствия адаптеров для наших портов.

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

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

В данном случае случае модуль покрыт тестами на 100% и нам это далось абсолютно бесплатно. Мы не писали кода больше, чем требуется для выполнении поставленной задачи (т.е. требования заказчика). Для успешного применения TDD необходимо, чтобы тесты были невероятно быстрыми, чтобы их запускать часто и делать маленькие шажки на пути к реализации логики. Поэтому я закрывал обращения к другим ресурсам stub-ами. Интеграционные тесты не подходят для TDD по причине их медлительности, мы не можем из запускать по нескольку раз в минуту. Однако никто не говорит, что они не нужны. Просто на этапе разработки ими пользоваться бесполезно, их задача в другом - на этапе сборки проекта убедиться, что система работает целиком в условиях максимально приближенным к реальным.

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

В данном примере тесты являются документацией. Наш сервис умеет:

  1. Отвечать приветствием по имени

  2. Отсылать приветствие во внешнюю систему

  3. Хранить дату и время запроса приветствия для конкретного имени и возвращать их по запросу

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

"Пишут тесты и не могут остановиться, пишут на каждый сеттер, геттер, покрывают вызов каждого исключения, ни единой строчки кода без теста. А, и что бы 2 раза не вставать замечу, что когда тесты покрывают код целиком и полностью, не оставляя живого места, любая попытка изменения функции или ее интерфейса приводит к дикому баттхёрту. И никакая самая продвинутая IDE не поможет менять вслед за кодом тесты легко и просто"

В данном примере показано, что при тесты придется переписывать, только при кардинальном изменении первоначальных требований (например, если поменяется шаблон приветствия с "Welcome, ${имя}" на "Hello, ${имя}"). Во всех других случаях бизнес логику трогать не надо:

  • если нужно будет сменить вендора базы данных с PostgreSQL на Mongo - мы перепишем адаптер (реализацию) порта (интерфейса) GreetingTimestampRepository

  • если нужно будет сменить способ отправки приветствия удаленному ресурсу с http на message broker - перепишем адаптер (реализацию) порта (интерфейса) HttpResource, и возможно поменяем название на MessageBroker. Такие изменения не слишком затронут ядро (domain) бизнес логики

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

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

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

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

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

"Юнит тесты хорошо, а другие – плохо. Объясняют [приверженцы TDD] это обычно тем, что юнит тесты быстрые и могут запускаться и работать даже на вершине Эвереста. И, блин, не поспоришь - действительно быстрые (про Эверест правда не проверял). Только вот ведь в чем беда - тестируют они только небольшие и несвязанные, строительные кубики кода - там где ошибок в большинстве случаев и нет. Ошибки, по опыту, начинаются после того, как из этих кубиков начинаешь строить что-то большее"

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

  • вынести бизнес логику в слой domain

  • вынести точки входа в бизнес логику и точки взаимодействия с внешними системами в слой port

  • вынести реализацию взаимодействия бизнес логики с внешними системами в слой adapter

  • вынести логику настройки приложения в слой infrastructure (например, конфигурацию фреймворка)

"Еще один тезис адептов: “Только с TDD ваш код станет правильным и начнет цвести и пахнуть“. Без оного все конечно же будет печально и грустно”."

Это действительно так, из-за наличия стадии рефакторинга в цикле разработки red-green-refactor по TDD. Когда тесты написаны и работают, мы можем изменять модуль каким угодно образом. До тех пор, пока они зеленые - модуль ведет себя ожидаемым образом. Можно достичь приемлемого качества кода за счет уверенности, что все задокументированные через тесты особенности останутся на месте. Юнит тестам нужно доверять, они - источник истины.

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

Спасибо, что дочитали!

Автор: Максим

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js