JavaRush /Курси /Claude code /Unit-, integration- і API-тести

Unit-, integration- і API-тести

Claude code
Рівень 19 , Лекція 2
Відкрита

1. «Тест» — це ще не відповідь

Слово «тест» саме по собі майже нічого не говорить. Коли ви тільки починаєте працювати з тестами, здається, що тест — це будь-який файл з assert. Але одна й та сама вада перевіряється на різних рівнях, і від рівня залежить усе: швидкість, надійність, вартість підтримки та те, чи зловите ви реальну проблему, чи гарно промахнетеся повз неї.

Коли пріоритетний сценарій уже вибрано і TDD-ритм зрозумілий, наступне запитання неминуче: на якому рівні ставити перевірку. Один ризик, різні способи, різна ціна сигналу.

Візьму Commerce OS і знайоме повернення замовлення. Уявімо, що ви правите логіку в refunds. Є правило «після 14 днів повернення заборонене», є сервіс, що створює заявку, є база, є endpoint POST /api/orders/{id}/refund, яким користується адмінка. На папері — одна задача. Усередині — три різні запитання.

Іноді ви запитуєте: «Чи можна взагалі робити повернення після 14 днів?» Це запитання до чистого бізнес-правила. Іноді інше: «Чи не створює сервіс дві заявки, якщо користувач натиснув кнопку двічі?» Ризик живе на стику сервісу та сховища. А іноді найважливіше: «Що побачить клієнт, якщо надішле від’ємну суму?» Це вже запитання до HTTP-контракту.

Зручно тримати це в голові як маленьку карту:

Запитання Найкращий перший рівень
Чи дозволене повернення після 14 днів? Unit
Чи створюється лише одна заявка під час повторного запиту? Integration
Чи поверне endpoint 400 Bad Request на від’ємну суму? API

Саме тут і криється головна практична думка лекції. Немає «найправильнішого» рівня тесту взагалі. Є рівень, який краще відповідає на конкретний ризик. Перевіряти просте правило повернення через HTTP, базу і половину Spring Boot — як їхати по хліб на вантажівці: доїдете, але надто дорого. Звести ж усе до unit-тестів — і ви не помітите, як на реальному стику компонентів система розвалюється бадьоро й зовсім не за unit-правилами.

2. Unit-тест: перевірка правила, а не всього світу

Unit-тест особливо добрий там, де потрібно перевірити ізольоване правило, а не весь світ. Тому з них зручно починати: компактні, чесні за формулюванням запитання. Бізнес-правила, валідатори, обчислення, невелика логіка без бази, мережі та HTTP — їхня територія. Потрібна точна відповідь на одне запитання — unit зазвичай перший хороший інструмент.

Припустімо, у Commerce OS є клас RefundPolicy, який вирішує, чи можна схвалити повернення. Чиста логіка: скільки днів минуло, чи було повернення раніше, чи не сплив період. Якщо баг у тому, що повернення дозволялося на пʼятнадцятий день, підіймати HTTP, JSON, контролер і базу немає сенсу. Потрібен короткий тест на саме правило.

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class RefundPolicyTest {
    @Test
    void forbidsRefundAfter14Days() {
        assertFalse(RefundPolicy.canRefund(15, false)); // після 14 днів повернення заборонене
    }
}

Цей приклад добрий не тому, що він маленький, а тому, що точно відповідає на запитання. Зламається правило — побачите одразу. Тест швидкий, не залежить від стану середовища, не змушує гадати, де проблема: у логіці, у JSON чи в налаштуванні Spring. Для TDD — подарунок: failing test, правка, зелений, далі.

Але в unit-тесту є чесні обмеження. Про те, як логіка живе всередині застосунку, він майже нічого не скаже: не знає, як дані читаються з бази, не бачить транзакцію, не бачить серіалізацію JSON, не помітить, що сервіс створив дві записи замість однієї. Звідси часта помилка: розробник або Claude пише прекрасний unit-тест на «математику», а справжній баг сидить на стику сервісу та репозиторію. Формально тест є — ризик не закрито.

