Комментарии 15
Идея проста и элегантна: код, который легко и удобно тестировать, — это хорошо спроектированный код.
Было бы всё так просто в этом мире. Почему-то в книгах про архитектуру ПО обычно больше текста, чем одной фразы "используйте Dependency Injection и чистые функции", следования которой достаточно, чтобы весь код был юнит-тестируемым.
Почему и в ущерб чему последовательность локальных оптимизаций в виде приведения к "тестируемости" отдельных частей системы каким-то волшебным образом должна привести к простой в понимании и поддержке системе в совокупности, из статьи не ясно. Видимо, предлагается поверить на слово.
Исходя из собственного опыта, могу сказать, этот процесс себя исчерпает, как только закончатся "нетестируемые" классы и не исчезнут амбиции к ускорению разработки, и приведёт к очередному "поиску счастья" в других подходах по типу Clean Architecture или DDD.
Когда-нибудь на Хабре выйдет статья про качество кода с аргументацией уровня, отличного от "я так примерно почувствовал". Сегодня не получилось.
Никто все проблемы решить просто не предлагает, я же несколько раз пишу что это вспомогательный паттерн к более классической архитектуре. Это просто взгляд с другой стороны на те же вещи (solid, например). Обычно, оно работает лучше всего именно вместе.
А из проблем я бы только выделил то что большая часть разработчиков тесты или не пишет, или не до конца понимает как их писать правильно. И статья для них слишком абстрактная и поэтому, внезапно, сложная :)
Неплохой контент, но как же несет от стиля форматирования чатботом...
Интерфейсы (props, методы, события) ваших компонентов и модулей становятся чище и удобнее, так как вы проектируете их с точки зрения "потребителя"
В свое время ООП тоже пытались применить для работы с базами данных от задач потребителя. Как объекты предметной области рисовали, так и API для бэкенда предлагали реализовывать. Код был прекрасен с точки зрения тестов и поддержки. Но не работал эффективно с точки зрения производительности на больших массивах данных.
С точки зрения пользователя удобно рассчитать зарплату каждому сотруднику индивидуально, загружая из хранилища только необходимые данные. А с точки зрения машины на несколько порядко быстрее, если зарплату всем сотрудникам рассчитывать в потоке, предварительно загрузив требуемые данные в буфер.
Борьба между удобством пользователя и производительностью машины бесконечна. Где-то, благодаря поднявшейся производительности машины, мы позволили себе создать удобные функции, классы, интерфейсы. Но возрастающее количество обрабатываемых данных продолжает давить на разработчиков, наши заранее продуманные простые функции и классы снова превращаются в сборище исключений, пометок о техническом долге, пропущенных тестах. Иначе задачи бы не сдавались бы в прод, а мы бы остались вечными студентами в поисках чистого кода.
Статья от фронтенд-разработчика. Там специфика такова, что в большинстве проектов большими объемами данных приходится оперировать очень редко. Отчасти из-за того, что хранить состояние на клиенте - моветон. Отчасти из-за того, что а этом просто нет необходимости, потому что можно получать данные частями и работать с такими банками вполне нормально без ограничения в функциональности для конечного пользователя. Поэтому и статья написана с таким учётом, хотя явно не указано. А стоило бы. Часть вещей фреймворко и языкозависимая.
Привет от бэкэндеров!
Представьте, что наша общая цель — создать программу, которую легко понимать, тестировать и изменять, минимизируя при этом головную боль от асинхронности ), т.е. привязки к внешнему контексту.
1. "Чистая кухня" в центре, "зона ожидания" по краям (Functional Core / Imperative Shell)
Идея: Разделить программу на две части:
"Чистая кухня" (Functional Core - Синяя зона): Это сердце вашей программы, где происходит вся сложная логика, расчеты, принятие решений. Функции здесь:
Не выполняют никаких внешних действий (не ходят в интернет, не пишут в базу данных, не выводят на экран).
Получают данные на вход, обрабатывают их и возвращают результат. Всегда одинаковый результат для одинаковых входных данных.
Их очень легко тестировать: дал данные, проверил результат.
Это "синие" функции.
"Зона ожидания и внешних связей" (Imperative Shell - Красная зона): Это внешний слой вашей программы, который взаимодействует с "реальным миром". Функции здесь:
Общаются с базами данных, отправляют запросы в интернет, получают ввод от пользователя, выводят информацию.
Запускают "красные" операции и ждут их завершения.
Обрабатывают ошибки, которые могут прийти из внешнего мира.
Это "красные" функции.
Как это уменьшает сложность "красных" задач?
Изоляция: Вся "краснота" (асинхронность, работа с I/O) сосредоточена в "оболочке" (Shell). Ядро (Core) остается "синим" и чистым.
Уменьшение объема "красного" кода: Вместо того чтобы "красные" вызовы были разбросаны по всей программе, они концентрируются на границе. Объем кода, который нужно тестировать с учетом асинхронности, становится меньше.
Тестируемость: "Синее" ядро можно тестировать очень легко и быстро, без моков и реальных внешних систем. "Красную" оболочку тоже нужно тестировать, но таких тестов будет меньше, и они будут более сфокусированы на интеграции.
Пример на кухне:
Ядро (синее): Рецепт торта (правила, список ингредиентов, шаги смешивания, температура и время выпечки). Это чистая информация. Вы можете проверить рецепт на логичность, не пачкая рук.
Оболочка (красное):
Поход в магазин за продуктами (I/O, ожидание).
Включение духовки и ожидание, пока она нагреется (I/O, ожидание).
Проверка готовности торта (I/O).
Логика самого рецепта (ядро) не зависит от того, в каком магазине вы купили муку или какая у вас модель духовки.
2. Четкие "заказы" для внешнего мира (Ports & Adapters / Hexagonal Architecture)
Идея: Ваше "чистое ядро" (бизнес-логика) не должно знать, как именно оно получает данные или отправляет команды во внешний мир. Оно лишь определяет, что ему нужно.
Порт (Port - Синий интерфейс): Это как "форма заказа" или "требование", которое определяет ядро. Например: "Мне нужен пользователь по ID" или "Сохрани этого пользователя". Это "синий" контракт (интерфейс), который не говорит ничего о том, как это будет сделано (синхронно или асинхронно, из какой базы данных).
Адаптер (Adapter - Красная или Синяя реализация): Это конкретный исполнитель "заказа".
"Красный" адаптер (для продакшена): Реализует порт, обращаясь к реальной базе данных, отправляя реальный email. Он будет "красным" (асинхронным).
"Синий" адаптер (для тестов): Реализует тот же порт, но работает с данными в памяти, без реального I/O. Он будет "синим".
Как это уменьшает сложность "красных" задач?
Отвязка от конкретики: Ядро не зависит от конкретной "красной" реализации. Оно работает с абстрактным "портом".
Замена реализаций: Вы можете легко поменять способ получения данных (например, перейти с одной базы данных на другую), просто написав новый "красный" адаптер. Ядро при этом не изменится.
Тестирование ядра с "синими" адаптерами: Вы можете тестировать всю бизнес-логику ядра, подставляя "синие" адаптеры, которые мгновенно возвращают нужные данные. Это сильно упрощает тесты.
"Краснота" снова изолирована в конкретных адаптерах.
Пример на кухне:
Ядро (синее): Рецепт говорит: "Взять 1 кг муки".
Порт (синий интерфейс):
Вызывает ИнтерфейсДоставкиПродуктов { ДоставитьПродукт(название: Строка, количество: Число): Продукт }
Адаптеры:
АдаптерДоставкиИзМагазинаУДома (красный): Реально идет в магазин, ждет в очереди, приносит муку.
ТестовыйАдаптерДоставки (синий): В тестах рецепта он мгновенно говорит: "Вот твоя мука (из моего воображаемого склада)".
Рецепту все равно, откуда пришла мука, главное, чтобы она была.
3. Рецепт как список шагов, а потом его кто-то выполняет (Free Monad + Interpreter)
Идея: Вместо того чтобы сразу выполнять "красные" действия, мы сначала описываем программу как последовательность этих действий (как чистую структуру данных). А потом отдельный "исполнитель" (интерпретатор) проходит по этому описанию и выполняет его.
Описание программы (AST - Абстрактное Синтаксическое Дерево - Синее): Вы создаете список или дерево команд: "1. Получи пользователя с ID=5. 2. Если пользователь есть, отправь ему email. 3. Верни 'Успех'". Это просто структура данных, "синяя". Она не выполняет никаких действий сама по себе.
Интерпретатор (Красный или Синий):
"Красный" интерпретатор (для продакшена): Читает описание. Видит "Получи пользователя" – делает реальный асинхронный запрос в базу. Видит "Отправь email" – реально отправляет email.
"Синий" интерпретатор (для тестов): Читает описание. Видит "Получи пользователя" – возвращает заранее подготовленного тестового пользователя из памяти. Видит "Отправь email" – записывает в лог, что email был бы отправлен.
Как это уменьшает сложность "красных" задач?
Логика становится чистой: Ваша бизнес-логика теперь – это функция, которая просто строит это "описание программы". Она "синяя" и легко тестируется (проверяется, правильное ли описание она построила).
"Краснота" в одном месте: Вся асинхронность и работа с I/O сосредоточены в "красном" интерпретаторе. Это один компонент, который нужно тщательно протестировать, но сама логика программы от него отделена.
Анализ и оптимизация программы: Поскольку программа – это данные, ее можно анализировать, изменять, оптимизировать перед выполнением.
Пример на кухне:
Описание программы (синее):
Пишет рецепт, записанный на бумаге: "Шаг 1: Взять 2 яйца. Шаг 2: Взбить их. Шаг 3: Поставить сковороду на огонь. Шаг 4: Ждать 2 минуты..."
Интерпретаторы:
ПоварНаРеальнойКухне (красный): Читает рецепт и реально берет яйца, включает плиту, ждет.
ТестировщикРецептов (синий): Читает рецепт, для шага "Ждать 2 минуты" просто ставит галочку и переходит к следующему, проверяя логическую связность.
4. Гибкий рецепт для разных "способов приготовления" (Tagless Final / MTL-style)
Идея: Написать бизнес-логику так, чтобы она не зависела от конкретного "способа выполнения" (синхронного, асинхронного, тестового). Логика параметризуется этим "способом".
Алгебра (Интерфейс с параметром-эффектом F[_]): Вы определяете операции, которые вам нужны, но тип результата оборачивается в некий абстрактный "контейнер эффекта" F. Например: trait ХранилищеПользователей[F[_]] { def найти(id: Id): F[Пользователь] }. F может быть чем угодно: Future (для асинхронности), Option (если результат может отсутствовать), Id (для отсутствия эффекта в тестах).
Бизнес-логика (Полиморфна по F - Синяя): Ваша логика использует эти операции и пишется так, как будто F – это обычный тип. Она не знает, будет ли F асинхронным или нет. Она "синяя" по своей структуре.
Выбор конкретного F ("на краю мира"):
Для продакшена (красный): Вы говорите, что F – это Future или IO (асинхронные типы). И предоставляете реализацию ХранилищаПользователей[Future], которая реально ходит в базу.
Для тестов (синий): Вы говорите, что F – это Id (просто значение без обертки) или State (для отслеживания состояния). И предоставляете реализацию ХранилищаПользователей[Id], которая работает с данными в памяти.
Как это уменьшает сложность "красных" задач?
Максимальная переиспользуемость логики: Одна и та же бизнес-логика работает и для асинхронного выполнения, и для синхронного тестирования.
"Краснота" выбирается в конце: Асинхронность "внедряется" только на самом верхнем уровне, когда вы собираете программу для запуска. Сама логика остается чистой.
Мощное тестирование: Вы можете тестировать логику с "синими" F, полностью контролируя окружение.
Пример на кухне:
Алгебра (набор простых операций): ИнтерфейсПоварскихОпераций[Способ] { Нагреть(ингредиент, до_температуры): Способ[НагретыйИнгредиент] }
Бизнес-логика (рецепт, композиция простых операций): Использует Нагреть, не зная, какой Способ будет (газовая плита, микроволновка, воображаемый нагрев).
Выбор Способа:
На кухне: Способ = РаботаСГазовойПлитой (красный, требует времени).
При проверке рецепта на бумаге: Способ = МгновенноеВоображаемоеНагревание (синий).
5. "Умные комбайны", скрывающие сложность (Современные эффект-системы: ZIO, Cats Effect, Arrow Fx)
Идея: Эти библиотеки предоставляют мощные типы данных (например, IO, Task, ZIO) для описания "красных" операций. Эти типы сами по себе являются "синими" значениями (описаниями), но они очень умные.
Инкапсуляция эффектов: Когда вы пишете IO { println("Привет") } или Task.defer { httpClient.get(...) }, вы не выполняете действие сразу. Вы создаете "синее" описание этого действия.
Композиция: Эти типы легко комбинируются для описания сложных последовательностей, параллельных выполнений, обработки ошибок, управления ресурсами (например, автоматическое закрытие файлов).
Отложенное выполнение: Реальное выполнение "красной" операции происходит только в самом конце, когда вы явно запускаете это описание (например, myProgram.unsafeRunSync()).
Как это уменьшает сложность "красных" задач?
Декларативность: Вы описываете что должно произойти, а не как управлять потоками, коллбэками, промисами.
Безопасность: Встроенные механизмы для управления ресурсами и ошибками помогают избежать утечек и необработанных исключений.
Тестируемость: Поскольку IO значения – это просто данные, их можно сравнивать, передавать, а также использовать специальные тестовые рантаймы, которые позволяют контролировать время и другие аспекты асинхронного выполнения.
"Краснота" не распространяется на логику: Ваша бизнес-логика, оперирующая этими IO значениями, остается композируемой и более предсказуемой, чем код с "голыми" промисами или коллбэками. Хотя сам эффект (I/O) "красный", программа, его описывающая, сохраняет многие свойства "синего" кода.
Пример на кухне:
Представьте себе супер-умный кухонный комбайн (это и есть IO или ZIO библиотеки).
Вы даете ему рецепт: "1. Взять данные из холодильника (IO). 2. Нагреть на плите (IO). 3. Смешать (чистая функция). 4. Подать на стол (IO)."
Комбайн сам разбирается с тем, как открыть холодильник, как управлять плитой, как дождаться нагрева. Ваша задача – правильно составить последовательность команд для комбайна.
Общий вывод по всем практикам:
Цель – не полностью избавиться от "красных" функций (это невозможно, если программа взаимодействует с внешним миром), а:
Уменьшить их количество в тех частях кода, где содержится основная, сложная логика.
Изолировать их на границах системы или в специальных компонентах (адаптерах, интерпретаторах).
Сделать их более управляемыми и предсказуемыми с помощью абстракций (порты, алгебры, эффект-системы).
Это приводит к коду, который:
Проще понимать (меньше асинхронных "прыжков" в основной логике).
Значительно проще тестировать (большую часть можно тестировать как "синий" код).
Легче изменять и развивать (изменения в способе I/O не затрагивают ядро).
сколько способом мышления, который сделает любой ваш код лучше, а любую архитектуру – яснее.
Ага, обязательно, ваше мышление балабола. Ответьте ка для начала на все пункты в которые вас тыкали носом неоднократно, а разных местах, а вы их игнорировали:
https://habr.com/ru/companies/vk/articles/839632/comments/#comment_27247278
Все ваши слова - пустой звук и ничего не весят . Потому что вы их не можете никогда подкрепить реальными кейсами. Так же и тут, придумали себе розовый мир.
Идея проста и элегантна: код, который легко и удобно тестировать, — это хорошо спроектированный код. Точка.
Ага, и это выполнимо только при одном условии - при написании небольшой библиотечки. Точка. Во всех остальных случаев в игру вступает реальный мир, в котором всё переплетено со всем, множество сайд эффектов, АПИ, комбинаций сценариев использования(а это 100500 разветвлений после каждого клика), более того, проект меняется, развивается, логика меняется и одномоментно сотни бесполезных тестов перестают работать, а ты и так уже не разработчик, а тестировщик, раз все что ты делаешь, это не разрабатываешь, а пишешь постоянно тесты, ведь если не писать их 90% времени, то в реальности они будут бестолковыми и как это бывает в 99.999% случаев для вида. Классно, вам пора профессию менять, она называется QA. Полноценно тестировать приложение реального мира могут только люди, ручные тестировщики, точка.
код, который легко и удобно тестировать, — это хорошо спроектированный код. Точка.
Тут причина перепутана со следствием. Хорошо спроектированный код легко использовать в разных контекстах. В частности в контексте тестирования. Особенно, если тестовый фреймворк спроектирован под архитектуру твоего кода. Если же просто исковеркать свой код под тестирование каким-то первым попавшимся инструментом, то использовать его будет сложно. В частности, оптимизация кода под юнит-тестирование порождает мало того, что сложный в использовании и поддержке код, так ещё и избыточное число очень хрупких и малополезных тестов.
В целом конечно, в статье очень много утверждений, не подкрепленные каким-либо доказательством.
Идея проста и элегантна: код, который легко и удобно тестировать, — это хорошо спроектированный код.
Откуда это следует? Я вот совсем связи не прослеживаю. Код, который легко и удобно тестировать - легко и удобно тестируемый, но это не делает его автоматически хорошо спроектированным. Да, он может быть лучше спроектирован, чем код, который писался без оглядки на тесты, но до "хорошо" ему монет быть как до луны.
Представьте, что вы строите дом и в первую очередь думаете о том, как его будут проверять на прочность, безопасность и удобство. Логично, что такой дом получится качественным.
Так себе аналогия. В отличие от дома более-менее средний проект (если это не мвп или проект одной задачи) постоянно эволюционирует. И требования могут меняться, что может потребовать различных твистов, вплоть до переписывания большей части проекта. С домом такое редко бывает, разве что вы изначально не знаете, что вам нужно и дом постоянно перестраивается и стройка никогда не закончится.
Очень спорная статья. Я бы сказал, что хорошо спроектированным код - это код, который легко понимать и изменять под новые требования, само собой не ломая существующий функционал. А как этого достичь, это обширная тема, причем на только технического характера, но и организационная часть имеет тут немалое значение
Архитектура от тестов: Проектируем код, который легко поддерживать