This article in English

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

Вступление

Test-driven development

В своей основе TDD представляет собой простой, бесконечный Red-Green-Refactor цикл:

  • Red: Пишем падающий тест для новой функциональности.

  • Green: Пишем минимально необходимое количество кода, чтобы тест прошёл.

  • Refactor: Как только вы убедились, что код работает, делаем его более читабельным и понятным.

Используете ли вы этот подход ежедневно? Пробовали ли вы его, но не нашли привлекательным? Если вы ответили "нет" хотя бы на один из этих вопросов, я приглашаю вас вместе исследовать TDD в этой статье.

Почему люди испытывают трудности с внедрением TDD?

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

  • Крутая кривая обучаемости: Развитие навыка написания эффективных тестов и их интеграции требует времени и практики. Определение правильных тестовых сценариев на начальном этапе может быть непростым. Однако эти инвестиции в конечном итоге приводят к созданию более надёжного и поддерживаемого кода.

  • Существующая кодовая база: Внедрение TDD в проекты с большим объёмом существующего кода — с тестами, написанными постфактум, или, что ещё хуже, вообще без тестов — может быть сложным и трудоёмким. По этой причине люди часто отказываются от использования TDD в реальных проектах. Тем не менее я считаю, что, хотя это и сложно, существуют способы преодолеть это и сделать ваш код более тестируемым, подготавливая его к TDD в будущем.

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

  • Отсутствие быстрых результатов: Преимущества TDD могут быть не сразу очевидны. Возникает вопрос: зачем тратить дополнительное время на начальном этапе? Хотя многим нравится сама концепции TDD на бумаге, отсутствие какой-либо отдачи может привести к тому, что практика покажется излишней. Однако долгосрочные преимущества TDD накапливаются со временем, приводя к меньшему количеству ошибок, более лёгкому рефакторингу и большей уверенности в кодовой базе.

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

Какие преимущества TDD дает лично мне?

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

  • Уверенность в том, что должна делать система: На мой взгляд, основное различие между подходами "test-first" и "test-last" заключается в том, что они собой представляют. Представьте себе керамическую вазу. Вы создали её с нуля, а затем придали ей форму с помощью опалубки. В следующий раз вы можете использовать эту опалубку, чтобы убедиться, что другая ваза имеет ту же форму. Но это не гарантирует, что сама опалубка отражает предполагаемую форму. Однако, если вы начнёте с опалубки и выразите свои намерения через неё, ваза, произведённая ею, несомненно, будет соответствовать ожиданиям, как и последующие. То же самое относится и к тестам. Тесты, написанные после реализации, часто представляют только то, что делает код, а не его предполагаемое поведение. TDD позволяет нам начать с другой стороны и объявить наши ожидания в исполняемом формате. Этот подход "сначала тест" гарантирует, что наш код создан для удовлетворения конкретных, заранее определённых ожиданий, что приводит к большей уверенности в его предполагаемой функциональности.

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

  • Гарантирует простоту использования программного обеспечения: TDD меняет ваш взгляд на создаваемое вами программное обеспечение. Он ставит вас по другую сторону, делая вас первым пользователем, пусть даже только для целей тестирования. Вы неизбежно начнёте видеть различные шаблоны использования, возможно, задаваясь вопросом, почему это не может быть проще или очевиднее. С другой стороны, вы не теряете взгляд изнутри, поскольку вам всё ещё необходимо настроить систему для эффективного тестирования. Эта временная смена перспективы позволяет вам предвидеть потребности пользователей и выявлять потенциальные проблемы использования на ранних этапах процесса разработки, что в конечном итоге приводит к архитектуре, которая сочетает простоту с необходимой гибкостью, избегая чрезмерного раскрытия внутренних деталей только для нужд тестирования.

  • Возможность быстро проверять бизнес теории: Иногда люди задают "А что, если" вопросы. Product manager может задаться вопросом: "Что, если определенный функционал будет использоваться непредусмотренным образом?" QA engineer спросит: "Что, если в систему будет переданы некорректные данные?" Вы можете захотеть спросить Business analyst: "Что должно произойти, если пользователь сделает то или это?" В таких случаях фокус TDD на входных и выходных данных обеспечивает простой способ перевести эти теоретические вопросы в исполняемые сценарии.

  • Предотвращает повторное появление багов: Баги неизбежны. Успешно выполненные тесты ничего не говорят о количестве багов в вашем программном обеспечении; лишь указывают на то, что пока не найдено ни одного. Поэтому, когда они наконец появляются, вам нужно быть готовым. Вам нужно предотвратить их повторное появление, установив правильный ожидаемый результат для проблемного места в коде с помощью теста. Следование TDD не только даёт вам такую возможность, но и создаёт достаточно надёжные тесты, чтобы выдержать неизбежный будущий рефакторинг.