І тут корисно пам’ятати просте правило. Unit-тест — це не «найпростіший тест, який вдалося написати», а тест на ізольоване правило. Запитання вже не про правило, а про взаємодію компонентів — чесно піднімайтеся рівнем вище. Claude обожнює пропонувати unit-тести: їх легше генерувати. Тим важливіший ваш контроль — ви перевіряєте не красу синтаксису, а відповідність реальному ризику.

3. Integration-тест: стикування важливіше за окрему деталь

Якщо ризик живе на стику компонентів, unit-тесту вже мало. Щойно баг сидить не в одній функції, а в зв’язці кількох частин системи, unit надто сильно спрощує реальність — тут і з’являються integration-тести. Вони перевіряють, як разом працюють сервіс, репозиторій, транзакція, мапінг, тестова база. Не «одна формула», а маленький шматок живого застосунку.

Уявімо, що користувач надсилає запит на повернення двічі. Бізнес-правило може бути ідеальним, а система все одно створить дві однакові заявки. Причина вже не в RefundPolicy, а у зв’язці RefundService + RefundRequestRepository. Якщо повністю замокати репозиторій, тест вийде зручним, але небезпечно пригладженим: він перевірить те, що ви самі вигадали для mock-об’єкта, а не поведінку на реальному стику.

Тут integration-тест дає чесніший сигнал:

import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class RefundServiceTest {
    @Autowired RefundService refundService;
    @Autowired RefundRequestRepository repository;

    @Test
    void doesNotCreateDuplicateRequest() {
        refundService.requestRefund(101L, new BigDecimal("50.00"));
        refundService.requestRefund(101L, new BigDecimal("50.00"));
        assertEquals(1, repository.count()); // дубль не створюється
    }
}

Так, такий тест важчий за unit-тест: підіймає контекст, потребує часу та акуратності. Але перевіряє саме те місце, де живе ризик. Дублікат проскакує через особливості репозиторію, транзакції або унікального обмеження — unit про це не дізнається. Integration дізнається, бо дивиться на справжній стик, а не на навчальну картинку.

При цьому integration-тест не зобов’язаний підіймати «весь світ». Це ще одна улюблена крайність новачків і іноді самого Claude: раз уже ми робимо integration, давайте підіймемо весь застосунок, усі модулі і, можливо, Місяць. Не треба. Хороший integration-тест вузький — конкретна зв’язка, конкретний ризик. Якщо проєкт уже використовує test slice, Testcontainers, тестовий профіль або окрему базу для integration-перевірок, дотримуйтеся наявного шаблону, а не приносіть новий заради краси. Інакше ви не тести пишете, а влаштовуєте маленьку міграцію фреймворків просто всередині PR.

4. API-тест: дивитися очима клієнта

API-тест захищає зовнішній контракт застосунку. Тут вас цікавить не лише внутрішня логіка, а й те, що реально побачить клієнт: URL, статус відповіді, JSON, валідація, іноді авторизація. Integration запитує «чи працюють разом сервіс і репозиторій», API — «Що саме отримує зовнішній споживач і чи не зламали ми обіцяний контракт?»

Повернімося до POST /api/orders/{id}/refund. Нехай у вас уже є коректне бізнес-правило і сервіс створює рівно одну заявку. Але фронтенд усе одно може надіслати від’ємну суму. Якщо endpoint за контрактом має повернути 400 Bad Request, це варто захистити окремим тестом. Важливо не те, який метод контролера викликався всередині, а який HTTP-результат вийшов назовні.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(RefundController.class)
class RefundControllerTest {
    @Autowired MockMvc mvc;

    @Test
    void returnsBadRequestForNegativeAmount() throws Exception {
        mvc.perform(post("/api/orders/101/refund")
                .contentType("application/json")
                .content("{\"amount\":\"-10.00\"}"))
           .andExpect(status().isBadRequest()); // 400 Bad Request
    }
}

Такий тест особливо корисний, коли endpoint уже хтось використовує: адмінка, фронтенд, зовнішній інтегратор, мобільний застосунок. У таких випадках поломка контракту часто болючіша за поломку внутрішньої деталі. Сервіс ідеальний, а клієнт отримує 500 замість очікуваного 400 — реальний баг, який unit та integration часом просто не помічають.

