Комментарии 32
Вы начали писать тесты, глядя на картинку. Это значит, что до написания тестов, уже было сформирована какя-то архитектура, и какой-то базовый API.
Но идеологи TDD говорят что вачале должны быть тесты.
Или я ничего не понимаю.
Я не автор, но для критичных вещей TDD использую. Попробую ответить.
В целом - да. До написания тестов какая-то работа проводится: анализ, построение общей архитектуры решения. Чего не делается, это сам код решения не пишется.
И написанные тесты - они красные.
В моей практике на самом деле и тесты и код решения пишутся одновременно, но разными людьми. В параллель.
Выглядит это как игра в догонялки:
Делается проработка решения (требования, анализ, дизайн, архитектура)
Пишутся красные тесты
Пишется код делающий тесты зелёными
Тесты пишутся быстрее - они становятся каркасом API решения. И на этом моменте они отлично проверяют архитектуру и на то, на сколько этим API будет в итоге удобно пользоваться.
Все проекты, что делал по такому подходу в итоге вышли в прод и ни с одного не было заведено критичного дефекта в течении года эксплуатации.
Но дорого ;) поэтому не всегда используем TDD.
В моей практике на самом деле и тесты и код решения пишутся одновременно, но разными людьми. В параллель.
А с какой целью вы параллелите тесты и разработку? Ведь это вполне можно делать силами одного разработчика и тогда вы не тратите х2 времени 2-х программистов или у вас тесты пишет QA отдел?
Тесты пишутся быстрее - они становятся каркасом API решения. И на этом моменте они отлично проверяют архитектуру и на то, на сколько этим API будет в итоге удобно пользоваться.
Не хочу показаться занудой, но удобство пользования API лучше проверить на этапе анализа и проектирования самого API, чем в момент написания тестов. Да, бывает такое, что в момент реализации могут обнаружится некоторые тонкости и доработка базовой структуры (если в ходе задачи что-то пришлось переосмыслить).
Все проекты, что делал по такому подходу в итоге вышли в прод и ни с одного не было заведено критичного дефекта в течении года эксплуатации.
Я думаю, большая заслуга не в TDD, а том, что весь функционал покрывается тестами, что покрывает основные сценарии :)
Я в своей практике тоже практикую TDD, но только если это реально уместно для задачи. У нас на проекте проектирование API построено таким образом:
* проводится анализ нужной фичи - метода, доп параметров и тд
* пишется документация, где описывается API метод, его параметры и пример ответа
* при реализации сразу пишутся тесты, на основании заложенного контракта API метода (когда ожидается, что будет ошибка валидации, успешный ответ и тд)
* реализация совмещенная с TDD для некоторых модулей (валидация и тд)
Работы параллелятся чтобы успеть в поставленные сроки.
Тесты пишет другой человек для того чтобы "глаза не были замылены". Не критикую, но обычно так получается если разработчик за собой (до или после - не важно) пишет тесты, то он покрывает только те сценарии в которых уверен :)
Когда тесты пишет другой разработчик, то, обычно, они оказываются качественнее еще и по причине "игрового момента" - один пишет, другой закрывает. Плюс написание тестов достаточно удобная тема чтобы новых сотрудников вводить в курс дела в проекте.
QA тесты пишет, но они пишут интеграционные тесты. А Unit-тестирование на плечах разработчиков.
И эти тесты нам полезны чтобы в последствии отлавливать случайно вносимые регрессии. Если новый функционал ломает какие-либо тесты, то их не бездумно правят, но делаем анализ на что на самом деле может повлиять такое изменение. Иногда откатываем и ищем другое решение, но чаще видим, что изменение контролируемо и можно пропустить.
удобство пользования API лучше проверить на этапе анализа и проектирования самого API, чем в момент написания тестов
Все так. Но не всегда это срабатывает. Бывает на бумаге всё красиво и чино, а начинаешь писать клиентский код (тест) и понимаешь, что не удобно, или непривычно и программист делает неожиданные логические ошибки и т.д.
Одно другому не мешает и не отменяет, конечно.
Я думаю, большая заслуга не в TDD, а том, что весь функционал покрывается тестами, что покрывает основные сценарии :)
И да и нет. Наличие тестов до написания кода сокращает количество итераций, собственно, написания и правки кода. Это экономит на самом деле кучу времени.
А также дает большее понимание у разработчиков в том фукнционале который они должны сделать.
Мы TDD применяем не повсеместно (ибо дорого и не всегда нужно). Поэтому есть с чем сравнивать. И те команды которые участвовали в TDD в последствии сами просят продолжать его применение.
Сложность с TDD я бы сказал в другой плоскости, а именно в менеджерской:
сложно убедить в начале проекта (когда не понятно выстрелит или нет) необходимость написания большого количества тестов. Ведь их разработчики приучили, что только с N-ой итерации переписывания всего и вся что-то нормальное для прода получается. И в этих переписываниях сопровождение тестов - очень большая статья расходов.
сложно убедить начальство, что сломанная сборка (тесты красные / не компилятся) это нормальный производственный процесс
У нас проектирование немного в другой последовательности. Мы документацию сразу не пишем. То, что имеем сразу - это OpenApi сгенерированную спецификацию. Если подход "стреляет" и мы его фиксируем (а это уже итерация стабилизации) - вот тогда пишется полноценная документация.
Тесты пишет другой человек для того чтобы "глаза не были замылены". Не критикую, но обычно так получается если разработчик за собой (до или после - не важно) пишет тесты, то он покрывает только те сценарии в которых уверен :)
Согласен, есть такая проблема, мы ее от части решаем обязательным 100% покрытием всех модулей, которые затрагиваются в рамках задачи, это конечно не гарантирует покрытие всех сценариев, но хотя бы самые тривиальные ошибки закрывает.
QA тесты пишет, но они пишут интеграционные тесты. А Unit-тестирование на плечах разработчиков.
А QA тесты вы запускаете в момент билда в прод или совместно с unit тестами, пока задача еще в разработке? У нас разработчики пишут и интеграционные тесты тоже, которые гоняются на каждый коммит в GitLab, а QA тесты запускаются на реальных данных при сборке релиза.
Бывает на бумаге всё красиво и чино, а начинаешь писать клиентский код (тест) и понимаешь, что не удобно, или непривычно и программист делает неожиданные логические ошибки и т.д.
Тоже с таким сталкивались.
У нас проектирование немного в другой последовательности. Мы документацию сразу не пишем. То, что имеем сразу - это OpenApi сгенерированную спецификацию. Если подход "стреляет" и мы его фиксируем (а это уже итерация стабилизации) - вот тогда пишется полноценная документация.
Мы раньше тоже после реализации писали, но решили поменять подход, во первых чтобы сразу на этапе проектирования можно было увидеть конечный результат и описать, как это работае, а во вторых, чтобы представить описание API его клиента и получить от них фитбек, все ли их устраивает
Звучит интересно, я теоретизировал на эту тему, но до практики не дошло. Расскажите больше. Тесты пишет архитектор, а заставляют их работать программисты? Тесты на все или только на АПИ? Как изолируется окружение типа БД и прочие зависимости, которые сложно замокать?
Тесты пишет архитектор, а заставляют их работать программисты?
Тесты пишут программисты и программисты их заставляют работать. Архитектор + аналитик пишут тест-сценарии.
Тесты на все или только на АПИ?
Это зависит от объема работ и специфики проекта. Обычно через TDD делаются либо ядро системы, либо какие-то её ключевые элементы. Когда пропущенную ошибку в последствии будет очень больно исправлять. Или когда на этом функционале как на фундаменте будет построено всё остальное.
Как изолируется окружение типа БД и прочие зависимости, которые сложно замокать?
Иногда мокается если это не сложное API (типа CRUD'а). Если что-то сложное, то такой функционал тестируется уже через интеграционные тесты. А Unit- покрывает остальной функционал.
Вот только, что вы описали, это не TDD, это просто подход Test First, в котором сначала продумывается архитектура, пишутся тесты (API), а затем под тесты пишется код.
В TDD же вы за одну итерацию пишете ровно один тест, причём тест минимальный и обязательно красный. А затем пишете минимальное количество кода, что проходил этот тест, и не поломались тесты, написанные ранее.
Апологеты TDD утверждают, что тесты надо писать строго по одному, потому что если вы пишете тесты пачкой, то можете создать пересекающиеся тесты, а это признак плохого тестирования. Аналогично, если вы пишете за раз слишком много кода, значит, вы пишете то, что ещё не покрыто тестами. И нет, нельзя сразу писать по-человечески, нужно сначала сделать криво-косо, а затем рефакторить.
Если у нас есть модуль, который состоит из нескольких других модулей и мы его тестируем. Это считается юнит тестом или интеграционным?
Автор относит этот случай к юнит тестам, но на мой взгляд, он подходит под оба определения
В случае модуля, который зависит от других модулей, я бы тестировал все отдельно друг от друга. При этом взаимосвязи между ними перекрыл портами (выходной порт из одного модуля, входной порт в другом). Таким образом в случае монолита адаптер будет просто перенаправлять вызовы в нужный модуль, если микросервисы - делать http вызовы или что-то другое. Для тестов же это позволит сделать mock или stub модуля, от которого зависит тестируемый и задать ему "правильное" поведение, которое не зависит от реального текущего (вдруг там ошибка и тесты текущего модуля сломаются, так как зависимость вернула неожиданный результат)
Лично я считаю unit-тестом всё, что не зависит от "реального мира" — файловых операций, сетей, БД итп итд. Так-то можно всю систему представить как "юнит", замокав только "внешний мир".
Поэтому, в зависимости от потребностей, можно тестировать как один модуль, так и их связку для проверки того, что их связка корретно работает.
Более того, я бы добавил, что зависимости между модулями могут быть:
- зависят — это как пример у автора. Один модуль/сервис взаимодействует тем или иным образом с внешним (с точки зрения модуля) модулем. Например, модуль формирования приветствия и модуль БД.
- включает — модуль целиком используется "внутри" другого модуля. Сферический пример в вакууме: мы зачем-то решили сделать свой класс строк, и он используется в модуле формирования приветствия. Логичным будет тестировать как класс строки, так и модуль формирования приветствия, при этом при его тестировании мы абстрагируемся от каких-то там строк внутри и тестируем его интерфейс. Мне кажется логичным, что в данном примере не имеет никакого смысла выносить класс строки "вовне", реализовывать принцип портов и мокать его.
ИМХО, в любом сколько-нибудь сложном проекте будут оба типа зависимостей.
если нужно будет сменить способ отправки приветствия удаленному ресурсу с http на message broker — перепишем адаптер (реализацию) порта (интерфейса) HttpResource, и возможно поменяем название на MessageBroker. Такие изменения не слишком затронут ядро (domain) бизнес логику
Это достаточно наивный взгляд на вещи. Если http является по сути синхронным взаимодействием, то MB — асинхронное по природе. Т.е. у вас сразу появляется два канала, один для исходящих сообщений к смежной системе, и второй — для входящих от нее. А дальше начинаются асинхронные приколы, например, в какой-то момент смежная система перестает вам гарантировать, что порядок доставки ответов совпадает с порядком получения запросов (зачем они так сделали? ну например, ввели у себя параллелизм, чтобы запросы обрабатывались быстрее, т.е. вам же лучше хотели сделать). И опаньки… ваша бизнес логика пошла лесом, далеко-далеко.
Мне кажется выбраны не самые удачные названия. HttpResource конкретно определяет как канал, так и специфику взаимодействия, скрыть за ним не http и правда будет трудоемко.
В этой ситуации подходящим было бы что то типа RemoteResource, с реализациями HttpResource и MessageBrokerResource. Где вся асинхронность оканчивается на уровне адаптера. Да, всех плюшек брокера приложение не получит, но зато скоп изменений будет ограничен адаптером. Если же нужно использовать асинхронность по полной, то, как правильно заметили, нужен редизайн уже за пределами интерфейса.
>Где вся асинхронность оканчивается на уровне адаптера.
Ну, как бы, адаптеру придется ждать ответов. А значит — хранить список того, на что не ответили. И вероятно, он будет в отдельном потоке работать, чтобы не блокировать остальное. Вот вряд ли вы спрячете такое за API адаптера, чтобы приложение этого не заметило.
Не хочу никак обидеть автора, но это очередная статья о "просветлении" и познании истинного смысла TDD начинающим разработчиком(в чем автор честно признается). По мне, практически все статьи с примерами TDD можно охарактеризовать так:
Сейчас я покажу вам как быстро пробежать марафон, но к сожалению бежать марафон очень долго, поэтому я покажу как быстро пробежать 100 метров, но поверьте, в этом нет никакой принципиальной разницы, просто бегите так же быстро остальные 42 километра и у вас все получится!
Да, к сожалению, все выбирают какую то прямо издевательски простую задачу для демонстрации.
Да, согласен, пример кустарный (как и везде в подобных статьях). И все же я надеялся, что основные мысли будут понятны: нужно правильно разграничивать бизнес логику, фреймворки, бибилиотеки, связи между модулями; тестированию при TDD подвергается прежде всего требуемый функционал от системы.
Проблема не в кустарном примере, а в том, что TDD за исключением отдельных ситуаций, работает только в кустарных примерах. Я бы вам советовал более критично оценивать все эти *-Driven-Development и не слушать всяких "гуру" с очередными методологиями, которые решают все проблемы разработки. Аналогия с марафоном была для того, чтобы подчеркнуть абсурдность подобной аргументации в TDD.
Насколько я помню книгу GoF'а- тесты нужны для того, чтобы безопасно делать рефакторинг, а рефакторинг нужен для того, чтобы сохранять код максимально простым и понятным (KISS). Если оставить тесты, но выкинуть рефакторинг- KISS пошел по бороде, ну или надо быть гением и сразу писать идеальный код, наперед зная будущие выверты заказчика.
Если честно выполнить это:
мы пишем тест по принципу черного ящика, проверяя только входные и выходные параметры
То на основе вышеприведенных тестов нельзя быть уверенным, что метод createGreeting
вернет "Welcome, ${name}!" для произвольного name
.
Факт 100% покрытия - не может дать такой уверенности потому, что раз мы имеем дело с черным ящиком, то мы должны считать, что не знаем как реализована подстановка строк "Welcome, ${name}!". Например, "Welcome, ${name}!" мог бы для некоторых предопределенных name
возвращать пустую строку.
Выглядит так, что приведенные тесты дают уверенность в правильном поведении, только когда мы прочитали код реализации и точно знаем как работает каждая операция.
Если же мы хотим уверенности работая с черным ящиком - то нам нужен тест проверяющий все варианты name
. И в плюс к этому нужно откуда-то получить гарантию, что createGreeting
- чистая (pure) функция.
Если нет гарантии чистоты createGreeting
, то возможность использовать черный ящик пропадает полностью: для уверенности в правильной работе createGreeting
нужно прочитать весь её код, найти обращения к методам зависящим от сайд-эффектов и протестировать её для [(все возможные сайд-эффекты) X (все возможные значения агрументов)].
Понятно, что для синтетического примера с подстановкой строки приведенные выше рассуждения выглядят надуманными. Но представим, что в createGreeting
10, или 20, или (как бывает в реальных проектах с миллионами строк кода) 100+ строк. И сразу рассуждения такого рода становятся насущной необходимостью.
Будет ли более верным сказать, что на практике TDD может работать только с "белым ящиком"?
TDD ни в коем случае нельзя практиковать при подходе с "белым ящиком". Это убивает одно из его преимуществ: даже минимальный рефакторинг все ломает. Мне кажется: либо сразу понятны граничные случаи - тогда мы обсуждаем их с заказчиком (что будет если name = "") и делаем тесты на них, либо они неочевидны - тогда скорее всего мы с ними столкнемся в виде багов - и снова напишем тест (баг воспроизвелся, тест красный), исправим и проверим (тест зеленый).
"Если же мы хотим уверенности работая с черным ящиком - то нам нужен тест проверяющий все варианты
name
"
Своему ядру (бизнес логике, домену) нужно доверять. Городить повсюду проверку на null или пустую строку. Для таких случаев, по-моему, хорошо иметь anticorruption layer - на входе в домен мы проверяем входные данные (имя не null, не пусто), после этого внутри ядра мы доверяем тому факту, что имя логически корректно.
По поводу белого/черного ящика.
Я занимаюсь разработкой на C, и в коде можно встретить выделение памяти с проверкой. Да, это очень простой случай, да, можете сказать, что не всегда нехватка памяти приведет к NULL, но тем не менее, подойдет для примера:
int* array = (int*)malloc(size * sizeof(int));
if (array == NULL) {
// handle error
}
Соответственно, если мы хотим проверить, что такой случай обрабатывается, необходимо написать тест. Придется замокать функцию malloc
и заствить наш мок вернуть NULL. Все, здесь мы используем уже подход белого ящика. Да, придется править тест при существенном изменении реализации. Щито поделать.
На практике подход тестирования обычно гибридный — какие-то тест кейсы тестируют то, что видно с точки зрения интерфейса (черный ящик), но какие-то тестируют и вот что-то в этом духе.
Для таких случаев, по-моему, хорошо иметь anticorruption layer - на входе в домен мы проверяем входные данные (имя не null, не пусто), после этого внутри ядра мы доверяем тому факту, что имя логически корректно.
Я думаю в этом случае нужно ввести новый класс, что то вроде Name, который будет в конструкторе проверять на пустоту, например. И дальше в ядре мы используем именно этот класс вместо строки и тогда доверять не нужно будет, этим компилятор заниматься будет
Понадобилось как-то переписать библиотеку одного из проектов с открытым кодом с одного языка на другой. Переписав, конечно же столкнулся с проблемами. У неё была папочка с тестами. Решив, что они помогут выявить баги быстрее, переписал и их. Результат: потрачено время на их переписывание, выловлены баги в тестах, и они помогли найти всего пару несущественных багов, и, к сожалению, место сбоя всё равно приходилось долго искать отладкой. Остальные баги всплывали и отлавливались только при работе, хотя они весьма сильно влияли на результат работы.
Если мы хотим быть уверенными, что наш код делает то, что должен, то нужно четко понимать, что без интеграционных тестов описанных подход не работает. Сейчас вы тестируете, что ваш useCase вызывает с нужными параметрами методы связанных с ним портов. Проблема в том, что именно вы отвечаете за реализацию адаптеров. Грубо говоря, если вы в adapter'e HttpResource в методе send ничего не напишете, все ваши тесты будут зелеными, хотя код будет не рабочим.
Опять же теперь ваши тесты завязаны на гексагональную архитектуру и, если вдруг вы решите переписать код по-другому, то придется переписать и все тесты.
Я в текущем проекте использую чуть более широкие границы в тестах. Мы тестируем бэкенд и, соответственно:
1. Входными параметрами у нас являются не параметры useCase'a, а входной запрос от клиента.
2. Mock реализации делаются для тех частей, за реализацию, которых мы не отвечаем: запрос к БД, отправка сообщения в Message brocker, HTTP запрос к стороннему API и т.п.
3. Тестируются как раз правильные запросы к mock'ам, а также реакция на success/failure ответы от них.
Таким образом я не привязан к способу написания кода. В проекте есть как старые куски написанные в плоском стиле: вызови то, вызови это и т.д., так и новый код, как раз на гексагональной архитектуре. Рефакторинг при таком подходе не ломает тесты.
Тесты придется переписывать, только при изменении бизнес логики или замене одного внешнего сервиса на другой(одну БД, на другую). При описанном у вас подходе, такие замены вроде как не ломают тесты, но это только кажется, потому что, как я уже сказал, вам нужны интеграционные и сломаются уже они.
Интеграционные несомненно нужны, спору нет. Однако они находятся за рамками подхода TDD. Для описанного примера, если его расширить, нужны: тесты ядра (они описаны в статье), тесты различных адаптеров (например, что вызов удаленного ресурса происходит с нужными параметрами и его ответ правильно преобразуется и возвращается в доменный слой), интеграционные тесты (в окруженнии максимально близком в реальному). Как можно заметить, буду тесты и доменного слоя, и слоя приложения (с помощью тестов адаптеров) и слоя инфраструктуры (интеграционные).
Я бы оспорил тезис про документацию. В статье просто приведен простой случай. В реальности будут моки, много моков, хитрые, нетривиальные моки. Даже если моков нет, а функция чистая, на вход функции может передаваться очень большой кусок данных, и придется передавать его в тест, и в куче вариантов для покрытия разных сценариев.
В итоге, кроме самых простых случаев, тесты могут быть сложнее кода, который тестируется. Читаемость этого как документации никакая.
Согласен, однако если сравнивать это с полным отсутствием документации, то тесты это хотя-бы что-то.
Как не крути, но иногда мы делаем в коде костыли. И если костыль покрыт тестом, то это может сэкономить очень много времени в будущем.
Казалось бы для костыля можно добавить комментарий, однако комментарий может быть не актуальным, слишком абстрактным и не достаточно понятным.
Тесты более однозначные, допустим есть кусок кода написанный в TDD. Чтобы понять зачем в каком-то месте сделано именно так, а не вот так, можно просто поменять код и посмотреть, что отвалится. Если ничего - значит рефакторинг готов. Думаю вся идея TDD в этом.
Я пробовал писать в TDD чистые функции. И это довольно удобно. Даже если данные большие, чистые функции их не портят и можно использовать одни и те же для тестирования разных кейсов с минимальными усилиями.
Правильное TDD