В следующих разделах я проиллюстрирую, как следование принципам TDD может обеспечить значительные преимущества в долгосрочной перспективе, как прийти к более эффективной, надёжной и быстрой разработке. Преодоление начальных трудностей имеет решающее значение; вы должны осознать TDD и позволить вписаться в вашу философию разработки, чтобы вы могли продолжать следовать этому подходу. Я не сразу осознал его ценность; только позже я наконец-то начал видеть мой рост как инженера. Мне потребовалось довольно много попыток, чтобы начать, и ещё больше, чтобы найти персональный подход, который действительно повышает мою продуктивность. В следующей главе я надеюсь поделиться идеями, которые могут помочь в вашем путешествии в мире TDD.

Советы и рекомендации

Тесты должны быть быстрыми

Если вы ловите себя на том, что зависаете в социальных сетях на телефоне или смотрите на второй монитор во время выполнения тестов, то ваши тесты, вероятно, недостаточно быстры. И хотя скорость сильно зависит от проекта, мы должны стремиться к тому, чтобы все модульные тесты занимали не более 5 секунд. В противном случае разработчики начинают терять концентрацию при запуске, часто решая пропустить их или запустить позже. Цикл обратной связи становится слишком долгим. TDD подразумевает многократное выполнение тестов, быстрое подтверждения стабильности. Стремясь к быстрым модульным тестам, крайне важно помнить, что интеграционные тесты также имеют своё место, и принципы TDD, безусловно, могут быть применены и к ним. Ваше основное внимание должно уделяется предметной области, но не оставляйте точки интеграции без внимания, их мы хотим тестировать отдельно. Просто TDD лучше всего работает с быстрым циклом Red-Green-Refactor, а любые I/O, HTTP или использование контейнеров значительно замедляют его.

Стремитесь к наименьшему изменению

Движение маленькими шагами жизненно важно, чтобы уверенно стоять на ногах. Худшее время для принятия любого решения — это прямо сейчас, потому что сейчас мы обладаем минимумом информации о проблеме. Откладывая решение, вы даете себе возможность получить знаний, которые приходят со временем. Делая огромный прыжок, меняя сотню файлов одновременно, вы рискуете, что решение не подойдёт к проблеме. И даже если в конце концов вы потерпите неудачу, возможно, вы найдёте некоторые ценные части, которые захотите сохранить и переиспользовать в новой попытке. Так что в следующий раз не бросайтесь сразу в бой; выдохните, расслабьтесь, подумайте о следующем самом маленьком тесте, который вы можете написать и сделать "зелёным" за пару минут. Если вы чувствуете ваша идея не вписывается в текущие рамки, сначала проведите рефакторинг, чтобы освободить место под нее (например, создайте интерфейс для нескольких реализаций), сохраняя при этом исходное поведение, а следующим шагом уже начинайте ее внедрение (например, новой реализации). Иногда вы видите возможность для нового теста до того, как расправились с текущим. Не поддавайтесь соблазну, запишите в свой список дел, просто сосредоточьтесь на своей текущей цели. Такой подход не только даёт вам возможность отследить изменения при необходимости и попробовать подступиться к проблеме по другому, но и в качестве бонуса вы получаете небольшую дозу дофамина делая каждый раз коммит с хоть и маленьким, но улучшением.