Тут є свій типовий промах. Новачок пише API-тест, а всередині перевіряє не контракт, а внутрішню реалізацію: який сервісний метод викликався, який private mapper спрацював, який DTO зібрався. Це не API-тест, а спроба однією перевіркою охопити все на світі. API-тест має дивитися очима клієнта. Статус, тіло відповіді, формат помилки, заголовки, доступність маршруту — ось його територія. Полізли всередину контролера — значить, тест обрав не свою роботу.

5. Вибір рівня і дисципліна Claude у стеку

На практиці один тест на задачу потрібен майже ніколи. Зазвичай потрібен набір датчиків на різних рівнях, і кожен ловить свій тип поломки. Добрий тестовий дизайн — не фан-клуб одного виду тестів, а акуратна система перевірок на різних межах.

Можна тримати в голові дуже просту евристику. Запитання звучить як «чи правильне це правило?» — починайте з unit. Як «чи правильно працюють разом дві частини системи?» — це integration. Як «що побачить зовнішній клієнт?» — потрібен API-тест. Одна задача нерідко вимагає кількох рівнів одразу. Для refund-сценарію це природно: unit захищає правило 14 днів, integration стежить за відсутністю дублікатів, API страхує контракт 400 Bad Request.

У роботі з Claude тут особливо важлива дисципліна. Скажете просто «напиши тести для refund» — модель спробує зробити все підряд, а заодно притягне новий helper, нову бібліотеку і новий стиль іменування, бо їй так зручніше. Спочатку заземлюйте Claude в наявний проєктний патерн. Не в абстрактне «як правильно в Java», а саме в те, як це влаштовано у вашому Commerce OS.

Добрий запит виглядає так:

Спочатку прочитай тести в src/test/java/com/acme/refunds/.
Додай integration-тест для RefundService у наявному стилі.
Не змінюй production-код.
Не додавай нові тестові бібліотеки та нові helper-класи без узгодження.

Тут сильна не магія формулювання, а межі. Ви не просите «зробити красиво». Ви кажете, де дивитися, який рівень потрібен, чого торкатися не можна і що вважається порушенням дисципліни. Золоте правило: спочатку читати тести поруч, потім писати свої. Не навпаки.

Ще краще, коли це знання закріплене в CLAUDE.md проєкту, а не живе лише у вас у голові. Тоді будь-яка нова сесія Claude отримує одні й ті самі рамки:

## Тестовий стек Commerce OS

- Backend: JUnit 5 + Spring Boot Test + MockMvc
- Спочатку читати тести в тому самому пакеті й копіювати їх стиль
- Не додавати нові бібліотеки та нові test helpers без узгодження
- Якщо ризик на стику компонентів, не спрощувати його до unit-тесту

Це маленький фрагмент, але він робить важливу річ: не дає Claude влаштувати локальну революцію в тестовому стеку. Десь уже MockMvc, десь RestAssured, десь спеціалізовані test slices. Завдання моделі — не принести «найкращий у світі» стиль, а вбудуватися в наявний. Новачкові особливо важко помітити, що AI гарно написав робочий тест… просто в чужому фреймворку і повз прийнятий стиль команди.

Якщо в команди вже є Workflow Kit, цю логіку можна упакувати ще суворіше — наприклад, в окремий агент або skill для тестів. Але навіть без складної автоматизації правило залишається тим самим. Спочатку обираєте рівень тесту за ризиком. Потім змушуєте Claude читати наявні приклади. І тільки потім дозволяєте писати код.

Гарний набір тестів у Commerce OS зрештою — не склад випадкових перевірок, а система точних датчиків. Unit ловить поломку правила, integration — поломку стику, API — поломку обіцянки клієнту. Коли ви починаєте думати саме так, Claude перестає бути генератором «ще одного тестового файла» і стає нормальним інженерним помічником, який пише тести туди і на тому рівні, де вони справді захищають поведінку.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