Pull to refresh

Архитектура от тестов: Проектируем код, который легко поддерживать

Level of difficultyEasy
Reading time7 min
Views3.4K

Привет! Мы, фронтендеры, постоянно в поиске идеальной архитектуры. Слои, фича-слайсы, атомарный дизайн, фрактальность... Все эти подходы имеют право на жизнь. Но сегодня я хочу поделиться не столько новой структурой папок, сколько способом мышления, который сделает любой ваш код лучше, а любую архитектуру – яснее.

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

Давайте применим этот принцип к нашему коду, мысленно "проверяя" его через призму различных типов тестов. И сразу скажу: вам не обязательно бросаться писать все эти тесты, если вы (пока!) не готовы или не видите в этом острой нужды. Главное – это паттерн мышления.

TLDR:

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

  2. Разные типы тестов помогают увидеть разные грани структуры: они подсказывают, как лучше выделить системные части и их интерфейсы.

  3. Это не 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: взгляд сверху

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

Как это влияет на дизайн кода?

  • Фокус на пользовательском опыте (UX): Мы невольно начинаем больше думать о консистентности интерфейса и поведения приложения на всех шагах пользовательского пути. Все ли элементы на своих местах? Понятны ли переходы? Нет ли "тупиков"?

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

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

Чем можно проверить (если решите): Основные игроки здесь – Cypress и Playwright. Это мощные инструменты для автоматизации браузера, позволяющие писать тесты, максимально приближенные к действиям реального пользователя. Но и обо всей инфраструктуре придется подумать больше - очередная возможность сдружиться с отсальными разработчиками продукта (бекенд, девопс и т.п.).

SOLID: Хотя прямого соответствия одному принципу нет, E2E-мышление усиливает Принцип единственной ответственности (SRP) уже на уровне целых пользовательских сценариев или фич. Каждая большая фича должна быть максимально целостной и, по возможности, независимой.

Почему это работает? Ключевые преимущества

  1. Интуитивно понятная ментальная модель: Размышление категориями тестов помогает естественным образом декомпозировать систему на логические слои и компоненты с ясными обязанностями.

  2. Готовность к проверке: Даже если вы не пишете тесты сразу, такая архитектура готова к ним. А если напишете – получите уверенность в своем коде.

  3. Улучшение API модулей: Интерфейсы (props, методы, события) ваших компонентов и модулей становятся чище и удобнее, так как вы проектируете их с точки зрения "потребителя".

  4. Снижение связанности, повышение сфокусированности: Модули делают что-то одно (высокое зацепление), но делают это хорошо и меньше зависят от других (низкая связанность). Это упрощает поддержку.

  5. Легкость рефакторинга и развития: Вносить изменения или добавлять новую функциональность в такой код проще и безопаснее.

Совместимость с другими подходами

Этот "архитектурный взгляд со стороны тестов" не стремится заменить всё, что вы знали. Он прекрасно дополняет другие подходы:

  • Фрактальная архитектура или Feature-Sliced Design: Отлично описывают структуру папок и файлов — где что лежит. Наш метод помогает определить, каким должен быть код внутри этих папок.

  • Паттерны управления состоянием (Flux, MobX, Reatom и т.д.): Отвечают за потоки данных и управление состоянием. Мышление "от тестов" поможет вам спроектировать ваши хранилища, экшены, редюсеры или эффекты более тестируемыми и, следовательно, более предсказуемыми и надежными.

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

В сухом остатке

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

Простые ориентиры для старта:

  1. Задайте себе вопрос: "Как бы я это протестировал, будь это изолированный кусок кода?" перед тем, как начать писать.

  2. Нужна простая логика без эффектов? Держите в уме юнит-тестирование. Стремитесь к чистым функциям и простым компонентам.

  3. Создаете самодостаточный виджет или модуль с внутренним состоянием? Подумайте, как бы вы его проверили как "черный ящик" через его API. Это поможет сделать API четким.

  4. Связываете несколько модулей в единый пользовательский сценарий? Представьте, как бы вы проверили их совместную работу. Это поможет выявить "оркестратора" и его интерфейсы.

  5. Продумываете сквозной путь пользователя? Мысленная E2E-проверка поможет не забыть о консистентности и удобстве.

Попробуйте этот образ мышления. Уверен, ваш код, ваши коллеги и вы сами в будущем скажете за это спасибо. И кто знает, может, в конечном итоге вы даже найдете удовольствие в написании настоящих тестов! ;)

Tags:
Hubs:
+5
Comments12

Articles