Как можно быстрее двигайтесь к зеленым тестам

Когда тест падает, вы хотите заставить его пройти. Проводите как можно меньше времени на этой "красной" стадии; вы можете написать самый уродливый, самый медленный код — имеет значение только, что он работает. Во время перехода к "зелёной" стадии мы хотим найти решение, доказать, что оно устраняет текущую проблему. Случалось ли с вами, что вы тратили значительные усилия на красивый код, только чтобы в конце обнаружить, что он на самом деле не помогает с проблемой? Мне нравится мантра, приписываемая Кенту Беку, пионеру TDD: "First, make it work, then make it pretty, and if necessary make it fast". Описанный порядок не случаен. Приоритет на работающем коде позволяет нам проверить решение, прежде чем инвестировать время в эстетику или его производительность. Преждевременная оптимизация может привести к напрасной трате усилий и потенциально к более сложному и менее поддерживаемому коду. Кроме того, оптимизация даже не является необязательной; я видел очень мало примеров, где скорость была во главе угла. Когда вы уже находитесь на "зелёной" стадии, у вас есть возможность либо улучшить выбранное решение, либо, возможно, попробовать другое. Помните, история в git и ваш набор тестов здесь, чтобы направлять вас и, при необходимости, помочь сделать один шаг назад, а затем два шага вперёд.

Изолируйте все внешние зависимости

Ваша предметная область, ваша бизнес-логика, ваш основной модуль, причина, по которой вы пишете программное обеспечение, его самое сердце — должны быть защищены от внешнего мира. Фреймворк, сторонние библиотеки, база данных, кэш, брокер сообщений, всё остальное должно быть абстрагировано. В SOLID принцип инверсии зависимостей (Dependency Inversion) гласит, что высокоуровневые модули не должны зависеть от низкоуровневых модулей; оба типа модулей должны зависеть от абстракций (например, интерфейсов). Для иллюстрации рассмотрим классическую трехуровневую архитектуру.

Есть одно нарушение: Service слой зависит от Persistence слоя. Бизнес-логика зависит от механизма хранения данных и может потребовать изменений, если изменится хранилище. Чтобы разорвать эту связь, мы можем использовать один простой приём: создадим интерфейс в Service слое, который будет представлять внешнюю зависимость без упоминания конкретной технологии. Например, нам нужен способ добавить новую книгу в систему и позже получить её по ID. Какова будет фактическая реализация — база данных SQL, NoSQL, in-memory база данных, кэш, HTTP-вызов к другому сервису — это не имеет значения.

Важно то, что основной модуль сам определяет свои потребности, становится владельцем интерфейса, а реализации подчиняются контракту. Они не диктуют, как их использовать; вместо этого бизнес-логика диктует ожидаемое поведение. В итоге ваша архитектура будет больше напоминать Hexagonal architecture (или "Clean architecture" Роберта Мартина). Вы предоставляете точку входа в домен для вызова фреймворком или набором тестов, а также порты для интеграции внешних зависимостей через адаптеры.

Mock-объекты вам не друзья

