📐 Раздел 18 · Вопрос #15

Как SOLID помогает в тестировании кода

Принципы SOLID называют фундаментом концепции Design for Testability (Проектирование для тестируемости).


🟢 Junior Level

Принципы SOLID называют фундаментом концепции Design for Testability (Проектирование для тестируемости).

Майкл Физерс в классической книге «Эффективная работа с унаследованным кодом» дал знаменитое определение:

«Legacy-код — это код, не покрытый модульными тестами.»

Если код написан без учета SOLID (содержит жесткие вызовы new, статические обращения к базе данных или скрытые зависимости от системного времени), написать для него быстрый модульный тест (Unit Test) практически невозможно.

Житейская аналогия

Тестирование двигателя автомобиля:

  • Код без SOLID: Двигатель намертво заварен внутри кузова, приварен к коробке передач и бензобаку. Чтобы проверить работоспособность генератора, вам приходится заправлять полный бак, выезжать на скоростное шоссе и разгонять всю машину.
  • Код с SOLID: Двигатель собран из независимых блоков со стандартизированными разъемами. Вы можете снять генератор за 1 минуту, положить его на верстак в лаборатории, подключить тестовый стенд и проверить его параметры без бензина и колес.

Наглядный пример: Нетестируемый код vs SOLID

Плохо (Невозможно протестировать без внешнего мира):

public class OrderService {
    // Нарушение SRP и DIP: жесткая прямая связь с базой данных и SMS-шлюзом
    public void checkout(String userPhone, BigDecimal amount) {
        // Как протестировать этот метод на CI-сервере без реальной БД и интернета?
        PostgresDatabase db = new PostgresDatabase("jdbc:postgresql://prod-db:5432/shop");
        db.insertOrder(userPhone, amount);

        TwilioSmsClient sms = new TwilioSmsClient("LIVE_SECRET_API_KEY");
        sms.sendDirectSms(userPhone, "Заказ оформлен на сумму " + amount);
    }
}

Хорошо (Легко протестировать за 1 миллисекунду):

public class OrderService {
    private final OrderRepository orderRepository;
    private final NotificationChannel notificationChannel;

    // DIP: зависимости передаются через конструктор (интерфейсы)
    public OrderService(OrderRepository orderRepository, NotificationChannel notificationChannel) {
        this.orderRepository = orderRepository;
        this.notificationChannel = notificationChannel;
    }

    public void checkout(String userPhone, BigDecimal amount) {
        orderRepository.save(new Order(userPhone, amount));
        notificationChannel.send(userPhone, "Заказ оформлен на сумму " + amount);
    }
}

// Чистый модульный тест на Mockito без баз данных и сети:
class OrderServiceTest {
    @Test
    void shouldSuccessfullyCheckoutOrder() {
        OrderRepository mockRepo = mock(OrderRepository.class);
        NotificationChannel mockNotify = mock(NotificationChannel.class);
        OrderService service = new OrderService(mockRepo, mockNotify);

        service.checkout("+79991112233", BigDecimal.valueOf(5000));

        verify(mockRepo).save(any(Order.class));
        verify(mockNotify).send(eq("+79991112233"), contains("5000"));
    }
}

🟡 Middle Level

Вклад каждого из 5 принципов в пирамиду тестирования

Принцип Роль в процессе тестирования Влияние на тестовый набор
S (SRP) Локализация и простота тестов У класса одна ответственность $\rightarrow$ один тестовый класс с небольшим числом тестовых сценариев. Тест проверяет только бизнес-логику класса, не настраивая моки для несвязанных вещей.
O (OCP) Регрессионная стабильность Новое поведение добавляется через создание новых классов $\rightarrow$ пишутся только новые тесты. Старый проверенный тестовый сьют не требует правок и изменений.
L (LSP) Контрактное тестирование (Contract Tests) Все реализации интерфейса обязаны удовлетворять единому базовому набору тестов (Abstract Test Pattern), гарантируя полную взаимозаменяемость.
I (ISP) Легковесные моки (No Dummy Mocks) Узкие интерфейсы спасают от необходимости мокать 25 неиспользуемых методов «жирного» интерфейса ради вызова одного единственного.
D (DIP) Изоляция и подмена внешнего мира Возможность подставить в тестируемый класс Mock, Stub или In-Memory Fake вместо БД, брокера Kafka, файловой системы или стороннего REST API.

