Блог Томаша Фяялковского о программировании

Плохая система всегда победит хорошего человека. Чтобы эффективно разрабатывать программное обеспечение, критически важно контролировать его качество, в частности архитектуру и структуру кода. В Java в этом может помочь ArchUnit — библиотека для тестирования некоторых аспектов архитектуры и дизайна. В этой статье я приведу десять практических тестов ArchUnit, основанных на моём опыте разработки приложения на базе Spring Framework.

Хотя ArchUnit — мощный инструмент, полностью использовать его возможности и понять, что стоит тестировать в первую очередь, может быть непросто. Описанные тесты не появились сразу. Я начал с двух–трёх простых проверок и затем, в течение нескольких лет, добавлял новые или переопределял существующие тесты по мере того, как нарушались правила дизайна. Эти правила могут нарушаться по разным причинам: в спешке изменений легко забыть определённые принципы, а новые участники команды могут не знать о существующих ограничениях проектирования.

Записывать правила в виде тестов — способ формализовать архитектуру и дизайн, помочь поддерживать качество проекта. В результате у меня выработался практичный набор тестов, который я могу применять в своих проектах для обеспечения их консистентности и соответствия архитектурным предпосылкам. Ниже я представляю эти тесты с использованием библиотеки ArchUnit, которую применяю почти в каждом Java‑проекте. Если они вам пригодятся, просто скопируйте их в свой проект. Некоторые тесты универсальны, другие более привязаны к структуре конкретного проекта. Для лучшего понимания и адаптации приведу пример структуры пакетов.

Эта структура не является типичной слоистой архитектурой, поэтому в статье вы не найдёте тестов вида «контроллеры используют сервисы, а сервисы используют репозитории». Также я помещаю все ArchUnit‑тесты в один тестовый класс, где объявляю две константы, полезные для тестов. Описывая тесты, я предполагаю, что они встроены в такой класс.

Когда говоришь об архитектурных тестах, стоит упомянуть хорошую практику: если архитектурное правило задокументировано, например, в ADR (Architecture Decision Record), полезно, чтобы тест, проверяющий это правило, ссылался на соответствующий документ. Тест должен ясно объяснять, почему данное инвариантное требование важно. Следует избегать ситуации, когда падающий тест оставляет разработчика в недоумении: «А почему? Что плохого в том, чтобы сделать так?». Такие ситуации могут привести к удалению теста и потере первоначальных архитектурных допущений.

Один из самых популярных тестов ArchUnit — проверка циклических зависимостей. Это простой, но важный тест, с которого стоит начать. В приведённой структуре пакетов модули первого уровня — users, orders, products и commons. Важно, чтобы зависимости между этими модулями были прозрачны и соответствовали предположениям: например, users не должны зависеть от products.

ArchUnit не предоставляет абстракции «модуль», но даёт средства для тестирования слоистой архитектуры, например, проверки, что контроллеры зависят от сервисов, а сервисы — от репозиториев. Рассматривая модули как слои, можно определить тест, который верифицирует зависимости между модулями. Представление модулей в виде слоёв вносит некоторое шумовое усложнение в тест, но это наиболее краткое решение в ArchUnit для достижения цели проверки модульных зависимостей.

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

При этом у теста есть ограничение: если на диаграмме указана зависимость A → B, тест пройдёт даже если в коде такой зависимости нет. Иными словами, диаграмма определяет возможные переходы, которые могут, но не обязательно появятся в коде. Фундаментальное правило гексагональной архитектуры (ports and adapters), луковой архитектуры или чистой архитектуры — принцип инверсии зависимостей: модули высокого уровня не должны напрямую зависеть от модулей низкого уровня. Конкретно: домен не должен зависеть от инфраструктуры, а инфраструктура может зависеть от домена. Это правило можно проверить простым тестом.

Если проект строго следует руководящим принципам гексагональной архитектуры, можно определить отдельный тест для каждого модуля. Такой тест жёстче контролирует соблюдение принципов согласованности и ответственности отдельных пакетов. Однако проверка того, что домен не зависит от инфраструктуры, не гарантирует, что класс из инфраструктуры действительно расположен в правильном месте. В одном проекте случилось так, что класс с SQL‑кодом оказался в домене. Чтобы предотвратить такие ошибки, добавили тест, запрещающий наличие в домене зависимостей от пакетов, относящихся к базе данных. Аналогично можно исключать пакеты, связанные с Kafka, JSON‑сериализацией или другими технологиями, которые не должны присутствовать в домене. Такие тесты помогают сохранять код домена чистым и независимым от технических деталей — важное условие для поддерживаемости и гибкости архитектуры.

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

Хорошей практикой в Spring является внедрение зависимостей через конструктор, а не через поля или сеттеры. Чтобы гарантировать отсутствие field‑based injection, достаточно проверить, что ни одно поле не аннотировано @Autowired или @Value. Такой тест обеспечивает, что все зависимости передаются через конструктор, что считается лучшей практикой в Spring.

В Spring легко собирать метрики с помощью Micrometer, и хорошие метрики — важная составляющая эксплуатации приложения в проде. В частности, стоит иметь данные по каждому энтпоинту. Поэтому каждый публичный метод контроллера должен быть аннотирован @Timed. Это легко забыть, но ArchUnit‑тест поможет предотвратить пропуск такой аннотации. В моих проектах классы сервисов являются входной точкой в домен, поэтому я также собираю метрики для каждого публичного метода в сервисных классах.

Имена классов и пакетов тоже стоит проверять. В разных проектах контроллеры могут называться по‑разному: *Controller, *Endpoint, *Resource, *Api, *Handler. Но правило именования часто не соблюдается последовательно в пределах одного проекта. Поэтому полезно определить ArchUnit‑тесты, гарантирующие, например, чтобы все классы контроллеров оканчивались на Controller. Аналогично можно тестировать имена репозиториев, сущностей или пакетов, в которых они находятся. Это повышает согласованность и облегчает сопровождение кода.

В своих проектах я придерживаюсь правила, что у каждого модуля должен быть единый точечный вход — один класс, который может производить настроенный модуль. Это можно описать как фабрику для настроенного модуля. Для меня это класс Config, который создаёт bean, служащий фасадом модуля. Иными словами, у каждого модуля должен быть один‑единственный конфигурационный класс. Этот класс конфигурации также используется в модульных тестах для создания тестируемого модуля, что облегчает верификацию доменной части модуля целиком.

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

В моём коде я не использую аннотации типа @Service или @Component. Вещи регистрируются как бины в конфигурационном классе через @Bean. Это соответствует правилу, что класс Config является фабрикой, предоставляющей настроенный модуль. Тест проверяет, что компоненты создаются в конфигурационных классах, а не помечаются аннотациями @Service или @Component на самих классах. Такой подход позволяет централизированно управлять конфигурацией бинов в проекте, упрощая наблюдение, изменение и тестирование.

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

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

Программирование Chi (или Qi) — блог о скромных идеях, багах и провалах, с которыми я сталкиваюсь ежедневно. К счастью, вместе с неудачами появляются и многочисленные успехи и элегантные решения, о которых я надеюсь тоже писать.