В большинстве случаев мы не хотим использовать наши реальные зависимости во время тестирования; мы хотим заменить их. Проблема с использованием Mock-объектов может быть неочевидной на первый взгляд. На старте для их настройки требуется всего пара строк. Но современные системы обычно используют множество других зависимостей. Поэтому вам требуется все больше и больше Mock-объектов; конфигурация становится такой длинной, что к ее концу вы забываете, что хотели протестировать в начале. Превозмогая боль, тест удается заставить пройти, и вы переключаетесь на другие задачи. Но в конце концов вы снова натыкаетесь на него, пытаясь провести рефакторинг тестируемой функциональности. И каждое небольшое движение приводит к тому, что Mock-объекты немедленно ломаются. Это то, что они должны делать; они должны быть очень хрупкими. Хотя у такой особенности есть свои преимущества, в конечном итоге это препятствует рефакторингу программного обеспечения; это не позволяет ему развиваться. Одна из важнейших частей успеха, это способность адаптировать и изменять систему в новых рыночных условиях. И мы не должны так легко сбрасывать это со счетов. В качестве альтернативы потенциальным недостаткам Mock-объектов рассмотрите возможность использования Fake-объектов. Они представляют собой упрощенные реализации зависимостей, предлагающие рабочую замену реальному компоненту (например, базу данных в памяти). Очевидной проблемой может быть то, что разработка таких Fake-объектов требует определенных усилий. Но по моему опыту написание замены базы данных на основе HashMap и выполнение сложных запросов с использованием Stream API занимает максимум 20 минут. Иногда может понадобиться, чтобы зависимость отвечала неправильно, в этом случае рекомендую создать отдельный Fake-объект (например, база данных, которая всегда выдает исключение).

Тестирование через публичный API

Признайтесь, конечному пользователю все равно, будет ли запрос в другой сервис или вызывается определенный метод в определенном классе. Что действительно важно, так это наблюдаемое поведение, входные и выходные данные. Чтобы писать эффективные тесты, вам нужно посмотреть на вашу систему, ваш модуль, со стороны. Спросите себя: «Какой наиболее эффективный способ для пользователя взаимодействовать с этой системой и получать от нее выгоду?» Сосредоточьтесь на домене (например, создать заказ в системе электронной коммерции), а не на конкретной технологии (например, сохранить заказ в базе данных SQL). Создайте API, точки входа и используйте их при тестировании. Это позволяет скрыть детали реализации внутри тестируемого модуля, поскольку они в конечном счете не важны и всегда могут измениться. В качестве дополнительного преимущества публичные API изменяются реже, что позволяет вам выполнять рефакторинг, не опасаясь исправления сотен тестовых сценариев. Вы когда-нибудь оказывались в ситуации, когда извлечение части класса в отдельный файл или разделение одного метода на несколько приводило к сбою многих тестов, и вы тратили значительно больше времени на их исправление, чем на рефакторинг? Именно этой ситуации мы пытаемся избежать; она будет сдерживать вас от развития системы.

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

Всегда запускайте новый тест и подтверждайте его провал, прежде чем продолжить. Не стоит недооценивать важность этого шага. Вам нужно увидеть, как тест проваливается именно так, как он должен проваливаться. Я был в ситуации, когда тест завершался с ошибкой, о которой я не думал. Это означало, что мне нужно исправить что-то в других местах, чтобы иметь возможность продолжить с первоначальной идеей. Я также был в ситуациях, когда тест проходил сразу. Ваш разум часто не может (и скорее всего не хочет) вместить всю сложную систему, которую вы разрабатываете, со всеми ее нюансами. И это совершенно нормально; поздравьте себя с хорошо выполненной работой и переходите к следующей задаче. Помните, не увидев красный тест, вы не можете точно сказать, что новый написанный вами код способствовал тому, что тест стал зеленым. Наша цель — избегать ненужного кода, писать только самый минимум, чтобы тест прошел.

Избегайте Primitive obsession

Я считаю, что в какой-то момент любой разработчик создает метод или класс с несколькими параметрами одного типа, а затем случайно передает аргументы в неправильном порядке. Хорошим примером может служить метод addBookToCart с параметрами userId и bookId одного и того же целочисленного типа и передачей bookId первым и userId последним. Даже в тестах такую простую человеческую ошибку на самом деле довольно сложно обнаружить. Сделайте себе одолжение: относитесь к компилятору как к своей первой линии обороны. Избегайте primitive obsession, используя value-классы (например, Java records). В приведенном выше примере, если бы вы использовали такие типы, как UserId и BookId, компилятор бы активно помогал вам и указывал на перепутанные аргументы. Этот очень простой прием, хотя, делая кодовую базу немного многословной, позволяет писать код на языке предметной области. В конечном итоге он помогает оставаться на короткой ноге с экспертами со стороны бизнеса и итеративно развивать продукт вместе. Я настоятельно рекомендую вам попробовать его, если еще не пользуетесь.

Управляйте временем

Иногда при проверке выходных данных вам могут помешать значения, основанные на времени (например, временные метки). Вы можете их игнорировать, но я обнаружил, что удобнее вместо этого контролировать время. Существующий класс Java java.time.Clock может помочь вам создать объект, представляющий фиксированный момент времени, что помогает обеспечить повторяемость тестов. К сожалению, нет встроенной реализации для ручного перевода часов назад или вперед, однако вам потребуется не более 5 минут, чтобы написать свою собственную. Нет необходимости даже использовать другую библиотеку! Другая распространенная ситуация — запланированные задачи. Как протестировать код, который должен запускаться только в полночь, можете спросить вы. Чтобы найти ответ, вам нужно изменить перспективу. Ключ здесь — отделить триггер от действия. Определите, что вы хотите, чтобы произошло в какой-то момент в вашем домене, вызовите это в тестовом случае, сверьте результат на выходе. Затем останется только изменить триггер для целевого окружения.

Модульное тестирование

Я хочу обратить ваше внимание на термин «unit тестирование». Обычно люди думают о тестировании одного класса или одного метода, когда слышат о unit тестировании. Это правда, хотя и не всегда. Мартин Фаулер в своей статье определяет два типа модульных тестов: sociable и solitary. В отличие от sociable, solitary тесты заменяют все зависимости класса на Mock-объекты, чтобы изолировать ошибку, если она произойдет. Однако в своей практике я обнаружил, что наиболее эффективным способом является объединение обоих этих подходов в один. Я предпочитаю использовать для этого термин «модульное тестирование» вместо просто «unit тестирование». Идея здесь заключается в том, чтобы рассматривать модуль/систему как единое целое и тестировать его изолированно от внешних зависимостей. Все еще возможно отследить источник проблемы, если она возникнет, даже если в одном тесте задействовано несколько классов. Когда дело доходит до разделения тестовых случаев, я люблю помещать их в разные классы, представляющие разные функции. Например, в домене о продаже книг могут быть BookCatalogTests, ShoppingCartTests, CheckoutTests.

Acceptance тестирование

Обычно люди пытаются протестировать саму систему: бизнес-логику, базу данных, классы, вызывающие другие классы и т. д. Однако TDD, с моей точки зрения, лучше всего работает с так называемыми acceptance тестами. Они больше сосредоточены на том, что мы хотим проверить, а не как (например, проверка выходных данных, а не вызываемых методов или состояния). Они проверяют, что программное обеспечение удовлетворяет бизнес-требованиям с точки зрения конечного пользователя, рассматривая его как черный ящик. Такую тест очень легко придумать; просто спросите своего бизнес-аналитика или менеджера проекта. Например, если у вас есть существующий пользователь и существующая книга, когда пользователь добавляет книгу в корзину, то корзина содержит книгу. Такое требование очень легко перевести в тестовый код и оно не включает детали реализации, что позволяет вам изменить позже реализацию. Основные бизнес-требования, описанные acceptance тестами, очень редко меняются. Вы также можете выбрать более традиционное модульное тестирование для некоторых частей системы. Однако я бы лично рекомендовал придерживаться этого стиля. Если вы считаете, что тестирование требует большего внимания к определенному подмножеству бизнес-требований, возможно, имеет смысл выделить эту часть системы в новый компонент, а затем импортировать его как любую другую зависимость. Сосредоточившись на этих высокоуровневых функциональных целях, acceptance тесты дают четкое направление для разработки и гарантируют, что программное обеспечение представляет ценность для конечного пользователя.

Похожий код, разные правила