TDD (Test-Driven Development) как индикатор чистоты архитектуры

В методологии разработки через тестирование (TDD) тесты выступают в роли «первого клиента» вашего кода:

  • Если при написании теста вам физически больно (приходится писать 40 строк подготовительного кода when(...).thenReturn(...) или настраивать приватные поля через рефлексию) — это безошибочный маркер архитектурного дефекта.
  • Архитектурный запах (Test Smell): Высокий Mock-to-Assertion Ratio (когда 80% теста занимает настройка моков, и только 20% — полезные проверки) говорит о нарушении SRP и чрезмерной связности (Tight Coupling).

🔴 Senior Level

Abstract Contract Test Pattern для верификации LSP

Для гарантии того, что все полиморфные реализации одного интерфейса в системе удовлетворяют LSP, Senior-инженеры создают параметризованные контрактные тесты:

// Базовый параметризованный тест контракта (Contract Test)
public abstract class IdempotentPaymentGatewayContractTest {

    protected abstract PaymentGateway createGateway();

    @Test
    void paymentShouldBeStrictlyIdempotent() {
        PaymentGateway gateway = createGateway();
        PaymentRequest request = new PaymentRequest("order-101", BigDecimal.valueOf(100));

        PaymentResult firstAttempt = gateway.charge(request);
        PaymentResult secondAttempt = gateway.charge(request);

        assertTrue(firstAttempt.isSuccess());
        assertEquals(firstAttempt.transactionId(), secondAttempt.transactionId(),
                "LSP Violation: Repeated call produced different transaction!");
    }
}

// Тест для реальной интеграции с Stripe (запускается с Testcontainers)
class StripePaymentGatewayTest extends IdempotentPaymentGatewayContractTest {
    @Override
    protected PaymentGateway createGateway() {
        return new StripePaymentGateway(stripeTestContainer.getEndpoint());
    }
}

// Тест для InMemory заглушки для быстрых юнит-тестов
class InMemoryPaymentGatewayTest extends IdempotentPaymentGatewayContractTest {
    @Override
    protected PaymentGateway createGateway() {
        return new InMemoryPaymentGateway();
    }
}

Детерминизм во времени и случайностях: Инъекция Clock и Random

