📐 Розділ 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("+380991112233", BigDecimal.valueOf(5000));

        verify(mockRepo).save(any(Order.class));
        verify(mockNotify).send(eq("+380991112233"), 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% рядків у методі повинні бути замокані (овермокінг робить тести крихкими).

Пов’язані теми