Многие мои коллеги относятся к тестам так же, как и к production коду. Однако цели различаются: production код должен быть надежным, настраиваемым и пригодным для повторного использования, в то время как тесты должны быть простыми для чтения и понимания, изолированными и быстрыми. Хотя мы стремимся к хорошим практикам написания кода в нашем production коде, приоритеты для тестового кода немного отличаются. Например, обычно мы стараемся избегать магических чисел и строк по очевидным причинам, но в тестах я нахожу их более выразительными. Вместо прыжков по разным файлам проекта, попыток найти точное значение, скрывающееся в константе, поле или переменной, чтобы понять сбой теста, простые числа и строки позволяют вам увидеть всю картину, читая сам тестовый сценарий. То же самое можно отнести и к дублированию кода. Обычно мы следуем принципу «Не повторяйся», но в тестах копирование некоторых частей обеспечивает лучшую изоляцию. Когда вы вернетесь к ним, вы будете рады не тратить время на чтение всего файла целиком, достаточно будет одного интересующего вас кусочка. Чтобы добавить больше контекста, больше смысла, чтобы помочь в будущем, вы можете использовать длинные описательные имена методов. Опять же, хорошей идеей будет принять стандартный стиль кода Java, но в случае тестов вы можете дать волю своему воображению! Существует множество шаблонов для названий методов тестирования, мне особенно нравится этот: givenBookExists_whenUserAddsBookToCart_thenCartContainsThatBook. Также есть возможность определить отображаемое имя, но по моему мнению «физического» имени часто бывает достаточно.

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

Итак, вы готовы, вы сидите и смотрите в монитор. Вы планируете использовать TDD, вы знаете, чего хотите достичь. Но вы застряли, пытаясь придумать первый тест. Когда я оказываюсь в такой ситуации, мне нравится использовать ZOMBIES, чтобы начать двигаться вперед. ZOMBIES — это аббревиатура, которая означает:

  • Zero: Случай с нулем, пустой строкой или отсутствием элементов. Например, если нет книг, когда пользователь запрашивает доступные для покупки книги, то пользователь не видит книг для покупки.

  • One: Случай с единицей. Например, если существует всего одна книга, когда пользователь запрашивает доступные для покупки книги, то пользователь видит только эту книгу.

  • Many: Обычно много при тестировании означает два значения. Например, если существует две книги, когда пользователь запрашивает доступные для покупки книги, то пользователь видит только эти книги.

  • Boundary: Сосредоточьтесь на тестировании пограничных значений ваших входных данных. Что происходит при максимальном или минимальном допустимом значении? Есть ли какие-либо ошибки с отклонением на единицу? Например, если книга имеет заголовок длиной больше максимально допустимой, когда пользователь добавляет книгу в каталог, то возникает исключение.

  • Interactions: Подумайте о других компонентах и зависимостях, с которыми взаимодействует ваша система. Придумайте сценарии, в которых они участвуют. Например, если книга имеет рейтинг 5 звезд во внешней системе рейтинга, когда пользователь просматривает книгу, то он видит рейтинг 5 звезд.

  • Errors: Проверьте, как ваш код обрабатывает ошибки и исключения. Что происходит, когда вводятся недопустимые значения либо внешняя система не отвечает? Предоставляете ли вы достаточно контекста, чтобы пользователь мог понять, что пошло не так? Например, если книга с таким названием уже существует, когда пользователь добавляет другую книгу с таким же названием, то пользователь видит ошибку с описанием «Книга с таким же названием уже существует» и с названием книги.

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

Legacy код

Довольно легко применять TDD с самого начала нового проекта. Однако внедрение TDD в долгосрочный проект с плохо спроектированными абстракциями и хрупкими или отсутствующими тестами может быть пугающим. В таких случаях лучшим вариантом было бы взять и положить весь тестируемый код в черный ящик, спрятать текущее решение за интерфейсом одного домена. Затем подумать о простейших вариантах использования, сначала протестировать их, чтобы оценить удобство TDD. Вы все еще можете некоторое время сохранять старый набор тестов, ставя целью постепенно заменить его на улучшенный, поддерживающий рефакторинг и движение вперед. Убедитесь, что ваши основные тестовые сценарии имеют достаточно быстры. Определите, что замедляет тесты, например запуск контейнеров или вычитка сообщений из брокера, замените их чем-то более быстрым, чтобы оставаться в цикле обратной связи. Не забывайте активно проводить рефакторинг и искать возможности для достижения лучшего разделения ответственности между компонентами. По моему опыту, определенно возможно ретроспективно внедрить TDD и в то же время не слишком навредить разработке новых функций. Я чувствовал больше свободы и уверенности в том, что могу внедрить больше нового функционала в продукт, и он при этом не развалится.