Тесты не должны «мигать» (Flaky Tests) из-за наступления полуночи, високосного года или смены часового пояса:

  • Прямой вызов LocalDateTime.now() или System.currentTimeMillis() нарушает DIP, делая класс нетестируемым во времени.
  • Решение по DIP: инъекция системных часов java.time.Clock через конструктор: ```java @Service public class TokenExpirationValidator { private final Clock clock; // Абстракция времени по DIP

    public TokenExpirationValidator(Clock clock) { this.clock = clock; }

    public boolean isExpired(Instant expirationTime) { return clock.instant().isAfter(expirationTime); } }

// В тесте фиксируем точное время до наносекунды: Clock fixedClock = Clock.fixed(Instant.parse(“2026-01-01T12:00:00Z”), ZoneOffset.UTC); TokenExpirationValidator validator = new TokenExpirationValidator(fixedClock);


---

### Скорость выполнения тестов: Unit vs `@SpringBootTest`

В крупных проектах с 10 000+ тестов соблюдение SOLID напрямую определяет время работы CI/CD пайплайна:

| Тип теста | Архитектурная основа | Время запуска 1 теста | Время прогона 10 000 тестов |
|---|---|---|---|
| **Чистый Unit Test (JUnit 5 + Mockito)** | **Строгий SOLID (DIP, SRP):** зависимости переданы в конструктор, Spring не поднимается. | **~1–5 мс** | **~30–50 секунд** |
| **Spring Integration Test (`@SpringBootTest`)** | Нарушение DIP/SRP: класс невозможно протестировать без поднятия `ApplicationContext`. | **~2–15 секунд** (холодный старт контекста) | **~2–4 часа** (падение производительности CI) |

---

## 4 Tricky Questions

### 1. Почему избыточное использование Mockito (Over-mocking) является симптомом нарушения ISP и SRP?
**Ответ:**
Когда разработчик тестирует класс `OrderService` и вынужден настроить 10 моков (`mockUserRepo`, `mockEmailService`, `mockPdfGenerator`, `mockAuditLog`, `mockSmsGateway`), это означает:
1. Класс совмещает в себе слишком много обязанностей (нарушение **SRP**).
2. Класс зависит от «жирных» интерфейсов, у которых вызывается по одному методу (нарушение **ISP**).
Тест становится жестко привязан к внутренней структуре реализации (*white-box coupling*). Любой внутренний рефакторинг без изменения внешнего поведения ломает такой тест, порождая хрупкость тестов (*Fragile Tests*).

### 2. Как различить два подхода к тестированию: Лондонскую школу (Mockist) и Детройтскую/Чикагскую школу (Classicist) в контексте SOLID?
**Ответ:**
- **Лондонская школа (Mockist / Mock-driven):** изолирует класс абсолютно от всех соседей, подменяя все зависимости моками (акцент на DIP и верификацию взаимодействия `verify()`). Плюс: точная локализация ошибок. Минус: хрупкость при рефакторинге.
- **Детройтская / Чикагская школа (Classicist / State-based):** мокает только границы системы (I/O, сеть, БД), а всю внутреннюю доменную модель запускает с реальными объектами, проверяя конечное состояние (`assertEquals()`). Плюс: свобода рефакторинга внутренней структуры.
Senior-разработчик использует Детройтскую школу для доменных сущностей (SRP-моделей) и Лондонскую — для изоляции внешних инфраструктурных адаптеров (портов DIP).

### 3. Как проверить соблюдение принципов SOLID в архитектуре проекта автоматически внутри CI/CD?
**Ответ:**
С помощью библиотеки архитектурного тестирования **ArchUnit**:
```java
// Проверка DIP: сервисы не должны зависеть от контроллеров или инфраструктуры
@ArchTest
public static final ArchRule dip_rule = noClasses()
    .that().resideInAPackage("..domain..")
    .should().dependOnClassesThat().resideInAPackage("..infrastructure..");

// Проверка SRP / Cohesion: интерфейсы не должны быть раздутыми
@ArchTest
public static final ArchRule isp_rule = classes()
    .that().areInterfaces()
    .should().haveLessThanNMethods(10);

Тесты ArchUnit запускаются как обычные JUnit-тесты и роняют сборку при появлении архитектурных нарушений.

4. Допустимо ли менять видимость приватных методов (делать public или package-private), чтобы протестировать их?

Ответ: Нет, это грубейший антипаттерн тестирования. Приватные методы представляют собой скрытые детали реализации. Если приватный метод настолько сложен, что требует отдельного юнит-тестирования, это прямое доказательство того, что он содержит самостоятельную ответственность и нарушает SRP. Правильное решение: применить рефакторинг Extract Class, вынести эту логику в отдельный самостоятельный класс с публичным методом и протестировать его напрямую.


🎯 Шпаргалка для интервью

Главные тезисы (30 секунд на ответ):

  • Суть: SOLID превращает систему в набор слабосвязанных изолированных блоков («Design for Testability»).
  • DIP: Позволяет подменять базы данных, внешние API и системное время (Clock) на Mock/Fake.
  • SRP: Уменьшает количество зависимостей и строк подготовки моков в тестах.
  • LSP: База для Contract Tests (Abstract Test Pattern), гарантирующих эквивалентность поведения всех реализаций.
  • Скорость: Чистый SOLID позволяет запускать 10 000 тестов за 30 секунд без тяжелого @SpringBootTest.

Чего избегать (Red Flags):

  • ❌ Делать приватные методы публичными или удалять private ради тестов (вместо выноса в отдельный класс по SRP).
  • ❌ Использовать @SpringBootTest для проверки тривиальной бизнес-логики одного класса.
  • ❌ Заявлять, что 100% строк в методе должны быть замоканы (овермокинг делает тесты хрупкими).

Связанные темы: