Привет, Хабр! На написание этого поста меня вдохновил другой пост 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:

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

  • Не было написано некоторых "логичных" тестов. Например, что модуль вернет 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% случаев "хотелки" бизнеса можно реализовывать на основе правильно выбранной архитектуры приложения написав сначала тест, запустив его и убедившись, что он падает, написав минимально необходимый код для прохождения теста, убедившись, что тест проходит, и не забыв выполнить рефакторинг в конце.

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

Комментарии (32)


  1. ultrinfaern
    15.08.2021 17:01
    +6

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

    Но идеологи TDD говорят что вачале должны быть тесты.

    Или я ничего не понимаю.


    1. PrinceKorwin
      15.08.2021 17:52
      +2

      Я не автор, но для критичных вещей TDD использую. Попробую ответить.

      В целом - да. До написания тестов какая-то работа проводится: анализ, построение общей архитектуры решения. Чего не делается, это сам код решения не пишется.

      И написанные тесты - они красные.

      В моей практике на самом деле и тесты и код решения пишутся одновременно, но разными людьми. В параллель.

      Выглядит это как игра в догонялки:

      • Делается проработка решения (требования, анализ, дизайн, архитектура)

      • Пишутся красные тесты

      • Пишется код делающий тесты зелёными

      Тесты пишутся быстрее - они становятся каркасом API решения. И на этом моменте они отлично проверяют архитектуру и на то, на сколько этим API будет в итоге удобно пользоваться.

      Все проекты, что делал по такому подходу в итоге вышли в прод и ни с одного не было заведено критичного дефекта в течении года эксплуатации.

      Но дорого ;) поэтому не всегда используем TDD.


      1. distroid
        15.08.2021 19:06

        В моей практике на самом деле и тесты и код решения пишутся одновременно, но разными людьми. В параллель.

        А с какой целью вы параллелите тесты и разработку? Ведь это вполне можно делать силами одного разработчика и тогда вы не тратите х2 времени 2-х программистов или у вас тесты пишет QA отдел?

        Тесты пишутся быстрее - они становятся каркасом API решения. И на этом моменте они отлично проверяют архитектуру и на то, на сколько этим API будет в итоге удобно пользоваться.

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

        Все проекты, что делал по такому подходу в итоге вышли в прод и ни с одного не было заведено критичного дефекта в течении года эксплуатации.

        Я думаю, большая заслуга не в TDD, а том, что весь функционал покрывается тестами, что покрывает основные сценарии :)

        Я в своей практике тоже практикую TDD, но только если это реально уместно для задачи. У нас на проекте проектирование API построено таким образом:

        * проводится анализ нужной фичи - метода, доп параметров и тд
        * пишется документация, где описывается API метод, его параметры и пример ответа
        * при реализации сразу пишутся тесты, на основании заложенного контракта API метода (когда ожидается, что будет ошибка валидации, успешный ответ и тд)
        * реализация совмещенная с TDD для некоторых модулей (валидация и тд)


        1. PrinceKorwin
          15.08.2021 21:57
          +1

          Работы параллелятся чтобы успеть в поставленные сроки.

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

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

          QA тесты пишет, но они пишут интеграционные тесты. А Unit-тестирование на плечах разработчиков.

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

          удобство пользования API лучше проверить на этапе анализа и проектирования самого API, чем в момент написания тестов

          Все так. Но не всегда это срабатывает. Бывает на бумаге всё красиво и чино, а начинаешь писать клиентский код (тест) и понимаешь, что не удобно, или непривычно и программист делает неожиданные логические ошибки и т.д.

          Одно другому не мешает и не отменяет, конечно.

          Я думаю, большая заслуга не в TDD, а том, что весь функционал покрывается тестами, что покрывает основные сценарии :)

          И да и нет. Наличие тестов до написания кода сокращает количество итераций, собственно, написания и правки кода. Это экономит на самом деле кучу времени.

          А также дает большее понимание у разработчиков в том фукнционале который они должны сделать.

          Мы TDD применяем не повсеместно (ибо дорого и не всегда нужно). Поэтому есть с чем сравнивать. И те команды которые участвовали в TDD в последствии сами просят продолжать его применение.

          Сложность с TDD я бы сказал в другой плоскости, а именно в менеджерской:

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

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

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


          1. distroid
            15.08.2021 23:25

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

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

            QA тесты пишет, но они пишут интеграционные тесты. А Unit-тестирование на плечах разработчиков.

            А QA тесты вы запускаете в момент билда в прод или совместно с unit тестами, пока задача еще в разработке? У нас разработчики пишут и интеграционные тесты тоже, которые гоняются на каждый коммит в GitLab, а QA тесты запускаются на реальных данных при сборке релиза.

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

            Тоже с таким сталкивались.

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

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


            1. PrinceKorwin
              16.08.2021 10:46

              QA тесты запускаются в начале стабилизации и перед её окончанием. Как и SVT если необходимо.


      1. andreyverbin
        15.08.2021 19:36

        Звучит интересно, я теоретизировал на эту тему, но до практики не дошло. Расскажите больше. Тесты пишет архитектор, а заставляют их работать программисты? Тесты на все или только на АПИ? Как изолируется окружение типа БД и прочие зависимости, которые сложно замокать?


        1. PrinceKorwin
          15.08.2021 22:02

          Тесты пишет архитектор, а заставляют их работать программисты?

          Тесты пишут программисты и программисты их заставляют работать. Архитектор + аналитик пишут тест-сценарии.

          Тесты на все или только на АПИ?

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

          Как изолируется окружение типа БД и прочие зависимости, которые сложно замокать?

          Иногда мокается если это не сложное API (типа CRUD'а). Если что-то сложное, то такой функционал тестируется уже через интеграционные тесты. А Unit- покрывает остальной функционал.


          1. andreyverbin
            15.08.2021 22:38

            А в чем профит от того, что тесты и код пишут разные люди?


            1. PrinceKorwin
              15.08.2021 22:46
              +1

              Быстрее, через написание текстов проще ввести нового человека в проект, глаз не замылен, так проще писать тесты "беспристрастно", некий соревновательный элемент поднимает настроение в команде в конце концов.


      1. DistortNeo
        16.08.2021 17:21
        +1

        Вот только, что вы описали, это не TDD, это просто подход Test First, в котором сначала продумывается архитектура, пишутся тесты (API), а затем под тесты пишется код.


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


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


  1. Vlad_Murashchenko
    15.08.2021 20:28
    +1

    Если у нас есть модуль, который состоит из нескольких других модулей и мы его тестируем. Это считается юнит тестом или интеграционным?

    Автор относит этот случай к юнит тестам, но на мой взгляд, он подходит под оба определения


    1. xini Автор
      16.08.2021 08:52

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


    1. Alex_ME
      16.08.2021 10:24
      +1

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


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


      Более того, я бы добавил, что зависимости между модулями могут быть:


      • зависят — это как пример у автора. Один модуль/сервис взаимодействует тем или иным образом с внешним (с точки зрения модуля) модулем. Например, модуль формирования приветствия и модуль БД.
      • включает — модуль целиком используется "внутри" другого модуля. Сферический пример в вакууме: мы зачем-то решили сделать свой класс строк, и он используется в модуле формирования приветствия. Логичным будет тестировать как класс строки, так и модуль формирования приветствия, при этом при его тестировании мы абстрагируемся от каких-то там строк внутри и тестируем его интерфейс. Мне кажется логичным, что в данном примере не имеет никакого смысла выносить класс строки "вовне", реализовывать принцип портов и мокать его.

      ИМХО, в любом сколько-нибудь сложном проекте будут оба типа зависимостей.


  1. sshikov
    15.08.2021 20:42
    +2

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

    Это достаточно наивный взгляд на вещи. Если http является по сути синхронным взаимодействием, то MB — асинхронное по природе. Т.е. у вас сразу появляется два канала, один для исходящих сообщений к смежной системе, и второй — для входящих от нее. А дальше начинаются асинхронные приколы, например, в какой-то момент смежная система перестает вам гарантировать, что порядок доставки ответов совпадает с порядком получения запросов (зачем они так сделали? ну например, ввели у себя параллелизм, чтобы запросы обрабатывались быстрее, т.е. вам же лучше хотели сделать). И опаньки… ваша бизнес логика пошла лесом, далеко-далеко.


    1. Vollger
      16.08.2021 08:53

      Мне кажется выбраны не самые удачные названия. HttpResource конкретно определяет как канал, так и специфику взаимодействия, скрыть за ним не http и правда будет трудоемко.

      В этой ситуации подходящим было бы что то типа RemoteResource, с реализациями HttpResource и MessageBrokerResource. Где вся асинхронность оканчивается на уровне адаптера. Да, всех плюшек брокера приложение не получит, но зато скоп изменений будет ограничен адаптером. Если же нужно использовать асинхронность по полной, то, как правильно заметили, нужен редизайн уже за пределами интерфейса.


      1. sshikov
        16.08.2021 10:27
        +1

        Не, возможно. Но реально замена синхронного на асинхронное ломает логику на раз-два. Поэтому упрощать тут не стоит.

        >Где вся асинхронность оканчивается на уровне адаптера.
        Ну, как бы, адаптеру придется ждать ответов. А значит — хранить список того, на что не ответили. И вероятно, он будет в отдельном потоке работать, чтобы не блокировать остальное. Вот вряд ли вы спрячете такое за API адаптера, чтобы приложение этого не заметило.


  1. nick1612
    15.08.2021 21:10
    +12

    Не хочу никак обидеть автора, но это очередная статья о "просветлении" и познании истинного смысла TDD начинающим разработчиком(в чем автор честно признается). По мне, практически все статьи с примерами TDD можно охарактеризовать так:

    Сейчас я покажу вам как быстро пробежать марафон, но к сожалению бежать марафон очень долго, поэтому я покажу как быстро пробежать 100 метров, но поверьте, в этом нет никакой принципиальной разницы, просто бегите так же быстро остальные 42 километра и у вас все получится!


    1. kemsky
      15.08.2021 22:02
      +4

      Да, к сожалению, все выбирают какую то прямо издевательски простую задачу для демонстрации.


      1. Tiriet
        16.08.2021 16:49
        +2

        и все равно- демонстрация не удается. какая последовательность работы в стиле ТыДыДы? Красный-Зеленый-Рефакторинг! Сколько раз автор сделал рефакторинг? ноль. И какой-же это тогда TDD?


    1. xini Автор
      16.08.2021 08:56
      -1

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


      1. nick1612
        16.08.2021 10:21
        +2

        Проблема не в кустарном примере, а в том, что TDD за исключением отдельных ситуаций, работает только в кустарных примерах. Я бы вам советовал более критично оценивать все эти *-Driven-Development и не слушать всяких "гуру" с очередными методологиями, которые решают все проблемы разработки. Аналогия с марафоном была для того, чтобы подчеркнуть абсурдность подобной аргументации в TDD.


        1. Tiriet
          16.08.2021 16:59

          Насколько я помню книгу GoF'а- тесты нужны для того, чтобы безопасно делать рефакторинг, а рефакторинг нужен для того, чтобы сохранять код максимально простым и понятным (KISS). Если оставить тесты, но выкинуть рефакторинг- KISS пошел по бороде, ну или надо быть гением и сразу писать идеальный код, наперед зная будущие выверты заказчика.


  1. artemvkepke
    15.08.2021 23:11
    +2

    Если честно выполнить это:

    мы пишем тест по принципу черного ящика, проверяя только входные и выходные параметры

    То на основе вышеприведенных тестов нельзя быть уверенным, что метод createGreeting вернет "Welcome, ${name}!" для произвольного name.

    Факт 100% покрытия - не может дать такой уверенности потому, что раз мы имеем дело с черным ящиком, то мы должны считать, что не знаем как реализована подстановка строк "Welcome, ${name}!". Например, "Welcome, ${name}!" мог бы для некоторых предопределенных name возвращать пустую строку.

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

    Если же мы хотим уверенности работая с черным ящиком - то нам нужен тест проверяющий все варианты name. И в плюс к этому нужно откуда-то получить гарантию, что createGreeting - чистая (pure) функция.
    Если нет гарантии чистоты createGreeting , то возможность использовать черный ящик пропадает полностью: для уверенности в правильной работе createGreeting нужно прочитать весь её код, найти обращения к методам зависящим от сайд-эффектов и протестировать её для [(все возможные сайд-эффекты) X (все возможные значения агрументов)].

    Понятно, что для синтетического примера с подстановкой строки приведенные выше рассуждения выглядят надуманными. Но представим, что в createGreeting 10, или 20, или (как бывает в реальных проектах с миллионами строк кода) 100+ строк. И сразу рассуждения такого рода становятся насущной необходимостью.

    Будет ли более верным сказать, что на практике TDD может работать только с "белым ящиком"?


    1. xini Автор
      16.08.2021 09:05

      TDD ни в коем случае нельзя практиковать при подходе с "белым ящиком". Это убивает одно из его преимуществ: даже минимальный рефакторинг все ломает. Мне кажется: либо сразу понятны граничные случаи - тогда мы обсуждаем их с заказчиком (что будет если name = "") и делаем тесты на них, либо они неочевидны - тогда скорее всего мы с ними столкнемся в виде багов - и снова напишем тест (баг воспроизвелся, тест красный), исправим и проверим (тест зеленый).

      "Если же мы хотим уверенности работая с черным ящиком - то нам нужен тест проверяющий все варианты name"

      Своему ядру (бизнес логике, домену) нужно доверять. Городить повсюду проверку на null или пустую строку. Для таких случаев, по-моему, хорошо иметь anticorruption layer - на входе в домен мы проверяем входные данные (имя не null, не пусто), после этого внутри ядра мы доверяем тому факту, что имя логически корректно.


      1. Alex_ME
        16.08.2021 10:31

        По поводу белого/черного ящика.
        Я занимаюсь разработкой на C, и в коде можно встретить выделение памяти с проверкой. Да, это очень простой случай, да, можете сказать, что не всегда нехватка памяти приведет к NULL, но тем не менее, подойдет для примера:


        int* array = (int*)malloc(size * sizeof(int));
        if (array == NULL) {
            //  handle error
        }

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


        На практике подход тестирования обычно гибридный — какие-то тест кейсы тестируют то, что видно с точки зрения интерфейса (черный ящик), но какие-то тестируют и вот что-то в этом духе.


      1. dopusteam
        16.08.2021 13:20
        +1

        Для таких случаев, по-моему, хорошо иметь anticorruption layer - на входе в домен мы проверяем входные данные (имя не null, не пусто), после этого внутри ядра мы доверяем тому факту, что имя логически корректно.

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


  1. quaer
    15.08.2021 23:42
    +1

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


  1. vvovas
    16.08.2021 01:12
    +1

    Если мы хотим быть уверенными, что наш код делает то, что должен, то нужно четко понимать, что без интеграционных тестов описанных подход не работает. Сейчас вы тестируете, что ваш useCase вызывает с нужными параметрами методы связанных с ним портов. Проблема в том, что именно вы отвечаете за реализацию адаптеров. Грубо говоря, если вы в adapter'e HttpResource в методе send ничего не напишете, все ваши тесты будут зелеными, хотя код будет не рабочим.

    Опять же теперь ваши тесты завязаны на гексагональную архитектуру и, если вдруг вы решите переписать код по-другому, то придется переписать и все тесты.

    Я в текущем проекте использую чуть более широкие границы в тестах. Мы тестируем бэкенд и, соответственно:
    1. Входными параметрами у нас являются не параметры useCase'a, а входной запрос от клиента.
    2. Mock реализации делаются для тех частей, за реализацию, которых мы не отвечаем: запрос к БД, отправка сообщения в Message brocker, HTTP запрос к стороннему API и т.п.
    3. Тестируются как раз правильные запросы к mock'ам, а также реакция на success/failure ответы от них.
    Таким образом я не привязан к способу написания кода. В проекте есть как старые куски написанные в плоском стиле: вызови то, вызови это и т.д., так и новый код, как раз на гексагональной архитектуре. Рефакторинг при таком подходе не ломает тесты.

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


    1. xini Автор
      16.08.2021 09:10

      Интеграционные несомненно нужны, спору нет. Однако они находятся за рамками подхода TDD. Для описанного примера, если его расширить, нужны: тесты ядра (они описаны в статье), тесты различных адаптеров (например, что вызов удаленного ресурса происходит с нужными параметрами и его ответ правильно преобразуется и возвращается в доменный слой), интеграционные тесты (в окруженнии максимально близком в реальному). Как можно заметить, буду тесты и доменного слоя, и слоя приложения (с помощью тестов адаптеров) и слоя инфраструктуры (интеграционные).


  1. Alex_ME
    16.08.2021 10:35
    +3

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


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


    1. Vlad_Murashchenko
      17.08.2021 13:44

      Согласен, однако если сравнивать это с полным отсутствием документации, то тесты это хотя-бы что-то.

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

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

      Тесты более однозначные, допустим есть кусок кода написанный в TDD. Чтобы понять зачем в каком-то месте сделано именно так, а не вот так, можно просто поменять код и посмотреть, что отвалится. Если ничего - значит рефакторинг готов. Думаю вся идея TDD в этом.

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