Инструмент, а не ограничение

Помните, что TDD — это инструмент, который должен вам помогать. Поэкспериментируйте с этими советами и не забывайте, что все, что было сказано, призвано принести вам пользу. Попробуйте, следуйте им дословно в течение некоторого времени, посмотрите, работает ли это для вас. Но не стесняйтесь немного отойти от проложенного пути или отбросить что-то, если это вам не подходит. Не исключайте полностью тестирование классов и методов, иногда используйте Mock-объекты, двигайтесь с подходящей для вас скоростью по циклу «красный-зеленый-рефакторинг», пропускайте некоторые шаги, если вы уверены в себе. Не будьте догматичны; оцените все варианты и используйте самые полезные из них.

Итоги

Надеюсь, вы получили свежую перспективу и практические идеи. Если вы уже разочаровались, я настоятельно рекомендую вам пересмотреть TDD. Помните, это не врожденный талант, а навык, который развивается с практикой. Спасибо за ваше время и внимание!

Полезные ссылки

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


  1. danilovmy
    06.07.2025 22:32

    Не знаю зачем заминусили: норм tdd вводная, не лучше и не хуже многих о premature testing на хабре.

    Автор: @xini в описании тестов sociable задвоилось. Все же Sociable и Solitary Unit Tests. В английской версии статьи та же ошибка, но ссылка на английскую версию тоже с ошибкой, ведёт на 404.

    А по теме - tdd прекрасно работает с ai агентами. Известно, что модели с самого начала писали тесты лучше, чем сам код. Потому даём тз, просим написать тесты, если хреновые тесты - уточняем тз. Тесты устраивают - просим написать код проходящий тесты . В этой цепочке модель пишет код явно лучше, чем в случае код по тз напрямую.


    1. xini Автор
      06.07.2025 22:32

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


  1. ddv88
    06.07.2025 22:32

    Сейчас бы в 2025 писать тесты ради тестов. Эта статья на 100% передает весь дух TDD (даже половины не осилил).

    Софт становится легаси еще на этапе планирования. Сейчас кто первый выкатил киллерфичу того и тапки. Эра предрелизов и постфиксов. Все остальное шелуха.


    1. Xoccta
      06.07.2025 22:32

      И это огорчает. Общество довольно непривередливо, что, как мне кажется, довольно несправедливо.
      Касательно ТДД, ИМХО, тесты выступают в качестве спецификации. Но я работаю - ТЗ часто переписывают, добавляют и изменяют старый функционал, так как, "Уточнили - не так работает/не понравилось/подставить свое". В таком случае все переписывать, конечно, самоубийственно. В теории, практика оч крутая, на практике хз, может кому-то подойдет


    1. jdev
      06.07.2025 22:32

      Я работаю через ТДД и там где есть возможность пытаюсь рефлексировать стоит ли оно того.

      Пока набралось два кейса:

      1. Сравнение трудозатрат на первоначальную разработку и её полный реинжиниринг

      2. Субъективное ощущение продакта на разработку большой фичи (человеко-год) по ТДД в сравнении с другими фичами в том же проекте. Тут ретру пока не публиковал

      И в обоих кейсах разработка по ТДД была не медленнее разработки без тестов и команда допуска в 2-4 раза меньше багов.

      Ну то есть с ТДД можно шипать фичи с той же скоростью, но при этом спокойно спать и в целом меньше стрессовать:)


  1. amazingname
    06.07.2025 22:32

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

    В итоге тесты нужны агенту, чтобы абы как накиданный им сырой код ожил.

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


    1. Kerman
      06.07.2025 22:32

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


      1. Ryav
        06.07.2025 22:32

        Не раскрыто из-за чего падает :)


        1. Kerman
          06.07.2025 22:32

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


    1. SergeyEgorov
      06.07.2025 22:32

      Вместо описания новой фичи, я пишу тест агенту. Агент пишет код, я пишу следующий тест и так далее, пока не получится код новой фичи.


    1. xini Автор
      06.07.2025 22:32

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


      1. amazingname
        06.07.2025 22:32

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

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


  1. jdev
    06.07.2025 22:32

    Спасибо, хорошая и нужная статья.

    Только я бы добавил про

    Изолируйте все внешние зависимости

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

    У меня последние лет 5 в куче разных проектов (среди прочих - автоматизация выделенного бизнес-процесса крупного ритейлера, медтех, автоматизация работы юридического департамента) 90% "причины по которой я пишу ПО" - положить данные в РСУБД. И поэтому 90% кода тестировать в изоляции от внешнего мира особого смысла нет. В общем, на мой взгляд, изоляция внешних зависимостей - не универсальное правило и нужна только в сложных предметных областях (я таких не видел, но предполагаю, что к ним относится банкинг, страхование, логистика, e-commerce).

    А в остальных случаях эффективнее по соотношению трудозатраты/(скорость + качество разработки), писать тесты как минимум с реальным PostgreSQL на RAM-диске.

    И если заморочиться, такие тесты будут не критично медленнее - до 10 секунд на запуск 1 теста, и по 50мс в среднем на тест при запуске всего набора.

    В остальном - всё плюсую.


    1. xini Автор
      06.07.2025 22:32

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


  1. Kerman
    06.07.2025 22:32

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

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


    1. jdev
      06.07.2025 22:32

      Это, наверное, зависит от контекста.

      Я обычно перед тем как писать какой-то код, проектирую и согласовываю АПИ с фронтом. И тесты пишу через это АПИ. А если там будут существенные изменения, то модификация тестов - будет меньшей из проблем:)


      1. jdev
        06.07.2025 22:32

        А, и собственно HTTP- запрос я заворачиваю в хелпер, поэтому если меняется только синтаксис, а не семантика (например параметр из пути в запрос уезжает), то это тест-кейсы не затрагивает.


    1. xini Автор
      06.07.2025 22:32

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

      bookService.createBook(bookId, ...);
      fakeRatings.createRating(bookId, 5);
      assertThat(bookService.rating(bookId)).isEqualTo(5);

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


      1. skovoroad
        06.07.2025 22:32

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


      1. Kerman
        06.07.2025 22:32

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

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

        Я не спорю, есть задачи (как правило сильно мельче и сильно детерминированнее), которые можно и нужно делать через TDD. Но у меня вот так. Когда я решаю проблемы конкретных людей, а не железяк, мне нужно показывать прототип конкретным людям и получать фидбек. Мне никакие тесты не скажут, решил я проблему или нет.


    1. AbitLogic
      06.07.2025 22:32

      Если вы не знаете что тестировать значит либо не поняли задачу, либо вам её плохо поставили, стоит остановиться и подумать над задачей лучше, если задача звучала как сделай как-нибудь, то и делай как нибудь, получишь соответственно, TDD занимается вопросами как надо писать код, как не надо изучает психиатрия


      1. Kerman
        06.07.2025 22:32

        Если вы не знаете что тестировать значит либо не поняли задачу, либо вам её плохо поставили

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

        Поэтому у вас сейчас всё просто. Не надо абсолютизма. TDD - не волшебная пилюля, у него есть границы применимости. Если вы не видите их - значит вы просто слепо верите в TDD.

        TDD занимается вопросами как надо писать код, как не надо изучает психиатрия

        Нет


        1. xini Автор
          06.07.2025 22:32

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