Архитектура от тестов: Проектируем код, который легко поддерживать
- суббота, 10 мая 2025 г. в 00:00:08
Привет! Мы, фронтендеры, постоянно в поиске идеальной архитектуры. Слои, фича-слайсы, атомарный дизайн, фрактальность... Все эти подходы имеют право на жизнь. Но сегодня я хочу поделиться не столько новой структурой папок, сколько способом мышления, который сделает любой ваш код лучше, а любую архитектуру – яснее.
Идея проста и элегантна: код, который легко и удобно тестировать, — это хорошо спроектированный код. Точка.
Представьте, что вы строите дом и в первую очередь думаете о том, как его будут проверять на прочность, безопасность и удобство. Логично, что такой дом получится качественным.
Давайте применим этот принцип к нашему коду, мысленно "проверяя" его через призму различных типов тестов. И сразу скажу: вам не обязательно бросаться писать все эти тесты, если вы (пока!) не готовы или не видите в этом острой нужды. Главное – это паттерн мышления.
TLDR:
Код, удобный для тестов, удобен для всего остального: для других модулей, для новых разработчиков, для вас самих через полгода.
Разные типы тестов помогают увидеть разные грани структуры: они подсказывают, как лучше выделить системные части и их интерфейсы.
Это не TDD (Test-Driven Development). Речь не о том, чтобы писать тесты до кода. Речь о том, чтобы проектировать код так, как будто его собираются тщательно тестировать. Это простой способ сделать себе же лучше в будущем.
Итак, приступим!
В чем суть юнит-теста? Это тест самой маленькой, изолированной части системы. Чистая функция, максимально простой компонент. Такой юнит не должен знать о внешнем мире, о других сервисах или общем состоянии приложения. Он получает данные на вход – возвращает результат на выход. Важно: юнит не должен иметь внутреннего состояния, которое меняется от вызова к вызову, и не должен производить побочных эффектов (side effects), вроде запросов к API или изменения глобальных переменных. Его поведение предсказуемо. Рекомендую почитать про идемпотентность.
Как это влияет на дизайн кода?
Чистые функции повсюду: Вы инстинктивно начинаете выносить логику в чистые функции. formatPrice(price, currency)
– идеальный кандидат. Никаких сюрпризов.
Презентационные ("глупые") компоненты: Компоненты, чья единственная задача – отобразить данные, полученные через props, и передать события наверх. UserProfileView(userData, onEditClick)
. Они не ходят за данными сами и не управляют сложным состоянием.
Явные зависимости: Все, что нужно функции или компоненту для работы, передается ему в качестве аргументов/props. Никакой скрытой магии.
Чем можно проверить (если решите): Для этого типа тестов подойдет что угодно: от uvu до vitest. Для UI-компонентов тоже подойдет что угодно, а в случае использования сторонней библиотеки можно добавить скриншот тесты, для упрощения обновлений.
SOLID: Это чистейший Принцип единственной ответственности (SRP). Каждый юнит делает что-то одно и делает это хорошо.
В чем суть модульного теста? Здесь мы проверяем отдельный, относительно самодостаточный кусок функциональности. Это может быть виджет (например, интерактивный календарь, форма поиска с автодополнением) или целый бизнес-модуль (например, "Корзина товаров"). Такой модуль для теста — это "черный ящик": мы взаимодействуем с его публичным интерфейсом, не вдаваясь в детали внутренней реализации. Ключевое отличие от юнитов: модуль или виджет как раз и предназначен для того, чтобы инкапсулировать в себе набор связанных состояний и эффектов, необходимых для его работы.
Как это влияет на дизайн кода?
Четкий публичный API у модулей: У каждого "виджета" или "модуля" появляется понятный контракт: какие данные он принимает на вход (props, методы)? Какие события генерирует вовне?
Настоящая инкапсуляция: Внутреннее устройство модуля и его состояние скрыты от внешнего мира. Взаимодействие происходит только через определенный интерфейс. Это защищает модуль от непреднамеренных изменений извне и упрощает его использование.
Локальное, управляемое состояние: Модули могут и должны управлять своим внутренним состоянием, но оно остается их личным делом.
Чем можно проверить (если решите): Для тестирования логических моделей подойдет все что может мокать IO, а вот для UI-компонентов можно взять соответствующую testing library или компонентные тесты в безголовом браузере. Они позволяют взаимодействовать с компонентами так, как это делал бы пользователь (находит элементы, кликает, вводит текст), проверяя результат по видимому результату, а не по деталям реализации.
SOLID: Принцип открытости/закрытости (OCP) – модуль можно расширять (использовать в разных местах, по-разному конфигурировать через его API), но его внутренняя логика закрыта для прямого изменения. И, конечно, инкапсуляция.
В чем суть интеграционного теста? Он проверяет, как несколько модулей (виджетов, сервисов) работают вместе, образуя некий пользовательский сценарий или бизнес-процесс. Часто это проверка функциональности в рамках одного роута или сложного взаимодействия между несколькими частями интерфейса. Например: "заполнить многоступенчатую форму, отправить ее, получить уведомление об успехе и переход на другой роут".
Как это влияет на дизайн кода?
Появление "оркестраторов": Возникает необходимость в сущностях, которые будут координировать взаимодействие модулей. Это могут быть компоненты-контейнеры или DI провайдер. Они управляют потоками данных и состоянием для группы модулей.
Роутер как основа интеграции: Роутинг становится естественным способом организации таких пользовательских сценариев. Каждая страница или значимое состояние приложения – это узел, где интегрируются различные модули. Грамотно спроектированный роутер – уже половина хорошей архитектуры.
Взаимодействие через интерфейсы, а не реализации: "Оркестраторы" должны зависеть от публичных API модулей, а не от их внутренних деталей.
Чем можно проверить (если решите): Здесь также хорошо подходят Testing Library, но уже для тестирования более крупных кусков приложения, часто с моками уже на инфраструктурном уровне (например, с помощью Mock Service Worker - MSW). Для более "толстых" интеграционных тестов, которые могут даже частично затрагивать бэкенд-систему (или ее заглушки), можно использовать Cypress или Playwright.
SOLID: Принцип инверсии зависимостей (DIP). Высокоуровневые модули (наши "оркестраторы" сценариев) не должны зависеть от конкретных низкоуровневых модулей (конкретных виджетов). И те, и другие должны зависеть от абстракций (их четко определенных публичных API).
В чем суть E2E (End-to-End) теста? Это полная имитация действий реального пользователя, который проходит по ключевым сценариям приложения от начала до конца. Такая проверка затрагивает все слои системы, включая (опционально) реальный бэкенд. Например: "создать сущность, посмотреть ее в списке, отредактировать, удалить".
Как это влияет на дизайн кода?
Фокус на пользовательском опыте (UX): Мы невольно начинаем больше думать о консистентности интерфейса и поведения приложения на всех шагах пользовательского пути. Все ли элементы на своих местах? Понятны ли переходы? Нет ли "тупиков"?
Управляемая связанность приложения: E2E-тесты очень чувствительны к хрупкости. Если небольшое изменение в одном месте ломает множество сценариев, это сигнал о слишком сильной и неуправляемой связанности частей приложения. Такой взгляд подталкивает к созданию более независимых или четко сопряженных фич.
Надежность инфраструктурного кода: Заставляет обратить внимание на код, отвечающий за инициализацию приложения, глобальное управление состоянием, обработку ошибок на уровне всего приложения, взаимодействие с окружением (SSR, разные API).
Чем можно проверить (если решите): Основные игроки здесь – Cypress и Playwright. Это мощные инструменты для автоматизации браузера, позволяющие писать тесты, максимально приближенные к действиям реального пользователя. Но и обо всей инфраструктуре придется подумать больше - очередная возможность сдружиться с отсальными разработчиками продукта (бекенд, девопс и т.п.).
SOLID: Хотя прямого соответствия одному принципу нет, E2E-мышление усиливает Принцип единственной ответственности (SRP) уже на уровне целых пользовательских сценариев или фич. Каждая большая фича должна быть максимально целостной и, по возможности, независимой.
Интуитивно понятная ментальная модель: Размышление категориями тестов помогает естественным образом декомпозировать систему на логические слои и компоненты с ясными обязанностями.
Готовность к проверке: Даже если вы не пишете тесты сразу, такая архитектура готова к ним. А если напишете – получите уверенность в своем коде.
Улучшение API модулей: Интерфейсы (props, методы, события) ваших компонентов и модулей становятся чище и удобнее, так как вы проектируете их с точки зрения "потребителя".
Снижение связанности, повышение сфокусированности: Модули делают что-то одно (высокое зацепление), но делают это хорошо и меньше зависят от других (низкая связанность). Это упрощает поддержку.
Легкость рефакторинга и развития: Вносить изменения или добавлять новую функциональность в такой код проще и безопаснее.
Этот "архитектурный взгляд со стороны тестов" не стремится заменить всё, что вы знали. Он прекрасно дополняет другие подходы:
Фрактальная архитектура или Feature-Sliced Design: Отлично описывают структуру папок и файлов — где что лежит. Наш метод помогает определить, каким должен быть код внутри этих папок.
Паттерны управления состоянием (Flux, MobX, Reatom и т.д.): Отвечают за потоки данных и управление состоянием. Мышление "от тестов" поможет вам спроектировать ваши хранилища, экшены, редюсеры или эффекты более тестируемыми и, следовательно, более предсказуемыми и надежными.
Вы можете использовать фрактальную структуру для организации файлов, Flux-подобный подход для управления глобальным состоянием, а "мышление от тестов" – для дизайна самих компонентов, модулей и их взаимодействия.
Думать об архитектуре "от тестов" — это не про дополнительную нагрузку, а про проявление инженерной зрелости и заботы о качестве и долговечности вашего кода.
Простые ориентиры для старта:
Задайте себе вопрос: "Как бы я это протестировал, будь это изолированный кусок кода?" перед тем, как начать писать.
Нужна простая логика без эффектов? Держите в уме юнит-тестирование. Стремитесь к чистым функциям и простым компонентам.
Создаете самодостаточный виджет или модуль с внутренним состоянием? Подумайте, как бы вы его проверили как "черный ящик" через его API. Это поможет сделать API четким.
Связываете несколько модулей в единый пользовательский сценарий? Представьте, как бы вы проверили их совместную работу. Это поможет выявить "оркестратора" и его интерфейсы.
Продумываете сквозной путь пользователя? Мысленная E2E-проверка поможет не забыть о консистентности и удобстве.
Попробуйте этот образ мышления. Уверен, ваш код, ваши коллеги и вы сами в будущем скажете за это спасибо. И кто знает, может, в конечном итоге вы даже найдете удовольствие в написании настоящих тестов! ;)