Ваш набор тестов проверяет, что calculateTotal возвращает 42 при правильном входе. Он не проверяет, что src/domain/Invoice.ts может импортировать src/infrastructure/Database.ts. Компилятор доволен и тем, и другим. Ваши юнит-тесты довольны и тем, и другим. Но один из них — архитектурное нарушение, которое обойдётся вам в неделю рефакторинга через полгода.
Это слепое пятно. Мы пишем тесты для логики и полагаем, что структура позаботится о себе сама. Но это не так.
Что такое архитектурный тест на самом деле
Архитектурный тест — это утверждение о структуре вашего кода, а не о его поведении. Он проверяет, что граф зависимостей соответствует дизайну, на котором вы договорились. Он ломает сборку, когда разработчик импортирует не тот слой, создаёт циклическую зависимость или называет класс репозитория без суффикса, который вы стандартизировали.
Это не правила линтинга. Линтинг ловит нарушения стиля. Архитектурные тесты ловят структурные нарушения. Разница важна, потому что у структуры есть семантика. Модуль, который импортирует своего родителя, — это не проблема стиля. Это проблема дизайна.
Большинство команд документирует эти правила в вики, README или закреплённом сообщении Slack от техлида. Документация полезна для онбординга. Бесполезна для принуждения. Архитектурный тест переносит правило из человеческой памяти в пайплайн сборки, где забыть его стоит красной сборки вместо инцидента в продакшене.
Что можно принудить с помощью теста
Очевидный сценарий использования — направление зависимостей. Domain не должен импортировать infrastructure. UI не должен напрямую импортировать data access. Эти правила чётко отображаются на границы пакетов или директорий.
Но архитектурные тесты могут проверять больше, чем импорты. Вот паттерны, которые действительно важны в продакшен-кодбазах.
Циклические зависимости. Пакет, который импортирует сам себя через цепочку из трёх других пакетов, всё равно остаётся циклическим. Ваши глаза не поймают это на код-ревью. Тест, который обходит граф импортов, поймает.
Соглашения об именовании. Если ваша команда решила, что каждая реализация репозитория должна заканчиваться на Repository, тест может это принудить. Это звучит педантично, пока кто-нибудь не создаст UserDao и UserRepo в одной кодбазе, и новые инженеры не смогут понять, какой из них использовать.
Запрещённые зависимости от конкретных библиотек. Может быть, ваш domain-слой не должен зависеть от axios, pg или fs. Может быть, ваш фронтенд не должен импортировать lodash, потому что вы стандартизируетесь на нативных методах. Архитектурный тест может утверждать, что конкретный пакет никогда не появляется в дереве зависимостей конкретного модуля.
Правила аннотаций и наследования. В Java вы можете проверить, что ни один класс в ..domain.. не использует @Autowired. В C# вы можете проверить, что ни один класс в Infrastructure не реализует интерфейс из Application. Это структурные ограничения, которые статический анализ сам по себе не может выразить без доменных знаний.
Как написать один
Лучшие архитектурные тесты выглядят как обычные юнит-тесты. Они запускаются в вашем существующем тест-раннере. Они появляются в той же CI-джобе, что и ваши другие тесты. Единственная разница — в том, на что они утверждают.
Java с ArchUnit:
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
@AnalyzeClasses(packages = "com.mycompany")
class ArchitectureTest {
@ArchTest
static final ArchRule no_cycles =
slices()
.matching("com.mycompany.(*)..")
.should().beFreeOfCycles();
@ArchTest
static final ArchRule domain_is_independent =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..");
}
Это JUnit-тест. Он запускается через mvn test. Он ломает сборку. Никакой специальной CI-джобы не требуется.
C# с NetArchTest:
using NetArchTest.Rules;
using Xunit;
public class ArchitectureTests
{
[Fact]
public void Domain_Should_Not_Depend_On_Infrastructure()
{
var result = Types.InCurrentDomain()
.That()
.ResideInNamespace("MyApp.Domain")
.ShouldNot()
.DependOnAny(Types.InNamespace("MyApp.Infrastructure"))
.GetResult();
Assert.True(result.IsSuccessful);
}
}
Python с import-linter:
У Python нет зрелого эквивалента ArchUnit, но import-linter даёт вам декларативный конфиг, который работает как тест:
# .importlinter
[importlinter:contract:domain-independent]
name = Domain does not import infrastructure
type = forbidden
source_modules =
myapp.domain
forbidden_modules =
myapp.infrastructure
Запускайте через lint-imports в CI. Он выходит с ненулевым кодом при нарушении.
Go: напишите свой
У Go нет мейнстрим-библиотеки для архитектурного тестирования. Большинство команд пишет небольшой тест, который обходит AST:
import (
"strings"
"testing"
"golang.org/x/tools/go/packages"
)
func TestDomainDoesNotImportInfrastructure(t *testing.T) {
cfg := &packages.Config{
Mode: packages.NeedImports | packages.NeedFiles,
}
pkgs, err := packages.Load(cfg, "./domain/...")
if err != nil {
t.Fatal(err)
}
for _, pkg := range pkgs {
for path := range pkg.Imports {
if strings.Contains(path, "/infrastructure/") {
t.Errorf("domain package %s imports infrastructure: %s", pkg.PkgPath, path)
}
}
}
}
Это двадцать строк. Он живёт в вашем наборе тестов. Он запускается на каждом PR. В этом и суть. Вам не нужен фреймворк. Вам нужно утверждение.
Компромиссы, о которых никто не говорит
Архитектурные тесты не бесплатны. Они вводят новую категорию падений сборки, а новые категории падений сборки всегда создают трение.
Они медленнее юнит-тестов. Архитектурный тест, который сканирует каждый импорт в кодбазе на 500 000 строк, занимает время. Не часы. Секунды, иногда десятки секунд. Но это на порядок медленнее типичного юнит-теста. Если вы запускаете их в той же джобе, что и ваши быстрые юнит-тесты, вы теряете цикл обратной связи, который делает юнит-тестирование ценным.
Прагматичное решение — запускать архитектурные тесты в выделенной CI-джобе или помечать их как интеграционные и запускать после прохождения быстрого набора. Правило всё ещё блокирует мерж. Просто оно не замедляет ваш локальный эквивалент npm test.
Ложные срабатывания случаются, когда правило слишком широкое. Если вы запретите все импорты из node_modules в вашем domain-слое, вы сломаете легитимное использование date-fns или zod. Правилам нужны исключения, а исключениям нужно обслуживание. Правило с тридцатью записями pathNot не принуждает архитектуру. Оно кодирует ваш текущий беспорядок.
Они могут давать ложную уверенность. Прохождение архитектурного теста не означает, что ваш дизайн хорош. Это означает, что ваш дизайн соответствует написанным вами правилам. Если правила неверны, тесты — это просто автоматизированный культ карго.
Как добавить их, не сломав CI
Не начинайте с десяти правил. Начните с одного. Выберите направление зависимостей, которое доставляло вам больше всего боли. Может быть, это domain, импортирующий infrastructure. Может быть, это ваш фронтенд, напрямую импортирующий бэкенд-код.
Напишите тест. Запустите локально. Посчитайте падения. Если счёт ноль, вы либо очень дисциплинированы, либо правило не ловит то, что вы думаете. Проверьте намеренным нарушением.
Если счёт — пятьдесят, у вас есть выбор. Исправить все пятьдесят в одном героическом PR или добавить исключения для существующих нарушений и запретить новые. Второй вариант менее удовлетворительный и более устойчивый.
Заставьте тест ломать CI. Не предупреждать. Ломать. Предупреждение — это правило, которое инженеры учатся игнорировать.
FAQ
Заменяют ли архитектурные тесты код-ревью?
Нет. Они автоматизируют те части код-ревью, в которых люди плохи, например, выявление транзитивных импортов через двадцать файлов. Люди всё ещё лучше судят, имеет ли смысл новая зависимость.
А что с микросервисами?
Архитектурные тесты работают лучше всего внутри одного развёртываемого юнита. Между сервисами вы принуждаете границы с помощью контрактов API и изоляции развёртывания, а не графов импортов.
Стоит ли тестировать соглашения об именовании?
Только если непоследовательность вызывает реальную путаницу. Тест, который принуждает суффиксы Repository, полезен в команде из десяти разработчиков. В соло-проекте это, вероятно, шум.
Можно ли использовать это с монорепозиториями?
Да. Nx, Bazel и Turborepo все имеют принуждение границ модулей. Если вы уже используете один из них, используйте встроенные правила. Они работают быстрее и интегрируются с графом зависимостей. Если нет, автономный архитектурный тест — это лёгкая точка входа.
Начните с одного правила
Ваша кодбаза уже имеет неявные правила архитектуры. Они живут в голове вашего сениора. Они принуждаются на код-ревью, когда этот инженер не в отпуске. Они нарушаются в 11 вечера перед дедлайном.
Запишите одно из них в виде теста. Сделайте его красным. Исправьте нарушения. Заставьте его блокировать CI.
В следующий раз, когда кто-то спросит: «Разрешено ли нам импортировать infrastructure из domain-слоя?» — ответ не будет в вики. Он будет в красной сборке.