← Valtive QA Quiz quiz.valtive.io/notes/restassured valtive.io
📖 Про цей гайд📖 About this guide
Для когоWho it's for
Для QA-автоматизаторів на Java, які йдуть на співбесіду з API-тестування. Гайд починається з синтаксису given/when/then і закінчується тим, про що питають senior-кандидатів: паралельний запуск, кешування токенів, контрактні тести, eventual consistency і дизайн фреймворку. Приклади написані під REST Assured 5.x, JUnit 5, Hamcrest 2, Jackson і AssertJ, а кожне з 98 питань квізу «REST Assured» покрите щонайменше однією карткою.
For Java test automation engineers heading into an API testing interview. The guide starts with the given/when/then syntax and ends with what senior candidates get asked: parallel runs, token caching, contract tests, eventual consistency and framework design. The examples target REST Assured 5.x, JUnit 5, Hamcrest 2, Jackson and AssertJ, and each of the 98 questions of the REST Assured quiz is covered by at least one card.
Як користуватисьHow to use it
Фільтруй картки за рівнем або розділом. Прочитай відповідь, потім перепиши приклад у своєму IDE проти публічного API чи локального WireMock і зламай його навмисно — так видно, яке повідомлення дає кожен матчер. Статичні імпорти в прикладах спільні: import static io.restassured.RestAssured.*; і import static org.hamcrest.Matchers.*;. Наостанок закрий текст і розкажи відповідь уголос за блоком 🗣 Як читати вголос — інтерв'юер чує, як ти пояснюєш, а не бачить, що ти прочитав.
Filter the cards by level or section. Read the answer, then retype the example in your IDE against a public API or a local WireMock and break it on purpose — that shows the message each matcher produces. The examples share the same static imports: import static io.restassured.RestAssured.*; and import static org.hamcrest.Matchers.*;. Finally hide the text and give the answer out loud using the 🗣 How to say it out loud block — the interviewer hears how you explain, not what you have read.
Три якорі — з них випливає більшість відповідейThree anchors — most answers follow from them
ЯКІР 1 · given/when/then і специфікаціїANCHOR 1 · given/when/then and specs
given() збирає запит, when() його надсилає, then() перевіряє відповідь. Усе спільне для багатьох тестів — base URI, заголовки, auth, очікування — виноситься в RequestSpecification і ResponseSpecification.
given() builds the request, when() sends it, then() checks the response. Anything shared across tests — base URI, headers, auth, expectations — moves into a RequestSpecification and a ResponseSpecification.
ЯКІР 2 · GPath і POJOANCHOR 2 · GPath and POJOs
Рядок у body("...") — це вираз GPath, а не просто ключ. Складні відповіді зручніше десеріалізувати в POJO через Jackson і перевіряти AssertJ, а тіла запитів будувати з об'єктів, а не з рядків.
The string in body("...") is a GPath expression, not just a key. Complex responses are easier to deserialize into POJOs with Jackson and check with AssertJ, and request bodies are better built from objects than from strings.
ЯКІР 3 · Логи, фільтри, AllureANCHOR 3 · Logs, filters, Allure
Падіння API-тесту має бути зрозумілим без повторного запуску. Для цього є log().ifValidationFails(), фільтри, що бачать кожен запит, і AllureRestAssured, який кладе запит і відповідь у звіт.
A failing API test should be understandable without a rerun. That is what log().ifValidationFails(), filters that see every request, and AllureRestAssured, which puts the request and response into the report, are for.
60
питаньquestions
3
рівніlevels
—
voice-блокиvoice blocks
98/98
питань квізу покритоquiz questions covered
📚 9 РОЗДІЛІВ📚 9 SECTIONS
—
Junior
—
Middle
—
Senior
▶ 1 9
1 Що таке REST Assured і як підключити його до Maven-проєкту? What is REST Assured and how do you add it to a Maven project?
ОсновиBasics Junior ▶

Що це. REST Assured — це Java-бібліотека для тестування HTTP API. Вона дає fluent-DSL у стилі BDD (given/when/then), сама серіалізує тіла запитів, розбирає JSON і XML відповіді через GPath та інтегрується з матчерами Hamcrest. Під капотом працює Apache HttpClient, а синтаксис виразів успадкований від Groovy.

What it is. REST Assured is a Java library for testing HTTP APIs. It offers a BDD-style fluent DSL (given/when/then), serializes request bodies for you, parses JSON and XML responses with GPath and plugs into Hamcrest matchers. Apache HttpClient does the networking underneath, and the expression syntax comes from Groovy.

Як підключити. Основний артефакт — io.rest-assured:rest-assured зі scope test. Старі координати com.jayway.restassured належать до гілки 2.x і більше не оновлюються: якщо бачиш їх у проєкті, це ознака дуже старого коду. Додаткові модулі підключаються окремо: json-schema-validator для перевірки схем, spring-mock-mvc і spring-web-test-client для Spring. Jackson або Gson потрібно додати самому — без них REST Assured не зможе перетворити POJO на JSON.

How to add it. The main artifact is io.rest-assured:rest-assured with test scope. The old com.jayway.restassured coordinates belong to the 2.x line and are no longer updated: seeing them in a project means very old code. Extra modules come separately: json-schema-validator for schema checks, spring-mock-mvc and spring-web-test-client for Spring. You add Jackson or Gson yourself — without one of them REST Assured cannot turn a POJO into JSON.

Підводні камені. Змішування версій основного артефакту й модулів (наприклад, 5.5.0 і 4.x) дає дивні помилки на кшталт NoSuchMethodError, тому версію краще тримати в одній властивості. Ще одна типова проблема — конфлікт Groovy, який тягнуть інші бібліотеки: перевір mvn dependency:tree, якщо тест падає ще до першого запиту.

Pitfalls. Mixing versions of the core artifact and its modules (say 5.5.0 and 4.x) produces odd errors such as NoSuchMethodError, so keep the version in one property. Another common problem is a Groovy conflict pulled in by other libraries: check mvn dependency:tree when a test fails before the first request is even sent.

pom.xml: REST Assured, валідатор схем, Jackson, JUnit 5
pom.xml: REST Assured, schema validator, Jackson, JUnit 5
<properties>
  <rest-assured.version>5.5.0</rest-assured.version>
</properties>
<dependencies>
  <dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <version>${rest-assured.version}</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>json-schema-validator</artifactId>
    <version>${rest-assured.version}</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.2</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.3</version>
    <scope>test</scope>
  </dependency>
</dependencies>
🔧 Розбір коду: Властивість rest-assured.version гарантує, що ядро й модуль валідатора завжди однієї версії. jackson-databind — це об'єктний маппер, який REST Assured знайде в classpath автоматично. Усе зі scope test, бо в production-код ці бібліотеки не потрапляють.
🔧 Code breakdown: The rest-assured.version property keeps the core and the validator module on the same version. jackson-databind is the object mapper REST Assured finds on the classpath automatically. Everything uses test scope because none of it belongs in production code.
💡 Простими словами: REST Assured — це Postman у вигляді Java-коду: запит, відповідь і перевірки живуть у тесті під версійним контролем.
💡 In plain words: REST Assured is Postman as Java code: request, response and checks live in a test under version control.
🗣 Як читати вголос REST Assured я описую як DSL для HTTP API на Java: я пишу given, when, then, а бібліотека бере на себе HTTP-клієнт, серіалізацію і розбір відповіді. Підключаю io.rest-assured:rest-assured зі scope test, поруч модуль json-schema-validator тієї самої версії і Jackson, бо без маппера POJO в JSON не перетвориться. Якщо в проєкті бачу com.jayway, знаю, що це версія 2.x і її треба мігрувати. А версії всіх модулів тримаю в одній Maven-властивості, щоб не ловити NoSuchMethodError.
🗣 How to say it out loud I describe REST Assured as a Java DSL for HTTP APIs: I write given, when and then, and the library handles the HTTP client, serialization and response parsing. I add io.rest-assured:rest-assured in test scope, the json-schema-validator module at the same version next to it, and Jackson, because without a mapper a POJO never becomes JSON. If I see com.jayway in a project I know it is 2.x and needs migrating. And I keep all module versions in one Maven property so I never chase a NoSuchMethodError.
2 Що робить кожна частина ланцюжка given/when/then і звідки беруться get() та post()? What does each part of the given/when/then chain do, and where do get() and post() come from?
ОсновиBasics Junior ▶

given() повертає RequestSpecification — об'єкт, у якому ти описуєш запит: заголовки, параметри, тіло, auth, логування, фільтри. Нічого ще не надсилається.

given() returns a RequestSpecification, the object where you describe the request: headers, params, body, auth, logging, filters. Nothing is sent yet.

when() — синтаксичний цукор: на RequestSpecification він повертає ту саму специфікацію, щоб код читався як речення. Методи get(), post(), put(), patch(), delete(), head(), options() оголошені в інтерфейсі RequestSender, який RequestSpecification розширює. Статичний RestAssured.when() без given() одразу повертає RequestSender. Саме виклик HTTP-методу виконує запит і повертає Response.

when() is syntactic sugar: on a RequestSpecification it returns the same specification so the code reads like a sentence. The get(), post(), put(), patch(), delete(), head() and options() methods are declared in the RequestSender interface, which RequestSpecification extends. The static RestAssured.when() without given() returns a RequestSender directly. Calling the HTTP method is what actually sends the request and returns a Response.

then() перетворює Response на ValidatableResponse, де доступні statusCode(), body(), header(), time() та extract(). Слова and() і assertThat() теж лише для читабельності.

then() turns the Response into a ValidatableResponse with statusCode(), body(), header(), time() and extract(). The words and() and assertThat() are there only for readability as well.

given() чи RestAssured.given(). Це той самий метод: коротка форма працює завдяки import static io.restassured.RestAssured.*. Типова помилка новачка — поставити налаштування запиту після when(): код скомпілюється, бо тип той самий, але читач заплутається, тому порядок given → when → then тримаємо завжди.

given() or RestAssured.given(). It is the same method: the short form works thanks to import static io.restassured.RestAssured.*. A typical beginner mistake is putting request settings after when(): it compiles because the type is the same, but it confuses readers, so always keep the given → when → then order.

Три частини одного тесту
The three parts of one test
@Test
void createdUserCanBeFetched() {
    given()                                   // RequestSpecification: опис запиту
        .baseUri("https://api.example.com")
        .header("Accept", "application/json")
        .pathParam("id", 42)
    .when()                                   // лише для читабельності
        .get("/users/{id}")                   // RequestSender: запит іде тут
    .then()                                   // ValidatableResponse
        .statusCode(200)
        .body("id", equalTo(42))
        .body("email", endsWith("@example.com"));
}
@Test
void createdUserCanBeFetched() {
    given()                                   // RequestSpecification: describe the request
        .baseUri("https://api.example.com")
        .header("Accept", "application/json")
        .pathParam("id", 42)
    .when()                                   // readability only
        .get("/users/{id}")                   // RequestSender: the request is sent here
    .then()                                   // ValidatableResponse
        .statusCode(200)
        .body("id", equalTo(42))
        .body("email", endsWith("@example.com"));
}
🔧 Розбір коду: Відступи показують три фази. pathParam("id", 42) підставляє значення в шаблон {id}. get() — перший метод у ланцюжку, що має побічний ефект: до нього були лише налаштування. Після then() кожен body() приймає вираз GPath і матчер Hamcrest.
🔧 Code breakdown: The indentation shows the three phases. pathParam("id", 42) fills the {id} template. get() is the first call in the chain with a side effect: everything before it was configuration. After then() each body() takes a GPath expression and a Hamcrest matcher.
💡 Простими словами: given — що відправляю, when — куди і яким методом, then — що очікую отримати.
💡 In plain words: given is what I send, when is where and with which method, then is what I expect back.
🗣 Як читати вголос Я пояснюю так: given повертає RequestSpecification, де я збираю заголовки, параметри, тіло й авторизацію. When нічого не робить, він для читабельності, а get чи post приходять з інтерфейсу RequestSender, і саме цей виклик надсилає запит. Then дає ValidatableResponse, де я перевіряю статус, тіло й заголовки або витягую значення. Статичний given і RestAssured.given — одне й те саме, різниця лише в імпорті.
🗣 How to say it out loud I explain it like this: given returns a RequestSpecification where I collect headers, params, body and auth. When does nothing, it is there for readability, while get or post come from the RequestSender interface, and that call is what sends the request. Then gives me a ValidatableResponse where I check status, body and headers or extract values. The static given and RestAssured.given are the same thing, the only difference is the import.
3 Чим Response відрізняється від ValidatableResponse і що повертає extract().response()? How does Response differ from ValidatableResponse, and what does extract().response() return?
ОсновиBasics Junior ▶

Response — це сирий результат запиту. Його повертає будь-який HTTP-метод, зокрема статичні скорочення без BDD-ланцюжка на кшталт RestAssured.get("/health"). У нього є геттери: statusCode(), getHeader(), getCookies(), asString(), jsonPath(), as(User.class), time(). Інтерфейс ResponseOptions, який Response реалізує, описує саме цю частину: статус, заголовки, cookies, час.

Response is the raw result of a request. Any HTTP method returns it, including the static shortcuts without the BDD chain such as RestAssured.get("/health"). It has getters: statusCode(), getHeader(), getCookies(), asString(), jsonPath(), as(User.class), time(). The ResponseOptions interface that Response implements describes exactly that part: status, headers, cookies, time.

ValidatableResponse — те, що повертає then(). У ньому є лише перевірки, які кидають AssertionError, коли щось не так.

ValidatableResponse is what then() returns. It only holds checks, and they throw an AssertionError when something is wrong.

extract() — міст назад: з ValidatableResponse він повертає ExtractableResponse, а extract().response() — повноцінний Response, який можна зберегти в змінну й використати в наступному кроці. Зручний патерн: спершу перевірити статус, а потім витягнути дані, тоді тест падає з чесним повідомленням про статус, а не з NullPointerException на наступному рядку.

extract() is the way back: from a ValidatableResponse it returns an ExtractableResponse, and extract().response() gives a full Response you can store in a variable and use in the next step. A handy pattern is to check the status first and extract afterwards, so the test fails with an honest message about the status rather than a NullPointerException on the next line.

Коли що брати. Для простих перевірок — ланцюжок then(). Коли треба ідентифікатор для наступного запиту чи складна перевірка AssertJ — extract(). Статичні get(url) підходять для службових викликів у setup, але в самому тесті BDD-ланцюжок читається краще.

When to use which. For simple checks use the then() chain. When you need an id for the next request or a complex AssertJ check, use extract(). Static get(url) calls suit utility calls in setup, but inside the test itself the BDD chain reads better.

Статичний виклик, перевірка і витягування
Static call, validation and extraction
// Статичне скорочення: повертає Response, нічого не перевіряє
Response health = get("/health");
assertEquals(200, health.statusCode());

// Спершу перевірка, потім витягування
Response created = given()
        .contentType(ContentType.JSON)
        .body(Map.of("name", "Olena"))
    .when()
        .post("/users")
    .then()
        .statusCode(201)
        .extract().response();

long id = created.path("id");
String location = created.getHeader("Location");
// Static shortcut: returns a Response and checks nothing
Response health = get("/health");
assertEquals(200, health.statusCode());

// Validate first, then extract
Response created = given()
        .contentType(ContentType.JSON)
        .body(Map.of("name", "Olena"))
    .when()
        .post("/users")
    .then()
        .statusCode(201)
        .extract().response();

long id = created.path("id");
String location = created.getHeader("Location");
🔧 Розбір коду: get("/health") — статичний метод із RestAssured, тому після нього немає then(). У другому прикладі statusCode(201) виконується до extract(): якщо сервер повернув 400, ми побачимо саме це. path("id") повертає узагальнений тип, тож Java сама приводить результат до long.
🔧 Code breakdown: get("/health") is a static method from RestAssured, so no then() follows it. In the second example statusCode(201) runs before extract(): if the server returned 400, that is what we see. path("id") returns a generic type, so Java casts the result to long for us.
💡 Простими словами: then() — щоб перевіряти, extract() — щоб забрати дані з собою далі.
💡 In plain words: then() is for checking, extract() is for taking data along to the next step.
🗣 Як читати вголос Response — це просто результат запиту з геттерами: статус, заголовки, тіло, час. Його дають і статичні виклики на зразок RestAssured.get. Коли я пишу then, отримую ValidatableResponse, де кожен метод — це перевірка. Якщо мені потрібен id для наступного кроку, я спочатку перевіряю статус, а потім роблю extract().response() або extract().path. Так тест падає з нормальним повідомленням, а не з NullPointerException.
🗣 How to say it out loud Response is just the result of a request with getters: status, headers, body, time. The static calls like RestAssured.get give you one too. When I write then I get a ValidatableResponse where every method is a check. If I need an id for the next step, I check the status first and then call extract().response() or extract().path. That way the test fails with a proper message instead of a NullPointerException.
4 Як виконати CRUD-запити в REST Assured і чим PUT відрізняється від PATCH? How do you run CRUD requests in REST Assured, and how is PUT different from PATCH?
ОсновиBasics Junior ▶

Методи. Кожен HTTP-метод — окремий виклик після when(): post() створює, get() читає, put() і patch() оновлюють, delete() видаляє. Є також head(), options() і універсальний request(Method.TRACE, "/x") для рідкісних методів.

Methods. Each HTTP method is its own call after when(): post() creates, get() reads, put() and patch() update, delete() removes. There are also head(), options() and a generic request(Method.TRACE, "/x") for rare methods.

PUT vs PATCH. PUT замінює ресурс цілком: поля, яких немає в тілі, сервер має скинути або вважати помилкою. Він ідемпотентний — два однакові PUT дають той самий стан. PATCH змінює лише передані поля (JSON Merge Patch або JSON Patch) і за специфікацією не зобов'язаний бути ідемпотентним. На співбесіді варто додати: реальні API часто порушують ці правила, тому тест має перевіряти саме задокументовану поведінку, а не підручник.

PUT vs PATCH. PUT replaces the whole resource: fields missing from the body should be reset by the server or treated as an error. It is idempotent — two identical PUTs leave the same state. PATCH changes only the fields you send (JSON Merge Patch or JSON Patch) and by the spec is not required to be idempotent. In an interview it is worth adding that real APIs often bend these rules, so a test should check the documented behaviour, not the textbook.

PUT без тіла. given().when().put("/users/42/activate") надсилає запит без тіла — REST Assured це дозволяє. Для DELETE тіло теж можна передати, але багато серверів і проксі його ігнорують.

PUT without a body. given().when().put("/users/42/activate") sends a request with no body, and REST Assured allows it. You can pass a body to DELETE too, but many servers and proxies ignore it.

Що перевіряти. POST — 201 і заголовок Location; GET — 200 і дані; PUT/PATCH — 200 або 204 і повторний GET; DELETE — 204, а потім GET дає 404. Найчастіша помилка — перевірити лише статус оновлення, не прочитавши ресурс повторно.

What to check. POST: 201 and a Location header; GET: 200 and data; PUT/PATCH: 200 or 204 plus a follow-up GET; DELETE: 204, then GET returns 404. The most common mistake is checking only the status of the update without reading the resource back.

Повний CRUD-цикл з перевірками
A full CRUD cycle with checks
int id = given().contentType(ContentType.JSON)
        .body(Map.of("name", "Olena", "role", "qa"))
    .when().post("/users")
    .then().statusCode(201).header("Location", containsString("/users/"))
    .extract().path("id");

given().contentType(ContentType.JSON)
        .body(Map.of("name", "Olena K", "role", "lead"))   // PUT: повний об'єкт
    .when().put("/users/{id}", id)
    .then().statusCode(200);

given().contentType(ContentType.JSON)
        .body(Map.of("role", "qa"))                         // PATCH: лише зміна
    .when().patch("/users/{id}", id)
    .then().statusCode(200).body("name", equalTo("Olena K"));

when().put("/users/{id}/activate", id).then().statusCode(204);  // PUT без тіла

when().delete("/users/{id}", id).then().statusCode(204);
when().get("/users/{id}", id).then().statusCode(404);
int id = given().contentType(ContentType.JSON)
        .body(Map.of("name", "Olena", "role", "qa"))
    .when().post("/users")
    .then().statusCode(201).header("Location", containsString("/users/"))
    .extract().path("id");

given().contentType(ContentType.JSON)
        .body(Map.of("name", "Olena K", "role", "lead"))   // PUT: the full object
    .when().put("/users/{id}", id)
    .then().statusCode(200);

given().contentType(ContentType.JSON)
        .body(Map.of("role", "qa"))                         // PATCH: only the change
    .when().patch("/users/{id}", id)
    .then().statusCode(200).body("name", equalTo("Olena K"));

when().put("/users/{id}/activate", id).then().statusCode(204);  // PUT without a body

when().delete("/users/{id}", id).then().statusCode(204);
when().get("/users/{id}", id).then().statusCode(404);
🔧 Розбір коду: put("/users/{id}", id) — неіменований path-параметр: значення підставляється за порядком. Після PATCH ми перевіряємо, що name не змінилось, — саме так видно різницю з PUT. Останні два рядки доводять видалення: 204 сам по собі ще нічого не гарантує.
🔧 Code breakdown: put("/users/{id}", id) uses an unnamed path parameter: the value is filled in by position. After the PATCH we check that name did not change — that is exactly how the difference from PUT shows. The last two lines prove the deletion, because a 204 on its own guarantees nothing.
💡 Простими словами: PUT — «ось увесь об’єкт», PATCH — «зміни лише ось це».
💡 In plain words: PUT says here is the whole object; PATCH says change only this.
🗣 Як читати вголос У REST Assured кожен метод — окремий виклик: post, get, put, patch, delete. Різниця PUT і PATCH для мене така: PUT замінює ресурс повністю і має бути ідемпотентним, а PATCH міняє тільки передані поля. У тестах я не довіряю одному статусу: після оновлення роблю GET і дивлюсь, що змінилось лише потрібне, а після DELETE перевіряю, що ресурс віддає 404. PUT без тіла теж можна надіслати, просто не викликаю body.
🗣 How to say it out loud In REST Assured every method is its own call: post, get, put, patch, delete. The way I see PUT versus PATCH: PUT replaces the resource entirely and should be idempotent, PATCH changes only the fields you send. In tests I do not trust a single status code: after an update I do a GET and check that only the intended fields changed, and after a DELETE I check the resource returns 404. A PUT without a body is fine too, I just do not call body.
5 Як задати baseURI, port і basePath і навіщо потрібен RestAssured.reset()? How do you set baseURI, port and basePath, and why do you need RestAssured.reset()?
ОсновиBasics Junior ▶

Глобально. Статичні поля RestAssured.baseURI, RestAssured.port і RestAssured.basePath застосовуються до кожного запиту в JVM. За замовчуванням це http://localhost і порт 8080. basePath — спільний префікс, наприклад /api/v2: тоді в тесті пишеш лише get("/orders").

Globally. The static fields RestAssured.baseURI, RestAssured.port and RestAssured.basePath apply to every request in the JVM. The defaults are http://localhost and port 8080. basePath is a shared prefix such as /api/v2, so a test only writes get("/orders").

Локально. Ті самі значення можна задати на рівні запиту (given().baseUri(...).basePath(...).port(...)) або в RequestSpecBuilder. Локальне значення перекриває глобальне.

Locally. The same values can be set per request (given().baseUri(...).basePath(...).port(...)) or in a RequestSpecBuilder. The local value overrides the global one.

reset(). RestAssured.reset() повертає всі статичні налаштування до стандартних: baseURI, port, basePath, auth, фільтри, config, специфікації. Його викликають в @AfterAll або після тесту, що навмисно міняв глобальний стан, щоб наступний клас не успадкував чужий basePath.

reset(). RestAssured.reset() puts every static setting back to its default: baseURI, port, basePath, auth, filters, config, specifications. Call it in @AfterAll or after a test that deliberately changed global state, so the next class does not inherit someone else's basePath.

Підводні камені. Статичні поля — не потокобезпечні налаштування на тест: якщо паралельні класи присвоюють різні baseURI, запити полетять не туди. Тому в зрілих фреймворках глобально задають лише незмінні речі один раз, а все інше передають через специфікації. Ще одна дрібниця: якщо порт є і в baseURI, і в полі port, легко отримати незрозумілу адресу — задавай його в одному місці.

Pitfalls. Static fields are not per-test settings and are not thread-safe: if parallel classes assign different baseURIs, requests go to the wrong place. Mature frameworks therefore set only immutable values once globally and pass everything else through specifications. One more detail: if the port appears both in baseURI and in the port field, it is easy to end up with a confusing address, so set it in one place only.

Глобальні значення, локальне перекриття і reset
Global values, local override and reset
class OrdersApiTest {

    @BeforeAll
    static void setUp() {
        RestAssured.baseURI = System.getProperty("api.url", "https://staging.example.com");
        RestAssured.basePath = "/api/v2";
    }

    @Test
    void listsOrders() {
        when().get("/orders")                 // -> https://staging.example.com/api/v2/orders
            .then().statusCode(200);
    }

    @Test
    void healthLivesOutsideTheApiPrefix() {
        given().basePath("")                  // локально перекриваємо
            .when().get("/health")
            .then().statusCode(200);
    }

    @AfterAll
    static void tearDown() {
        RestAssured.reset();
    }
}
class OrdersApiTest {

    @BeforeAll
    static void setUp() {
        RestAssured.baseURI = System.getProperty("api.url", "https://staging.example.com");
        RestAssured.basePath = "/api/v2";
    }

    @Test
    void listsOrders() {
        when().get("/orders")                 // -> https://staging.example.com/api/v2/orders
            .then().statusCode(200);
    }

    @Test
    void healthLivesOutsideTheApiPrefix() {
        given().basePath("")                  // local override
            .when().get("/health")
            .then().statusCode(200);
    }

    @AfterAll
    static void tearDown() {
        RestAssured.reset();
    }
}
🔧 Розбір коду: System.getProperty("api.url", ...) дозволяє перемкнути середовище з командного рядка через -Dapi.url=.... basePath("") у другому тесті скидає префікс лише для цього запиту. reset() в @AfterAll прибирає глобальний стан після класу.
🔧 Code breakdown: System.getProperty("api.url", ...) lets you switch environments from the command line with -Dapi.url=.... basePath("") in the second test clears the prefix for that request only. reset() in @AfterAll removes the global state once the class is done.
💡 Простими словами: baseURI — адреса сервера, basePath — спільний початок шляху, reset() — прибирання після себе.
💡 In plain words: baseURI is the server address, basePath is the shared start of the path, reset() is cleaning up after yourself.
🗣 Як читати вголос BaseURI, port і basePath я можу задати статично через RestAssured, тоді вони діють на всі запити, або локально в given чи в специфікації, і локальне значення перемагає. BasePath зручний для префікса на кшталт api/v2. RestAssured.reset повертає все до стандартних значень, я кличу його після класу, який міняв глобальний стан. І одразу кажу про ризик: статика спільна для всіх потоків, тому в паралельних прогонах я тримаю такі речі в специфікаціях.
🗣 How to say it out loud I can set baseURI, port and basePath statically on RestAssured, and then they apply to every request, or locally in given or in a specification, and the local value wins. BasePath is handy for a prefix like api/v2. RestAssured.reset puts everything back to the defaults, and I call it after a class that changed global state. I also mention the risk straight away: statics are shared by all threads, so in parallel runs I keep these values in specifications.
6 Чим відрізняються pathParam, queryParam, formParam і param у REST Assured? How do pathParam, queryParam, formParam and param differ in REST Assured?
ОсновиBasics Junior ▶

pathParam. Підставляє значення в шаблон шляху: pathParam("id", 5) + get("/users/{id}"). Той самий ефект дають неіменовані параметри get("/users/{id}", 5). Значення кодуються автоматично, тож пробіли та кирилиця не ламають URL.

pathParam. Fills a template in the path: pathParam("id", 5) plus get("/users/{id}"). Unnamed parameters get("/users/{id}", 5) do the same. Values are encoded automatically, so spaces and non-Latin characters do not break the URL.

queryParam. Додає пару після ?: queryParam("page", 2) дає ?page=2. Кілька значень одного ключа — queryParam("tag", "a", "b"), мапа — queryParams(Map).

queryParam. Adds a pair after the ?: queryParam("page", 2) gives ?page=2. Several values for one key: queryParam("tag", "a", "b"); a map: queryParams(Map).

formParam. Кладе параметр у тіло як application/x-www-form-urlencoded. Так відправляють HTML-форми і, наприклад, запит OAuth2 на токен.

formParam. Puts the parameter in the body as application/x-www-form-urlencoded. That is how HTML forms are sent and, for example, an OAuth2 token request.

param. Універсальний і тому підступний: для GET він стає query-параметром, а для POST — form-параметром. Через це тест, у якому метод змінили з GET на POST, раптом надсилає інший запит. У коді, який читатимуть інші, краще писати явні queryParam і formParam.

param. Generic and therefore sneaky: for GET it becomes a query parameter, for POST a form parameter. So a test whose method changed from GET to POST suddenly sends a different request. In code other people will read, prefer explicit queryParam and formParam.

Підводний камінь. Не склеюй URL рядками на кшталт "/users?name=" + name: спецсимволи не будуть закодовані, а в логах буде складно зрозуміти, що саме передали.

Pitfall. Do not glue URLs together like "/users?name=" + name: special characters will not be encoded, and the logs will not make clear what was actually sent.

Чотири види параметрів в одному прикладі
Four kinds of parameters in one example
given()
    .pathParam("shopId", "kyiv-01")
    .queryParam("status", "PAID")
    .queryParam("tag", "promo", "b2b")        // ?tag=promo&tag=b2b
.when()
    .get("/shops/{shopId}/orders")
.then()
    .statusCode(200);

given()
    .formParam("grant_type", "client_credentials")
    .formParam("scope", "orders:read")
.when()
    .post("/oauth/token")                     // тіло: grant_type=...&scope=...
.then()
    .statusCode(200);
given()
    .pathParam("shopId", "kyiv-01")
    .queryParam("status", "PAID")
    .queryParam("tag", "promo", "b2b")        // ?tag=promo&tag=b2b
.when()
    .get("/shops/{shopId}/orders")
.then()
    .statusCode(200);

given()
    .formParam("grant_type", "client_credentials")
    .formParam("scope", "orders:read")
.when()
    .post("/oauth/token")                     // body: grant_type=...&scope=...
.then()
    .statusCode(200);
🔧 Розбір коду: Перший запит стає GET /shops/kyiv-01/orders?status=PAID&tag=promo&tag=b2b. У другому formParam змушує REST Assured поставити Content-Type: application/x-www-form-urlencoded і закодувати пари в тілі.
🔧 Code breakdown: The first request becomes GET /shops/kyiv-01/orders?status=PAID&tag=promo&tag=b2b. In the second one formParam makes REST Assured set Content-Type: application/x-www-form-urlencoded and encode the pairs in the body.
💡 Простими словами: Path — частина адреси, query — фільтр після знака питання, form — дані в тілі форми.
💡 In plain words: Path is part of the address, query is a filter after the question mark, form is data in the form body.
🗣 Як читати вголос PathParam підставляє значення в фігурні дужки шляху, наприклад id користувача. QueryParam додає параметри після знака питання — пагінацію, фільтри. FormParam кладе дані в тіло як url-encoded форму, я так отримую OAuth-токен. Є ще просто param, але я його уникаю: для GET він стає query, а для POST — form, і це легко проґавити. Ну і URL руками не склеюю, бо тоді ніхто нічого не кодує.
🗣 How to say it out loud PathParam fills the curly-brace slot in the path, for example a user id. QueryParam adds parameters after the question mark, like paging or filters. FormParam puts data in the body as a url-encoded form, which is how I get an OAuth token. There is also plain param, but I avoid it: for GET it turns into a query parameter and for POST into a form parameter, which is easy to miss. And I never glue URLs by hand, because then nothing gets encoded.
7 Як передати один чи кілька заголовків і правильно задати Content-Type та Accept? How do you pass one or several headers and set Content-Type and Accept correctly?
ОсновиBasics Junior ▶

Один заголовок. header("X-Request-Id", uuid). Повторний виклик з тим самим іменем додає ще одне значення, а не замінює старе — поведінку можна змінити через HeaderConfig.

One header. header("X-Request-Id", uuid). Calling it again with the same name adds another value rather than replacing the old one; HeaderConfig changes that behaviour.

Кілька. Є три рівноцінні способи: ланцюжок header(), headers("A", "1", "B", "2") з парами ім'я-значення, або headers(Map.of(...)). Для спільних заголовків на весь набір тестів краще RequestSpecBuilder.addHeader().

Several. There are three equivalent ways: a chain of header() calls, headers("A", "1", "B", "2") with name-value pairs, or headers(Map.of(...)). For headers shared by a whole suite, RequestSpecBuilder.addHeader() is better.

Content-Type. Три варіанти дають той самий заголовок: contentType(ContentType.JSON), contentType("application/json") і header("Content-Type", "application/json"). Перший безпечніший: ContentType.JSON — enum, де немає місця для опечатки. Content-Type описує тіло запиту, а accept(ContentType.JSON) каже серверу, який формат ти хочеш отримати.

Content-Type. Three options produce the same header: contentType(ContentType.JSON), contentType("application/json") and header("Content-Type", "application/json"). The first is safer: ContentType.JSON is an enum, so there is no room for a typo. Content-Type describes the request body, while accept(ContentType.JSON) tells the server which format you want back.

Підводні камені. REST Assured може дописати до Content-Type ; charset=.... Деякі сервери суворо порівнюють заголовок і відповідають 415; вимикається це через EncoderConfig.appendDefaultContentCharsetToContentTypeIfUndefined(false). І не забувай, що для POJO без явного Content-Type бібліотека сама обирає JSON — краще задати його явно, щоб тест документував контракт.

Pitfalls. REST Assured may append ; charset=... to the Content-Type. Some servers compare the header strictly and answer 415; you turn that off with EncoderConfig.appendDefaultContentCharsetToContentTypeIfUndefined(false). Also remember that for a POJO without an explicit Content-Type the library picks JSON itself — set it explicitly so the test documents the contract.

Заголовки, Content-Type і Accept
Headers, Content-Type and Accept
given()
    .header("X-Request-Id", UUID.randomUUID().toString())
    .headers("X-Tenant", "ua", "X-Client-Version", "7.3.0")
    .headers(Map.of("X-Feature", "new-checkout"))
    .contentType(ContentType.JSON)            // тіло, яке надсилаємо
    .accept(ContentType.JSON)                 // формат, який хочемо
    .body(new CartItem("SKU-1", 2))
.when()
    .post("/cart/items")
.then()
    .statusCode(201)
    .contentType(ContentType.JSON);

// Для суворого сервера: без автоматичного charset
RestAssured.config = RestAssuredConfig.config().encoderConfig(
        EncoderConfig.encoderConfig().appendDefaultContentCharsetToContentTypeIfUndefined(false));
given()
    .header("X-Request-Id", UUID.randomUUID().toString())
    .headers("X-Tenant", "ua", "X-Client-Version", "7.3.0")
    .headers(Map.of("X-Feature", "new-checkout"))
    .contentType(ContentType.JSON)            // the body we send
    .accept(ContentType.JSON)                 // the format we want
    .body(new CartItem("SKU-1", 2))
.when()
    .post("/cart/items")
.then()
    .statusCode(201)
    .contentType(ContentType.JSON);

// For a strict server: no automatic charset
RestAssured.config = RestAssuredConfig.config().encoderConfig(
        EncoderConfig.encoderConfig().appendDefaultContentCharsetToContentTypeIfUndefined(false));
🔧 Розбір коду: headers("X-Tenant", "ua", "X-Client-Version", "7.3.0") приймає пари через varargs. ContentType.JSON відповідає application/json, а в then() той самий enum перевіряє Content-Type відповіді. Останній рядок змінює глобальний конфіг кодування.
🔧 Code breakdown: headers("X-Tenant", "ua", "X-Client-Version", "7.3.0") takes pairs as varargs. ContentType.JSON maps to application/json, and in then() the same enum checks the response Content-Type. The last statement changes the global encoder config.
💡 Простими словами: Content-Type — «що я надсилаю», Accept — «що я хочу отримати».
💡 In plain words: Content-Type means what I am sending; Accept means what I want back.
🗣 Як читати вголос Один заголовок я додаю через header, кілька — через headers з парами або з мапою, а спільні для всіх тестів — у RequestSpecBuilder. Content-Type ставлю через contentType з енамом ContentType.JSON, бо так немає опечаток. Accept — це вже про те, який формат я хочу у відповідь, його задає метод accept. Якщо сервер повертає 415, перше, що дивлюсь у логах, — чи не дописав REST Assured charset до заголовка.
🗣 How to say it out loud I add a single header with header, several with headers using pairs or a map, and headers shared by all tests go into the RequestSpecBuilder. I set Content-Type with contentType and the ContentType.JSON enum, because that rules out typos. Accept is about the format I want back, and the accept method sets it. If a server returns 415, the first thing I look at in the logs is whether REST Assured appended a charset to the header.
8 Як надсилати й читати cookies та зберігати сесію між запитами? How do you send and read cookies and keep a session between requests?
ОсновиBasics Middle ▶

Надіслати. cookie("lang", "uk") для однієї, cookies(Map) або cookies("a", "1", "b", "2") для кількох. Можна передати і готовий об'єкт Cookie з атрибутами.

Sending. cookie("lang", "uk") for one, cookies(Map) or cookies("a", "1", "b", "2") for several. You can also pass a ready Cookie object with attributes.

Прочитати. У відповіді getCookie("SESSION") повертає значення, getCookies() — мапу ім'я → значення, а getDetailedCookie("SESSION") — об'єкт з атрибутами Secure, HttpOnly, Max-Age, SameSite. Перевірити можна в ланцюжку: then().cookie("SESSION", notNullValue()).

Reading. On a response getCookie("SESSION") returns the value, getCookies() a map of name → value, and getDetailedCookie("SESSION") an object with the Secure, HttpOnly, Max-Age and SameSite attributes. In the chain you can check then().cookie("SESSION", notNullValue()).

Зберегти сесію. Кожен запит REST Assured незалежний: cookies з попередньої відповіді самі не повертаються. Є два фільтри. SessionFilter запам'ятовує саме cookie сесії (за замовчуванням JSESSIONID, ім'я задається через SessionConfig) і підставляє його в наступні запити. CookieFilter зберігає всі cookies, як браузер.

Keeping a session. Every REST Assured request is independent: cookies from the previous response are not sent back on their own. There are two filters. SessionFilter remembers only the session cookie (JSESSIONID by default; SessionConfig sets the name) and adds it to later requests. CookieFilter keeps all cookies, like a browser.

Тестова цінність. Атрибути безпеки — окрема перевірка: сесійна cookie без HttpOnly чи Secure — реальний дефект. Пастка: один екземпляр фільтра, спільний для паралельних тестів, змішає сесії різних користувачів — створюй новий фільтр на кожен тест.

Test value. Security attributes deserve their own check: a session cookie without HttpOnly or Secure is a real defect. The trap: one filter instance shared by parallel tests mixes up the sessions of different users, so create a new filter per test.

Вхід, збереження сесії й атрибути cookie
Login, keeping the session and cookie attributes
SessionFilter session = new SessionFilter();

Response login = given()
        .filter(session)
        .formParam("username", "qa@example.com")
        .formParam("password", secret)
    .when().post("/login")
    .then().statusCode(302)
        .cookie("JSESSIONID", notNullValue())
    .extract().response();

Cookie jsession = login.getDetailedCookie("JSESSIONID");
assertTrue(jsession.isHttpOnly(), "session cookie must be HttpOnly");
assertTrue(jsession.isSecured(), "session cookie must be Secure");

given().filter(session)                       // та сама сесія
    .cookie("lang", "uk")
.when().get("/profile")
.then().statusCode(200).body("email", equalTo("qa@example.com"));
SessionFilter session = new SessionFilter();

Response login = given()
        .filter(session)
        .formParam("username", "qa@example.com")
        .formParam("password", secret)
    .when().post("/login")
    .then().statusCode(302)
        .cookie("JSESSIONID", notNullValue())
    .extract().response();

Cookie jsession = login.getDetailedCookie("JSESSIONID");
assertTrue(jsession.isHttpOnly(), "session cookie must be HttpOnly");
assertTrue(jsession.isSecured(), "session cookie must be Secure");

given().filter(session)                       // same session
    .cookie("lang", "uk")
.when().get("/profile")
.then().statusCode(200).body("email", equalTo("qa@example.com"));
🔧 Розбір коду: Той самий екземпляр session передається в кожен із двох запитів — у цьому вся суть. getDetailedCookie повертає io.restassured.http.Cookie, де isHttpOnly() і isSecured() читають атрибути з заголовка Set-Cookie. Звичайна cookie("lang", "uk") додається поверх сесійної.
🔧 Code breakdown: The same session instance goes into both requests — that is the whole point. getDetailedCookie returns an io.restassured.http.Cookie whose isHttpOnly() and isSecured() read the attributes from the Set-Cookie header. The plain cookie("lang", "uk") is added on top of the session one.
💡 Простими словами: REST Assured не браузер: без SessionFilter чи CookieFilter він «забуває» cookies після кожного запиту.
💡 In plain words: REST Assured is not a browser: without SessionFilter or CookieFilter it forgets cookies after every request.
🗣 Як читати вголос Cookies я надсилаю через cookie або cookies, а читаю через getCookie чи getDetailedCookie, якщо мені потрібні атрибути. Важливо, що REST Assured сам нічого між запитами не пам'ятає, тому для сесії я беру SessionFilter, а якщо сервер ставить кілька cookies — CookieFilter. І завжди перевіряю HttpOnly та Secure на сесійній cookie, це дешева перевірка безпеки. У паралельних тестах фільтр у кожного свій, інакше сесії перемішаються.
🗣 How to say it out loud I send cookies with cookie or cookies and read them with getCookie, or getDetailedCookie when I need the attributes. The key point is that REST Assured does not remember anything between requests, so for a session I use SessionFilter, and if the server sets several cookies I use CookieFilter. I always check HttpOnly and Secure on the session cookie; it is a cheap security check. In parallel tests each test gets its own filter, otherwise sessions get mixed up.
9 Як перевірити статус-код, заголовки й тип вмісту відповіді та як отримати їх у змінну? How do you assert the status code, headers and content type of a response, and how do you get them into a variable?
ОсновиBasics Junior ▶

Перевірка в ланцюжку. statusCode(200) або statusCode(anyOf(is(200), is(204))) з матчером; statusLine(containsString("OK")) для рядка статусу. Заголовок: header("Cache-Control", "no-store") або з матчером header("Content-Length", notNullValue()); кілька — headers("A", "1", "B", "2"). Тип вмісту: contentType(ContentType.JSON) або contentType(containsString("json")) — кожен із цих методів приймає і enum, і рядок, і матчер.

Checking in the chain. statusCode(200) or statusCode(anyOf(is(200), is(204))) with a matcher; statusLine(containsString("OK")) for the status line. A header: header("Cache-Control", "no-store") or with a matcher header("Content-Length", notNullValue()); several: headers("A", "1", "B", "2"). Content type: contentType(ContentType.JSON) or contentType(containsString("json")) — both forms accept an enum, a string or a matcher.

Отримати значення. З Response: statusCode() або getStatusCode() (це синоніми), getHeader("Location") або header("Location"), getHeaders() для всіх заголовків як об'єкт Headers, getContentType(). З ланцюжка: then().extract().statusCode() або extract().header("Location").

Getting the values. From a Response: statusCode() or getStatusCode() (they are synonyms), getHeader("Location") or header("Location"), getHeaders() for all headers as a Headers object, getContentType(). From the chain: then().extract().statusCode() or extract().header("Location").

Чому це важливо. Статус без тіла — половина перевірки, але й тіло без статусу небезпечне: сервер може повернути 200 зі сторінкою помилки. Типова помилка — перевіряти Content-Type через equalTo("application/json"): реальний заголовок часто має суфікс ; charset=utf-8, і тест падає без причини. Тому для типу вмісту — enum або containsString.

Why it matters. A status without the body is half a check, but a body without the status is dangerous too: a server may return 200 with an error page. A common mistake is checking Content-Type with equalTo("application/json"): the real header often ends with ; charset=utf-8 and the test fails for no reason. So for the content type use the enum or containsString.

Перевірки статусу, заголовків, типу і їх витягування
Status, header and type checks, and extracting them
Response response = given().pathParam("id", 42)
    .when().get("/invoices/{id}/pdf")
    .then()
        .statusCode(200)
        .statusLine(containsString("OK"))
        .contentType("application/pdf")
        .header("Content-Disposition", startsWith("attachment"))
        .header("Cache-Control", "no-store")
    .extract().response();

int status = response.statusCode();
String disposition = response.getHeader("Content-Disposition");
Headers all = response.getHeaders();
assertTrue(all.hasHeaderWithName("ETag"));
🔧 Розбір коду: Уся валідація відбувається до extract(), тож змінні заповнюються лише для вже перевіреної відповіді. Headers — колекція з методами hasHeaderWithName(), getValue() і getValues() для заголовків з кількома значеннями.
🔧 Code breakdown: All validation happens before extract(), so the variables are filled only from a response that has already been checked. Headers is a collection with hasHeaderWithName(), getValue() and getValues() for multi-value headers.
💡 Простими словами: Статус, тип і тіло — три речі, які я перевіряю завжди разом.
💡 In plain words: Status, type and body are three things I always check together.
🗣 Як читати вголос Статус перевіряю через statusCode, можна з матчером, якщо допустимі кілька кодів. Заголовки — через header з точним значенням або матчером, тип вмісту — через contentType з енамом, бо equalTo на application/json ламається, щойно сервер додає charset. Якщо значення потрібне далі, беру extract().statusCode чи extract().header, або getHeaders з Response, коли треба пройтись по всіх. І завжди перевіряю статус разом із тілом, бо 200 зі сторінкою помилки теж буває.
🗣 How to say it out loud I check the status with statusCode, with a matcher if several codes are acceptable. Headers go through header with an exact value or a matcher, and the content type through contentType with the enum, because equalTo on application/json breaks as soon as the server adds a charset. If I need a value later, I use extract().statusCode or extract().header, or getHeaders on the Response when I need all of them. And I always check the status together with the body, because a 200 with an error page does happen.
▶ 2 8
10 Що таке RequestSpecification і як зібрати її через RequestSpecBuilder? What is a RequestSpecification and how do you build one with RequestSpecBuilder?
СпецифікаціїSpecs Junior ▶

Що це. RequestSpecification — шаблон запиту: base URI, basePath, заголовки, тип вмісту, auth, фільтри, логування. Замість того щоб повторювати ці рядки в кожному тесті, їх описують один раз і підключають через given().spec(requestSpec) або given(requestSpec).

What it is. A RequestSpecification is a request template: base URI, basePath, headers, content type, auth, filters, logging. Instead of repeating these lines in every test, you describe them once and plug them in with given().spec(requestSpec) or given(requestSpec).

Як зібрати. new RequestSpecBuilder() має методи setBaseUri(), setBasePath(), setContentType(), setAccept(), addHeader(), addQueryParam(), addFilter(), setAuth(), setConfig(), log(LogDetail.URI), а наприкінці build(). Можна додати одну специфікацію до іншої через addRequestSpecification(), наприклад базову і «адмінську».

How to build it. new RequestSpecBuilder() offers setBaseUri(), setBasePath(), setContentType(), setAccept(), addHeader(), addQueryParam(), addFilter(), setAuth(), setConfig(), log(LogDetail.URI), and finally build(). You can merge one spec into another with addRequestSpecification(), for example a base spec and an admin spec.

Злиття. Після spec() у тому самому given() можна дописати свої заголовки чи параметри — вони злиються зі специфікацією, а однакові одиничні налаштування (base URI, content type) перекриють її значення.

Merging. After spec() in the same given() you can add your own headers or params — they merge with the spec, while single-valued settings (base URI, content type) override its values.

Підводні камені. Не змінюй спільну специфікацію під конкретний тест — збудуй нову через builder, інакше отримаєш неочевидні залежності між тестами. Глобальна RestAssured.requestSpecification зручна, але в паралельних прогонах її переприсвоєння — перегони. Найкраще тримати специфікації як незмінні константи в окремому класі й будувати їх з конфігурації середовища.

Pitfalls. Do not tweak a shared spec for one test — build a new one with the builder, otherwise you create hidden dependencies between tests. The global RestAssured.requestSpecification is convenient, but reassigning it in parallel runs is a race. The best option is to keep specs as immutable constants in a dedicated class and build them from the environment config.

Базова і адмінська специфікації
A base spec and an admin spec
public final class Specs {
    public static final RequestSpecification BASE = new RequestSpecBuilder()
            .setBaseUri(Env.apiUrl())
            .setBasePath("/api/v2")
            .setContentType(ContentType.JSON)
            .setAccept(ContentType.JSON)
            .addHeader("X-Client", "api-tests")
            .addFilter(new ErrorLoggingFilter())
            .build();

    public static final RequestSpecification ADMIN = new RequestSpecBuilder()
            .addRequestSpecification(BASE)
            .setAuth(RestAssured.oauth2(Env.adminToken()))
            .build();

    private Specs() { }
}

@Test
void adminSeesAllUsers() {
    given().spec(Specs.ADMIN).queryParam("limit", 50)
    .when().get("/users")
    .then().statusCode(200).body("items", hasSize(lessThanOrEqualTo(50)));
}
🔧 Розбір коду: ADMIN успадковує все з BASE через addRequestSpecification і додає лише auth. RestAssured.oauth2(token) — статичний фабричний метод, що повертає AuthenticationScheme для setAuth. У тесті queryParam додається поверх специфікації.
🔧 Code breakdown: ADMIN inherits everything from BASE through addRequestSpecification and only adds auth. RestAssured.oauth2(token) is a static factory returning the AuthenticationScheme that setAuth needs. In the test queryParam is added on top of the spec.
💡 Простими словами: Специфікація — це «шапка» запиту, яку пишеш один раз і причіплюєш до кожного тесту.
💡 In plain words: A spec is the letterhead of a request: written once and attached to every test.
🗣 Як читати вголос RequestSpecification — це шаблон запиту, куди я виношу все спільне: base URI, basePath, заголовки, тип вмісту, фільтри, авторизацію. Будую її через RequestSpecBuilder і підключаю в тесті через given().spec. Для різних ролей роблю кілька специфікацій, які розширюють базову через addRequestSpecification. Специфікації тримаю як незмінні константи і не міняю під окремий тест, а глобальну RestAssured.requestSpecification у паралельних прогонах не переприсвоюю.
🗣 How to say it out loud A RequestSpecification is a request template where I move everything shared: base URI, basePath, headers, content type, filters, auth. I build it with RequestSpecBuilder and plug it into a test with given().spec. For different roles I make several specs that extend the base one through addRequestSpecification. I keep specs as immutable constants and never tweak them for a single test, and I do not reassign the global RestAssured.requestSpecification in parallel runs.
11 Навіщо потрібна ResponseSpecification і що варто класти в ResponseSpecBuilder? Why do you need a ResponseSpecification, and what belongs in a ResponseSpecBuilder?
СпецифікаціїSpecs Middle ▶

Що це. ResponseSpecification — багаторазовий набір очікувань до відповіді. Його будують через new ResponseSpecBuilder() з методами expectStatusCode(), expectContentType(), expectHeader(), expectBody(path, matcher), expectResponseTime(matcher) і застосовують через then().spec(responseSpec).

What it is. A ResponseSpecification is a reusable set of expectations for a response. You build it with new ResponseSpecBuilder() using expectStatusCode(), expectContentType(), expectHeader(), expectBody(path, matcher) and expectResponseTime(matcher), and apply it with then().spec(responseSpec).

Що туди класти. Лише інваріанти, які справді однакові для групи викликів: «успішна JSON-відповідь» (200 і application/json), «помилка валідації» (400, тіло з полями code і errors), наявність заголовка X-Request-Id. Специфічні для тесту перевірки лишаються в самому тесті, інакше читач не бачить, що саме перевіряється.

What goes in. Only invariants that really are the same for a group of calls: a successful JSON response (200 and application/json), a validation error (400 with a body that has code and errors), the presence of an X-Request-Id header. Checks specific to one test stay in that test, otherwise the reader cannot see what is being verified.

Поширена помилка. Одна глобальна специфікація з expectStatusCode(200) через RestAssured.responseSpecification ламає всі негативні тести, які очікують 4xx. Краще мати кілька іменованих специфікацій на кшталт OK_JSON, CREATED, VALIDATION_ERROR. Ще нюанс: у специфікації можна вказати rootPath(), і тоді шляхи в expectBody рахуються відносно нього — корисно для обгорток data.

A common mistake. One global spec with expectStatusCode(200) set through RestAssured.responseSpecification breaks every negative test that expects a 4xx. It is better to have several named specs such as OK_JSON, CREATED and VALIDATION_ERROR. Another detail: a spec can set rootPath(), and then the paths in expectBody are relative to it, which helps with data wrappers.

Бонус. Помилки специфікації і тесту REST Assured збирає в одне повідомлення, тож при падінні видно всі розбіжності одразу.

Bonus. REST Assured gathers the failures of the spec and of the test into one message, so a failing run shows every mismatch at once.

Іменовані специфікації відповіді
Named response specifications
public static final ResponseSpecification OK_JSON = new ResponseSpecBuilder()
        .expectStatusCode(200)
        .expectContentType(ContentType.JSON)
        .expectHeader("X-Request-Id", not(emptyOrNullString()))
        .build();

public static final ResponseSpecification VALIDATION_ERROR = new ResponseSpecBuilder()
        .expectStatusCode(400)
        .expectContentType(ContentType.JSON)
        .expectBody("code", equalTo("VALIDATION_FAILED"))
        .expectBody("errors", not(empty()))
        .build();

@Test
void rejectsNegativeQuantity() {
    given().spec(Specs.BASE).body(Map.of("sku", "A-1", "qty", -1))
    .when().post("/cart/items")
    .then().spec(VALIDATION_ERROR)
        .body("errors.field", hasItem("qty"));
}
🔧 Розбір коду: not(emptyOrNullString()) — матчер Hamcrest 2.1+, що відкидає і null, і порожній рядок. Після spec(VALIDATION_ERROR) тест додає свою перевірку: помилка стосується саме поля qty. Вираз errors.field збирає поле field з кожного елемента масиву.
🔧 Code breakdown: not(emptyOrNullString()) is a Hamcrest 2.1+ matcher that rejects both null and an empty string. After spec(VALIDATION_ERROR) the test adds its own check: the error is about the qty field. The expression errors.field collects field from every array element.
💡 Простими словами: ResponseSpecification — це «стандартний вигляд» відповіді, а тест додає лише те, що унікальне для нього.
💡 In plain words: A ResponseSpecification is the standard shape of a response; the test adds only what is unique to it.
🗣 Як читати вголос ResponseSpecification я використовую для повторюваних очікувань: успішна JSON-відповідь, створення ресурсу, помилка валідації. Будую через ResponseSpecBuilder з expectStatusCode, expectContentType, expectBody і підключаю через then().spec. Туди кладу лише інваріанти, а унікальне для тесту пишу в самому тесті. Глобальну специфікацію з двохсотим статусом не ставлю, бо вона зламає всі негативні сценарії, — краще кілька іменованих.
🗣 How to say it out loud I use a ResponseSpecification for repeated expectations: a successful JSON response, a created resource, a validation error. I build it with ResponseSpecBuilder using expectStatusCode, expectContentType and expectBody, and apply it with then().spec. Only invariants go in there, and whatever is unique to a test stays in the test. I never set a global spec expecting 200, because it would break every negative scenario; several named specs work better.
12 Що таке RestAssuredConfig і як через нього налаштувати кодування запиту та DecoderConfig? What is RestAssuredConfig, and how do you use it to set request encoding and DecoderConfig?
СпецифікаціїSpecs Middle ▶

Що це. RestAssuredConfig — незмінний контейнер усіх низькорівневих налаштувань: HttpClientConfig (таймаути, параметри клієнта), RedirectConfig, LogConfig, EncoderConfig, DecoderConfig, ObjectMapperConfig, JsonConfig, SSLConfig, SessionConfig, HeaderConfig та інші. Кожен метод на кшталт encoderConfig(...) повертає новий об'єкт, тому конфіг зручно будувати ланцюжком. Задається глобально (RestAssured.config = ...), у специфікації (setConfig) або на запит (given().config(...)).

What it is. RestAssuredConfig is an immutable container for all low-level settings: HttpClientConfig (timeouts, client params), RedirectConfig, LogConfig, EncoderConfig, DecoderConfig, ObjectMapperConfig, JsonConfig, SSLConfig, SessionConfig, HeaderConfig and more. Each method such as encoderConfig(...) returns a new object, so a config is naturally built as a chain. You set it globally (RestAssured.config = ...), in a spec (setConfig) or per request (given().config(...)).

Кодування. EncoderConfig.encoderConfig().defaultContentCharset("UTF-8") задає charset для тіл без явного charset, defaultQueryParameterCharset — для query-параметрів. Це рятує тести з кирилицею, коли сервер отримує знаки питання замість літер.

Encoding. EncoderConfig.encoderConfig().defaultContentCharset("UTF-8") sets the charset for bodies without an explicit one, and defaultQueryParameterCharset does it for query params. That saves tests with Cyrillic text when the server receives question marks instead of letters.

Декодування. Класу DecodeContentConfig не існує — налаштування стиснення відповідей живе в DecoderConfig. За замовчуванням REST Assured просить і розпаковує GZIP та DEFLATE. contentDecoders(ContentDecoder.GZIP) залишає лише GZIP, noContentDecoders() вимикає декодування — корисно, щоб перевірити, що сервер справді стискає відповідь, а не лише декларує це. Там само defaultContentCharset для відповідей без charset.

Decoding. There is no DecodeContentConfig class — response compression settings live in DecoderConfig. By default REST Assured asks for and unpacks GZIP and DEFLATE. contentDecoders(ContentDecoder.GZIP) keeps only GZIP, and noContentDecoders() turns decoding off, which helps you verify that the server really compresses the response rather than just claiming it. The same class has defaultContentCharset for responses without a charset.

Пастка. RestAssured.config().encoderConfig(...) не змінює глобальний стан сам по собі — результат треба присвоїти.

Trap. RestAssured.config().encoderConfig(...) does not change global state by itself — you have to assign the result.

Кодування, декодування і перевірка стиснення
Encoding, decoding and a compression check
RestAssured.config = RestAssuredConfig.config()
        .encoderConfig(EncoderConfig.encoderConfig()
                .defaultContentCharset("UTF-8")
                .defaultQueryParameterCharset("UTF-8"))
        .decoderConfig(DecoderConfig.decoderConfig()
                .contentDecoders(DecoderConfig.ContentDecoder.GZIP));

@Test
void catalogIsServedGzipped() {
    given()
        .config(RestAssured.config().decoderConfig(DecoderConfig.decoderConfig().noContentDecoders()))
        .header("Accept-Encoding", "gzip")
    .when().get("/catalog")
    .then().statusCode(200)
        .header("Content-Encoding", "gzip");
}
🔧 Розбір коду: Перший вираз присвоює новий глобальний конфіг. У тесті RestAssured.config() бере поточний конфіг, а decoderConfig(...) повертає його копію без декодерів лише для цього запиту. Заголовок Accept-Encoding ставимо вручну, бо без декодерів бібліотека його не додає.
🔧 Code breakdown: The first statement assigns a new global config. In the test RestAssured.config() takes the current config, and decoderConfig(...) returns a copy without decoders for this request only. We set Accept-Encoding by hand because without decoders the library does not add it.
💡 Простими словами: RestAssuredConfig — це «пульт» з усіма тонкими налаштуваннями, і кожне натискання дає новий пульт.
💡 In plain words: RestAssuredConfig is a remote with every fine setting, and each button press gives you a new remote.
🗣 Як читати вголос RestAssuredConfig — це незмінний об'єкт, де зібрані всі низькорівневі налаштування: HTTP-клієнт, редиректи, логування, кодування, маппер, SSL. Я задаю його глобально, у специфікації або на один запит. Для кирилиці ставлю EncoderConfig з UTF-8. Стиснення відповідей налаштовує DecoderConfig: за замовчуванням GZIP і DEFLATE, а через noContentDecoders я можу перевірити, що сервер справді віддає gzip. Головне — пам'ятати, що кожен виклик повертає новий конфіг, і його треба присвоїти.
🗣 How to say it out loud RestAssuredConfig is an immutable object that holds every low-level setting: the HTTP client, redirects, logging, encoding, the mapper, SSL. I set it globally, in a spec or for a single request. For Cyrillic I use EncoderConfig with UTF-8. Response compression is controlled by DecoderConfig: GZIP and DEFLATE by default, and with noContentDecoders I can verify that the server really serves gzip. The main thing is to remember that each call returns a new config that has to be assigned.
13 Як налаштувати таймаути з'єднання і читання в REST Assured? How do you configure connection and read timeouts in REST Assured?
СпецифікаціїSpecs Middle ▶

Проблема. За замовчуванням REST Assured не обмежує час очікування: якщо сервіс завис, тест висить до таймауту всього CI-джоба. Для API-тестів це найгірший сценарій, бо звіт не показує, де саме зависло.

The problem. By default REST Assured does not limit waiting time: if a service hangs, the test hangs until the whole CI job times out. For API tests that is the worst case, because the report does not show where it got stuck.

Рішення. У REST Assured немає окремого методу timeout(). Таймаути передаються як параметри Apache HttpClient через HttpClientConfig.httpClientConfig().setParam(...): http.connection.timeout — скільки чекати встановлення TCP-з'єднання; http.socket.timeout — скільки чекати даних від уже відкритого з'єднання (фактично таймаут читання); http.connection-manager.timeout — скільки чекати вільного з'єднання з пулу (тип Long). Значення в мілісекундах. Задається глобально через RestAssured.config або для одного запиту через given().config(...).

The fix. REST Assured has no dedicated timeout() method. Timeouts are passed as Apache HttpClient parameters through HttpClientConfig.httpClientConfig().setParam(...): http.connection.timeout — how long to wait for the TCP connection to open; http.socket.timeout — how long to wait for data on an open connection (in practice the read timeout); http.connection-manager.timeout — how long to wait for a free pooled connection (a Long). Values are in milliseconds. Set them globally via RestAssured.config or for one request via given().config(...).

Як обрати значення. Таймаут з'єднання короткий (2–5 с): якщо TCP не встановився, чекати далі немає сенсу. Таймаут читання — з запасом над нормальним часом найповільнішого ендпоінта, але значно менший за таймаут джоба. Для довгих операцій (генерація звіту) краще асинхронний патерн з опитуванням, ніж таймаут на хвилину.

Choosing values. Keep the connect timeout short (2–5 s): if TCP did not connect, waiting longer is pointless. The read timeout should comfortably exceed the normal time of your slowest endpoint but stay well below the job timeout. For long operations (report generation) an async pattern with polling beats a one-minute timeout.

Що перевіряти. Коли спрацьовує таймаут, тест падає з SocketTimeoutException або ConnectTimeoutException — це не assertion, і в звіті варто відрізняти такі падіння від функціональних.

What to watch. When a timeout fires, the test fails with SocketTimeoutException or ConnectTimeoutException — not an assertion — and the report should tell such failures apart from functional ones.

Глобальні таймаути і виняток для повільного ендпоінта
Global timeouts and an exception for a slow endpoint
@BeforeAll
static void timeouts() {
    RestAssured.config = RestAssured.config().httpClient(HttpClientConfig.httpClientConfig()
            .setParam("http.connection.timeout", 3_000)
            .setParam("http.socket.timeout", 10_000)
            .setParam("http.connection-manager.timeout", 3_000L));
}

@Test
void monthlyReportIsGenerated() {
    given()
        .config(RestAssured.config().httpClient(HttpClientConfig.httpClientConfig()
                .setParam("http.socket.timeout", 60_000)))   // лише тут
    .when().post("/reports/monthly")
    .then().statusCode(200);
}
@BeforeAll
static void timeouts() {
    RestAssured.config = RestAssured.config().httpClient(HttpClientConfig.httpClientConfig()
            .setParam("http.connection.timeout", 3_000)
            .setParam("http.socket.timeout", 10_000)
            .setParam("http.connection-manager.timeout", 3_000L));
}

@Test
void monthlyReportIsGenerated() {
    given()
        .config(RestAssured.config().httpClient(HttpClientConfig.httpClientConfig()
                .setParam("http.socket.timeout", 60_000)))   // only here
    .when().post("/reports/monthly")
    .then().statusCode(200);
}
🔧 Розбір коду: Ключі — це рядкові імена параметрів Apache HttpClient 4 (константи CoreConnectionPNames), тож опечатка в імені просто нічого не змінить. Параметр менеджера з'єднань очікує Long, звідси 3_000L. Другий тест перевизначає лише таймаут читання для одного запиту.
🔧 Code breakdown: The keys are string names of Apache HttpClient 4 parameters (the CoreConnectionPNames constants), so a typo in a name silently changes nothing. The connection manager parameter expects a Long, hence 3_000L. The second test overrides only the read timeout for one request.
💡 Простими словами: Таймаут з'єднання — «чи достукався я», таймаут читання — «чи відповіли мені вчасно».
💡 In plain words: The connect timeout asks whether I got through; the read timeout asks whether they answered in time.
🗣 Як читати вголос Окремого методу timeout у REST Assured немає, тому я задаю параметри Apache HttpClient через HttpClientConfig: http.connection.timeout на встановлення з'єднання і http.socket.timeout на очікування даних. Роблю це глобально в конфігу, а для одного повільного ендпоінта перевизначаю через given().config. Без таймаутів зависання сервісу вішає весь CI-джоб, і ніхто не розуміє, де проблема. А довгі операції краще тестувати опитуванням, ніж хвилинним таймаутом.
🗣 How to say it out loud REST Assured has no timeout method, so I set Apache HttpClient parameters through HttpClientConfig: http.connection.timeout for opening the connection and http.socket.timeout for waiting on data. I do it globally in the config and override it with given().config for one slow endpoint. Without timeouts a hanging service freezes the whole CI job and nobody knows where the problem is. And long operations are better tested with polling than with a one-minute timeout.
14 Як REST Assured обробляє редиректи і як перевірити сам 302 без переходу? How does REST Assured handle redirects, and how do you check the 302 itself without following it?
СпецифікаціїSpecs Middle ▶

За замовчуванням. REST Assured автоматично йде за редиректами (301, 302, 303, 307, 308), тому then() бачить уже кінцеву відповідь. Це зручно, але ховає сам редирект: тест «логін успішний» пройде, навіть якщо сервер відправив користувача не на ту сторінку.

Default. REST Assured follows redirects automatically (301, 302, 303, 307, 308), so then() sees the final response. That is convenient but hides the redirect itself: a "login succeeds" test passes even if the server sent the user to the wrong page.

Вимкнути для запиту. given().redirects().follow(false) — і в then() приходить відповідь з кодом 3xx та заголовком Location. Ці ж методи дають redirects().max(3) для обмеження ланцюжка і redirects().allowCircular(true) для циклічних редиректів.

Turning it off per request. given().redirects().follow(false), and then() receives the 3xx response with its Location header. The same methods offer redirects().max(3) to cap the chain and redirects().allowCircular(true) for circular redirects.

Глобально. RestAssured.config = config().redirect(RedirectConfig.redirectConfig().followRedirects(false)). Це рідко потрібно: краще вимикати точково там, де редирект і є предметом перевірки.

Globally. RestAssured.config = config().redirect(RedirectConfig.redirectConfig().followRedirects(false)). You rarely need that: it is better to switch it off only where the redirect is what you are testing.

Коли тестувати редирект. Перенесені ендпоінти (301 зі старої версії API), OAuth-флоу з redirect_uri, логін з поверненням на збережену сторінку, HTTP → HTTPS. У безпековому тесті окремо перевіряють, що параметр на кшталт returnUrl=https://evil.com не потрапляє в Location (open redirect).

When to test redirects. Moved endpoints (a 301 from an old API version), OAuth flows with a redirect_uri, login that returns to a saved page, HTTP → HTTPS. A security test also checks that a parameter such as returnUrl=https://evil.com never ends up in Location (open redirect).

Перевірка редиректу і захисту від open redirect
Checking a redirect and open-redirect protection
@Test
void oldEndpointIsPermanentlyMoved() {
    given().redirects().follow(false)
    .when().get("/api/v1/orders")
    .then()
        .statusCode(301)
        .header("Location", endsWith("/api/v2/orders"));
}

@Test
void loginIgnoresForeignReturnUrl() {
    given().redirects().follow(false)
        .formParam("username", "qa").formParam("password", secret)
        .queryParam("returnUrl", "https://evil.example.org")
    .when().post("/login")
    .then()
        .statusCode(302)
        .header("Location", not(containsString("evil.example.org")));
}
🔧 Розбір коду: redirects() повертає об'єкт налаштувань редиректу для цього запиту, а follow(false) повертає назад RequestSpecification, тож ланцюжок продовжується. endsWith не прив'язує тест до хоста середовища.
🔧 Code breakdown: redirects() returns the redirect settings for this request, and follow(false) returns the RequestSpecification again, so the chain continues. endsWith keeps the test independent of the environment host.
💡 Простими словами: Хочеш перевірити «куди відправили» — вимкни автоматичний перехід, інакше побачиш лише «куди прийшли».
💡 In plain words: To check where you were sent, switch off following, otherwise you only see where you arrived.
🗣 Як читати вголос За замовчуванням REST Assured сам іде за редиректами, і в then я бачу вже кінцеву сторінку. Коли мені важливий сам редирект, я пишу given().redirects().follow(false) і перевіряю код 301 чи 302 та заголовок Location. Так я тестую перенесені ендпоінти, OAuth і логін. А ще це основа тесту на open redirect: підсовую чужий returnUrl і дивлюсь, що він не потрапив у Location. Глобально редиректи вимикаю рідко.
🗣 How to say it out loud By default REST Assured follows redirects on its own, and in then I only see the final page. When the redirect itself matters, I write given().redirects().follow(false) and check the 301 or 302 and the Location header. That is how I test moved endpoints, OAuth and login. It is also the basis of an open-redirect test: I pass a foreign returnUrl and check it never reaches Location. I rarely turn redirects off globally.
15 Як надіслати запити REST Assured через проксі, глобально і для одного запиту? How do you send REST Assured requests through a proxy, globally and for a single request?
СпецифікаціїSpecs Middle ▶

Для одного запиту. given().proxy("localhost", 8888), given().proxy(8888) для localhost або given().proxy(host("proxy.corp").withPort(3128)).

For one request. given().proxy("localhost", 8888), given().proxy(8888) for localhost, or given().proxy(host("proxy.corp").withPort(3128)).

Глобально. Статичне поле RestAssured.proxy = host("127.0.0.1").withPort(8080);, де host() — статичний метод ProxySpecification. Є і скорочення RestAssured.proxy("127.0.0.1", 8080). ProxySpecification також має withScheme("https") і withAuth(user, password) для корпоративного проксі з авторизацією. У специфікації — RequestSpecBuilder.setProxy(...).

Globally. The static field RestAssured.proxy = host("127.0.0.1").withPort(8080);, where host() is a static method of ProxySpecification. There is also the shortcut RestAssured.proxy("127.0.0.1", 8080). ProxySpecification also has withScheme("https") and withAuth(user, password) for a corporate proxy that needs credentials. In a spec: RequestSpecBuilder.setProxy(...).

Навіщо. Три типові причини: корпоративна мережа, де зовнішні API доступні лише через проксі; налагодження — пустити трафік через Charles, Fiddler чи mitmproxy і подивитись сирі байти; перевірка поведінки через проксі-інструменти безпеки (OWASP ZAP), щоб пасивно сканувати запити тестів.

Why. Three typical reasons: a corporate network where external APIs are reachable only through a proxy; debugging, by routing traffic through Charles, Fiddler or mitmproxy to see the raw bytes; running the tests through security tools such as OWASP ZAP so they passively scan the requests.

Підводні камені. Коли проксі перехоплює HTTPS, він підставляє свій сертифікат, і запит впаде з SSL-помилкою, доки ти не довіриш сертифікат проксі (truststore) або не ввімкнеш relaxed-валідацію — останнє лише локально. І не лишай глобальний проксі в коді після налагодження: у CI його немає, і всі тести падають з Connection refused.

Pitfalls. A proxy that intercepts HTTPS presents its own certificate, and the request fails with an SSL error until you trust the proxy certificate (a truststore) or enable relaxed validation — the latter only locally. And do not leave a global proxy in code after debugging: CI does not have it, and every test fails with Connection refused.

Проксі з конфігурації, а не з коду
A proxy from configuration, not from code
import static io.restassured.specification.ProxySpecification.host;

@BeforeAll
static void proxyIfConfigured() {
    String proxyHost = System.getenv("API_PROXY_HOST");
    if (proxyHost != null) {
        RestAssured.proxy = host(proxyHost)
                .withPort(Integer.parseInt(System.getenv().getOrDefault("API_PROXY_PORT", "8080")))
                .withAuth(System.getenv("API_PROXY_USER"), System.getenv("API_PROXY_PASSWORD"));
    }
}

@Test
void debugOneCallThroughMitmproxy() {
    given().proxy("localhost", 8888)
    .when().get("/orders/42")
    .then().statusCode(200);
}
🔧 Розбір коду: Статичний імпорт host робить запис коротким. Глобальний проксі вмикається лише тоді, коли задана змінна середовища, тож локальний налагоджувальний проксі ніколи не потрапить у CI. Другий тест пускає через mitmproxy лише один запит.
🔧 Code breakdown: The static import of host keeps the line short. The global proxy is switched on only when an environment variable is set, so a local debugging proxy never leaks into CI. The second test routes just one request through mitmproxy.
💡 Простими словами: Проксі — це посередник між тестом і сервером: корисний для мережі компанії й для підглядання в трафік.
💡 In plain words: A proxy is a middleman between the test and the server: useful for company networks and for peeking at traffic.
🗣 Як читати вголос Для одного запиту я пишу given().proxy з хостом і портом. Глобально — присвоюю RestAssured.proxy через host з ProxySpecification і withPort, а для корпоративного проксі додаю withAuth. Використовую це в закритих мережах, для налагодження через mitmproxy чи Charles і щоб пустити тести через ZAP. Проксі беру зі змінних середовища, а не хардкоджу, бо інакше тести впадуть у CI. І пам'ятаю, що HTTPS через проксі вимагає довіри до його сертифіката.
🗣 How to say it out loud For one request I write given().proxy with a host and port. Globally I assign RestAssured.proxy using host from ProxySpecification and withPort, and for a corporate proxy I add withAuth. I use this in closed networks, for debugging through mitmproxy or Charles, and to run tests through ZAP. I take the proxy from environment variables instead of hardcoding it, otherwise the tests fail in CI. And I remember that HTTPS through a proxy needs trust in its certificate.
16 Що робить relaxedHTTPSValidation(), чим він ризикований і яка правильна альтернатива? What does relaxedHTTPSValidation() do, why is it risky, and what is the right alternative?
СпецифікаціїSpecs Middle ▶

Що робить. given().relaxedHTTPSValidation() або глобальний RestAssured.useRelaxedHTTPSValidation() змушує клієнт довіряти будь-якому сертифікату і не перевіряти ім'я хоста. Це прибирає помилки SSLHandshakeException і PKIX path building failed на стендах із самопідписаними сертифікатами. Є перевантаження з протоколом, наприклад relaxedHTTPSValidation("TLSv1.2").

What it does. given().relaxedHTTPSValidation() or the global RestAssured.useRelaxedHTTPSValidation() makes the client trust any certificate and skip the hostname check. That removes SSLHandshakeException and PKIX path building failed errors on environments with self-signed certificates. There is an overload with a protocol, for example relaxedHTTPSValidation("TLSv1.2").

Чим ризикований. Тест перестає помічати реальні проблеми TLS: прострочений сертифікат, невідповідність домену, неповний ланцюжок, підміну сертифіката проксі. Якщо ця опція потрапила в тести, які ганяють на staging чи production, команда дізнається про прострочений сертифікат від користувачів. Крім того, в корпоративній мережі це робить тест вразливим до MITM, коли через нього передаються справжні облікові дані.

Why it is risky. The test stops noticing real TLS problems: an expired certificate, a domain mismatch, an incomplete chain, a certificate swapped by a proxy. If the option ends up in tests that run against staging or production, the team learns about an expired certificate from its users. On a corporate network it also leaves the test open to MITM while real credentials pass through it.

Альтернатива. Додати сертифікат тестового CA в truststore і передати його REST Assured: trustStore("certs/test-ca.jks", password) на запит, RestAssured.trustStore(...) глобально або через SSLConfig. Для mutual TLS — keyStore(...) з клієнтським сертифікатом. Relaxed-режим допустимий лише локально, з явним прапорцем і ніколи для середовищ із реальними даними. Окремий smoke-тест може навмисно перевіряти, що сертифікат валідний і не спливає найближчі 14 днів.

The alternative. Add the test CA certificate to a truststore and hand it to REST Assured: trustStore("certs/test-ca.jks", password) per request, RestAssured.trustStore(...) globally, or via SSLConfig. For mutual TLS use keyStore(...) with the client certificate. Relaxed mode is acceptable only locally, behind an explicit flag, and never for environments with real data. A separate smoke test can deliberately check that the certificate is valid and does not expire within the next 14 days.

Truststore замість relaxed-режиму, relaxed лише за прапорцем
A truststore instead of relaxed mode, relaxed only behind a flag
@BeforeAll
static void tls() {
    if (Boolean.getBoolean("tls.insecure")) {          // лише локально: -Dtls.insecure=true
        RestAssured.useRelaxedHTTPSValidation();
    } else {
        RestAssured.config = RestAssured.config().sslConfig(SSLConfig.sslConfig()
                .trustStore("certs/test-ca.jks", System.getenv("TRUSTSTORE_PASSWORD")));
    }
}

@Test
void partnerApiRequiresClientCertificate() {
    given()
        .keyStore("certs/qa-client.p12", System.getenv("CLIENT_CERT_PASSWORD"))
    .when().get("https://partner.example.com/v1/ping")
    .then().statusCode(200);
}
@BeforeAll
static void tls() {
    if (Boolean.getBoolean("tls.insecure")) {          // local only: -Dtls.insecure=true
        RestAssured.useRelaxedHTTPSValidation();
    } else {
        RestAssured.config = RestAssured.config().sslConfig(SSLConfig.sslConfig()
                .trustStore("certs/test-ca.jks", System.getenv("TRUSTSTORE_PASSWORD")));
    }
}

@Test
void partnerApiRequiresClientCertificate() {
    given()
        .keyStore("certs/qa-client.p12", System.getenv("CLIENT_CERT_PASSWORD"))
    .when().get("https://partner.example.com/v1/ping")
    .then().statusCode(200);
}
🔧 Розбір коду: Boolean.getBoolean("tls.insecure") читає системну властивість JVM, тож небезпечний режим треба ввімкнути свідомо. SSLConfig.trustStore приймає шлях до файлу чи ресурсу і пароль. keyStore на рівні запиту додає клієнтський сертифікат для mTLS.
🔧 Code breakdown: Boolean.getBoolean("tls.insecure") reads a JVM system property, so the unsafe mode has to be switched on deliberately. SSLConfig.trustStore takes a file or resource path and a password. keyStore at request level adds the client certificate for mTLS.
💡 Простими словами: relaxedHTTPSValidation — це вимкнений детектор диму: тихо, але пожежу ти не помітиш.
💡 In plain words: relaxedHTTPSValidation is a disabled smoke detector: it is quiet, but you will not notice the fire.
🗣 Як читати вголос RelaxedHTTPSValidation каже клієнту довіряти будь-якому сертифікату і не перевіряти ім'я хоста. Це швидко прибирає SSL-помилки на тестовому стенді, але ховає прострочені сертифікати, чужі домени і підміну посередником. Тому я кладу сертифікат тестового CA в truststore і передаю його через trustStore або SSLConfig, а для mTLS додаю keyStore. Relaxed-режим дозволяю лише локально за явним прапорцем, і ніколи для середовищ з реальними даними.
🗣 How to say it out loud RelaxedHTTPSValidation tells the client to trust any certificate and skip the hostname check. It quickly silences SSL errors on a test environment, but it hides expired certificates, wrong domains and a man in the middle. So I put the test CA certificate into a truststore and pass it with trustStore or SSLConfig, and for mTLS I add a keyStore. I allow relaxed mode only locally behind an explicit flag, and never for environments with real data.
17 Які пастки статичного стану RestAssured при паралельному запуску тестів і як будувати потокобезпечні тести? What are the traps of static RestAssured state in parallel test runs, and how do you build thread-safe tests?
СпецифікаціїSpecs Senior ▶

Суть проблеми. RestAssured.baseURI, basePath, port, authentication, requestSpecification, responseSpecification, config, proxy і список фільтрів — звичайні статичні поля, спільні для всіх потоків JVM. Вони не ThreadLocal. Коли JUnit 5 чи TestNG запускає класи паралельно, а кожен клас у @BeforeAll присвоює свій baseURI або токен, запити одного класу підуть з налаштуваннями іншого. Симптоми нестабільні: 401 «раз на десять прогонів», запит на чужий сервіс, змішані логи. RestAssured.reset() в @AfterAll тут навіть шкодить — він скидає стан посеред тесту іншого потоку.

The core problem. RestAssured.baseURI, basePath, port, authentication, requestSpecification, responseSpecification, config, proxy and the filter list are plain static fields shared by every thread in the JVM. They are not ThreadLocal. When JUnit 5 or TestNG runs classes in parallel and each class assigns its own baseURI or token in @BeforeAll, requests from one class go out with another class's settings. The symptoms are unstable: a 401 once in ten runs, a request hitting the wrong service, interleaved logs. RestAssured.reset() in @AfterAll even makes it worse — it wipes state in the middle of another thread's test.

Рішення. Глобально задавати лише те, що однакове для всього прогону, і лише один раз до старту (наприклад, у статичному ініціалізаторі базового класу або JUnit-розширенні). Усе, що залежить від тесту, — через локальні специфікації: given().spec(...) створює новий об'єкт запиту на кожен виклик, а зібрані й незмінні специфікації можна безпечно ділити. Стейтфул-об'єкти (SessionFilter, CookieFilter, власні фільтри з полями) — окремий екземпляр на тест. Токени — в потокобезпечному кеші, а не в статичному полі, яке перезаписує кожен клас.

The fix. Set globally only what is identical for the whole run, and only once before start (for instance in a static initializer of a base class or a JUnit extension). Everything test-specific goes through local specs: given().spec(...) creates a new request object on every call, and built, immutable specs are safe to share. Stateful objects (SessionFilter, CookieFilter, your own filters with fields) get one instance per test. Tokens live in a thread-safe cache, not in a static field every class overwrites.

Як довести. Прогнати набір з parallel.mode.default=concurrent кілька разів і порівняти, а в логах мати ідентифікатор потоку та X-Request-Id. Також варто перевірити, що тестові дані унікальні для кожного потоку, інакше паралельність ламає не REST Assured, а спільні записи в базі.

How to prove it. Run the suite with parallel.mode.default=concurrent several times and compare, and put the thread id and X-Request-Id into the logs. Also make sure test data is unique per thread; otherwise it is not REST Assured that breaks parallelism but shared rows in the database.

Небезпечний і безпечний варіанти
The unsafe and the safe variant
// ПОГАНО: два класи паралельно переписують спільну статику
class BillingTest {
    @BeforeAll static void init() { RestAssured.baseURI = Env.billingUrl(); }
}
class CatalogTest {
    @BeforeAll static void init() { RestAssured.baseURI = Env.catalogUrl(); }
}

// ДОБРЕ: незмінні специфікації, стейтфул-фільтр на кожен тест
class BillingApi {
    private static final RequestSpecification SPEC = new RequestSpecBuilder()
            .setBaseUri(Env.billingUrl()).setContentType(ContentType.JSON).build();

    Response invoice(long id, String token) {
        return given().spec(SPEC).auth().oauth2(token)
                .filter(new SessionFilter())
                .when().get("/invoices/{id}", id);
    }
}
// BAD: two classes overwrite shared statics in parallel
class BillingTest {
    @BeforeAll static void init() { RestAssured.baseURI = Env.billingUrl(); }
}
class CatalogTest {
    @BeforeAll static void init() { RestAssured.baseURI = Env.catalogUrl(); }
}

// GOOD: immutable specs, one stateful filter per test
class BillingApi {
    private static final RequestSpecification SPEC = new RequestSpecBuilder()
            .setBaseUri(Env.billingUrl()).setContentType(ContentType.JSON).build();

    Response invoice(long id, String token) {
        return given().spec(SPEC).auth().oauth2(token)
                .filter(new SessionFilter())
                .when().get("/invoices/{id}", id);
    }
}
junit-platform.properties для паралельного прогону
junit-platform.properties for a parallel run
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.mode.classes.default = concurrent
🔧 Розбір коду: У поганому варіанті результат залежить від того, чий @BeforeAll виконався останнім. У доброму кожен виклик given().spec(SPEC) копіює налаштування в новий запит, токен передається параметром, а new SessionFilter() не ділиться між потоками. Файл властивостей вмикає паралельність і для методів, і для класів.
🔧 Code breakdown: In the bad variant the result depends on whose @BeforeAll ran last. In the good one every given().spec(SPEC) call copies the settings into a new request, the token comes in as a parameter, and new SessionFilter() is never shared between threads. The properties file enables concurrency for both methods and classes.
💡 Простими словами: Статика RestAssured — одна дошка оголошень на всю JVM: якщо кожен клас пише на ній своє, читають усі.
💡 In plain words: RestAssured statics are one notice board for the whole JVM: if every class writes on it, everyone reads it.
🗣 Як читати вголос Я одразу кажу, що baseURI, auth, config, фільтри і глобальні специфікації в RestAssured — звичайні статичні поля, не ThreadLocal. Якщо паралельні класи присвоюють туди різні значення, запити летять з чужими налаштуваннями, і це виглядає як рідкісний флак з 401. Тому глобально я ставлю лише незмінне і один раз, а решту передаю через незмінні специфікації. SessionFilter і подібні фільтри створюю на кожен тест, токени кешую потокобезпечно, і reset в паралельному прогоні не викликаю.
🗣 How to say it out loud I say straight away that baseURI, auth, config, filters and the global specs in RestAssured are plain static fields, not ThreadLocal. If parallel classes assign different values there, requests go out with someone else's settings, and it looks like a rare flaky 401. So globally I set only immutable values, once, and pass everything else through immutable specs. I create SessionFilter and similar filters per test, cache tokens in a thread-safe way, and never call reset during a parallel run.
▶ 3 7
18 Як передати JSON-тіло запиту: рядком, Map чи POJO, і коли що обирати? How do you pass a JSON request body — as a String, a Map or a POJO — and when do you pick each?
Тіло і серіалізаціяBody and mapping Junior ▶

Рядок. body("{\"name\":\"Olena\"}") разом з contentType(ContentType.JSON). REST Assured надсилає рядок як є. Підходить для навмисно зламаного JSON у негативних тестах або для точного відтворення запиту з баг-репорту. Для звичайних тестів — погано: екранування лапок, опечатки видно лише під час запуску, рефакторинг полів не підхоплюється.

String. body("{\"name\":\"Olena\"}") together with contentType(ContentType.JSON). REST Assured sends the string as is. It suits deliberately broken JSON in negative tests or reproducing a request from a bug report exactly. For ordinary tests it is a poor choice: escaped quotes, typos that show up only at run time, and field renames that refactoring does not pick up.

Map. body(Map.of("name", "Olena", "age", 30)) — маппер (Jackson чи Gson) серіалізує мапу в JSON. Зручно для невеликих тіл і для тестів, де треба прибрати поле або передати неправильний тип ("age", "thirty"), чого POJO не дозволить. Вкладені об'єкти — вкладені мапи й списки.

Map. body(Map.of("name", "Olena", "age", 30)) — the mapper (Jackson or Gson) serializes the map to JSON. Handy for small bodies and for tests that need to drop a field or send a wrong type ("age", "thirty"), which a POJO will not allow. Nested objects are nested maps and lists.

POJO. body(new User("Olena", 30)) — найкращий варіант для позитивних сценаріїв: компілятор перевіряє поля, IDE перейменовує їх разом з кодом, клас можна перевикористати для десеріалізації відповіді. Потрібен Jackson або Gson у classpath, інакше буде помилка «Cannot serialize object because no JSON serializer found».

POJO. body(new User("Olena", 30)) is the best option for positive scenarios: the compiler checks fields, the IDE renames them along with the code, and the class can be reused to deserialize the response. Jackson or Gson must be on the classpath, otherwise you get "Cannot serialize object because no JSON serializer found".

Інші джерела. body(new File("payloads/order.json")) і body(byte[]) — для великих еталонних тіл або бінарних даних. Якщо Content-Type не задано, об'єкт серіалізується в JSON, але явний тип краще: він документує контракт.

Other sources. body(new File("payloads/order.json")) and body(byte[]) for large reference bodies or binary data. If no Content-Type is set, an object is serialized to JSON, but an explicit type is better because it documents the contract.

Три способи передати тіло
Three ways to pass a body
// 1. Рядок: для зламаного JSON у негативному тесті
given().contentType(ContentType.JSON)
    .body("{\"name\": \"Olena\", ")
.when().post("/users")
.then().statusCode(400);

// 2. Map: коли треба неправильний тип поля
given().contentType(ContentType.JSON)
    .body(Map.of("name", "Olena", "age", "thirty"))
.when().post("/users")
.then().statusCode(400).body("errors.field", hasItem("age"));

// 3. POJO: основний шлях для позитивних сценаріїв
record User(String name, int age) { }

given().contentType(ContentType.JSON)
    .body(new User("Olena", 30))
.when().post("/users")
.then().statusCode(201).body("name", equalTo("Olena"));
// 1. String: for broken JSON in a negative test
given().contentType(ContentType.JSON)
    .body("{\"name\": \"Olena\", ")
.when().post("/users")
.then().statusCode(400);

// 2. Map: when a field needs the wrong type
given().contentType(ContentType.JSON)
    .body(Map.of("name", "Olena", "age", "thirty"))
.when().post("/users")
.then().statusCode(400).body("errors.field", hasItem("age"));

// 3. POJO: the main path for positive scenarios
record User(String name, int age) { }

given().contentType(ContentType.JSON)
    .body(new User("Olena", 30))
.when().post("/users")
.then().statusCode(201).body("name", equalTo("Olena"));
🔧 Розбір коду: У першому прикладі тіло обірване навмисно — сервер має відповісти 400, а не 500. Map.of дозволяє покласти рядок туди, де контракт чекає число. Jackson 2.12+ серіалізує Java record за іменами компонентів, тож User перетвориться на {"name":"Olena","age":30}.
🔧 Code breakdown: In the first example the body is cut off on purpose — the server should answer 400, not 500. Map.of lets you put a string where the contract expects a number. Jackson 2.12+ serializes a Java record by its component names, so User becomes {"name":"Olena","age":30}.
💡 Простими словами: POJO — для «правильних» запитів, Map — для «трохи неправильних», рядок — для «зовсім зламаних».
💡 In plain words: POJOs for correct requests, maps for slightly wrong ones, strings for completely broken ones.
🗣 Як читати вголос Тіло я можу передати рядком, мапою або об'єктом. Для позитивних сценаріїв беру POJO чи record: компілятор ловить помилки, IDE перейменовує поля, і той самий клас потім десеріалізує відповідь. Мапу використовую, коли треба прибрати поле чи підсунути неправильний тип. Рядок — лише для навмисно зламаного JSON або щоб точно відтворити запит з бага. І завжди явно ставлю contentType JSON.
🗣 How to say it out loud I can pass the body as a string, a map or an object. For positive scenarios I use a POJO or a record: the compiler catches mistakes, the IDE renames fields, and the same class later deserializes the response. I use a map when I need to drop a field or slip in the wrong type. A string is only for deliberately broken JSON or for replaying a request from a bug exactly. And I always set the JSON content type explicitly.
19 Як працює object mapping у REST Assured і як він обирає між Jackson та Gson? How does object mapping work in REST Assured, and how does it choose between Jackson and Gson?
Тіло і серіалізаціяBody and mapping Middle ▶

Що це. Object mapping — автоматична серіалізація Java-об'єкта в тіло запиту (body(obj)) і десеріалізація відповіді в об'єкт (as(User.class)). REST Assured не має власного JSON-маппера, він делегує бібліотеці з classpath.

What it is. Object mapping is automatic serialization of a Java object into the request body (body(obj)) and deserialization of the response into an object (as(User.class)). REST Assured has no JSON mapper of its own; it delegates to a library on the classpath.

Як обирає. Для JSON перевіряє наявність бібліотек у фіксованому порядку: спершу Jackson 2 (jackson-databind), потім Jackson 1, Gson, Johnzon і JSON-B (Yasson). Для XML використовується JAXB. Формат визначається Content-Type: application/json — JSON-маппер, application/xml — JAXB.

How it chooses. For JSON it looks for libraries in a fixed order: Jackson 2 (jackson-databind) first, then Jackson 1, Gson, Johnzon and JSON-B (Yasson). For XML it uses JAXB. The format follows the Content-Type: application/json means the JSON mapper, application/xml means JAXB.

Явний вибір. Якщо в classpath кілька бібліотек, а тобі потрібна конкретна, передай тип явно: body(obj, ObjectMapperType.GSON) і as(User.class, ObjectMapperType.GSON), або глобально через ObjectMapperConfig.defaultObjectMapperType(...). Можна реалізувати й власний інтерфейс io.restassured.mapper.ObjectMapper.

Explicit choice. If several libraries are on the classpath and you need a specific one, pass the type explicitly: body(obj, ObjectMapperType.GSON) and as(User.class, ObjectMapperType.GSON), or globally via ObjectMapperConfig.defaultObjectMapperType(...). You can also implement your own io.restassured.mapper.ObjectMapper interface.

Підводні камені. Jackson і Gson по-різному поводяться з null-полями, датами і невідомими полями. Коли в проєкт транзитивно приїжджає Jackson, а тести писали під Gson, серіалізація «сама» змінюється — тому бібліотеку варто фіксувати явно. Друга пастка — дати: без модуля jackson-datatype-jsr310 LocalDate не серіалізується в ISO-рядок.

Pitfalls. Jackson and Gson treat null fields, dates and unknown fields differently. When Jackson arrives transitively in a project whose tests were written for Gson, serialization changes on its own — so pin the library explicitly. The second trap is dates: without the jackson-datatype-jsr310 module a LocalDate is not serialized as an ISO string.

Автоматичний і явний вибір маппера
Automatic and explicit mapper choice
// Jackson у classpath: обирається автоматично
Order created = given().contentType(ContentType.JSON)
        .body(new Order("SKU-1", 2))
    .when().post("/orders")
    .then().statusCode(201)
    .extract().as(Order.class);

// Явно Gson для одного виклику
Order viaGson = given().contentType(ContentType.JSON)
        .body(new Order("SKU-2", 1), ObjectMapperType.GSON)
    .when().post("/orders")
    .then().statusCode(201)
    .extract().as(Order.class, ObjectMapperType.GSON);

// Глобально зафіксувати бібліотеку
RestAssured.config = RestAssured.config().objectMapperConfig(
        ObjectMapperConfig.objectMapperConfig().defaultObjectMapperType(ObjectMapperType.JACKSON_2));
// Jackson on the classpath: picked automatically
Order created = given().contentType(ContentType.JSON)
        .body(new Order("SKU-1", 2))
    .when().post("/orders")
    .then().statusCode(201)
    .extract().as(Order.class);

// Gson explicitly for one call
Order viaGson = given().contentType(ContentType.JSON)
        .body(new Order("SKU-2", 1), ObjectMapperType.GSON)
    .when().post("/orders")
    .then().statusCode(201)
    .extract().as(Order.class, ObjectMapperType.GSON);

// Pin the library globally
RestAssured.config = RestAssured.config().objectMapperConfig(
        ObjectMapperConfig.objectMapperConfig().defaultObjectMapperType(ObjectMapperType.JACKSON_2));
🔧 Розбір коду: ObjectMapperType — enum з варіантами JACKSON_2, JACKSON_1, GSON, JAXB, JOHNZON, JSONB. Другий аргумент у body() та as() перекриває автоматичний вибір лише для цього виклику, а defaultObjectMapperType — для всіх.
🔧 Code breakdown: ObjectMapperType is an enum with JACKSON_2, JACKSON_1, GSON, JAXB, JOHNZON and JSONB. The second argument of body() and as() overrides auto-detection for that call only, while defaultObjectMapperType does it for every call.
💡 Простими словами: REST Assured сам JSON не пише — він кличе того маппера, якого знайде першим.
💡 In plain words: REST Assured does not write JSON itself — it calls whichever mapper it finds first.
🗣 Як читати вголос Object mapping — це коли я передаю в body об'єкт, а з відповіді роблю as(Class), і REST Assured сам конвертує. Своєї JSON-бібліотеки в нього немає: він шукає в classpath спершу Jackson 2, потім Gson та інші, а для XML бере JAXB. Якщо бібліотек кілька, я фіксую вибір через ObjectMapperType або defaultObjectMapperType у конфігу. Бо Jackson і Gson по-різному працюють з null, датами і невідомими полями, і транзитивна залежність може тихо змінити поведінку тестів.
🗣 How to say it out loud Object mapping means I pass an object to body and call as(Class) on the response, and REST Assured converts both ways. It has no JSON library of its own: it looks on the classpath for Jackson 2 first, then Gson and others, and uses JAXB for XML. If there are several libraries, I pin the choice with ObjectMapperType or defaultObjectMapperType in the config. Jackson and Gson handle nulls, dates and unknown fields differently, and a transitive dependency can quietly change how the tests behave.
20 Як підставити власний Jackson ObjectMapper у REST Assured і які налаштування варто змінити? How do you plug your own Jackson ObjectMapper into REST Assured, and which settings are worth changing?
Тіло і серіалізаціяBody and mapping Middle ▶

Як підключити. Через ObjectMapperConfig: objectMapperConfig().jackson2ObjectMapperFactory((type, charset) -> mapper). Фабрика отримує тип і кодування, але зазвичай просто повертає один заздалегідь налаштований екземпляр — ObjectMapper потокобезпечний після налаштування. Конфіг ставиться глобально (RestAssured.config) або в специфікацію через setConfig. У квізі цей клас іноді плутають з ObjectMappingConfig — правильна назва саме ObjectMapperConfig.

How to plug it in. Through ObjectMapperConfig: objectMapperConfig().jackson2ObjectMapperFactory((type, charset) -> mapper). The factory receives the type and the charset, but usually just returns one preconfigured instance — an ObjectMapper is thread-safe once configured. The config goes globally (RestAssured.config) or into a spec via setConfig. Quizzes sometimes call this class ObjectMappingConfig; the correct name is ObjectMapperConfig.

Що варто налаштувати. JavaTimeModule і вимкнений WRITE_DATES_AS_TIMESTAMPS — щоб LocalDate і Instant йшли ISO-рядками. PropertyNamingStrategies.SNAKE_CASE, якщо API в snake_case, а POJO — у camelCase. JsonInclude.Include.NON_NULL — щоб не надсилати "field": null там, де сервер розрізняє «не передано» і «null». FAIL_ON_UNKNOWN_PROPERTIES — свідомий вибір, а не за замовчуванням.

What to configure. JavaTimeModule with WRITE_DATES_AS_TIMESTAMPS disabled, so LocalDate and Instant go out as ISO strings. PropertyNamingStrategies.SNAKE_CASE when the API is snake_case and the POJOs are camelCase. JsonInclude.Include.NON_NULL, so you do not send "field": null where the server tells "not sent" apart from "null". FAIL_ON_UNKNOWN_PROPERTIES as a deliberate choice rather than a default.

Трейдоф FAIL_ON_UNKNOWN_PROPERTIES. Якщо його вимкнути, тести не падають, коли бекенд додає нове поле, — це стійкість до еволюції API. Якщо залишити, ти одразу бачиш неочікувані поля, наприклад випадково відданий passwordHash. Поширене рішення: у спільному маппері вимкнути, а для відповідей з чутливими даними мати окремий суворий тест або JSON Schema з additionalProperties: false.

The FAIL_ON_UNKNOWN_PROPERTIES trade-off. If you turn it off, tests do not break when the backend adds a new field — resilience to API evolution. If you keep it on, you see unexpected fields immediately, such as an accidentally exposed passwordHash. A common answer is to turn it off in the shared mapper and cover responses with sensitive data by a separate strict test or a JSON Schema with additionalProperties: false.

Налаштований ObjectMapper для всього набору
A configured ObjectMapper for the whole suite
public final class Mapping {
    public static final ObjectMapper MAPPER = new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
            .setSerializationInclusion(JsonInclude.Include.NON_NULL)
            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

    public static RestAssuredConfig config() {
        return RestAssuredConfig.config().objectMapperConfig(
                ObjectMapperConfig.objectMapperConfig()
                        .jackson2ObjectMapperFactory((type, charset) -> MAPPER));
    }
}

// у специфікації
new RequestSpecBuilder().setConfig(Mapping.config()).setContentType(ContentType.JSON).build();
public final class Mapping {
    public static final ObjectMapper MAPPER = new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
            .setSerializationInclusion(JsonInclude.Include.NON_NULL)
            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

    public static RestAssuredConfig config() {
        return RestAssuredConfig.config().objectMapperConfig(
                ObjectMapperConfig.objectMapperConfig()
                        .jackson2ObjectMapperFactory((type, charset) -> MAPPER));
    }
}

// in the spec
new RequestSpecBuilder().setConfig(Mapping.config()).setContentType(ContentType.JSON).build();
🔧 Розбір коду: Лямбда (type, charset) -> MAPPER реалізує інтерфейс Jackson2ObjectMapperFactory. Один статичний екземпляр маппера ділиться всіма тестами. SNAKE_CASE перетворить поле createdAt на created_at в обох напрямках.
🔧 Code breakdown: The lambda (type, charset) -> MAPPER implements the Jackson2ObjectMapperFactory interface. One static mapper instance is shared by every test. SNAKE_CASE turns a createdAt field into created_at in both directions.
💡 Простими словами: Свій ObjectMapper — це правила перекладу між Java і JSON, записані один раз для всього проєкту.
💡 In plain words: Your own ObjectMapper is the translation rules between Java and JSON, written once for the whole project.
🗣 Як читати вголос Власний Jackson-маппер я підключаю через ObjectMapperConfig і jackson2ObjectMapperFactory, яка повертає один налаштований екземпляр. Там реєструю JavaTimeModule, щоб дати йшли ISO-рядками, ставлю snake_case, якщо API так пише, і NON_NULL, щоб не слати зайві null. Найцікавіше питання — FAIL_ON_UNKNOWN_PROPERTIES: вимкнений робить тести стійкими до нових полів, увімкнений ловить поля, які не мали витекти. Я зазвичай вимикаю його в маппері, а суворість забезпечую схемою.
🗣 How to say it out loud I plug in my own Jackson mapper through ObjectMapperConfig and jackson2ObjectMapperFactory, which returns one configured instance. There I register JavaTimeModule so dates go out as ISO strings, set snake_case if the API uses it, and NON_NULL so I do not send stray nulls. The interesting question is FAIL_ON_UNKNOWN_PROPERTIES: off makes tests resilient to new fields, on catches fields that should never have leaked. I usually turn it off in the mapper and get strictness from a schema.
21 Як десеріалізувати відповідь в об'єкт або список об'єктів і навіщо потрібен TypeRef? How do you deserialize a response into an object or a list of objects, and why do you need TypeRef?
Тіло і серіалізаціяBody and mapping Middle ▶

Один об'єкт. response.as(User.class) або then().extract().as(User.class). Можна десеріалізувати і частину відповіді: jsonPath().getObject("data.user", User.class) — корисно, коли API обгортає дані в конверт на кшталт {"data": {...}, "meta": {...}}.

One object. response.as(User.class) or then().extract().as(User.class). You can also deserialize part of the response: jsonPath().getObject("data.user", User.class) — handy when the API wraps data in an envelope like {"data": {...}, "meta": {...}}.

Список. Через стирання типів у Java не можна написати as(List<User>.class). Є три робочі варіанти: масив as(User[].class); jsonPath().getList("items", User.class) для вкладеного масиву; as(new TypeRef<List<User>>() {}) — анонімний підклас зберігає повний generic-тип, і маппер знає, що кожен елемент — User. TypeRef лежить у io.restassured.common.mapper і працює також для Map<String, List<Price>> і подібних.

A list. Because of type erasure Java will not let you write as(List<User>.class). There are three working options: an array as(User[].class); jsonPath().getList("items", User.class) for a nested array; as(new TypeRef<List<User>>() {}) — the anonymous subclass keeps the full generic type, so the mapper knows each element is a User. TypeRef lives in io.restassured.common.mapper and also works for Map<String, List<Price>> and the like.

Що дає десеріалізація. Після неї перевірки пишуться звичайним Java-кодом або AssertJ: рекурсивне порівняння, фільтри, сортування. Це читабельніше за довгий GPath.

What deserialization buys you. Afterwards checks are plain Java or AssertJ: recursive comparison, filters, sorting. That reads better than a long GPath.

Пастки. Спершу перевір статус: as() на тілі помилки дасть незрозумілий виняток маппера. І не роби POJO відповіді «дзеркалом» усього JSON без потреби — кожне поле, якого тест не перевіряє, лише створює причини для падіння при зміні API.

Traps. Check the status first: as() on an error body gives a confusing mapper exception. And do not make the response POJO a mirror of the whole JSON without a reason — every field the test does not check only adds ways to fail when the API changes.

Об'єкт, вкладений список і TypeRef
An object, a nested list and TypeRef
User user = given().pathParam("id", 7)
    .when().get("/users/{id}")
    .then().statusCode(200)
    .extract().as(User.class);

List<User> page = given().queryParam("page", 1)
    .when().get("/users")
    .then().statusCode(200)
    .extract().jsonPath().getList("items", User.class);

List<User> admins = given()
    .when().get("/admins")                    // корінь відповіді — масив
    .then().statusCode(200)
    .extract().as(new TypeRef<List<User>>() { });

assertThat(admins).extracting(User::role).containsOnly("ADMIN");
assertThat(page).allSatisfy(u -> assertThat(u.email()).contains("@"));
User user = given().pathParam("id", 7)
    .when().get("/users/{id}")
    .then().statusCode(200)
    .extract().as(User.class);

List<User> page = given().queryParam("page", 1)
    .when().get("/users")
    .then().statusCode(200)
    .extract().jsonPath().getList("items", User.class);

List<User> admins = given()
    .when().get("/admins")                    // the response root is an array
    .then().statusCode(200)
    .extract().as(new TypeRef<List<User>>() { });

assertThat(admins).extracting(User::role).containsOnly("ADMIN");
assertThat(page).allSatisfy(u -> assertThat(u.email()).contains("@"));
🔧 Розбір коду: new TypeRef<List<User>>() { } — фігурні дужки створюють анонімний клас, і саме він зберігає generic-тип у байткоді. getList("items", User.class) конвертує кожен елемент масиву items. Останні два рядки — AssertJ: extracting збирає поле з кожного об'єкта, allSatisfy перевіряє умову для всіх.
🔧 Code breakdown: new TypeRef<List<User>>() { } — the braces create an anonymous class, and that is what keeps the generic type in the bytecode. getList("items", User.class) converts each element of the items array. The last two lines are AssertJ: extracting pulls a field from every object, allSatisfy checks a condition for all of them.
💡 Простими словами: TypeRef — це «підказка» маппера: «тут не просто список, а список саме User».
💡 In plain words: TypeRef is a hint to the mapper: this is not just a list, it is a list of User.
🗣 Як читати вголос Один об'єкт я десеріалізую через extract().as(User.class), а якщо дані в конверті — через jsonPath().getObject з шляхом. Зі списками складніше через стирання типів: можна взяти масив User[], getList з класом елемента для вкладеного масиву або TypeRef, коли корінь відповіді — список. Анонімний клас TypeRef зберігає generic-тип, і маппер знає, що кожен елемент — User. Далі перевіряю звичайним AssertJ, це читабельніше за довгий GPath.
🗣 How to say it out loud I deserialize a single object with extract().as(User.class), or with jsonPath().getObject and a path if the data is inside an envelope. Lists are trickier because of type erasure: I can use a User array, getList with the element class for a nested array, or TypeRef when the response root is a list. The anonymous TypeRef class keeps the generic type, so the mapper knows each element is a User. Then I check it with plain AssertJ, which reads better than a long GPath.
22 Як надіслати form-параметри, multipart-запит з файлом і JSON-частиною? How do you send form parameters and a multipart request with a file and a JSON part?
Тіло і серіалізаціяBody and mapping Middle ▶

Форма. formParam("key", "value") або formParams(Map) дають тіло application/x-www-form-urlencoded. Content-Type REST Assured ставить сам.

Form. formParam("key", "value") or formParams(Map) produce an application/x-www-form-urlencoded body. REST Assured sets the Content-Type itself.

Multipart з файлом. multiPart("file", new File("src/test/resources/avatar.png")) — перший аргумент це ім'я поля форми (control name), яке чекає сервер. Можна вказати MIME-тип третім аргументом: multiPart("file", file, "image/png"). Замість файлу підійде byte[] або InputStream з іменем файлу: multiPart("file", "report.csv", bytes). Тип запиту автоматично стає multipart/form-data з boundary.

Multipart with a file. multiPart("file", new File("src/test/resources/avatar.png")) — the first argument is the form field name (control name) the server expects. You can pass the MIME type as a third argument: multiPart("file", file, "image/png"). Instead of a file you can use a byte[] or an InputStream with a file name: multiPart("file", "report.csv", bytes). The request type becomes multipart/form-data with a boundary automatically.

JSON-частина поруч із файлом. Багато API приймають метадані окремою частиною: multiPart("meta", json, "application/json"). Для об'єкта є MultiPartSpecBuilder: він серіалізує POJO, дозволяє задати ім'я, MIME-тип і charset частини.

A JSON part next to the file. Many APIs take metadata as a separate part: multiPart("meta", json, "application/json"). For an object there is MultiPartSpecBuilder: it serializes the POJO and lets you set the part's name, MIME type and charset.

Що перевіряти. Розмір і тип файлу у відповіді, заборонені типи (виконуваний файл під виглядом зображення), завеликий файл (очікуємо 413), порожній файл, ім'я з кирилицею чи ../. Пастка: якщо вручну поставити contentType("multipart/form-data") без boundary, сервер не розбере тіло — краще дозволити REST Assured зробити це самому.

What to check. File size and type in the response, forbidden types (an executable disguised as an image), an oversized file (expect 413), an empty file, a name with Cyrillic or ../. The trap: if you set contentType("multipart/form-data") by hand without a boundary, the server cannot parse the body — let REST Assured do it.

Файл, JSON-метадані і перевірка обмеження розміру
A file, JSON metadata and a size-limit check
@Test
void uploadsAvatarWithMetadata() {
    MultiPartSpecification meta = new MultiPartSpecBuilder(new AvatarMeta("profile", true))
            .controlName("meta")
            .mimeType("application/json")
            .charset("UTF-8")
            .build();

    given()
        .multiPart("file", new File("src/test/resources/avatar.png"), "image/png")
        .multiPart(meta)
    .when().post("/users/7/avatar")
    .then().statusCode(201)
        .body("contentType", equalTo("image/png"))
        .body("size", greaterThan(0));
}

@Test
void rejectsTooLargeFile() {
    given().multiPart("file", "big.bin", new byte[11 * 1024 * 1024])
    .when().post("/users/7/avatar")
    .then().statusCode(413);
}
🔧 Розбір коду: MultiPartSpecBuilder приймає об'єкт і серіалізує його тим самим маппером, що й body(). controlName("meta") — ім'я частини у формі. Другий тест будує 11-мегабайтний масив у пам'яті, щоб не тримати великий файл у репозиторії.
🔧 Code breakdown: MultiPartSpecBuilder takes an object and serializes it with the same mapper as body(). controlName("meta") is the part name in the form. The second test builds an 11 MB array in memory so no large file has to live in the repository.
💡 Простими словами: Multipart — це «конверт з кількома вкладеннями»: файл в одному відділенні, JSON в іншому.
💡 In plain words: Multipart is an envelope with several enclosures: the file in one pocket, the JSON in another.
🗣 Як читати вголос Звичайну форму надсилаю через formParam, і REST Assured сам ставить url-encoded. Для файлу беру multiPart з ім'ям поля, яке чекає сервер, файлом і за потреби MIME-типом. Якщо API хоче метадані поруч, додаю ще одну частину з JSON — рядком з типом application/json або через MultiPartSpecBuilder з об'єктом. Content-Type multipart руками не ставлю, бо загублю boundary. А тестую не лише успішне завантаження, а й завеликий файл, неправильний тип і дивні імена.
🗣 How to say it out loud I send a normal form with formParam, and REST Assured sets url-encoded on its own. For a file I use multiPart with the field name the server expects, the file and, if needed, the MIME type. If the API wants metadata alongside, I add another part with JSON, either a string with application/json or a MultiPartSpecBuilder with an object. I never set the multipart Content-Type by hand, because I would lose the boundary. And I test not only a successful upload but also an oversized file, a wrong type and odd names.
23 Як протестувати завантаження файлу з API, наприклад PDF чи CSV? How do you test downloading a file from an API, for example a PDF or a CSV?
Тіло і серіалізаціяBody and mapping Middle ▶

Отримати байти. extract().asByteArray() повертає тіло повністю, extract().asInputStream() — потік, зручний для великих файлів, які не хочеться тримати в пам'яті. asString() для бінарних даних не підходить: кодування зіпсує байти.

Getting the bytes. extract().asByteArray() returns the whole body, extract().asInputStream() a stream, which suits big files you do not want in memory. asString() is wrong for binary data: charset decoding corrupts the bytes.

Що перевіряти. Спершу заголовки: статус 200, Content-Type (application/pdf, text/csv), Content-Disposition: attachment; filename=... з очікуваним іменем, Content-Length, якщо сервер його віддає. Потім вміст: розмір більший за нуль; сигнатура файлу — PDF починається з байтів %PDF, ZIP і XLSX — з PK; для CSV можна розібрати рядки й перевірити заголовок і кількість записів; для еталонних файлів — порівняти SHA-256 з очікуваним.

What to check. Headers first: status 200, Content-Type (application/pdf, text/csv), Content-Disposition: attachment; filename=... with the expected name, and Content-Length if the server sends it. Then the content: size above zero; the file signature — a PDF starts with the bytes %PDF, ZIP and XLSX with PK; for a CSV you can split the lines and check the header and the record count; for reference files compare a SHA-256 with the expected one.

Чому не лише статус. Типовий баг: сервер повертає 200 і HTML-сторінку помилки або порожній файл. Тест, що перевіряє тільки статус, це пропустить.

Why not just the status. A typical bug: the server returns 200 with an HTML error page or an empty file. A test that checks only the status misses it.

Пастка. Якщо генерація файлу асинхронна (202 і посилання на результат), треба опитувати статус задачі через Awaitility, а не збільшувати таймаут читання. Для PDF із датою генерації хеш щоразу інший — тоді перевіряй текст через бібліотеку на кшталт PDFBox, а не байти.

Trap. If file generation is asynchronous (202 and a link to the result), poll the job status with Awaitility instead of raising the read timeout. For a PDF that embeds a generation date the hash changes every time — check its text with a library such as PDFBox instead of the bytes.

Перевірка PDF і CSV
Checking a PDF and a CSV
@Test
void invoicePdfIsARealPdf() {
    byte[] pdf = given().pathParam("id", 42)
        .when().get("/invoices/{id}/pdf")
        .then().statusCode(200)
            .contentType("application/pdf")
            .header("Content-Disposition", containsString("invoice-42.pdf"))
        .extract().asByteArray();

    assertThat(pdf).hasSizeGreaterThan(1_000);
    assertThat(new String(pdf, 0, 4, StandardCharsets.US_ASCII)).isEqualTo("%PDF");
}

@Test
void ordersExportHasHeaderAndRows() {
    String csv = given().queryParam("from", "2026-09-01")
        .when().get("/exports/orders.csv")
        .then().statusCode(200).contentType(containsString("text/csv"))
        .extract().asString();

    List<String> lines = csv.lines().toList();
    assertThat(lines.get(0)).isEqualTo("id,total,status");
    assertThat(lines).hasSizeGreaterThan(1);
}
🔧 Розбір коду: asByteArray() зберігає байти без перекодування, а перші чотири байти порівнюються з сигнатурою %PDF. Для CSV asString() доречний, бо це текст; lines() розбиває його на рядки. containsString("text/csv") толерує суфікс з charset.
🔧 Code breakdown: asByteArray() keeps the bytes without re-encoding, and the first four bytes are compared with the %PDF signature. For a CSV asString() is fine because it is text; lines() splits it into rows. containsString("text/csv") tolerates a charset suffix.
💡 Простими словами: 200 і порожній файл — теж баг, тому дивись у самі байти, а не лише на статус.
💡 In plain words: A 200 with an empty file is still a bug, so look at the bytes, not just the status.
🗣 Як читати вголос Для завантаження файлу я беру тіло як масив байтів або потік, але не як рядок, бо кодування зіпсує бінарні дані. Спочатку перевіряю статус, Content-Type і Content-Disposition з іменем файлу. Потім сам вміст: розмір, сигнатуру на початку файлу, для CSV — заголовок і кількість рядків, для стабільних файлів — хеш. Так я ловлю класичний баг, коли сервер віддає 200 з HTML-сторінкою помилки. Якщо генерація асинхронна, опитую статус задачі, а не піднімаю таймаут.
🗣 How to say it out loud For a download I take the body as a byte array or a stream, never as a string, because charset decoding corrupts binary data. First I check the status, the Content-Type and the Content-Disposition with the file name. Then the content itself: size, the signature at the start of the file, for a CSV the header and row count, for stable files a hash. That catches the classic bug where the server returns a 200 with an HTML error page. If generation is asynchronous, I poll the job status instead of raising the timeout.
24 Як організувати тіла запитів і тестові дані у великому наборі: POJO, білдери, JSON-файли чи фабрики? How do you organize request bodies and test data in a large suite: POJOs, builders, JSON files or factories?
Тіло і серіалізаціяBody and mapping Senior ▶

Проблема масштабу. На 20 тестах тіло можна писати прямо в тесті. На 800 тестах зміна одного обов'язкового поля ламає сотні файлів, а тести з різними «майже однаковими» JSON-ами неможливо рев'ювати.

The scale problem. With 20 tests you can write the body inline. With 800 tests a change to one required field breaks hundreds of files, and tests with many "almost identical" JSON blobs are impossible to review.

Шари, які працюють. 1) Модель запиту — POJO чи record з білдером (Lombok @Builder або вручну). Вона відповідає контракту і змінюється в одному місці. 2) Фабрика тестових даних — методи на кшталт Orders.validOrder(), які повертають білдер з мінімальним валідним набором полів і унікальними значеннями (UUID, лічильник, Faker). Тест змінює лише те, що важливо для сценарію: validOrder().quantity(0).build() — і з коду одразу видно, що перевіряється. 3) Сирі JSON-файли — лише для великих еталонних тіл, що приходять від партнерів, і для контрактних прикладів. 4) Map або рядок — лише для негативних тестів, де потрібна неприпустима для POJO форма.

Layers that work. 1) A request model — a POJO or record with a builder (Lombok @Builder or hand-written). It mirrors the contract and changes in one place. 2) A test data factory — methods such as Orders.validOrder() that return a builder with the minimal valid set of fields and unique values (UUID, a counter, Faker). The test changes only what matters for its scenario: validOrder().quantity(0).build(), and the code shows at once what is being tested. 3) Raw JSON files — only for large reference bodies coming from partners and for contract examples. 4) A map or a string — only for negative tests that need a shape a POJO cannot express.

Трейдофи. JSON-файли легко читає аналітик, але вони не рефакторяться і дублюються. Білдери дають мінімальні диффи, але потребують дисципліни: фабрика не повинна обростати десятками прапорців. Faker дає різноманіття, але без фіксованого seed падіння важко відтворити — логуй seed або тіло запиту.

Trade-offs. JSON files are easy for an analyst to read but do not refactor and get duplicated. Builders give minimal diffs but need discipline: a factory must not grow dozens of flags. Faker gives variety, but without a fixed seed failures are hard to reproduce — log the seed or the request body.

Ознака зрілості. Кожен тест явно показує єдине поле, заради якого він існує, а зміна контракту — це одна правка в моделі та фабриці.

A sign of maturity. Each test clearly shows the single field it exists for, and a contract change is one edit in the model and the factory.

Модель з білдером і фабрика валідних даних
A model with a builder and a factory of valid data
@Builder(toBuilder = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public record OrderRequest(String customerId, String sku, Integer quantity, String promoCode) { }

public final class Orders {
    private static final AtomicInteger SEQ = new AtomicInteger();

    public static OrderRequest.OrderRequestBuilder validOrder() {
        return OrderRequest.builder()
                .customerId("qa-" + UUID.randomUUID())
                .sku("SKU-" + SEQ.incrementAndGet())
                .quantity(1);
    }
}

@ParameterizedTest
@ValueSource(ints = {0, -1, 10_001})
void rejectsQuantityOutOfRange(int qty) {
    given().spec(Specs.BASE)
        .body(Orders.validOrder().quantity(qty).build())
    .when().post("/orders")
    .then().spec(Specs.VALIDATION_ERROR).body("errors.field", hasItem("quantity"));
}
🔧 Розбір коду: Lombok @Builder на record генерує OrderRequest.builder() і вкладений клас OrderRequestBuilder. NON_NULL не надсилає promoCode, якщо тест його не задав. Параметризований тест змінює лише quantity, а решта полів валідні й унікальні завдяки UUID і AtomicInteger.
🔧 Code breakdown: Lombok's @Builder on a record generates OrderRequest.builder() and a nested OrderRequestBuilder class. NON_NULL leaves promoCode out when the test did not set it. The parameterized test changes only quantity, while the other fields stay valid and unique thanks to the UUID and the AtomicInteger.
💡 Простими словами: Фабрика дає «нормальне замовлення», а тест псує в ньому рівно одну річ.
💡 In plain words: The factory hands you a normal order, and the test breaks exactly one thing in it.
🗣 Як читати вголос У великому наборі я розділяю модель і дані. Модель — це record або POJO з білдером, що відповідає контракту. Над нею фабрика на кшталт validOrder, яка дає мінімальний валідний і унікальний об'єкт, а тест міняє в ньому одне поле, і з коду видно, навіщо тест існує. JSON-файли лишаю для великих еталонних тіл від партнерів, мапи й рядки — для негативних кейсів. Якщо використовую Faker, обов'язково логую seed, інакше падіння не відтворю.
🗣 How to say it out loud In a large suite I separate the model from the data. The model is a record or POJO with a builder that mirrors the contract. On top of it sits a factory such as validOrder that returns a minimal valid and unique object, and the test changes one field, so the code shows why the test exists. I keep JSON files for large reference bodies from partners, and maps and strings for negative cases. If I use Faker I always log the seed, otherwise I cannot reproduce a failure.
▶ 4 6
25 Як витягнути значення з відповіді: path(), jsonPath() чи extract(), і чим вони відрізняються? How do you extract a value from a response — path(), jsonPath() or extract() — and how do they differ?
GPathGPath Junior ▶

path(). response.path("id") або then().extract().path("id") повертає значення за виразом GPath. Тип виводиться з присвоєння: String name = response.path("name");. Метод сам визначає, JSON це чи XML, за Content-Type відповіді.

path(). response.path("id") or then().extract().path("id") returns a value for a GPath expression. The type is inferred from the assignment: String name = response.path("name");. The method works out whether it is JSON or XML from the response Content-Type.

jsonPath(). response.jsonPath() повертає об'єкт JsonPath з типізованими методами: getString(), getInt(), getLong(), getBoolean(), getList(path, Class), getMap(), getObject(path, Class). Розбір виконується один раз, тож для кількох значень з однієї відповіді jsonPath() ефективніший і читабельніший.

jsonPath(). response.jsonPath() returns a JsonPath object with typed methods: getString(), getInt(), getLong(), getBoolean(), getList(path, Class), getMap(), getObject(path, Class). Parsing happens once, so for several values from one response jsonPath() is more efficient and reads better.

extract(). Це перехід з режиму перевірок у режим витягування: після then().statusCode(200) пишеш extract(), а далі path(), jsonPath(), as(Class), asString(), header(), cookie() або response(). Тіло як рядок — extract().body().asString() або коротше extract().asString().

extract(). This switches from checking to extracting: after then().statusCode(200) you write extract(), followed by path(), jsonPath(), as(Class), asString(), header(), cookie() or response(). The body as a string is extract().body().asString(), or shorter, extract().asString().

Коли що. Одне значення після перевірок — extract().path(). Кілька значень — extract().jsonPath() і змінна. Уся модель — as(). Помилка, яку часто бачу: витягувати значення без перевірки статусу, отримати null і падіння з NullPointerException у наступному запиті, де вже важко зрозуміти першопричину.

When to use which. One value after the checks: extract().path(). Several values: extract().jsonPath() in a variable. The whole model: as(). A mistake I often see is extracting without checking the status, getting null, and then a NullPointerException in the next request where the root cause is hard to spot.

Одне значення, кілька значень, увесь рядок
One value, several values, the whole string
int orderId = given().spec(Specs.BASE).body(Orders.validOrder().build())
    .when().post("/orders")
    .then().statusCode(201)
    .extract().path("id");

JsonPath order = given().spec(Specs.BASE)
    .when().get("/orders/{id}", orderId)
    .then().statusCode(200)
    .extract().jsonPath();

String status = order.getString("status");
BigDecimal total = new BigDecimal(order.getString("total"));
List<String> skus = order.getList("lines.sku", String.class);

String raw = when().get("/orders/{id}", orderId).then().extract().asString();
🔧 Розбір коду: extract().path("id") повертає узагальнений тип, і Java приводить його до int. Об'єкт JsonPath розбирає тіло один раз, а три наступні виклики лише читають з дерева. Суму читаємо рядком і створюємо BigDecimal, щоб не втратити точність через Float.
🔧 Code breakdown: extract().path("id") returns a generic type that Java casts to int. The JsonPath object parses the body once, and the next three calls only read from the tree. The total is read as a string and turned into a BigDecimal, so no precision is lost through Float.
💡 Простими словами: path() — для одного значення, jsonPath() — для кількох, as() — для всього об'єкта.
💡 In plain words: path() for one value, jsonPath() for several, as() for the whole object.
🗣 Як читати вголос Для одного значення я пишу extract().path з виразом GPath, тип визначається з присвоєння. Якщо потрібно кілька полів, беру extract().jsonPath, бо він розбирає тіло один раз і дає типізовані getString, getInt, getList. Якщо потрібна вся модель — extract().as з класом, а сире тіло — extract().asString. Головне правило: спочатку перевірити статус, а вже потім витягувати, інакше отримаю null і падіння в зовсім іншому місці.
🗣 How to say it out loud For one value I write extract().path with a GPath expression, and the type comes from the assignment. If I need several fields I use extract().jsonPath, because it parses the body once and gives typed getString, getInt and getList. If I need the whole model it is extract().as with a class, and the raw body is extract().asString. The main rule is to check the status first and extract afterwards, otherwise I get a null and a failure somewhere else entirely.
26 Навіщо використовувати клас JsonPath окремо від HTTP-запиту і яка пастка з дробовими числами? Why use the JsonPath class on its own, without an HTTP request, and what is the trap with decimal numbers?
GPathGPath Middle ▶

Окремо від запиту. io.restassured.path.json.JsonPath — самостійний парсер. JsonPath.from(jsonString), new JsonPath(file) або JsonPath.from(inputStream) дають той самий GPath без HTTP. Застосування: розібрати повідомлення з Kafka чи черги, файл-фікстуру, JSON з бази даних, вебхук, який перехопив WireMock, або відповідь, отриману іншим клієнтом. Корисні методи: setRootPath("data"), щоб не повторювати префікс; param("limit", 10) для параметрів у виразах; prettify() для логів.

Without a request. io.restassured.path.json.JsonPath is a standalone parser. JsonPath.from(jsonString), new JsonPath(file) or JsonPath.from(inputStream) give you the same GPath without HTTP. Uses: parsing a message from Kafka or a queue, a fixture file, JSON from a database, a webhook captured by WireMock, or a response obtained by another client. Useful methods: setRootPath("data") to avoid repeating a prefix; param("limit", 10) for parameters inside expressions; prettify() for logs.

Пастка з числами. За замовчуванням дробові числа JSON повертаються як Float (режим FLOAT_AND_DOUBLE). Тому body("price", equalTo(19.99)) падає: матчер порівнює Double 19.99 з Float 19.99f, а це різні об'єкти. Рішення: equalTo(19.99f), closeTo(19.99, 0.001) для чисел з плаваючою крапкою або, найкраще для грошей, режим BIG_DECIMAL через JsonConfig. Великі цілі значення, що не влазять в int, приходять як Long, тож equalTo(5_000_000_000L) з суфіксом.

The number trap. By default JSON decimals come back as Float (the FLOAT_AND_DOUBLE mode). So body("price", equalTo(19.99)) fails: the matcher compares a Double 19.99 with a Float 19.99f, and they are different objects. Fixes: equalTo(19.99f), closeTo(19.99, 0.001) for floating-point values, or, best for money, the BIG_DECIMAL mode via JsonConfig. Large integers that do not fit in an int come back as Long, so write equalTo(5_000_000_000L) with the suffix.

Висновок. Для фінансових API конфіг BIG_DECIMAL — перше, що варто встановити глобально, інакше тести то падають через тип, то проходять при втраті копійок.

Takeaway. For financial APIs the BIG_DECIMAL config is the first thing to set globally, otherwise tests either fail on the type or pass while cents go missing.

Розбір повідомлення з черги і BigDecimal для грошей
Parsing a queue message and BigDecimal for money
@Test
void paymentEventHasExpectedTotals() {
    String message = kafka.poll("payments", Duration.ofSeconds(5));   // будь-яке джерело JSON

    JsonPath event = JsonPath.from(message).setRootPath("payload");
    assertThat(event.getString("status")).isEqualTo("CAPTURED");
    assertThat(event.getList("items.sku")).contains("SKU-1");
}

@BeforeAll
static void moneyAsBigDecimal() {
    RestAssured.config = RestAssured.config().jsonConfig(
            JsonConfig.jsonConfig().numberReturnType(JsonPathConfig.NumberReturnType.BIG_DECIMAL));
}

@Test
void priceIsExact() {
    when().get("/products/SKU-1")
    .then().statusCode(200)
        .body("price", equalTo(new BigDecimal("19.99")));
}
@Test
void paymentEventHasExpectedTotals() {
    String message = kafka.poll("payments", Duration.ofSeconds(5));   // any JSON source

    JsonPath event = JsonPath.from(message).setRootPath("payload");
    assertThat(event.getString("status")).isEqualTo("CAPTURED");
    assertThat(event.getList("items.sku")).contains("SKU-1");
}

@BeforeAll
static void moneyAsBigDecimal() {
    RestAssured.config = RestAssured.config().jsonConfig(
            JsonConfig.jsonConfig().numberReturnType(JsonPathConfig.NumberReturnType.BIG_DECIMAL));
}

@Test
void priceIsExact() {
    when().get("/products/SKU-1")
    .then().statusCode(200)
        .body("price", equalTo(new BigDecimal("19.99")));
}
🔧 Розбір коду: kafka.poll — умовний хелпер проєкту; важливо, що JsonPath отримує звичайний рядок. setRootPath("payload") робить шляхи відносними. NumberReturnType.BIG_DECIMAL змушує і body(), і jsonPath() повертати BigDecimal, тому порівнюємо з new BigDecimal("19.99"), створеним з рядка.
🔧 Code breakdown: kafka.poll stands for a project helper; what matters is that JsonPath receives a plain string. setRootPath("payload") makes paths relative. NumberReturnType.BIG_DECIMAL makes both body() and jsonPath() return a BigDecimal, so we compare with new BigDecimal("19.99") built from a string.
💡 Простими словами: JsonPath працює з будь-яким JSON, не лише з HTTP-відповіддю, а дробові числа за замовчуванням — Float.
💡 In plain words: JsonPath works on any JSON, not only an HTTP response, and decimals are Float by default.
🗣 Як читати вголос Клас JsonPath — це самостійний парсер, я створюю його через JsonPath.from з рядка, файлу чи потоку і читаю тим самим GPath. Так розбираю повідомлення з Kafka, вебхуки з WireMock чи JSON з бази. Пастка, про яку обов'язково кажу: дробові числа за замовчуванням приходять як Float, тому equalTo з double падає. Для грошей я глобально вмикаю BIG_DECIMAL через JsonConfig і порівнюю з BigDecimal з рядка.
🗣 How to say it out loud The JsonPath class is a standalone parser; I create it with JsonPath.from from a string, a file or a stream and read it with the same GPath. That is how I parse Kafka messages, WireMock webhooks or JSON from the database. The trap I always mention: decimals come back as Float by default, so equalTo with a double fails. For money I switch on BIG_DECIMAL globally through JsonConfig and compare with a BigDecimal built from a string.
27 Що таке GPath і як з його допомогою дістатися вкладеного поля, елемента масиву чи кореня відповіді? What is GPath, and how do you use it to reach a nested field, an array element or the response root?
GPathGPath Junior ▶

Що це. GPath — синтаксис навігації по деревах з мови Groovy. REST Assured застосовує його і до JSON, і до XML, тому рядок у body("...") або path("...") — це вираз, а не просто ім'я ключа.

What it is. GPath is Groovy's syntax for navigating trees. REST Assured applies it to both JSON and XML, so the string in body("...") or path("...") is an expression, not just a key name.

Базові конструкції. Вкладене поле — через крапку: user.address.city. Елемент масиву — індекс з нуля: items[0].name; останній — items[-1]. Поле з усіх елементів — без індексу: items.name повертає список імен. Розмір — items.size(). Корінь відповіді — "$" або порожній рядок; для кореневого масиву зручно body("$", hasSize(3)). Ключ з дефісом чи крапкою — у лапках: headers.'x-trace-id'.

Basic constructs. A nested field uses dots: user.address.city. An array element uses a zero-based index: items[0].name; the last one is items[-1]. A field from every element uses no index: items.name returns a list of names. Size: items.size(). The response root is "$" or an empty string; for a root array body("$", hasSize(3)) is handy. A key with a dash or a dot goes in quotes: headers.'x-trace-id'.

rootPath. Якщо багато перевірок в одній гілці, then().rootPath("data.user") робить наступні шляхи відносними, а detachRootPath() скасовує.

rootPath. If many checks target one branch, then().rootPath("data.user") makes the following paths relative, and detachRootPath() undoes it.

Пастки. Звернення до неіснуючого поля не кидає виняток, а повертає null — тому notNullValue() не відрізняє відсутній ключ від ключа з null (про це окрема картка). І вираз items.name на масиві повертає список, тож перевірка equalTo("Pen") на ньому впаде з повідомленням про [Pen, Cup].

Traps. Accessing a missing field does not throw; it returns null, which is why notNullValue() cannot tell a missing key from a key holding null (a separate card covers this). And items.name on an array returns a list, so equalTo("Pen") on it fails with a message about [Pen, Cup].

Відповідь і вирази GPath до неї
A response and GPath expressions for it
{
  "data": {
    "user": { "name": "Olena", "address": { "city": "Kyiv" } },
    "items": [ { "name": "Pen", "price": 12 }, { "name": "Cup", "price": 80 } ],
    "headers": { "x-trace-id": "a1b2" }
  }
}
Перевірки
Checks
when().get("/cart")
.then()
    .rootPath("data")
    .body("user.address.city", equalTo("Kyiv"))
    .body("items[0].name", equalTo("Pen"))
    .body("items[-1].price", equalTo(80))
    .body("items.name", contains("Pen", "Cup"))
    .body("items.size()", equalTo(2))
    .body("headers.'x-trace-id'", equalTo("a1b2"));
🔧 Розбір коду: rootPath("data") додає префікс до кожного шляху, тож user.address.city насправді data.user.address.city. items[-1] — від'ємний індекс Groovy. items.name дає список, тому з ним працює колекційний матчер contains. Лапки навколо x-trace-id потрібні через дефіси.
🔧 Code breakdown: rootPath("data") prefixes every path, so user.address.city is really data.user.address.city. items[-1] is a Groovy negative index. items.name yields a list, which is why the collection matcher contains fits it. The quotes around x-trace-id are needed because of the dashes.
💡 Простими словами: GPath — це «адреса» в JSON: крапка — вглиб, дужки — номер у списку, без дужок — усі елементи.
💡 In plain words: GPath is an address inside JSON: a dot goes deeper, brackets pick a position, no brackets means every element.
🗣 Як читати вголос GPath — це синтаксис з Groovy, яким REST Assured ходить і по JSON, і по XML. Вкладене поле я пишу через крапку, елемент масиву — з індексом, останній — з мінус одиницею, а якщо індекс не вказати, отримаю список значень з усіх елементів. Розмір — через size(), корінь — через долар. Ключі з дефісами беру в лапки. Коли перевірок в одній гілці багато, ставлю rootPath. І пам'ятаю, що неіснуючий шлях повертає null, а не помилку.
🗣 How to say it out loud GPath is a Groovy syntax that REST Assured uses to walk both JSON and XML. I write a nested field with dots, an array element with an index, the last one with minus one, and if I leave the index out I get a list of values from every element. Size comes from size(), the root from the dollar sign. Keys with dashes go in quotes. When many checks hit one branch I set a rootPath. And I remember that a missing path returns null, not an error.
28 Як працюють find, findAll і collect у виразах GPath і як передати в них параметр? How do find, findAll and collect work in GPath expressions, and how do you pass a parameter into them?
GPathGPath Middle ▶

Що це. GPath підтримує замикання Groovy, де it — поточний елемент. find { умова } повертає перший елемент, що підходить, або null. findAll { умова } повертає список усіх відповідних елементів (можливо, порожній). collect { вираз } перетворює кожен елемент і повертає список результатів. Поверх списків працюють sum(), max(), min(), size(), а також max { it.price } для елемента з максимальним полем.

What it is. GPath supports Groovy closures, where it is the current element. find { condition } returns the first matching element, or null. findAll { condition } returns a list of all matching elements (possibly empty). collect { expression } transforms each element and returns a list of results. On top of lists you have sum(), max(), min() and size(), plus max { it.price } for the element with the largest field.

Приклади. items.find { it.sku == 'A-1' }.qty — кількість конкретного товару; items.findAll { it.price > 100 }.name — назви дорогих товарів; items.collect { it.price * it.qty }.sum() — сума кошика, яку можна порівняти з полем total.

Examples. items.find { it.sku == 'A-1' }.qty is the quantity of a specific product; items.findAll { it.price > 100 }.name gives the names of expensive products; items.collect { it.price * it.qty }.sum() is the cart total you can compare with the total field.

Параметри. Замість конкатенації рядків використовуй withArgs: у виразі %s, а значення передається окремо — body("items.find { it.sku == '%s' }.qty", withArgs(sku), equalTo(2)). У класі JsonPath для цього є param("name", value).

Parameters. Instead of concatenating strings, use withArgs: put %s in the expression and pass the value separately — body("items.find { it.sku == '%s' }.qty", withArgs(sku), equalTo(2)). The JsonPath class has param("name", value) for the same purpose.

Коли не варто. Якщо вираз займає пів рядка і містить кілька замикань, його важко читати й налагоджувати. Тоді краще десеріалізувати відповідь у POJO і перевірити через AssertJ або Java Streams.

When not to. If an expression takes half a line and holds several closures, it is hard to read and debug. Then it is better to deserialize the response into a POJO and check it with AssertJ or Java streams.

Пошук, фільтрація і перевірка суми кошика
Finding, filtering and checking the cart total
String sku = "A-1";

given().spec(Specs.BASE)
.when().get("/cart")
.then()
    .statusCode(200)
    .body("items.find { it.sku == '%s' }.qty", withArgs(sku), equalTo(2))
    .body("items.findAll { it.price > 100 }.name", everyItem(not(emptyString())))
    .body("items.findAll { it.stock == 0 }", empty())
    .body("items.collect { it.price * it.qty }.sum()", equalTo(260))
    .body("items.max { it.price }.sku", equalTo("B-7"));

// Те саме через JsonPath з іменованим параметром
JsonPath cart = when().get("/cart").then().extract().jsonPath();
int qty = cart.param("sku", sku).getInt("items.find { it.sku == sku }.qty");
String sku = "A-1";

given().spec(Specs.BASE)
.when().get("/cart")
.then()
    .statusCode(200)
    .body("items.find { it.sku == '%s' }.qty", withArgs(sku), equalTo(2))
    .body("items.findAll { it.price > 100 }.name", everyItem(not(emptyString())))
    .body("items.findAll { it.stock == 0 }", empty())
    .body("items.collect { it.price * it.qty }.sum()", equalTo(260))
    .body("items.max { it.price }.sku", equalTo("B-7"));

// The same with JsonPath and a named parameter
JsonPath cart = when().get("/cart").then().extract().jsonPath();
int qty = cart.param("sku", sku).getInt("items.find { it.sku == sku }.qty");
🔧 Розбір коду: withArgs(sku) підставляє значення в %s так само, як String.format, але без ризику зламати вираз конкатенацією. findAll { it.stock == 0 } з матчером empty() стверджує, що в кошику немає товарів без залишку. У JsonPath параметр sku доступний у замиканні як змінна.
🔧 Code breakdown: withArgs(sku) fills %s the way String.format does, without the risk of breaking the expression through concatenation. findAll { it.stock == 0 } with the empty() matcher asserts there are no out-of-stock items in the cart. In JsonPath the sku param is available inside the closure as a variable.
💡 Простими словами: find — «перший такий», findAll — «усі такі», collect — «з кожного зроби ось це».
💡 In plain words: find is the first such item, findAll is all such items, collect is do this to each one.
🗣 Як читати вголос У GPath можна писати замикання Groovy, де it — поточний елемент. Find повертає перший елемент за умовою, findAll — список усіх, collect перетворює кожен елемент, а потім можна викликати sum чи max. Наприклад, я перераховую суму кошика через collect і sum і порівнюю з полем total. Значення в умову передаю через withArgs, а не склеюванням рядків. Якщо вираз стає задовгим, переходжу на POJO і AssertJ, бо так легше читати і налагоджувати.
🗣 How to say it out loud GPath lets me write Groovy closures where it is the current element. Find returns the first element matching a condition, findAll returns a list of all of them, collect transforms each element, and then I can call sum or max. For example, I recompute the cart total with collect and sum and compare it with the total field. I pass values into the condition with withArgs rather than gluing strings. If an expression gets too long, I switch to a POJO and AssertJ, because that is easier to read and debug.
29 Як отримати список значень з JSON-масиву і перевірити розмір масиву? How do you get a list of values from a JSON array and check the size of the array?
GPathGPath Junior ▶

Отримати список. Є два рівноцінні шляхи. List<String> names = response.path("items.name"); — тип виводиться з присвоєння. response.jsonPath().getList("items.name", String.class) — явний тип елементів, що важливо для чисел: без класу ти отримаєш List<Integer> або List<Float> і можеш помилитися з приведенням. Для списку об'єктів — getList("items", Item.class).

Getting the list. There are two equivalent ways. List<String> names = response.path("items.name"); — the type comes from the assignment. response.jsonPath().getList("items.name", String.class) — an explicit element type, which matters for numbers: without the class you get a List<Integer> or List<Float> and can get the cast wrong. For a list of objects use getList("items", Item.class).

Перевірити розмір. Теж двома способами. У ланцюжку — body("items", hasSize(3)) з Hamcrest або body("items.size()", equalTo(3)) через метод GPath. Результат однаковий, але повідомлення про помилку різне: hasSize покаже весь масив, що допомагає зрозуміти, чого бракує. Для діапазонів — hasSize(greaterThan(0)) або hasSize(lessThanOrEqualTo(20)), для порожнього масиву — empty().

Checking the size. Also two ways. In the chain: body("items", hasSize(3)) with Hamcrest, or body("items.size()", equalTo(3)) with the GPath method. The result is the same but the failure message differs: hasSize prints the whole array, which helps you see what is missing. For ranges use hasSize(greaterThan(0)) or hasSize(lessThanOrEqualTo(20)), and for an empty array empty().

Коли що. Для простих перевірок — ланцюжок. Коли зі списком далі треба щось робити (сортування, дублікати, порівняння з даними з бази) — витягнути в Java-список і працювати з ним AssertJ.

When to use which. For simple checks, the chain. When you need to do more with the list (sorting, duplicates, comparing with data from the database), extract it into a Java list and work on it with AssertJ.

Пастка. Не перевіряй точний розмір там, де дані спільні з іншими тестами: hasSize(3) на списку всіх замовлень зламається, щойно паралельний тест створить четверте.

Trap. Do not assert an exact size where data is shared with other tests: hasSize(3) on the list of all orders breaks as soon as a parallel test creates a fourth one.

Списки й розмір
Lists and size
given().queryParam("customerId", customerId)
.when().get("/orders")
.then()
    .statusCode(200)
    .body("items", hasSize(3))
    .body("items.size()", equalTo(3))
    .body("items.id", everyItem(greaterThan(0)));

Response response = given().queryParam("customerId", customerId).when().get("/orders");
List<Integer> ids = response.jsonPath().getList("items.id", Integer.class);
List<Order> orders = response.jsonPath().getList("items", Order.class);

assertThat(ids).doesNotHaveDuplicates();
assertThat(orders).extracting(Order::customerId).containsOnly(customerId);
🔧 Розбір коду: Запит фільтрується за customerId цього тесту, тому точний розмір тут безпечний. getList("items.id", Integer.class) гарантує тип елементів, а getList("items", Order.class) десеріалізує кожен об'єкт масиву. doesNotHaveDuplicates() — перевірка AssertJ, якої немає серед простих матчерів ланцюжка.
🔧 Code breakdown: The request filters by this test's customerId, so an exact size is safe here. getList("items.id", Integer.class) guarantees the element type, and getList("items", Order.class) deserializes each object in the array. doesNotHaveDuplicates() is an AssertJ check with no simple equivalent among the chain matchers.
💡 Простими словами: Список з масиву — через path або getList, розмір — через hasSize або size().
💡 In plain words: A list from an array comes from path or getList; the size from hasSize or size().
🗣 Як читати вголос Список значень я витягую або через path з присвоєнням у List, або через jsonPath().getList з класом елемента, і другий варіант надійніший для чисел. Розмір масиву перевіряю через hasSize у ланцюжку або через size() у самому виразі GPath, але hasSize дає краще повідомлення, бо показує масив. Якщо зі списком треба робити щось складніше — дублікати, сортування, — витягую його і працюю через AssertJ. І точний розмір перевіряю тільки на даних, які створив сам тест.
🗣 How to say it out loud I extract a list of values either with path assigned to a List or with jsonPath().getList and the element class, and the second is more reliable for numbers. I check the array size with hasSize in the chain or with size() inside the GPath expression, but hasSize gives a better message because it prints the array. If I need something more complex, like duplicates or sorting, I extract the list and use AssertJ. And I only assert an exact size on data the test created itself.
30 Як тестувати XML-відповіді: XmlPath, атрибути, XPath і перевірка за XSD? How do you test XML responses: XmlPath, attributes, XPath and XSD validation?
GPathGPath Middle ▶

GPath для XML. Той самий body() працює з XML, якщо Content-Type відповіді XML-овий. Шлях починається з кореневого елемента: order.customer.name. Атрибути — через @: order.@id. Повторювані елементи — як список: order.items.item.size(), order.items.item[0].name, order.items.item.findAll { it.@type == 'digital' }. Значення елементів завжди рядки, тож equalTo("17"), а не equalTo(17).

GPath for XML. The same body() works on XML when the response Content-Type is an XML one. The path starts at the root element: order.customer.name. Attributes use @: order.@id. Repeated elements behave as a list: order.items.item.size(), order.items.item[0].name, order.items.item.findAll { it.@type == 'digital' }. Element values are always strings, so write equalTo("17"), not equalTo(17).

XmlPath. Аналог JsonPath для XML: response.xmlPath(), extract().xmlPath() або new XmlPath(xmlString), з методами getString, getInt, getList. Для SOAP з простором імен знадобиться XmlConfig.xmlConfig().declareNamespace(prefix, uri).

XmlPath. The XML counterpart of JsonPath: response.xmlPath(), extract().xmlPath() or new XmlPath(xmlString), with getString, getInt and getList. SOAP with namespaces needs XmlConfig.xmlConfig().declareNamespace(prefix, uri).

XPath. Якщо команда звикла до XPath, можна передати в body матчер Hamcrest hasXPath("/order/items/item[1]/name", equalTo("Pen")). Зверни увагу: в XPath індекси з одиниці, а в GPath — з нуля.

XPath. If the team is used to XPath, you can pass the Hamcrest matcher hasXPath("/order/items/item[1]/name", equalTo("Pen")) to body. Note that XPath indexes start at one while GPath indexes start at zero.

XSD. Структуру перевіряють матчери з RestAssuredMatchers: matchesXsd(xsdString) або matchesXsdInClasspath("schemas/order.xsd"), для DTD — matchesDtd. Схема ловить відсутні елементи, неправильні типи й порядок, а функціональні перевірки значень лишаються за GPath.

XSD. Structure is checked by matchers from RestAssuredMatchers: matchesXsd(xsdString) or matchesXsdInClasspath("schemas/order.xsd"), and matchesDtd for a DTD. The schema catches missing elements, wrong types and wrong order, while functional value checks stay with GPath.

XML-відповідь
An XML response
<order id="17" status="PAID">
  <customer><name>Olena</name></customer>
  <items>
    <item type="physical"><name>Pen</name><price>12</price></item>
    <item type="digital"><name>E-book</name><price>80</price></item>
  </items>
</order>
GPath, XPath, XmlPath і XSD
GPath, XPath, XmlPath and XSD
import static io.restassured.matcher.RestAssuredMatchers.matchesXsdInClasspath;

Response response = given().accept(ContentType.XML)
    .when().get("/orders/17")
    .then()
        .statusCode(200)
        .body(matchesXsdInClasspath("schemas/order.xsd"))
        .body("order.@id", equalTo("17"))
        .body("order.items.item.size()", equalTo(2))
        .body("order.items.item.find { it.@type == 'digital' }.name", equalTo("E-book"))
        .body(hasXPath("/order/items/item[1]/name", equalTo("Pen")))
    .extract().response();

XmlPath xml = response.xmlPath();
List<String> names = xml.getList("order.items.item.name");
int total = xml.getList("order.items.item.price", Integer.class).stream().mapToInt(Integer::intValue).sum();
🔧 Розбір коду: matchesXsdInClasspath шукає схему в src/test/resources. order.@id читає атрибут, а it.@type у замиканні — атрибут поточного елемента. item[1] в XPath — це перший елемент, тобто той самий, що item[0] у GPath. getList(path, Integer.class) конвертує рядкові значення в числа.
🔧 Code breakdown: matchesXsdInClasspath looks for the schema in src/test/resources. order.@id reads an attribute, and it.@type inside the closure reads the current element's attribute. item[1] in XPath is the first element, the same one as item[0] in GPath. getList(path, Integer.class) converts the string values to numbers.
💡 Простими словами: XML у REST Assured — той самий GPath, тільки атрибути з @, а значення завжди рядки.
💡 In plain words: XML in REST Assured is the same GPath, except attributes take an @ and values are always strings.
🗣 Як читати вголос XML я перевіряю тим самим body з GPath: шлях починається з кореневого елемента, атрибути пишу через собаку, а повторювані елементи працюють як список з size і findAll. Для витягування є XmlPath, аналог JsonPath. Якщо команда звикла до XPath, передаю hasXPath, але пам'ятаю, що там індекси з одиниці. Структуру перевіряю за XSD через matchesXsdInClasspath. І не забуваю, що всі значення в XML — рядки, тому порівнюю з рядком.
🗣 How to say it out loud I check XML with the same body and GPath: the path starts at the root element, attributes use the at sign, and repeated elements behave like a list with size and findAll. For extracting there is XmlPath, the counterpart of JsonPath. If the team is used to XPath, I pass hasXPath, remembering its indexes start at one. I validate the structure against an XSD with matchesXsdInClasspath. And I never forget that every XML value is a string, so I compare with strings.
▶ 5 8
31 Як перевірити кілька полів відповіді за допомогою equalTo, containsString і notNullValue і що буде, якщо впадуть одразу два? How do you check several response fields with equalTo, containsString and notNullValue, and what happens if two of them fail at once?
ПеревіркиAssertions Junior ▶

Базові матчери. equalTo(x) порівнює через equals(), тож тип важливий: equalTo(5) не дорівнює 5L. containsString("sub"), startsWith, endsWith і equalToIgnoringCase — для рядків. notNullValue() і nullValue() — для наявності значення. is(x) — синтаксичний цукор над equalTo. Комбінатори allOf, anyOf і not будують складніші умови.

Basic matchers. equalTo(x) compares with equals(), so the type matters: equalTo(5) is not equal to 5L. containsString("sub"), startsWith, endsWith and equalToIgnoringCase are for strings. notNullValue() and nullValue() are for presence of a value. is(x) is syntactic sugar over equalTo. The combinators allOf, anyOf and not build more complex conditions.

Кілька полів. Можна викликати body() кілька разів або передати пари в одному виклику: body("name", equalTo("Olena"), "role", equalTo("qa")). Ці два записи еквівалентні.

Several fields. You can call body() several times or pass pairs in one call: body("name", equalTo("Olena"), "role", equalTo("qa")). Both forms are equivalent.

Що буде при кількох падіннях. REST Assured перевіряє всі очікування одного then() і кидає одну помилку зі списком: «2 expectations failed», де кожен рядок показує шлях, очікуване й фактичне значення. Тобто всередині одного ланцюжка перевірки поводяться як м'які, і ти бачиш усі розбіжності за один прогін. Але якщо після then() йде extract() і далі звичайні assertEquals, перше падіння в Java-коді зупиняє тест.

What happens on several failures. REST Assured evaluates every expectation of one then() and throws a single error with a list: "2 expectations failed", with each line showing the path, the expected and the actual value. So inside one chain the checks behave softly, and you see all mismatches in one run. But if extract() follows then() and plain assertEquals calls come after it, the first failure in Java code stops the test.

Пастка. containsString на великому тілі (body(containsString("error"))) легко дає хибний результат: слово може бути в іншому полі. Перевіряй конкретний шлях.

Trap. containsString on a large body (body(containsString("error"))) easily gives a false result: the word may sit in another field. Check a specific path.

Кілька перевірок в одному ланцюжку
Several checks in one chain
given().pathParam("id", 7)
.when().get("/users/{id}")
.then()
    .statusCode(200)
    .body("id", equalTo(7))
    .body("name", equalTo("Olena"), "role", equalTo("qa"))
    .body("email", allOf(containsString("@"), endsWith(".com")))
    .body("createdAt", notNullValue())
    .body("status", anyOf(is("ACTIVE"), is("PENDING")));
Як виглядає падіння двох перевірок
What two failed checks look like
java.lang.AssertionError: 2 expectations failed.
JSON path name doesn't match.
Expected: Olena
  Actual: Olga

JSON path role doesn't match.
Expected: qa
  Actual: dev
🔧 Розбір коду: Друга перевірка тіла передає дві пари шлях-матчер в одному виклику. allOf вимагає обидві умови, anyOf — хоча б одну. Нижче видно, що REST Assured показав обидві розбіжності разом, а не зупинився на першій.
🔧 Code breakdown: The second body check passes two path-matcher pairs in one call. allOf requires both conditions, anyOf at least one. The output below shows REST Assured reporting both mismatches together rather than stopping at the first.
💡 Простими словами: Усе, що в одному then(), REST Assured перевіряє разом і показує всі помилки в одному звіті.
💡 In plain words: Everything inside one then() is checked together, and every failure shows up in one report.
🗣 Як читати вголос Основні матчери — equalTo для точного збігу з урахуванням типу, containsString, startsWith, endsWith для рядків, notNullValue для наявності значення, а allOf, anyOf і not їх комбінують. Кілька полів я перевіряю або кількома body, або парами в одному виклику. Корисна деталь: REST Assured проганяє всі очікування одного then і кидає одну помилку зі списком, наприклад «2 expectations failed», тож я бачу всі розбіжності одразу. А от після extract звичайні asserts уже зупиняються на першому падінні.
🗣 How to say it out loud The core matchers are equalTo for an exact, type-aware match, containsString, startsWith and endsWith for strings, notNullValue for presence, and allOf, anyOf and not combine them. I check several fields either with several body calls or with pairs in one call. A useful detail: REST Assured runs every expectation of one then and throws a single error with a list, such as "2 expectations failed", so I see all the mismatches at once. After extract, though, plain asserts stop at the first failure.
32 Чим відрізняються hasItem, hasItems, contains і containsInAnyOrder для масивів? How do hasItem, hasItems, contains and containsInAnyOrder differ for arrays?
ПеревіркиAssertions Middle ▶

hasItem(x). У колекції є хоча б один елемент, що дорівнює x. Інші елементи й порядок не важливі.

hasItem(x). The collection has at least one element equal to x. Other elements and order do not matter.

hasItems(a, b). Є кожен з перелічених елементів, у будь-якому порядку; зайві елементи дозволені. Це перевірка «містить принаймні».

hasItems(a, b). Each listed element is present, in any order; extra elements are allowed. It is an "at least contains" check.

contains(a, b, c). Колекція складається рівно з цих елементів у цьому порядку: ні зайвих, ні відсутніх, ні переставлених. Назва оманлива — у Hamcrest це не «містить», а «точно дорівнює послідовності». Тому contains(1, 2, 3) на [1, 2, 3, 4] впаде.

contains(a, b, c). The collection consists of exactly these elements in this order: nothing extra, nothing missing, nothing swapped. The name is misleading — in Hamcrest it does not mean "includes" but "equals this sequence exactly". So contains(1, 2, 3) on [1, 2, 3, 4] fails.

containsInAnyOrder(a, b, c). Той самий точний склад і та сама кількість, але порядок довільний. Це правильна перевірка, коли API не гарантує сортування, але набір має бути точним.

containsInAnyOrder(a, b, c). The same exact membership and the same count, but any order. It is the right check when the API does not guarantee sorting but the set must be exact.

Як обрати. Порядок — частина контракту (сортування, історія подій) — contains. Набір точний, порядок ні — containsInAnyOrder. Дані спільні й можуть з'являтися чужі записи — hasItems. Типова помилка — думати, що contains(1, 2, 3) перевіряє сортування: він перевіряє лише, що прийшли саме ці три значення саме в такому порядку, а для довільного списку сортування треба перевіряти окремо.

How to choose. Order is part of the contract (sorting, an event history): contains. The set is exact but the order is not: containsInAnyOrder. The data is shared and foreign records may appear: hasItems. A typical mistake is to think contains(1, 2, 3) checks sorting: it only checks that exactly these three values arrived in exactly this order, and sorting of an arbitrary list must be checked separately.

Чотири матчери на одному масиві ["qa", "dev", "ops"]
Four matchers on one array ["qa", "dev", "ops"]
// Відповідь: {"roles": ["qa", "dev", "ops"]}
then()
    .body("roles", hasItem("dev"))                           // OK: є dev
    .body("roles", hasItems("ops", "qa"))                    // OK: ops і qa є, порядок неважливий
    .body("roles", contains("qa", "dev", "ops"))             // OK: точний склад і порядок
    .body("roles", containsInAnyOrder("ops", "qa", "dev"));  // OK: точний склад, будь-який порядок

// Падають:
//   contains("qa", "dev")                 — бракує ops
//   contains("dev", "qa", "ops")          — інший порядок
//   containsInAnyOrder("qa", "dev")       — бракує ops
// Response: {"roles": ["qa", "dev", "ops"]}
then()
    .body("roles", hasItem("dev"))                           // OK: dev is there
    .body("roles", hasItems("ops", "qa"))                    // OK: both present, order ignored
    .body("roles", contains("qa", "dev", "ops"))             // OK: exact items and order
    .body("roles", containsInAnyOrder("ops", "qa", "dev"));  // OK: exact items, any order

// These fail:
//   contains("qa", "dev")                 — ops is missing
//   contains("dev", "qa", "ops")          — different order
//   containsInAnyOrder("qa", "dev")       — ops is missing
🔧 Розбір коду: Усі чотири перевірки проходять на масиві ["qa", "dev", "ops"], але з різних причин. Закоментовані рядки показують, де межа: contains чутливий і до кількості, і до порядку, containsInAnyOrder — лише до кількості та складу.
🔧 Code breakdown: All four checks pass on the array ["qa", "dev", "ops"], but for different reasons. The commented lines show where the boundary lies: contains is sensitive to both count and order, containsInAnyOrder only to count and membership.
💡 Простими словами: hasItems — «принаймні ці», containsInAnyOrder — «рівно ці», contains — «рівно ці й саме в такому порядку».
💡 In plain words: hasItems means at least these; containsInAnyOrder means exactly these; contains means exactly these in exactly this order.
🗣 Як читати вголос HasItem перевіряє, що є один конкретний елемент, hasItems — що є всі перелічені, а зайві дозволені. Contains — найсуворіший: рівно ці елементи, рівно в такому порядку і без зайвих, назва тут вводить в оману. ContainsInAnyOrder вимагає той самий точний склад, але порядок не важливий. Я обираю залежно від контракту: якщо порядок гарантований — contains, якщо ні — containsInAnyOrder, а на спільних даних — hasItems. І підкреслюю, що contains не перевіряє сортування довільного списку.
🗣 How to say it out loud HasItem checks that one specific element is present, hasItems that all the listed ones are, with extras allowed. Contains is the strictest: exactly these elements, exactly in this order and nothing else, and its name is misleading. ContainsInAnyOrder requires the same exact membership but ignores order. I choose based on the contract: if order is guaranteed I use contains, if not containsInAnyOrder, and on shared data hasItems. I also stress that contains does not check the sorting of an arbitrary list.
33 Чому notNullValue() не підходить для перевірки наявності ключа в JSON і що використати замість нього? Why is notNullValue() the wrong way to check that a key exists in JSON, and what should you use instead?
ПеревіркиAssertions Middle ▶

Проблема. Коли GPath звертається до ключа, якого немає, результат — null. Коли ключ є, але значення null, результат теж null. Тобто notNullValue() і nullValue() не розрізняють «поле відсутнє» і «поле є і дорівнює null». Для багатьох API це різні контракти: у PATCH відсутнє поле означає «не змінювати», а null — «очистити»; у відповіді "middleName": null може бути обов'язковим, а відсутність — дефектом серіалізації.

The problem. When GPath reaches for a key that does not exist, the result is null. When the key exists but holds null, the result is null too. So notNullValue() and nullValue() cannot tell "field missing" from "field present and null". For many APIs these are different contracts: in a PATCH a missing field means "do not change" and null means "clear it"; in a response "middleName": null may be required, and its absence a serialization defect.

Рішення. Перевіряти ключ на рівні батьківського об'єкта матчером мапи: body("user", hasKey("middleName")) — ключ є незалежно від значення; body("user", not(hasKey("password"))) — ключа точно немає; hasEntry("status", "ACTIVE") — пара ключ-значення. Для кореня відповіді — body("$", hasKey("id")). Далі, якщо треба, окремо body("user.middleName", nullValue()), і тепер це однозначно «ключ є і дорівнює null».

The fix. Check the key at the level of the parent object with a map matcher: body("user", hasKey("middleName")) — the key is present whatever its value; body("user", not(hasKey("password"))) — the key is definitely absent; hasEntry("status", "ACTIVE") — a key-value pair. For the response root use body("$", hasKey("id")). Then, if needed, add body("user.middleName", nullValue()), which now unambiguously means "the key is there and is null".

Де це важливо. Перевірка, що відповідь не містить чутливих полів (password, token). Через nullValue() такий тест пройде і тоді, коли поле є зі значенням null, і тоді, коли його немає, — а через not(hasKey(...)) він чесно гарантує відсутність ключа.

Where it matters. Checking that a response does not contain sensitive fields (password, token). With nullValue() such a test passes both when the field is present with null and when it is absent, while not(hasKey(...)) honestly guarantees the key is gone.

Наявність ключа, відсутність ключа і явний null
Key present, key absent and explicit null
// Відповідь: {"user": {"id": 7, "middleName": null, "status": "ACTIVE"}}
given().pathParam("id", 7)
.when().get("/users/{id}")
.then()
    .statusCode(200)
    .body("user", hasKey("middleName"))                 // ключ є
    .body("user.middleName", nullValue())               // і дорівнює null
    .body("user", not(hasKey("password")))              // ключа немає взагалі
    .body("user", hasEntry("status", "ACTIVE"))
    .body("$", hasKey("user"));

// Хибний тест: пройде і без ключа, і з null
// .body("user.password", nullValue())
// Response: {"user": {"id": 7, "middleName": null, "status": "ACTIVE"}}
given().pathParam("id", 7)
.when().get("/users/{id}")
.then()
    .statusCode(200)
    .body("user", hasKey("middleName"))                 // the key exists
    .body("user.middleName", nullValue())               // and holds null
    .body("user", not(hasKey("password")))              // no such key at all
    .body("user", hasEntry("status", "ACTIVE"))
    .body("$", hasKey("user"));

// A misleading test: passes with no key and with null
// .body("user.password", nullValue())
🔧 Розбір коду: hasKey і hasEntry — матчери Hamcrest для Map; JSON-об'єкт у REST Assured якраз представлений мапою. Тому шлях вказує на батьківський об'єкт user, а не на саме поле. "$" — корінь відповіді.
🔧 Code breakdown: hasKey and hasEntry are Hamcrest matchers for a Map, and a JSON object in REST Assured is represented as a map. That is why the path points to the parent object user, not to the field itself. "$" is the response root.
💡 Простими словами: Щоб перевірити, чи є ключ, дивися на батьківський об'єкт, а не на значення ключа.
💡 In plain words: To check whether a key exists, look at the parent object, not at the key's value.
🗣 Як читати вголос NotNullValue не підходить, бо GPath повертає null в обох випадках: коли ключа немає і коли ключ є зі значенням null. А для API це часто різні речі, наприклад у PATCH. Тому наявність ключа я перевіряю на батьківському об'єкті через hasKey, відсутність — через not hasKey, а пару ключ-значення — через hasEntry. Найважливіше це в тестах безпеки: перевірка, що в відповіді немає password, має бути саме через not hasKey, а не через nullValue.
🗣 How to say it out loud NotNullValue does not work because GPath returns null in both cases: when the key is missing and when it is present with a null value. For an API those are often different things, for example in a PATCH. So I check that a key exists on the parent object with hasKey, that it is absent with not hasKey, and a key-value pair with hasEntry. It matters most in security tests: checking that a response has no password must use not hasKey, not nullValue.
34 Як перевірити кожен елемент масиву і тип значень: everyItem, instanceOf, isA і числові матчери? How do you check every array element and the value types: everyItem, instanceOf, isA and numeric matchers?
ПеревіркиAssertions Middle ▶

everyItem. everyItem(matcher) застосовує матчер до кожного елемента колекції: body("items.price", everyItem(greaterThan(0))). Це природний спосіб перевірити інваріант списку: усі ціни додатні, усі статуси з дозволеного набору (everyItem(is(in(List.of(...))))), усі email з символом @.

everyItem. everyItem(matcher) applies a matcher to every element of a collection: body("items.price", everyItem(greaterThan(0))). It is the natural way to check a list invariant: all prices positive, all statuses from an allowed set (everyItem(is(in(List.of(...))))), all emails containing @.

Пастка everyItem. На порожній колекції він проходить завжди — умова «для кожного» вакуумно істинна. Якщо порожній список — дефект, додай not(empty()) поруч.

The everyItem trap. On an empty collection it always passes — a "for each" condition is vacuously true. If an empty list is a defect, add not(empty()) next to it.

Тип значення. Щоб переконатися, що поле — число, а не рядок "42": body("id", instanceOf(Integer.class)) або isA(Integer.class). Для «будь-якого числа» — instanceOf(Number.class), бо дрібні значення REST Assured повертає як Float, великі цілі — як Long. Для рядка — instanceOf(String.class); для булевого — instanceOf(Boolean.class).

Value type. To make sure a field is a number rather than the string "42": body("id", instanceOf(Integer.class)) or isA(Integer.class). For "any number" use instanceOf(Number.class), because REST Assured returns decimals as Float and large integers as Long. For a string use instanceOf(String.class); for a boolean instanceOf(Boolean.class).

Числові матчери. greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo, closeTo(value, delta) для Double, comparesEqualTo для BigDecimal, де equals враховує масштаб (1.0 не дорівнює 1.00). Числа в матчері мають бути того самого типу, що повертає JsonPath, інакше отримаєш ClassCastException чи незрозуміле падіння.

Numeric matchers. greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo, closeTo(value, delta) for a Double, and comparesEqualTo for a BigDecimal, whose equals takes scale into account (1.0 is not equal to 1.00). The numbers in a matcher must be the type JsonPath returns, otherwise you get a ClassCastException or a confusing failure.

Ширше. Коли типів і полів багато, перевірка типів окремими матчерами стає громіздкою — тоді JSON Schema робить це компактніше.

Broader view. When there are many fields and types, checking types matcher by matcher gets bulky, and JSON Schema does it more compactly.

Інваріанти списку й типи полів
List invariants and field types
when().get("/products?category=books")
.then()
    .statusCode(200)
    .body("items", not(empty()))                                  // інакше everyItem нічого не доводить
    .body("items.price", everyItem(greaterThan(0f)))
    .body("items.currency", everyItem(is(in(List.of("UAH", "EUR")))))
    .body("items.id", everyItem(instanceOf(Integer.class)))
    .body("items[0].title", isA(String.class))
    .body("items[0].inStock", instanceOf(Boolean.class))
    .body("items[0].rating", allOf(greaterThanOrEqualTo(0f), lessThanOrEqualTo(5f)));
when().get("/products?category=books")
.then()
    .statusCode(200)
    .body("items", not(empty()))                                  // otherwise everyItem proves nothing
    .body("items.price", everyItem(greaterThan(0f)))
    .body("items.currency", everyItem(is(in(List.of("UAH", "EUR")))))
    .body("items.id", everyItem(instanceOf(Integer.class)))
    .body("items[0].title", isA(String.class))
    .body("items[0].inStock", instanceOf(Boolean.class))
    .body("items[0].rating", allOf(greaterThanOrEqualTo(0f), lessThanOrEqualTo(5f)));
🔧 Розбір коду: Ціни й рейтинг дробові, тому порівнюємо з 0f і 5f — REST Assured за замовчуванням повертає їх як Float. is(in(...)) — матчер «значення з набору». Перший рядок після статусу захищає від вакуумної істини everyItem на порожньому списку.
🔧 Code breakdown: Prices and rating are decimals, so we compare with 0f and 5f — REST Assured returns them as Float by default. is(in(...)) is a "value from a set" matcher. The first line after the status guards against the vacuous truth of everyItem on an empty list.
💡 Простими словами: everyItem — «кожен елемент такий», але на порожньому списку він мовчки каже «так».
💡 In plain words: everyItem means every element is like this, but on an empty list it silently says yes.
🗣 Як читати вголос Для інваріантів списку я використовую everyItem: наприклад, усі ціни більші за нуль або всі валюти з дозволеного набору. Але завжди додаю not empty, бо на порожньому масиві everyItem проходить автоматично. Тип поля перевіряю через instanceOf або isA, щоб число не приїхало рядком. І стежу, щоб числа в матчерах були того ж типу, що повертає REST Assured: дробові за замовчуванням Float, великі цілі — Long, а для BigDecimal беру comparesEqualTo.
🗣 How to say it out loud For list invariants I use everyItem, for example every price above zero or every currency from an allowed set. But I always add not empty, because on an empty array everyItem passes automatically. I check a field type with instanceOf or isA so a number does not arrive as a string. And I make sure the numbers in matchers are the type REST Assured returns: decimals are Float by default, big integers are Long, and for BigDecimal I use comparesEqualTo.
35 Як перевірити, що рядок відповідає регулярному виразу, і як перевірити порожнє тіло відповіді? How do you check that a string matches a regular expression, and how do you check an empty response body?
ПеревіркиAssertions Middle ▶

matchesPattern. У Hamcrest 2.0+ є matchesPattern(String regex) і matchesPattern(Pattern) у org.hamcrest.Matchers. Вираз має збігтися з усім рядком, як у String.matches(), тому якорі ^ і $ не обов'язкові, а часткового збігу немає. Приклади: UUID, ISO-дата, номер замовлення формату ORD-2026-000123, маскований номер картки. У старих прикладах трапляється самописний матчер або containsString — у Hamcrest 2 для цього вже є стандартний.

matchesPattern. Hamcrest 2.0+ has matchesPattern(String regex) and matchesPattern(Pattern) in org.hamcrest.Matchers. The expression must match the whole string, as in String.matches(), so the ^ and $ anchors are optional and there is no partial match. Examples: a UUID, an ISO date, an order number such as ORD-2026-000123, a masked card number. Old examples use a hand-written matcher or containsString — Hamcrest 2 now has a standard one.

Коли регулярка доречна. Для значень, які генерує сервер і які неможливо передбачити, але формат яких — частина контракту. Для відомих значень краще equalTo: регулярка на кшталт .* проходить завжди й нічого не доводить.

When a regex fits. For values the server generates, which you cannot predict but whose format is part of the contract. For known values equalTo is better: a regex such as .* always passes and proves nothing.

Порожнє тіло. Для 204 No Content чи DELETE: body(emptyString()) — body() без шляху застосовує матчер до всього тіла як рядка. Альтернативи — body(emptyOrNullString()) або extract().asString() з isEmpty(). Старий isEmptyString() у Hamcrest 2 застарів. Додатково можна перевірити header("Content-Length", "0"), якщо сервер його надсилає. Пастка: body("$", empty()) — не те саме, це перевірка порожнього JSON-масиву [], а не відсутності тіла.

Empty body. For 204 No Content or a DELETE: body(emptyString()) — body() without a path applies the matcher to the whole body as a string. Alternatives are body(emptyOrNullString()) or extract().asString() with isEmpty(). The old isEmptyString() is deprecated in Hamcrest 2. You can also check header("Content-Length", "0") if the server sends it. Trap: body("$", empty()) is something else — it checks for an empty JSON array [], not for a missing body.

Формати значень і порожнє тіло
Value formats and an empty body
private static final String UUID_RE = "[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";

given().spec(Specs.BASE).body(Orders.validOrder().build())
.when().post("/orders")
.then()
    .statusCode(201)
    .body("id", matchesPattern(UUID_RE))
    .body("number", matchesPattern("ORD-\\d{4}-\\d{6}"))
    .body("createdAt", matchesPattern("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z"))
    .body("card", matchesPattern("\\*{12}\\d{4}"));

when().delete("/orders/{id}", id)
.then()
    .statusCode(204)
    .body(emptyString());
🔧 Розбір коду: У Java-рядку зворотний слеш подвоюється: "\\d{4}" — це регулярка \d{4}. Вираз номера картки \*{12}\d{4} вимагає рівно дванадцять зірочок і чотири цифри. body(emptyString()) без шляху перевіряє тіло цілком.
🔧 Code breakdown: In a Java string the backslash is doubled: "\\d{4}" is the regex \d{4}. The card pattern \*{12}\d{4} requires exactly twelve asterisks and four digits. body(emptyString()) without a path checks the whole body.
💡 Простими словами: matchesPattern — для значень, які не вгадаєш, але формат яких обіцяли; body(emptyString()) — для «тут нічого не має бути».
💡 In plain words: matchesPattern is for values you cannot guess but whose format was promised; body(emptyString()) is for nothing should be here.
🗣 Як читати вголос Для регулярних виразів у Hamcrest 2 є стандартний matchesPattern, і він вимагає збігу з усім рядком. Я використовую його для значень, які генерує сервер: UUID, номер замовлення, дата, маскована картка. Для відомих значень беру equalTo, бо слабка регулярка нічого не доводить. Порожнє тіло перевіряю через body(emptyString()) без шляху, а body з долар і empty — це вже про порожній масив, не про відсутність тіла.
🗣 How to say it out loud For regular expressions Hamcrest 2 has the standard matchesPattern, and it requires the whole string to match. I use it for values the server generates: a UUID, an order number, a date, a masked card. For known values I use equalTo, because a weak regex proves nothing. I check an empty body with body(emptyString()) without a path, while body with the dollar sign and empty is about an empty array, not a missing body.
36 Як перевірити, що список у відповіді справді відсортований, і чому contains() для цього не годиться? How do you check that a list in the response is really sorted, and why is contains() not the tool for it?
ПеревіркиAssertions Middle ▶

Чому не contains. contains(1, 2, 3) перевіряє, що список складається рівно з 1, 2, 3 саме в такому порядку. Він доводить сортування лише для цього конкретного набору, який ти знаєш заздалегідь. Як тільки даних більше або вони інші — матчер падає не через порушення сортування, а через інший склад. А для 50 записів з бази ти не випишеш очікувану послідовність руками.

Why not contains. contains(1, 2, 3) checks that the list consists of exactly 1, 2, 3 in that order. It proves sorting only for this particular set that you know in advance. As soon as there is more data, or different data, the matcher fails not because sorting broke but because the membership differs. And for 50 records from a database you will not write the expected sequence by hand.

Правильний спосіб. Витягнути значення поля сортування в Java-список і перевірити властивість «кожен наступний не менший за попередній»: AssertJ: assertThat(ids).isSorted() для природного порядку, isSortedAccordingTo(Comparator.reverseOrder()) для спадання або довільного компаратора. Без AssertJ: порівняти список з його відсортованою копією.

The right way. Extract the sort field into a Java list and check the property "each next element is not smaller than the previous one": AssertJ: assertThat(ids).isSorted() for natural order, isSortedAccordingTo(Comparator.reverseOrder()) for descending order or any comparator. Without AssertJ: compare the list with a sorted copy of itself.

Що ще врахувати. Сортування на одній сторінці нічого не каже про межу між сторінками — варто перевірити, що останній елемент сторінки 1 не більший за перший елемент сторінки 2. Для рядків важлива колація: сервер може сортувати з урахуванням локалі, а Java — за кодами символів. Для рівних значень основного ключа потрібне стабільне вторинне сортування, інакше пагінація дублює або губить записи. І тест має мати щонайменше два різні значення, інакше він проходить тривіально.

What else to consider. Sorting within one page says nothing about the boundary between pages — check that the last element of page 1 is not greater than the first element of page 2. For strings collation matters: the server may sort locale-aware while Java sorts by character codes. Equal values of the main key need a stable secondary sort, otherwise pagination duplicates or loses records. And the test needs at least two distinct values, otherwise it passes trivially.

Перевірка сортування AssertJ і без нього
Checking sorting with and without AssertJ
@Test
void ordersAreSortedByCreatedAtDescending() {
    JsonPath page = given().queryParam("sort", "createdAt,desc").queryParam("size", 50)
        .when().get("/orders")
        .then().statusCode(200)
        .extract().jsonPath();

    List<Instant> created = page.getList("items.createdAt", String.class)
            .stream().map(Instant::parse).toList();

    assertThat(created).hasSizeGreaterThan(1)
            .isSortedAccordingTo(Comparator.reverseOrder());
}

@Test
void idsAreAscending() {
    List<Integer> ids = when().get("/orders?sort=id").then().statusCode(200)
            .extract().jsonPath().getList("items.id", Integer.class);

    List<Integer> expected = new ArrayList<>(ids);
    Collections.sort(expected);
    assertEquals(expected, ids, "ids must be ascending");
}
🔧 Розбір коду: Дати приходять рядками, тому спершу перетворюємо їх на Instant — інакше порівнювали б рядки. hasSizeGreaterThan(1) не дає тесту пройти на порожньому чи одноелементному списку. У другому тесті копія сортується, а потім порівнюється з оригіналом.
🔧 Code breakdown: Dates come as strings, so we convert them to Instant first — otherwise we would be comparing strings. hasSizeGreaterThan(1) keeps the test from passing on an empty or single-element list. In the second test a copy is sorted and then compared with the original.
💡 Простими словами: Сортування — це властивість «кожен наступний не менший», а не конкретний список значень.
💡 In plain words: Sorting is the property that each next item is not smaller, not a specific list of values.
🗣 Як читати вголос Contains не перевіряє сортування, він перевіряє точний склад і порядок конкретних значень, які я знаю заздалегідь. Для реальних даних я витягую поле сортування через jsonPath().getList, перетворюю в правильний тип, наприклад Instant для дат, і перевіряю AssertJ через isSorted або isSortedAccordingTo з компаратором. Без AssertJ порівнюю список з його відсортованою копією. І додатково дивлюсь межу між сторінками та стабільність сортування при однакових значеннях.
🗣 How to say it out loud Contains does not check sorting, it checks the exact membership and order of specific values I know in advance. For real data I extract the sort field with jsonPath().getList, convert it to the right type, for example Instant for dates, and check it with AssertJ isSorted or isSortedAccordingTo with a comparator. Without AssertJ I compare the list with a sorted copy of itself. I also look at the boundary between pages and at stable ordering for equal values.
37 Як зробити soft assertions в API-тестах на REST Assured? How do you do soft assertions in REST Assured API tests?
ПеревіркиAssertions Middle ▶

Що вже є. Усередині одного then() REST Assured сам збирає всі невдалі очікування й показує їх разом, тож для перевірок статусу, заголовків і полів через GPath окремий інструмент не потрібен. Окремого API «soft assert» у REST Assured немає.

What you already have. Inside one then() REST Assured already collects every failed expectation and reports them together, so for status, header and GPath field checks you need no extra tool. REST Assured has no separate "soft assert" API.

Коли потрібна зовнішня бібліотека. Коли перевірки виходять за межі ланцюжка: після extract().as(Order.class), при порівнянні з базою даних, при кількох запитах в одному сценарії. Варіанти: SoftAssertions.assertSoftly(softly -> {...}) з AssertJ — збирає всі падіння і кидає одну помилку наприкінці; assertAll(...) з JUnit 5 — виконує всі лямбди й групує помилки; SoftAssert з TestNG з обов'язковим assertAll() наприкінці.

When you need an external library. When checks go beyond the chain: after extract().as(Order.class), when comparing with the database, across several requests in one scenario. Options: SoftAssertions.assertSoftly(softly -> {...}) from AssertJ collects every failure and throws one error at the end; assertAll(...) from JUnit 5 runs every lambda and groups the errors; TestNG's SoftAssert with a mandatory assertAll() at the end.

Коли soft — погана ідея. Якщо перевірка — передумова наступних кроків. Немає сенсу продовжувати, коли статус 500 чи тіло не розпарсилось: подальші перевірки дадуть десяток шумних падінь. Тому структура така: жорстко перевірити статус і тип, потім м'яко — набір незалежних полів.

When soft is a bad idea. When a check is a precondition for the next steps. There is no point continuing when the status is 500 or the body did not parse: the remaining checks produce a dozen noisy failures. So the structure is: check status and type strictly, then check a set of independent fields softly.

Пастка. SoftAssert у TestNG без виклику assertAll() не кидає нічого — тест зелений за будь-яких помилок. AssertJ assertSoftly цієї проблеми не має, бо викликає перевірку сам.

Trap. TestNG's SoftAssert without a call to assertAll() throws nothing — the test is green whatever goes wrong. AssertJ's assertSoftly does not have that problem because it runs the final check itself.

Жорсткий статус, м'які перевірки полів
A hard status check, soft field checks
@Test
void orderDetailsAreConsistent() {
    Order order = given().spec(Specs.BASE)
        .when().get("/orders/{id}", orderId)
        .then().statusCode(200).contentType(ContentType.JSON)     // передумова: жорстко
        .extract().as(Order.class);

    SoftAssertions.assertSoftly(softly -> {
        softly.assertThat(order.status()).isEqualTo("PAID");
        softly.assertThat(order.currency()).isEqualTo("UAH");
        softly.assertThat(order.lines()).hasSize(2);
        softly.assertThat(order.total()).isEqualByComparingTo(new BigDecimal("92.00"));
    });
}

// JUnit 5 варіант
assertAll("order",
        () -> assertEquals("PAID", order.status()),
        () -> assertEquals("UAH", order.currency()));
@Test
void orderDetailsAreConsistent() {
    Order order = given().spec(Specs.BASE)
        .when().get("/orders/{id}", orderId)
        .then().statusCode(200).contentType(ContentType.JSON)     // precondition: hard
        .extract().as(Order.class);

    SoftAssertions.assertSoftly(softly -> {
        softly.assertThat(order.status()).isEqualTo("PAID");
        softly.assertThat(order.currency()).isEqualTo("UAH");
        softly.assertThat(order.lines()).hasSize(2);
        softly.assertThat(order.total()).isEqualByComparingTo(new BigDecimal("92.00"));
    });
}

// JUnit 5 variant
assertAll("order",
        () -> assertEquals("PAID", order.status()),
        () -> assertEquals("UAH", order.currency()));
🔧 Розбір коду: Статус і тип перевіряються в ланцюжку до десеріалізації, бо без них решта не має сенсу. assertSoftly виконує лямбду, збирає всі падіння і кидає одну помилку. isEqualByComparingTo порівнює BigDecimal через compareTo, ігноруючи масштаб.
🔧 Code breakdown: Status and type are checked in the chain before deserialization, because nothing else makes sense without them. assertSoftly runs the lambda, collects every failure and throws one error. isEqualByComparingTo compares BigDecimals with compareTo, ignoring scale.
💡 Простими словами: Спершу жорстко перевір, що відповідь взагалі нормальна, а потім м'яко — усі її деталі.
💡 In plain words: First check hard that the response is sane at all, then check all its details softly.
🗣 Як читати вголос Окремого soft assert у REST Assured немає, але всередині одного then він і так збирає всі невдалі очікування в одну помилку. Коли перевірки виходять за ланцюжок — після десеріалізації чи порівняння з базою — я беру SoftAssertions з AssertJ або assertAll з JUnit 5. Статус і тип при цьому перевіряю жорстко, бо без них решта перевірок дасть лише шум. А з TestNG SoftAssert завжди пам'ятаю про assertAll наприкінці, інакше тест зелений за будь-яких помилок.
🗣 How to say it out loud REST Assured has no dedicated soft assert, but inside one then it already collects every failed expectation into one error. When checks go beyond the chain, after deserialization or a database comparison, I use AssertJ SoftAssertions or JUnit 5 assertAll. I still check the status and type hard, because without them the other checks only produce noise. And with TestNG SoftAssert I always remember assertAll at the end, otherwise the test stays green whatever fails.
38 Як перевірити відповідь за JSON Schema через matchesJsonSchemaInClasspath і що саме ловить схема? How do you validate a response against a JSON Schema with matchesJsonSchemaInClasspath, and what does a schema actually catch?
ПеревіркиAssertions Middle ▶

Підключення. Потрібен окремий артефакт io.rest-assured:json-schema-validator тієї ж версії, що й REST Assured. Статичний імпорт — io.restassured.module.jsv.JsonSchemaValidator.*. Схема кладеться в src/test/resources/schemas/ і перевіряється так: body(matchesJsonSchemaInClasspath("schemas/user.json")). Є й варіанти matchesJsonSchema(String | File | InputStream | URI).

Setup. You need the separate artifact io.rest-assured:json-schema-validator at the same version as REST Assured. The static import is io.restassured.module.jsv.JsonSchemaValidator.*. The schema goes into src/test/resources/schemas/ and is checked like this: body(matchesJsonSchemaInClasspath("schemas/user.json")). There are also matchesJsonSchema(String | File | InputStream | URI) variants.

Що ловить. Типи полів (число замість рядка), обов'язкові поля (required), зайві поля (additionalProperties: false), формати й обмеження (enum, minimum, pattern, minItems), структуру вкладених об'єктів і масивів. Одна схема замінює десятки матчерів на кшталт instanceOf і hasKey.

What it catches. Field types (a number instead of a string), required fields (required), extra fields (additionalProperties: false), formats and constraints (enum, minimum, pattern, minItems), and the structure of nested objects and arrays. One schema replaces dozens of matchers such as instanceOf and hasKey.

Чого не ловить. Бізнес-логіку: схема не знає, що сума має дорівнювати сумі рядків, а статус після оплати — PAID. Тому схема — доповнення до функціональних перевірок, а не заміна.

What it misses. Business logic: a schema does not know that the total must equal the sum of the lines or that the status after payment is PAID. So a schema complements functional checks; it does not replace them.

Нюанси. Модуль побудований на валідаторі java-json-tools, який підтримує draft-04, тож ключові слова новіших драфтів (наприклад, $defs або unevaluatedProperties) він не зрозуміє — схему варто писати під draft-04 або обрати інший валідатор. Надто сувора схема (additionalProperties: false скрізь) ламає тести на кожне нове поле; надто м'яка без required пропускає зникнення полів. Баланс — суворість для публічних контрактів і чутливих даних.

Nuances. The module is built on the java-json-tools validator, which supports draft-04, so keywords from newer drafts (such as $defs or unevaluatedProperties) are not understood — write the schema for draft-04 or pick another validator. An overly strict schema (additionalProperties: false everywhere) breaks tests on every new field; an overly loose one without required lets fields disappear unnoticed. The balance is strictness for public contracts and sensitive data.

schemas/user.json
schemas/user.json
{
  "$schema": "http://json-schema.org/draft-04/schema#",
  "type": "object",
  "required": ["id", "email", "role", "createdAt"],
  "additionalProperties": false,
  "properties": {
    "id":        { "type": "integer", "minimum": 1 },
    "email":     { "type": "string", "pattern": "^[^@]+@[^@]+$" },
    "role":      { "type": "string", "enum": ["qa", "dev", "admin"] },
    "createdAt": { "type": "string" },
    "middleName": { "type": ["string", "null"] }
  }
}
Тест: схема плюс бізнес-перевірка
Test: schema plus a business check
import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath;

@Test
void userMatchesContract() {
    given().pathParam("id", 7)
    .when().get("/users/{id}")
    .then()
        .statusCode(200)
        .body(matchesJsonSchemaInClasspath("schemas/user.json"))
        .body("role", equalTo("qa"));
}
🔧 Розбір коду: additionalProperties: false змусить тест впасти, якщо сервер додасть, наприклад, passwordHash. "type": ["string", "null"] дозволяє явний null для необов'язкового поля. У тесті схема перевіряє форму, а equalTo("qa") — конкретне значення для цього користувача.
🔧 Code breakdown: additionalProperties: false makes the test fail if the server adds, say, passwordHash. "type": ["string", "null"] allows an explicit null for an optional field. In the test the schema checks the shape, and equalTo("qa") checks the concrete value for this user.
💡 Простими словами: Схема перевіряє форму відповіді, а матчери — її зміст.
💡 In plain words: The schema checks the shape of the response; the matchers check its content.
🗣 Як читати вголос Для перевірки схеми я підключаю модуль json-schema-validator тієї ж версії, кладу схему в resources і пишу body(matchesJsonSchemaInClasspath). Схема ловить типи, обов'язкові поля, зайві поля через additionalProperties false, enum і обмеження. Бізнес-логіку вона не бачить, тому поруч лишаються звичайні матчери. І враховую, що валідатор модуля розуміє draft-04, тож новіші ключові слова там не спрацюють. Суворість обираю свідомо: для публічних і чутливих відповідей — сувора схема.
🗣 How to say it out loud For schema validation I add the json-schema-validator module at the same version, put the schema in resources and write body(matchesJsonSchemaInClasspath). The schema catches types, required fields, extra fields through additionalProperties false, enums and constraints. It cannot see business logic, so regular matchers stay alongside it. I also keep in mind that the module's validator understands draft-04, so newer keywords will not work there. I choose strictness deliberately: strict schemas for public and sensitive responses.
▶ 6 5
39 Чим auth().basic() відрізняється від auth().preemptive().basic() і коли потрібен кожен? How is auth().basic() different from auth().preemptive().basic(), and when do you need each?
AuthAuth Junior ▶

auth().basic(user, pass). Це challenged-режим: REST Assured спершу надсилає запит без облікових даних і додає заголовок Authorization: Basic ... лише тоді, коли сервер відповів 401 із заголовком WWW-Authenticate: Basic. Тобто кожен виклик — фактично два HTTP-запити.

auth().basic(user, pass). This is challenged mode: REST Assured first sends the request without credentials and adds the Authorization: Basic ... header only when the server answers 401 with a WWW-Authenticate: Basic header. So each call is effectively two HTTP requests.

auth().preemptive().basic(user, pass). Заголовок додається одразу в перший запит. Це швидше і працює з API, які не надсилають challenge: багато сучасних сервісів відповідають на запит без токена 401 без WWW-Authenticate, 403 або навіть 404, щоб не розкривати існування ресурсу. У такому разі звичайний basic() ніколи не надішле облікові дані, і тест падає з незрозумілим 401.

auth().preemptive().basic(user, pass). The header is added to the very first request. That is faster and works with APIs that send no challenge: many modern services answer an unauthenticated request with a 401 without WWW-Authenticate, a 403 or even a 404 so as not to reveal that the resource exists. In that case plain basic() never sends the credentials and the test fails with a confusing 401.

Глобально. RestAssured.authentication = preemptive().basic("qa", secret); або в специфікації setAuth(preemptive().basic(...)). Скасувати для одного запиту — auth().none(), що зручно для негативного тесту «без авторизації».

Globally. RestAssured.authentication = preemptive().basic("qa", secret); or in a spec with setAuth(preemptive().basic(...)). To cancel it for one request use auth().none(), which is handy for a negative "no auth" test.

Що тестувати. Правильні дані — 200; неправильний пароль — 401; відсутні дані — 401 з WWW-Authenticate, якщо API його обіцяє; інший користувач — 403 на чужий ресурс. І пам'ятай: Basic — лише base64, не шифрування, тому він має йти тільки через HTTPS.

What to test. Correct credentials: 200; wrong password: 401; missing credentials: 401 with WWW-Authenticate if the API promises one; another user: 403 on someone else's resource. And remember that Basic is just base64, not encryption, so it must only travel over HTTPS.

Preemptive, challenged і негативні сценарії
Preemptive, challenged and negative scenarios
@Test
void reportsApiAcceptsPreemptiveBasic() {
    given().auth().preemptive().basic("qa-bot", secret)
    .when().get("/reports")
    .then().statusCode(200);
}

@Test
void legacyApiSendsChallenge() {
    when().get("/legacy/reports")
    .then().statusCode(401)
        .header("WWW-Authenticate", startsWith("Basic"));

    given().auth().basic("qa-bot", secret)            // 401 -> повтор із заголовком
    .when().get("/legacy/reports")
    .then().statusCode(200);
}

@Test
void wrongPasswordIsRejected() {
    given().auth().preemptive().basic("qa-bot", "wrong")
    .when().get("/reports")
    .then().statusCode(401);
}
@Test
void reportsApiAcceptsPreemptiveBasic() {
    given().auth().preemptive().basic("qa-bot", secret)
    .when().get("/reports")
    .then().statusCode(200);
}

@Test
void legacyApiSendsChallenge() {
    when().get("/legacy/reports")
    .then().statusCode(401)
        .header("WWW-Authenticate", startsWith("Basic"));

    given().auth().basic("qa-bot", secret)            // 401 -> retried with the header
    .when().get("/legacy/reports")
    .then().statusCode(200);
}

@Test
void wrongPasswordIsRejected() {
    given().auth().preemptive().basic("qa-bot", "wrong")
    .when().get("/reports")
    .then().statusCode(401);
}
🔧 Розбір коду: auth() повертає AuthenticationSpecification, а preemptive() — PreemptiveAuthSpec, після basic() ланцюжок знову стає RequestSpecification. У другому тесті спершу доводимо, що сервер справді надсилає challenge, і лише потім покладаємось на challenged-режим.
🔧 Code breakdown: auth() returns an AuthenticationSpecification, preemptive() returns a PreemptiveAuthSpec, and after basic() the chain is a RequestSpecification again. The second test first proves that the server really sends a challenge and only then relies on challenged mode.
💡 Простими словами: basic() чекає, поки сервер попросить пароль; preemptive() показує пароль одразу.
💡 In plain words: basic() waits for the server to ask for the password; preemptive() shows it straight away.
🗣 Як читати вголос Звичайний auth().basic працює в режимі challenge: спершу запит іде без облікових даних, і лише після 401 з WWW-Authenticate REST Assured повторює його із заголовком. Preemptive додає заголовок одразу. Я майже завжди беру preemptive, бо багато API не надсилають challenge, і тоді звичайний basic просто ніколи не передасть пароль. Для тесту без авторизації використовую auth().none. І нагадую, що Basic — це base64, тому тільки через HTTPS.
🗣 How to say it out loud Plain auth().basic works in challenge mode: the request first goes out without credentials, and only after a 401 with WWW-Authenticate does REST Assured retry it with the header. Preemptive adds the header right away. I almost always use preemptive, because many APIs send no challenge, and then plain basic never passes the password at all. For a test without auth I use auth().none. And I point out that Basic is just base64, so it belongs on HTTPS only.
40 Як передати Bearer-токен: auth().oauth2() чи заголовок Authorization, і чи є між ними різниця? How do you pass a Bearer token — auth().oauth2() or an Authorization header — and is there a difference?
AuthAuth Junior ▶

Два способи. auth().oauth2(token) і header("Authorization", "Bearer " + token) дають однаковий заголовок Authorization: Bearer <token>. Перший виразніший і не дає помилитися з пробілом чи регістром слова Bearer; другий універсальний і підходить для нестандартних схем (Token, ApiKey) або коли заголовок називається інакше, наприклад X-API-Key. Для oauth2() бібліотека Scribe не потрібна — вона знадобиться лише для OAuth 1.

Two ways. auth().oauth2(token) and header("Authorization", "Bearer " + token) produce the same Authorization: Bearer <token> header. The first is more expressive and leaves no room for a missing space or a wrong case in the word Bearer; the second is generic and suits non-standard schemes (Token, ApiKey) or a header with another name such as X-API-Key. oauth2() does not need the Scribe library — that is only required for OAuth 1.

Де задавати. Для набору тестів — у специфікації: setAuth(RestAssured.oauth2(token)) або addHeader("Authorization", ...). Так тести не знають, звідки береться токен.

Where to set it. For a suite, in the spec: setAuth(RestAssured.oauth2(token)) or addHeader("Authorization", ...). That way the tests do not know where the token comes from.

Що тестувати навколо токена. Без токена — 401. Прострочений токен — 401 з тілом помилки invalid_token. Токен з недостатніми правами (scope) — 403. Токен іншого користувача — 403 або 404 на чужий ресурс. Підроблений підпис JWT — 401. Це окремий, дуже цінний клас тестів, бо помилки авторизації — критичні дефекти.

What to test around the token. No token: 401. An expired token: 401 with an invalid_token error body. A token without enough rights (scope): 403. Another user's token: 403 or 404 on someone else's resource. A tampered JWT signature: 401. This is a separate and very valuable class of tests, because authorization bugs are critical defects.

Пастка. Не хардкодь токен у коді й не комміть його: навіть «тестовий» токен часто має доступ до staging з реальними даними. Бери його зі змінних середовища або отримуй на старті прогону.

Trap. Do not hardcode a token or commit it: even a "test" token often has access to a staging environment with real data. Read it from environment variables or obtain it at the start of the run.

Позитивний і негативні сценарії з токеном
Positive and negative token scenarios
String token = System.getenv("API_TOKEN");

given().auth().oauth2(token)
.when().get("/me")
.then().statusCode(200).body("email", equalTo("qa@example.com"));

given().header("Authorization", "Bearer " + token)         // той самий заголовок
.when().get("/me")
.then().statusCode(200);

when().get("/me").then().statusCode(401);                    // без токена

given().auth().oauth2(Tokens.expired())
.when().get("/me")
.then().statusCode(401).body("error", equalTo("invalid_token"));

given().auth().oauth2(Tokens.withScope("orders:read"))
.when().delete("/orders/{id}", 42)
.then().statusCode(403);
String token = System.getenv("API_TOKEN");

given().auth().oauth2(token)
.when().get("/me")
.then().statusCode(200).body("email", equalTo("qa@example.com"));

given().header("Authorization", "Bearer " + token)         // the same header
.when().get("/me")
.then().statusCode(200);

when().get("/me").then().statusCode(401);                    // no token

given().auth().oauth2(Tokens.expired())
.when().get("/me")
.then().statusCode(401).body("error", equalTo("invalid_token"));

given().auth().oauth2(Tokens.withScope("orders:read"))
.when().delete("/orders/{id}", 42)
.then().statusCode(403);
🔧 Розбір коду: System.getenv("API_TOKEN") бере токен із середовища, а не з коду. Tokens.expired() і Tokens.withScope(...) — хелпери проєкту, що генерують чи отримують токени з потрібними властивостями. Останній тест перевіряє, що токен тільки на читання не дає видаляти.
🔧 Code breakdown: System.getenv("API_TOKEN") reads the token from the environment, not from code. Tokens.expired() and Tokens.withScope(...) are project helpers that generate or fetch tokens with the required properties. The last test checks that a read-only token cannot delete.
💡 Простими словами: oauth2(token) — це просто акуратний спосіб написати «Authorization: Bearer токен».
💡 In plain words: oauth2(token) is just a tidy way to write Authorization: Bearer token.
🗣 Як читати вголос Bearer-токен я передаю через auth().oauth2, це дає рівно той самий заголовок Authorization Bearer, що й ручний header, але без ризику помилитися з форматом. Ручний header беру для нестандартних схем, наприклад X-API-Key. Токен задаю в специфікації і беру зі змінних середовища, ніколи не хардкоджу. А навколо токена завжди є набір негативних тестів: без токена, прострочений, з недостатнім scope, чужий. Саме там найчастіше знаходяться критичні баги.
🗣 How to say it out loud I pass a Bearer token with auth().oauth2, which produces exactly the same Authorization Bearer header as a manual header call, without the risk of getting the format wrong. I use a manual header for non-standard schemes such as X-API-Key. I set the token in the spec and read it from environment variables, never hardcoding it. And around the token there is always a set of negative tests: no token, expired, insufficient scope, someone else's. That is where critical bugs most often hide.
41 Як отримати OAuth2-токен у тесті перед викликом API і де цей код має жити? How do you obtain an OAuth2 token in a test before calling the API, and where should that code live?
AuthAuth Middle ▶

Як отримати. Для сервісних тестів зазвичай використовують client credentials: POST /oauth/token з формою grant_type=client_credentials і scope, а client id і secret передаються через preemptive Basic або полями форми — залежно від сервера авторизації. Для тестів від імені користувача — password grant (якщо його ще дозволяє ваш провайдер) або спеціальний тестовий ендпоінт видачі токенів. Відповідь містить access_token, token_type і expires_in.

How to get it. Service tests usually use client credentials: POST /oauth/token with the form fields grant_type=client_credentials and scope, while the client id and secret travel either as preemptive Basic or as form fields, depending on the authorization server. For tests acting as a user there is the password grant (if your provider still allows it) or a dedicated test endpoint that issues tokens. The response contains access_token, token_type and expires_in.

Де має жити. Не в кожному тесті і не в @BeforeEach: запит на токен у кожному тесті множить навантаження на сервер авторизації, уповільнює прогін і може впертися в rate limit. Краще окремий клас AuthClient або TokenProvider, який повертає токен для ролі та кешує його до закінчення терміну дії. Специфікації отримують токен з нього.

Where it should live. Not in every test and not in @BeforeEach: a token request per test multiplies the load on the authorization server, slows the run and can hit a rate limit. Better a separate AuthClient or TokenProvider class that returns a token for a role and caches it until expiry. Specs get the token from it.

Що перевірити в самому флоу. Невірний secret — 401 і invalid_client; недозволений scope — 400 з invalid_scope; expires_in більше нуля; token_type — Bearer.

What to check in the flow itself. A wrong secret: 401 and invalid_client; a disallowed scope: 400 with invalid_scope; expires_in above zero; token_type is Bearer.

Альтернатива. Якщо тестується не авторизація, а бізнес-логіка, у деяких командах тестовий стенд приймає токени, підписані тестовим ключем, — тоді токен генерують локально бібліотекою JWT і не залежать від доступності IdP. Це прискорює тести, але вимагає окремого набору перевірок справжньої інтеграції з IdP.

Alternative. When the test is about business logic rather than auth, some teams make the test environment accept tokens signed with a test key; the token is then generated locally with a JWT library and the tests do not depend on the IdP being up. It speeds tests up, but requires a separate set of checks for the real IdP integration.

Клієнт авторизації з client credentials
An auth client using client credentials
public final class AuthClient {
    private static final RequestSpecification AUTH = new RequestSpecBuilder()
            .setBaseUri(Env.authUrl())
            .setContentType(ContentType.URLENC)
            .build();

    public static String clientToken(String clientId, String secret, String scope) {
        return given().spec(AUTH)
                .auth().preemptive().basic(clientId, secret)
                .formParam("grant_type", "client_credentials")
                .formParam("scope", scope)
            .when().post("/oauth/token")
            .then()
                .statusCode(200)
                .body("token_type", equalToIgnoringCase("bearer"))
                .body("expires_in", greaterThan(0))
            .extract().path("access_token");
    }
}

@Test
void wrongSecretIsRejected() {
    given().baseUri(Env.authUrl())
        .auth().preemptive().basic("orders-service", "wrong")
        .formParam("grant_type", "client_credentials")
    .when().post("/oauth/token")
    .then().statusCode(401).body("error", equalTo("invalid_client"));
}
🔧 Розбір коду: ContentType.URLENC — це application/x-www-form-urlencoded. Client id і secret ідуть як preemptive Basic, а решта параметрів — формою. Метод перевіряє статус і тип токена ще до того, як віддати його тестам, тож зламаний IdP проявиться одним зрозумілим падінням.
🔧 Code breakdown: ContentType.URLENC is application/x-www-form-urlencoded. The client id and secret go as preemptive Basic and the other parameters as a form. The method checks the status and token type before handing the token to tests, so a broken IdP shows up as one clear failure.
💡 Простими словами: Токен — це квиток: купуєш його один раз на касі (IdP) і показуєш на кожному вході (API).
💡 In plain words: A token is a ticket: you buy it once at the box office (the IdP) and show it at every door (the API).
🗣 Як читати вголос Для сервісних тестів я отримую токен через client credentials: POST на ендпоінт токена з grant_type у формі, а client id і secret — через preemptive basic або в полях форми, як вимагає сервер. Витягую access_token, попередньо перевіривши статус і expires_in. Цей код живе в окремому AuthClient, а не в кожному тесті, бо інакше я навантажую IdP і впираюсь у rate limit. Окремо тестую сам флоу: невірний secret і недозволений scope.
🗣 How to say it out loud For service tests I get a token with client credentials: a POST to the token endpoint with grant_type in the form, and the client id and secret as preemptive basic or as form fields, whichever the server expects. I extract access_token after checking the status and expires_in. This code lives in a separate AuthClient rather than in every test, otherwise I load the IdP and hit rate limits. I test the flow itself separately: a wrong secret and a disallowed scope.
42 Які ще схеми автентифікації підтримує REST Assured: Digest, OAuth 1, форма, сертифікат? Which other authentication schemes does REST Assured support: Digest, OAuth 1, form login, certificate?
AuthAuth Middle ▶

Digest. auth().digest(user, pass). Пароль не передається навіть у base64: сервер надсилає nonce, клієнт відповідає хешем. Digest завжди працює через challenge, preemptive-режиму для нього немає. Трапляється в старих корпоративних системах і мережевому обладнанні.

Digest. auth().digest(user, pass). The password is not sent even as base64: the server sends a nonce and the client answers with a hash. Digest always works through a challenge; there is no preemptive mode for it. You meet it in older corporate systems and network hardware.

OAuth 1.0. auth().oauth(consumerKey, consumerSecret, accessToken, tokenSecret) — кожен запит підписується HMAC. Для OAuth 1 у classpath має бути бібліотека Scribe (com.github.scribejava:scribejava-apis), без неї буде помилка під час виконання. Сьогодні трапляється рідко: старі API Twitter/X, деякі платіжні та облікові системи.

OAuth 1.0. auth().oauth(consumerKey, consumerSecret, accessToken, tokenSecret) — every request is signed with HMAC. OAuth 1 needs the Scribe library on the classpath (com.github.scribejava:scribejava-apis), otherwise you get a runtime error. It is rare today: old Twitter/X APIs and some payment and accounting systems.

Форма. auth().form(user, pass, new FormAuthConfig("/login", "username", "password")) — REST Assured надсилає форму входу, отримує сесійну cookie і використовує її далі. Корисно для застарілих вебзастосунків із сесією. Якщо форма має CSRF-токен, його треба передати окремо — у FormAuthConfig є налаштування для цього.

Form. auth().form(user, pass, new FormAuthConfig("/login", "username", "password")) — REST Assured submits the login form, receives the session cookie and uses it afterwards. Useful for legacy session-based web apps. If the form carries a CSRF token, it has to be passed as well, and FormAuthConfig has settings for that.

Сертифікат. auth().certificate("certs/client.p12", password) або keyStore(...) — mutual TLS, коли клієнт доводить свою особу сертифікатом. Типово для B2B-інтеграцій і банківських API.

Certificate. auth().certificate("certs/client.p12", password) or keyStore(...) — mutual TLS, where the client proves its identity with a certificate. Typical for B2B integrations and banking APIs.

Висновок. На співбесіді важливо не перелічити все, а пояснити, чим схеми відрізняються: що передається по мережі і чи потрібен попередній обмін (challenge).

Takeaway. In an interview the point is not to list everything but to explain how the schemes differ: what travels over the network and whether a preliminary exchange (a challenge) is needed.

Digest, OAuth 1, форма і сертифікат
Digest, OAuth 1, form and certificate
given().auth().digest("admin", secret)
.when().get("/router/status")
.then().statusCode(200);

given().auth().oauth(consumerKey, consumerSecret, accessToken, tokenSecret)   // потрібен Scribe
.when().get("/1.1/account/verify_credentials.json")
.then().statusCode(200);

given().auth().form("qa", secret, new FormAuthConfig("/login", "username", "password"))
.when().get("/admin/dashboard")
.then().statusCode(200);

given().auth().certificate("certs/qa-client.p12", System.getenv("CERT_PASSWORD"))
.when().get("https://bank.example.com/api/accounts")
.then().statusCode(200);
given().auth().digest("admin", secret)
.when().get("/router/status")
.then().statusCode(200);

given().auth().oauth(consumerKey, consumerSecret, accessToken, tokenSecret)   // needs Scribe
.when().get("/1.1/account/verify_credentials.json")
.then().statusCode(200);

given().auth().form("qa", secret, new FormAuthConfig("/login", "username", "password"))
.when().get("/admin/dashboard")
.then().statusCode(200);

given().auth().certificate("certs/qa-client.p12", System.getenv("CERT_PASSWORD"))
.when().get("https://bank.example.com/api/accounts")
.then().statusCode(200);
🔧 Розбір коду: Усі чотири схеми викликаються з того самого auth(). Для form() конфіг описує адресу форми та імена полів логіна й пароля. certificate() приймає шлях до сховища ключів і пароль, який тут береться зі змінної середовища.
🔧 Code breakdown: All four schemes hang off the same auth(). For form() the config describes the form address and the names of the login and password fields. certificate() takes a keystore path and a password, here read from an environment variable.
💡 Простими словами: Basic показує пароль, Digest показує лише хеш, OAuth 1 підписує кожен запит, сертифікат доводить особу без пароля.
💡 In plain words: Basic shows the password, Digest shows only a hash, OAuth 1 signs every request, a certificate proves identity with no password.
🗣 Як читати вголос Крім Basic і Bearer, REST Assured уміє Digest, де замість пароля летить хеш і завжди є challenge. Уміє OAuth 1, де кожен запит підписується, але для нього в classpath має бути Scribe. Є form-автентифікація, яка сама надсилає форму входу і далі тримає сесійну cookie, і сертифікатна — для mutual TLS у банківських та B2B API. На співбесіді я пояснюю не лише назви, а що саме передається по мережі в кожній схемі.
🗣 How to say it out loud Besides Basic and Bearer, REST Assured supports Digest, where a hash travels instead of the password and there is always a challenge. It supports OAuth 1, where every request is signed, but that needs Scribe on the classpath. There is form auth, which submits the login form and then keeps the session cookie, and certificate auth for mutual TLS in banking and B2B APIs. In an interview I explain not just the names but what actually goes over the wire in each scheme.
43 Як кешувати auth-токен через власний Filter, щоб тести не просили новий токен на кожен запит? How do you cache an auth token through a custom Filter so tests do not request a new token for every call?
AuthAuth Senior ▶

Навіщо. На великому наборі тести, що отримують токен у кожному @BeforeEach, роблять тисячі зайвих запитів до IdP: прогін повільніший, IdP упирається в rate limit, а флаки «401 на токені» маскуються під падіння бізнес-логіки. Токен живе, наприклад, 3600 секунд — його можна перевикористати.

Why. In a large suite, tests that fetch a token in every @BeforeEach make thousands of extra IdP calls: the run is slower, the IdP hits a rate limit, and flaky "401 on the token" failures masquerade as business logic failures. A token lives, say, 3600 seconds — it can be reused.

Архітектура. Два компоненти. TokenCache зберігає токен для кожного ключа (клієнт, роль чи користувач) разом із часом закінчення і оновлює його заздалегідь, із запасом 30–60 секунд. Кеш має бути потокобезпечним: ConcurrentHashMap.compute гарантує, що для одного ключа паралельні потоки не отримають одночасно десять токенів. BearerTokenFilter реалізує Filter: якщо в запиті ще немає Authorization, бере токен з кешу й додає заголовок, потім передає запит далі через ctx.next(). Фільтр додається в специфікацію ролі, тож тести про токени взагалі не знають.

Architecture. Two components. TokenCache stores a token per key (client, role or user) with its expiry time and refreshes it early, with a 30–60 second margin. The cache must be thread-safe: ConcurrentHashMap.compute ensures parallel threads do not fetch ten tokens for the same key at once. BearerTokenFilter implements Filter: if the request has no Authorization yet, it takes a token from the cache, adds the header and passes the request on through ctx.next(). The filter goes into the role's spec, so the tests do not know about tokens at all.

Трейдофи. Токени не можна ділити між тестами, які міняють стан сесії (logout, відкликання токена, зміна ролей) — такі тести мають отримувати власний токен поза кешем. Якщо сервер відкликає токени раніше за expires_in, кеш віддасть недійсний — тоді при 401 варто інвалідувати запис і повторити запит на рівні клієнта API. Кеш у пам'яті JVM не ділиться між форками Surefire, і це нормально. І найважливіше: негативні тести авторизації мають обходити фільтр, інакше він «виправить» їм відсутній заголовок.

Trade-offs. Tokens must not be shared with tests that change session state (logout, token revocation, role changes) — such tests should get their own token outside the cache. If the server revokes tokens before expires_in, the cache hands out an invalid one — then on a 401 invalidate the entry and retry at the API client level. An in-memory cache is not shared between Surefire forks, and that is fine. Most importantly, negative auth tests must bypass the filter, otherwise it "fixes" their missing header for them.

Потокобезпечний кеш і фільтр
A thread-safe cache and a filter
public final class TokenCache {
    private record Token(String value, Instant expiresAt) {
        boolean freshFor(Duration margin) { return Instant.now().plus(margin).isBefore(expiresAt); }
    }

    private static final Map<String, Token> TOKENS = new ConcurrentHashMap<>();

    public static String tokenFor(String clientId) {
        return TOKENS.compute(clientId, (id, t) ->
                t != null && t.freshFor(Duration.ofSeconds(60)) ? t : fetch(id)).value();
    }

    public static void invalidate(String clientId) { TOKENS.remove(clientId); }

    private static Token fetch(String clientId) {
        JsonPath body = given().baseUri(Env.authUrl())
                .auth().preemptive().basic(clientId, Env.secretFor(clientId))
                .formParam("grant_type", "client_credentials")
            .when().post("/oauth/token")
            .then().statusCode(200).extract().jsonPath();
        return new Token(body.getString("access_token"),
                Instant.now().plusSeconds(body.getLong("expires_in")));
    }
}

public final class BearerTokenFilter implements Filter {
    private final String clientId;
    public BearerTokenFilter(String clientId) { this.clientId = clientId; }

    @Override
    public Response filter(FilterableRequestSpecification req,
                           FilterableResponseSpecification res, FilterContext ctx) {
        if (!req.getHeaders().hasHeaderWithName("Authorization")) {
            req.header("Authorization", "Bearer " + TokenCache.tokenFor(clientId));
        }
        return ctx.next(req, res);
    }
}

// специфікація ролі
RequestSpecification ordersService = new RequestSpecBuilder()
        .addRequestSpecification(Specs.BASE)
        .addFilter(new BearerTokenFilter("orders-service"))
        .build();
public final class TokenCache {
    private record Token(String value, Instant expiresAt) {
        boolean freshFor(Duration margin) { return Instant.now().plus(margin).isBefore(expiresAt); }
    }

    private static final Map<String, Token> TOKENS = new ConcurrentHashMap<>();

    public static String tokenFor(String clientId) {
        return TOKENS.compute(clientId, (id, t) ->
                t != null && t.freshFor(Duration.ofSeconds(60)) ? t : fetch(id)).value();
    }

    public static void invalidate(String clientId) { TOKENS.remove(clientId); }

    private static Token fetch(String clientId) {
        JsonPath body = given().baseUri(Env.authUrl())
                .auth().preemptive().basic(clientId, Env.secretFor(clientId))
                .formParam("grant_type", "client_credentials")
            .when().post("/oauth/token")
            .then().statusCode(200).extract().jsonPath();
        return new Token(body.getString("access_token"),
                Instant.now().plusSeconds(body.getLong("expires_in")));
    }
}

public final class BearerTokenFilter implements Filter {
    private final String clientId;
    public BearerTokenFilter(String clientId) { this.clientId = clientId; }

    @Override
    public Response filter(FilterableRequestSpecification req,
                           FilterableResponseSpecification res, FilterContext ctx) {
        if (!req.getHeaders().hasHeaderWithName("Authorization")) {
            req.header("Authorization", "Bearer " + TokenCache.tokenFor(clientId));
        }
        return ctx.next(req, res);
    }
}

// role spec
RequestSpecification ordersService = new RequestSpecBuilder()
        .addRequestSpecification(Specs.BASE)
        .addFilter(new BearerTokenFilter("orders-service"))
        .build();
🔧 Розбір коду: compute блокує лише ключ clientId, тож перший потік отримує токен, а решта чекають і беруть готовий. freshFor оновлює токен за хвилину до закінчення, щоб він не протух посеред запиту. Фільтр не перезаписує вже заданий Authorization, тому окремий тест може передати власний токен.
🔧 Code breakdown: compute locks only the clientId key, so the first thread fetches the token and the rest wait and take the ready one. freshFor refreshes the token a minute before expiry so it does not go stale mid-request. The filter never overwrites an Authorization that is already set, so a specific test can pass its own token.
💡 Простими словами: Кеш токенів — як абонемент замість разового квитка: купив раз і ходиш, доки не закінчиться.
💡 In plain words: A token cache is a season pass instead of single tickets: buy once and use it until it expires.
🗣 Як читати вголос Я роблю два компоненти. TokenCache тримає токен на кожен client id разом із часом закінчення і оновлює його з запасом у хвилину, а ConcurrentHashMap.compute гарантує, що паралельні потоки не наберуть десять токенів одночасно. BearerTokenFilter додає заголовок, якщо його ще немає, і викликає ctx.next. Фільтр підключаю в специфікацію ролі, тож тести про токени не знають. Але тести на logout, відкликання і негативну авторизацію свідомо обходять кеш.
🗣 How to say it out loud I build two components. TokenCache keeps a token per client id with its expiry and refreshes it a minute early, and ConcurrentHashMap.compute makes sure parallel threads do not grab ten tokens at the same time. BearerTokenFilter adds the header if it is not there yet and calls ctx.next. I plug the filter into the role spec, so the tests know nothing about tokens. But tests for logout, revocation and negative auth deliberately bypass the cache.
▶ 7 5
44 Як логувати запит і відповідь у REST Assured і чому в CI краще log().ifValidationFails()? How do you log the request and response in REST Assured, and why is log().ifValidationFails() better in CI?
Логи і фільтриLogs and filters Junior ▶

Запит. given().log().all() друкує метод, URI, параметри, заголовки, cookies і тіло. Можна вибірково: log().uri(), log().method(), log().headers(), log().params(), log().body().

Request. given().log().all() prints the method, URI, params, headers, cookies and body. You can be selective: log().uri(), log().method(), log().headers(), log().params(), log().body().

Відповідь. then().log().all() — статус, заголовки й тіло; log().body(), log().status(), log().headers() вибірково. Умовні варіанти: log().ifError() для статусів 4xx і 5xx, log().ifStatusCodeIsEqualTo(500), log().ifValidationFails().

Response. then().log().all() prints status, headers and body; log().body(), log().status() and log().headers() print parts. Conditional variants: log().ifError() for 4xx and 5xx statuses, log().ifStatusCodeIsEqualTo(500), log().ifValidationFails().

Чому ifValidationFails. Лог усіх запитів у CI — це мегабайти тексту, в яких губиться єдиний корисний фрагмент. ifValidationFails() пише запит і відповідь лише тоді, коли перевірка в then() не пройшла, — саме тоді, коли вони потрібні для аналізу. Щоб увімкнути це для всіх тестів одним рядком: RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(). Для запиту given().log().ifValidationFails() теж існує.

Why ifValidationFails. Logging every request in CI produces megabytes of text in which the one useful fragment gets lost. ifValidationFails() writes the request and response only when a check in then() failed — exactly when you need them for analysis. To switch it on for every test in one line: RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(). There is also given().log().ifValidationFails() for the request.

Швидкий перегляд. response.prettyPrint() друкує відформатоване тіло й повертає його рядком; prettyPeek() друкує все і повертає Response, тож ланцюжок не переривається. Для налагодження зручно, але в закомічений тест це не варто лишати.

Quick look. response.prettyPrint() prints the formatted body and returns it as a string; prettyPeek() prints everything and returns the Response, so the chain continues. Handy for debugging, but it should not stay in a committed test.

Безпека. Лог містить заголовок Authorization і cookies. LogConfig.logConfig().blacklistHeader("Authorization", "Cookie") замінює їх значення зірочками.

Security. The log contains the Authorization header and cookies. LogConfig.logConfig().blacklistHeader("Authorization", "Cookie") replaces their values with asterisks.

Логування тільки при падінні і приховані заголовки
Logging only on failure and hidden headers
@BeforeAll
static void logging() {
    RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(LogDetail.ALL);
    RestAssured.config = RestAssured.config().logConfig(LogConfig.logConfig()
            .enablePrettyPrinting(true)
            .blacklistHeader("Authorization", "Cookie"));
}

@Test
void createsOrder() {
    given().spec(Specs.ORDERS_SERVICE)
        .log().ifValidationFails()
        .body(Orders.validOrder().build())
    .when().post("/orders")
    .then()
        .log().ifValidationFails()
        .statusCode(201);
}

// Під час налагодження, не для коміту
when().get("/orders/42").prettyPeek().then().statusCode(200);
@BeforeAll
static void logging() {
    RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(LogDetail.ALL);
    RestAssured.config = RestAssured.config().logConfig(LogConfig.logConfig()
            .enablePrettyPrinting(true)
            .blacklistHeader("Authorization", "Cookie"));
}

@Test
void createsOrder() {
    given().spec(Specs.ORDERS_SERVICE)
        .log().ifValidationFails()
        .body(Orders.validOrder().build())
    .when().post("/orders")
    .then()
        .log().ifValidationFails()
        .statusCode(201);
}

// While debugging, not for a commit
when().get("/orders/42").prettyPeek().then().statusCode(200);
🔧 Розбір коду: LogDetail.ALL визначає, що саме друкувати при падінні. blacklistHeader замінює значення заголовків на [ BLACKLISTED ] у логах. У тесті log().ifValidationFails() стоїть і в given(), і в then() — перший відповідає за запит, другий за відповідь.
🔧 Code breakdown: LogDetail.ALL sets what exactly is printed on failure. blacklistHeader replaces header values with [ BLACKLISTED ] in the logs. In the test log().ifValidationFails() appears in both given() and then() — the first covers the request, the second the response.
💡 Простими словами: Лог усього — це шум; лог при падінні — це доказ.
💡 In plain words: Logging everything is noise; logging on failure is evidence.
🗣 Як читати вголос Запит я логую через given().log(), відповідь — через then().log(), і можу обрати все або лише тіло, заголовки чи статус. У CI вмикаю глобально enableLoggingOfRequestAndResponseIfValidationFails, щоб запит і відповідь з'являлися лише для тестів, що впали, — інакше корисне губиться в мегабайтах. PrettyPrint і prettyPeek використовую тільки під час налагодження. І через LogConfig ховаю Authorization і Cookie, щоб токени не потрапили в артефакти CI.
🗣 How to say it out loud I log the request with given().log() and the response with then().log(), choosing everything or just the body, headers or status. In CI I switch on enableLoggingOfRequestAndResponseIfValidationFails globally, so the request and response show up only for failed tests — otherwise the useful part gets lost in megabytes. I use prettyPrint and prettyPeek only while debugging. And through LogConfig I hide Authorization and Cookie so tokens do not end up in CI artifacts.
45 Що таке фільтри в REST Assured, як працюють RequestLoggingFilter і ResponseLoggingFilter і як підключити фільтр глобально? What are filters in REST Assured, how do RequestLoggingFilter and ResponseLoggingFilter work, and how do you add a filter globally?
Логи і фільтриLogs and filters Middle ▶

Що це. Фільтр — перехоплювач навколо кожного запиту: він бачить запит до відправлення і відповідь після, може їх змінити, записати або навіть не відправляти запит і повернути свою відповідь. Фільтри утворюють ланцюжок: кожен викликає ctx.next(...), щоб передати керування наступному, і останній виконує реальний HTTP-виклик. Логування через log() теж реалізоване фільтрами під капотом.

What it is. A filter is an interceptor around every request: it sees the request before it is sent and the response afterwards, can change them, record them, or even skip sending and return its own response. Filters form a chain: each one calls ctx.next(...) to pass control on, and the last one performs the real HTTP call. Logging via log() is implemented with filters under the hood as well.

Вбудовані фільтри. RequestLoggingFilter логує лише запит, ResponseLoggingFilter — лише відповідь, ErrorLoggingFilter — відповіді з кодом 4xx і 5xx. Кожен приймає LogDetail і PrintStream, тож лог можна писати у файл, а не в консоль. Є також SessionFilter, CookieFilter і фільтри інтеграцій (Allure, Serenity).

Built-in filters. RequestLoggingFilter logs only the request, ResponseLoggingFilter only the response, ErrorLoggingFilter responses with 4xx and 5xx codes. Each takes a LogDetail and a PrintStream, so the log can go to a file instead of the console. There are also SessionFilter, CookieFilter and integration filters (Allure, Serenity).

Підключення. На запит — given().filter(f) або filters(f1, f2); у специфікації — addFilter(); глобально — RestAssured.filters(f1, f2), що додає фільтри до кожного запиту в JVM. RestAssured.replaceFiltersWith(...) замінює список, а reset() очищує.

Adding them. Per request: given().filter(f) or filters(f1, f2); in a spec: addFilter(); globally: RestAssured.filters(f1, f2), which adds the filters to every request in the JVM. RestAssured.replaceFiltersWith(...) replaces the list, and reset() clears it.

Пастки. Глобальні фільтри додаються при кожному виклику filters(): якщо робити це в @BeforeEach, лог дублюється десятки разів. Порядок має значення: фільтри виконуються в порядку додавання, а для явного порядку є інтерфейс OrderedFilter з методом getOrder().

Pitfalls. Global filters are added on every filters() call: doing it in @BeforeEach duplicates the log dozens of times. Order matters: filters run in the order they were added, and for an explicit order there is the OrderedFilter interface with a getOrder() method.

Логи у файл через глобальні фільтри
Logs to a file through global filters
@BeforeAll
static void httpLog() throws FileNotFoundException {
    PrintStream http = new PrintStream(new FileOutputStream("target/http.log", true), true);
    RestAssured.filters(
            new RequestLoggingFilter(LogDetail.URI, http),
            new ResponseLoggingFilter(LogDetail.STATUS, http),
            new ErrorLoggingFilter(http));                  // повне тіло лише для 4xx/5xx
}

@Test
void singleRequestWithItsOwnFilter() {
    given().filter(new RequestLoggingFilter(LogDetail.BODY))
        .body(Map.of("sku", "A-1"))
    .when().post("/cart/items")
    .then().statusCode(201);
}
@BeforeAll
static void httpLog() throws FileNotFoundException {
    PrintStream http = new PrintStream(new FileOutputStream("target/http.log", true), true);
    RestAssured.filters(
            new RequestLoggingFilter(LogDetail.URI, http),
            new ResponseLoggingFilter(LogDetail.STATUS, http),
            new ErrorLoggingFilter(http));                  // full body only for 4xx/5xx
}

@Test
void singleRequestWithItsOwnFilter() {
    given().filter(new RequestLoggingFilter(LogDetail.BODY))
        .body(Map.of("sku", "A-1"))
    .when().post("/cart/items")
    .then().statusCode(201);
}
🔧 Розбір коду: new FileOutputStream(..., true) дописує в кінець файлу, другий true у PrintStream вмикає автоматичне скидання буфера. Для всіх запитів у файл потрапляють лише URI й статус, а для помилок — повна відповідь. Другий тест додає фільтр лише до одного запиту.
🔧 Code breakdown: new FileOutputStream(..., true) appends to the file, and the second true in PrintStream turns on auto-flush. For every request only the URI and status go into the file, and for errors the full response. The second test adds a filter to one request only.
💡 Простими словами: Фільтр — це «митниця», через яку проходить кожен запит і кожна відповідь.
💡 In plain words: A filter is a customs checkpoint every request and response passes through.
🗣 Як читати вголос Фільтр у REST Assured — це перехоплювач навколо кожного запиту: бачить запит до відправлення, відповідь після і може їх змінити або записати. Фільтри виконуються ланцюжком через ctx.next. Вбудовані — RequestLoggingFilter для запиту, ResponseLoggingFilter для відповіді, ErrorLoggingFilter для помилок, і всі можуть писати в будь-який PrintStream. Підключаю на запит через filter, у специфікації через addFilter, глобально через RestAssured.filters, але лише один раз, інакше логи дублюються.
🗣 How to say it out loud A filter in REST Assured is an interceptor around every request: it sees the request before it goes out and the response afterwards, and can change or record them. Filters run as a chain through ctx.next. The built-in ones are RequestLoggingFilter for the request, ResponseLoggingFilter for the response and ErrorLoggingFilter for errors, and all of them can write to any PrintStream. I add them per request with filter, in a spec with addFilter, or globally with RestAssured.filters, but only once, otherwise the logs get duplicated.
46 Як написати власний Filter, наприклад для correlation id і вимірювання часу запиту? How do you write your own Filter, for example for a correlation id and request timing?
Логи і фільтриLogs and filters Middle ▶

Контракт. Інтерфейс io.restassured.filter.Filter має один метод: Response filter(FilterableRequestSpecification req, FilterableResponseSpecification res, FilterContext ctx). Усе, що до виклику ctx.next(req, res), стосується запиту; те, що після, — відповіді. Фільтр зобов'язаний повернути Response: або результат ctx.next, або власний.

The contract. The io.restassured.filter.Filter interface has one method: Response filter(FilterableRequestSpecification req, FilterableResponseSpecification res, FilterContext ctx). Everything before the ctx.next(req, res) call concerns the request; everything after concerns the response. The filter must return a Response: either the result of ctx.next or its own.

Що можна зробити. Через FilterableRequestSpecification — прочитати і змінити запит: getMethod(), getURI(), getHeaders(), header(), getBody(), queryParam(). Після ctx.next — прочитати статус, заголовки, тіло, час.

What you can do. Through FilterableRequestSpecification you read and change the request: getMethod(), getURI(), getHeaders(), header(), getBody(), queryParam(). After ctx.next you read the status, headers, body and time.

Корисні власні фільтри. Correlation id: додати X-Correlation-Id і записати його в лог, щоб за падінням тесту знайти запит у логах сервісу. Вимірювання часу й запис повільних викликів. Маскування секретів у власному лозі. Підпис запиту (HMAC) для API партнерів. Збирання покриття: які ендпоінти й методи тести взагалі викликали.

Useful custom filters. Correlation id: add X-Correlation-Id and write it to the log, so a failing test leads you to the request in the service logs. Timing and recording slow calls. Masking secrets in your own log. Request signing (HMAC) for partner APIs. Coverage collection: which endpoints and methods the tests actually called.

Пастки. Не читай тіло відповіді потоком, не зберігши його: response.asString() кешує тіло, а ручне читання asInputStream() може «з'їсти» його для наступних перевірок. Поля фільтра — спільний стан: якщо фільтр глобальний, а поля змінюються, у паралельному прогоні потрібна потокобезпечність. І не кидай винятки з фільтра без потреби — стек трейс вкаже на фільтр, а не на тест.

Pitfalls. Do not read the response body as a stream without keeping it: response.asString() caches the body, while reading asInputStream() by hand can consume it for later checks. Filter fields are shared state: if the filter is global and its fields change, a parallel run needs thread safety. And do not throw exceptions from a filter unless you must — the stack trace will point at the filter, not the test.

Фільтр correlation id і часу
A correlation id and timing filter
public final class TraceFilter implements Filter {
    private static final Logger LOG = LoggerFactory.getLogger(TraceFilter.class);
    private final Duration slow;

    public TraceFilter(Duration slow) { this.slow = slow; }

    @Override
    public Response filter(FilterableRequestSpecification req,
                           FilterableResponseSpecification res, FilterContext ctx) {
        String traceId = UUID.randomUUID().toString();
        req.header("X-Correlation-Id", traceId);

        long start = System.nanoTime();
        Response response = ctx.next(req, res);
        long ms = Duration.ofNanos(System.nanoTime() - start).toMillis();

        LOG.info("{} {} -> {} in {} ms [{}]", req.getMethod(), req.getURI(), response.statusCode(), ms, traceId);
        if (ms > slow.toMillis()) {
            LOG.warn("slow call: {} {} took {} ms [{}]", req.getMethod(), req.getURI(), ms, traceId);
        }
        return response;
    }
}

// один раз на прогін
RestAssured.filters(new TraceFilter(Duration.ofSeconds(2)));
public final class TraceFilter implements Filter {
    private static final Logger LOG = LoggerFactory.getLogger(TraceFilter.class);
    private final Duration slow;

    public TraceFilter(Duration slow) { this.slow = slow; }

    @Override
    public Response filter(FilterableRequestSpecification req,
                           FilterableResponseSpecification res, FilterContext ctx) {
        String traceId = UUID.randomUUID().toString();
        req.header("X-Correlation-Id", traceId);

        long start = System.nanoTime();
        Response response = ctx.next(req, res);
        long ms = Duration.ofNanos(System.nanoTime() - start).toMillis();

        LOG.info("{} {} -> {} in {} ms [{}]", req.getMethod(), req.getURI(), response.statusCode(), ms, traceId);
        if (ms > slow.toMillis()) {
            LOG.warn("slow call: {} {} took {} ms [{}]", req.getMethod(), req.getURI(), ms, traceId);
        }
        return response;
    }
}

// once per run
RestAssured.filters(new TraceFilter(Duration.ofSeconds(2)));
🔧 Розбір коду: Заголовок додається до ctx.next, тому сервер отримує його в запиті. Час вимірюється навколо ctx.next, тобто разом із мережею. Поле slow незмінне після створення, тож один екземпляр безпечно ділити між потоками. Фільтр повертає ту саму відповідь, не змінюючи її.
🔧 Code breakdown: The header is added before ctx.next, so the server receives it with the request. Time is measured around ctx.next, network included. The slow field never changes after construction, so one instance is safe to share across threads. The filter returns the same response untouched.
💡 Простими словами: Власний фільтр — найкраще місце для всього, що має статися з кожним запитом, але не повинно засмічувати тести.
💡 In plain words: A custom filter is the best place for anything that must happen to every request without cluttering the tests.
🗣 Як читати вголос Власний фільтр — це клас, що реалізує Filter з одним методом filter. Усе до ctx.next — це робота із запитом, усе після — з відповіддю, і метод обов'язково повертає Response. Я так додаю X-Correlation-Id і пишу його в лог, щоб за падінням тесту знайти запит у логах сервісу, а ще міряю час і позначаю повільні виклики. Фільтри роблю без змінного стану або потокобезпечними, бо глобальний фільтр працює з усіма потоками одночасно.
🗣 How to say it out loud A custom filter is a class implementing Filter, with a single filter method. Everything before ctx.next deals with the request, everything after with the response, and the method must return a Response. That is how I add an X-Correlation-Id and log it, so a failing test leads me to the request in the service logs, and I also time calls and flag slow ones. I keep filters free of mutable state or make them thread-safe, because a global filter serves every thread at once.
47 Як підключити AllureRestAssured, що він кладе у звіт і як зробити API-звіт корисним, а не просто великим? How do you plug in AllureRestAssured, what does it put into the report, and how do you make an API report useful rather than just big?
Логи і фільтриLogs and filters Senior ▶

Підключення. Залежність io.qameta.allure:allure-rest-assured. Далі два рівноцінні способи: на запит або в специфікації — given().filter(new AllureRestAssured()) чи addFilter(...); глобально — RestAssured.filters(new AllureRestAssured()) один раз на прогін. Фільтр для кожного HTTP-виклику створює в поточному кроці Allure два вкладення: запит (метод, URL, заголовки, cookies, тіло, cURL-команда) і відповідь (статус, заголовки, тіло).

Setup. The dependency is io.qameta.allure:allure-rest-assured. Then there are two equivalent ways: per request or in a spec — given().filter(new AllureRestAssured()) or addFilter(...); globally — RestAssured.filters(new AllureRestAssured()) once per run. For every HTTP call the filter creates two attachments in the current Allure step: the request (method, URL, headers, cookies, body, a cURL command) and the response (status, headers, body).

Налаштування. setRequestAttachmentName() і setResponseAttachmentName() змінюють назви вкладень, setRequestTemplate() і setResponseTemplate() — FreeMarker-шаблони, за якими вкладення рендеряться. Власний шаблон — нормальний спосіб прибрати з вкладення чутливі заголовки чи обрізати гігантські тіла.

Configuration. setRequestAttachmentName() and setResponseAttachmentName() rename the attachments, and setRequestTemplate() and setResponseTemplate() set the FreeMarker templates used to render them. A custom template is a proper way to strip sensitive headers from an attachment or to truncate huge bodies.

Як зробити звіт корисним. Сирі HTTP-виклики без контексту — це просто довгий лог. Корисний звіт виникає, коли методи шару API-клієнта позначені @Step("Create order for {customer}"): тоді кожне вкладення лежить під бізнес-кроком, і менеджер бачить «Створити замовлення → Оплатити → Перевірити статус», а інженер відкриває крок і бачить запит і cURL для відтворення. Додай @Epic, @Feature, посилання на тікети й ендпоінти, щоб звіт фільтрувався.

Making the report useful. Raw HTTP calls without context are just a long log. A useful report appears when the API client methods carry @Step("Create order for {customer}"): then each attachment sits under a business step, a manager sees "Create order → Pay → Check status", and an engineer opens the step to find the request and a cURL to reproduce it. Add @Epic, @Feature, and links to tickets and endpoints so the report can be filtered.

Ризики. Вкладення містять токени й персональні дані, а звіти Allure часто публікуються в CI відкрито — потрібні короткоживучі тестові облікові дані, маскування в шаблоні й обмежений доступ до звіту. Великі тіла та бінарні відповіді роздувають звіт до гігабайтів — обрізай їх або не прикладай для download-ендпоінтів. Разом з log().ifValidationFails() фільтр дає дублювання: обери одне джерело правди для CI.

Risks. Attachments contain tokens and personal data, and Allure reports are often published openly in CI — you need short-lived test credentials, masking in the template and restricted access to the report. Large bodies and binary responses bloat the report to gigabytes — truncate them or skip attachments for download endpoints. Combined with log().ifValidationFails() the filter duplicates output: choose one source of truth for CI.

Фільтр у специфікації і кроки в API-клієнті
The filter in a spec and steps in the API client
public final class Specs {
    private static final AllureRestAssured ALLURE = new AllureRestAssured()
            .setRequestAttachmentName("HTTP request")
            .setResponseAttachmentName("HTTP response")
            .setRequestTemplate("http-request-masked.ftl");     // без Authorization

    public static final RequestSpecification BASE = new RequestSpecBuilder()
            .setBaseUri(Env.apiUrl())
            .setContentType(ContentType.JSON)
            .addFilter(ALLURE)
            .build();
}

public class OrdersClient {
    @Step("Create order for customer {request.customerId}")
    public Response create(OrderRequest request) {
        return given().spec(Specs.BASE).body(request).when().post("/orders");
    }

    @Step("Pay order {orderId}")
    public Response pay(long orderId) {
        return given().spec(Specs.BASE).when().post("/orders/{id}/payment", orderId);
    }
}
public final class Specs {
    private static final AllureRestAssured ALLURE = new AllureRestAssured()
            .setRequestAttachmentName("HTTP request")
            .setResponseAttachmentName("HTTP response")
            .setRequestTemplate("http-request-masked.ftl");     // without Authorization

    public static final RequestSpecification BASE = new RequestSpecBuilder()
            .setBaseUri(Env.apiUrl())
            .setContentType(ContentType.JSON)
            .addFilter(ALLURE)
            .build();
}

public class OrdersClient {
    @Step("Create order for customer {request.customerId}")
    public Response create(OrderRequest request) {
        return given().spec(Specs.BASE).body(request).when().post("/orders");
    }

    @Step("Pay order {orderId}")
    public Response pay(long orderId) {
        return given().spec(Specs.BASE).when().post("/orders/{id}/payment", orderId);
    }
}
🔧 Розбір коду: Один екземпляр AllureRestAssured ділиться всіма запитами специфікації. Шаблон http-request-masked.ftl — копія стандартного в src/test/resources/tpl/ без заголовків авторизації. @Step на методах клієнта групує HTTP-вкладення під зрозумілою назвою, а {request.customerId} підставляє поле аргументу в назву кроку.
🔧 Code breakdown: One AllureRestAssured instance is shared by every request of the spec. The http-request-masked.ftl template is a copy of the standard one in src/test/resources/tpl/ without the auth headers. @Step on client methods groups HTTP attachments under a readable name, and {request.customerId} puts an argument field into the step name.
💡 Простими словами: AllureRestAssured дає сирі запити, а @Step дає їм сенс — разом виходить звіт, який читає не лише автор тестів.
💡 In plain words: AllureRestAssured gives you raw requests and @Step gives them meaning — together they make a report people other than the test author can read.
🗣 Як читати вголос AllureRestAssured — це фільтр, який для кожного виклику кладе в поточний крок Allure запит із cURL і відповідь. Я підключаю його в базову специфікацію, а не в кожен тест. Але сам по собі він дає лише довгий лог, тому методи API-клієнта позначаю через @Step, і тоді звіт читається як бізнес-сценарій, а технічні деталі лежать всередині кроку. Окремо думаю про ризики: токени й персональні дані в публічному звіті, тому власний шаблон з маскуванням, і гігабайтні тіла, які треба обрізати.
🗣 How to say it out loud AllureRestAssured is a filter that puts the request with a cURL and the response into the current Allure step for every call. I plug it into the base spec rather than into each test. On its own it only gives a long log, so I mark the API client methods with @Step, and then the report reads like a business scenario with the technical details inside each step. I also think about the risks: tokens and personal data in a public report, hence a custom masking template, and gigabyte bodies that need truncating.
48 Що таке SerenityRest і модулі spring-mock-mvc та spring-web-test-client і коли їх обирати? What are SerenityRest and the spring-mock-mvc and spring-web-test-client modules, and when do you choose them?
Логи і фільтриLogs and filters Middle ▶

SerenityRest. Обгортка REST Assured з Serenity BDD (net.serenitybdd:serenity-rest-assured). Синтаксис той самий — SerenityRest.given() або статичний імпорт, — але кожен запит автоматично записується у звіт Serenity поруч із кроками сценарію. Є SerenityRest.lastResponse() для доступу до останньої відповіді з кроків. Обирають, коли проєкт уже живе на Serenity і Screenplay; окремо тягнути Serenity лише заради звіту немає сенсу — для цього простіше Allure.

SerenityRest. A REST Assured wrapper from Serenity BDD (net.serenitybdd:serenity-rest-assured). The syntax is the same — SerenityRest.given() or a static import — but every request is recorded automatically in the Serenity report next to the scenario steps. SerenityRest.lastResponse() gives access to the latest response from within steps. Teams choose it when the project already runs on Serenity and Screenplay; pulling in Serenity just for the report makes little sense — Allure is simpler for that.

spring-mock-mvc. Модуль io.rest-assured:spring-mock-mvc, пакет io.restassured.module.mockmvc, клас RestAssuredMockMvc. Він дає DSL REST Assured поверх Spring MockMvc: контролери викликаються без запуску HTTP-сервера, прямо в JVM. Це швидко і зручно для тестів рівня компонента, які пишуть розробники, — можна підставити моки сервісів через standaloneSetup(controller) або весь контекст через webAppContextSetup.

spring-mock-mvc. The module io.rest-assured:spring-mock-mvc, package io.restassured.module.mockmvc, class RestAssuredMockMvc. It puts the REST Assured DSL on top of Spring MockMvc: controllers are invoked without starting an HTTP server, right inside the JVM. It is fast and convenient for component-level tests written by developers — you can plug in service mocks via standaloneSetup(controller) or the whole context via webAppContextSetup.

spring-web-test-client. Модуль io.rest-assured:spring-web-test-client, клас RestAssuredWebTestClient — те саме для реактивного Spring WebFlux поверх WebTestClient.

spring-web-test-client. The module io.rest-assured:spring-web-test-client, class RestAssuredWebTestClient — the same for reactive Spring WebFlux on top of WebTestClient.

Межа застосування. MockMvc не проходить через реальний сервлет-контейнер і мережу: фільтри безпеки, серіалізацію на рівні контейнера, проксі, TLS і таймаути такі тести не перевірять. Тому це доповнення до тестів проти розгорнутого сервісу, а не заміна.

Limits. MockMvc does not go through a real servlet container or network: such tests will not check container-level security filters and serialization, proxies, TLS or timeouts. So they complement tests against a deployed service rather than replace them.

Тест контролера через RestAssuredMockMvc
A controller test through RestAssuredMockMvc
import static io.restassured.module.mockmvc.RestAssuredMockMvc.given;
import static org.hamcrest.Matchers.equalTo;

class UserControllerTest {

    private final UserService service = Mockito.mock(UserService.class);

    @Test
    void returnsUserFromService() {
        Mockito.when(service.find(7L)).thenReturn(new User(7L, "Olena"));

        given()
            .standaloneSetup(new UserController(service))
        .when()
            .get("/users/{id}", 7)
        .then()
            .statusCode(200)
            .body("name", equalTo("Olena"));
    }
}
🔧 Розбір коду: Статичний given тут імпортується з RestAssuredMockMvc, а не з RestAssured — змішувати ці два імпорти в одному класі не можна. standaloneSetup створює MockMvc лише з одним контролером, куди передано мок сервісу, тож тест ізольований від бази й мережі.
🔧 Code breakdown: The static given here comes from RestAssuredMockMvc, not from RestAssured — you cannot mix both imports in one class. standaloneSetup builds a MockMvc with just this controller and its mocked service, so the test is isolated from the database and the network.
💡 Простими словами: MockMvc-модуль — той самий синтаксис REST Assured, але без сервера: швидко, але не все.
💡 In plain words: The MockMvc module is the same REST Assured syntax without a server: fast, but not everything.
🗣 Як читати вголос SerenityRest — це REST Assured, який сам записує запити у звіт Serenity, його беру лише якщо проєкт уже на Serenity. Модуль spring-mock-mvc дає синтаксис REST Assured поверх MockMvc: контролер викликається без HTTP-сервера, тож тести дуже швидкі й зручні для розробників. Для WebFlux є аналог на WebTestClient. Але я чітко кажу про межу: такі тести не бачать реального контейнера, фільтрів безпеки, мережі й TLS, тому вони доповнюють тести проти розгорнутого сервісу.
🗣 How to say it out loud SerenityRest is REST Assured that records requests into the Serenity report on its own, and I only choose it if the project already uses Serenity. The spring-mock-mvc module puts the REST Assured syntax on top of MockMvc: the controller is called without an HTTP server, so tests are very fast and handy for developers. WebFlux has a counterpart on WebTestClient. But I state the limit clearly: these tests do not see the real container, security filters, network or TLS, so they complement tests against a deployed service.
▶ 8 6
49 Як будувати негативні API-тести і які статус-коди в них очікувати? How do you design negative API tests, and which status codes should they expect?
Дизайн тестівTest design Middle ▶

Навіщо. Щасливий шлях зазвичай перевіряють розробники. Основна цінність QA в API — у тому, як сервіс поводиться з неправильним входом: чи не падає він з 500, чи не розкриває стек трейс, чи не змінює дані, коли мав відмовити.

Why. Developers usually check the happy path. QA's main value in API testing is how the service handles bad input: does it avoid a 500, does it hide stack traces, does it leave data untouched when it should refuse.

Класи негативних сценаріїв і очікувані коди. Синтаксично зламаний JSON — 400. Відсутнє обов'язкове поле, неправильний тип, значення поза межами, невалідний формат — 400 або 422 (залежить від домовленості API). Без автентифікації чи з простроченим токеном — 401. Автентифікований, але без прав, або чужий ресурс — 403 (або 404, якщо API ховає існування). Неіснуючий ресурс — 404; неправильний метод — 405; непідтримуваний Content-Type — 415; непідтримуваний Accept — 406. Конфлікт стану (дубль email, оплата вже оплаченого) — 409. Перевищення ліміту — 429 з Retry-After; завелике тіло — 413.

Classes of negative scenarios and expected codes. Syntactically broken JSON: 400. A missing required field, a wrong type, an out-of-range value, an invalid format: 400 or 422 (depends on the API's convention). No authentication or an expired token: 401. Authenticated but without rights, or someone else's resource: 403 (or 404 if the API hides existence). A missing resource: 404; a wrong method: 405; an unsupported Content-Type: 415; an unsupported Accept: 406. A state conflict (duplicate email, paying an order that is already paid): 409. Rate limit exceeded: 429 with Retry-After; a body that is too large: 413.

Що перевіряти, крім коду. Формат тіла помилки (код, повідомлення, поле) за єдиним контрактом; відсутність стек трейсу і внутрішніх деталей; що стан не змінився — повторний GET після відхиленого запиту. Саме останнє часто пропускають: сервер повертає 400, але запис уже частково створено.

What to check besides the code. The error body format (code, message, field) against a single contract; no stack trace or internal details; and that state did not change — a follow-up GET after the rejected request. That last one is often skipped: the server returns 400, yet the record was already partly created.

Техніка. Параметризовані тести з JUnit 5 (@MethodSource) і фабрика валідного об'єкта, де кожен рядок псує одне поле, покривають десятки кейсів без копіпасти. Межі беремо з класів еквівалентності й граничних значень.

Technique. Parameterized JUnit 5 tests (@MethodSource) plus a valid-object factory where each row breaks one field cover dozens of cases without copy-paste. Boundaries come from equivalence classes and boundary values.

Параметризовані негативні кейси з перевіркою незмінності стану
Parameterized negative cases with a check that state is unchanged
static Stream<Arguments> invalidUsers() {
    return Stream.of(
        Arguments.of("missing email", Users.valid().email(null).build(), "email"),
        Arguments.of("bad email",     Users.valid().email("no-at-sign").build(), "email"),
        Arguments.of("age too low",   Users.valid().age(17).build(), "age"),
        Arguments.of("age too high",  Users.valid().age(151).build(), "age"),
        Arguments.of("name too long", Users.valid().name("x".repeat(256)).build(), "name"));
}

@ParameterizedTest(name = "{0}")
@MethodSource("invalidUsers")
void rejectsInvalidUser(String caseName, UserRequest body, String field) {
    int before = countUsers();

    given().spec(Specs.BASE).body(body)
    .when().post("/users")
    .then()
        .statusCode(422)
        .body("code", equalTo("VALIDATION_FAILED"))
        .body("errors.field", hasItem(field))
        .body(not(containsString("Exception")));

    assertEquals(before, countUsers(), "nothing must be created");
}

@Test
void unsupportedContentTypeIs415() {
    given().spec(Specs.BASE).contentType(ContentType.XML).body("<user/>")
    .when().post("/users")
    .then().statusCode(415);
}
🔧 Розбір коду: Кожен рядок invalidUsers псує рівно одне поле валідного об'єкта. name = "{0}" робить назву кейсу у звіті читабельною. body(not(containsString("Exception"))) ловить стек трейс у відповіді. countUsers() — хелпер, що доводить: відхилений запит нічого не створив.
🔧 Code breakdown: Each row of invalidUsers breaks exactly one field of a valid object. name = "{0}" makes the case name readable in the report. body(not(containsString("Exception"))) catches a stack trace in the response. countUsers() is a helper proving the rejected request created nothing.
💡 Простими словами: Негативний тест доводить три речі: правильний код, зрозуміла помилка і нічого не зламалось у даних.
💡 In plain words: A negative test proves three things: the right code, a clear error, and nothing broken in the data.
🗣 Як читати вголос Негативні тести я будую за класами: зламаний JSON — 400, невалідні дані — 400 чи 422 за домовленістю, без токена — 401, без прав — 403, немає ресурсу — 404, конфлікт стану — 409, ліміт — 429. Роблю їх параметризованими: беру валідний об'єкт з фабрики і в кожному кейсі псую одне поле. Крім коду перевіряю формат помилки, що немає стек трейсу, і обов'язково, що стан не змінився, бо 400 при частково створеному записі — це реальний баг.
🗣 How to say it out loud I design negative tests by class: broken JSON gives 400, invalid data 400 or 422 by convention, no token 401, no rights 403, no resource 404, a state conflict 409, a rate limit 429. I make them parameterized: I take a valid object from the factory and break one field per case. Besides the code I check the error format, that there is no stack trace, and always that state did not change, because a 400 with a half-created record is a real bug.
50 Як перевірити час відповіді в REST Assured і чому такі перевірки у функціональних тестах слабкі? How do you assert response time in REST Assured, and why are such checks weak in functional tests?
Дизайн тестівTest design Senior ▶

Як. then().time(lessThan(2000L)) — матчер приймає Long, тож потрібен суфікс L; або з одиницями: time(lessThan(2L), TimeUnit.SECONDS). Отримати значення: response.time() або getTime() у мілісекундах, timeIn(TimeUnit.SECONDS), або then().extract().time(). У ResponseSpecBuilder є expectResponseTime(...).

How. then().time(lessThan(2000L)) — the matcher takes a Long, hence the L suffix; or with units: time(lessThan(2L), TimeUnit.SECONDS). Getting the value: response.time() or getTime() in milliseconds, timeIn(TimeUnit.SECONDS), or then().extract().time(). ResponseSpecBuilder has expectResponseTime(...).

Що саме вимірюється. Час від відправлення запиту до отримання відповіді на боці клієнта: сюди входять DNS, встановлення TCP і TLS, черга пулу з'єднань, мережа, а для першого запиту — ще й прогрів JVM та класів REST Assured. Це не час обробки на сервері.

What is measured. Client-side time from sending the request to getting the response: DNS, TCP and TLS setup, waiting for a pooled connection and the network, plus JVM and REST Assured class warm-up for the first request. It is not server processing time.

Чому це слабко. Одне вимірювання — одна точка без статистики: воно шумить залежно від навантаження на спільний стенд, сусідніх джобів у CI, холодного кешу. Поріг, підібраний під середнє значення, дає флаки; поріг із великим запасом нічого не ловить. Функціональний тест послідовний, тож він не бачить поведінки під навантаженням, перцентилів і деградації з часом.

Why it is weak. One measurement is one data point with no statistics: it jitters with the load on a shared environment, neighbouring CI jobs and cold caches. A threshold tuned to the average gives flaky tests; a threshold with a big margin catches nothing. A functional test is sequential, so it sees nothing about behaviour under load, percentiles or degradation over time.

Що робити замість. Продуктивність — окремим інструментом (Gatling, k6, JMeter) з перцентилями p95/p99 і стабільним середовищем. У функціональних тестах — лише грубий запобіжник від зависань (наприклад, 10 с), таймаути клієнта, а час писати в метрики або звіт для трендів. Час обробки на сервері, якщо потрібен, краще брати із заголовка Server-Timing або з APM.

What to do instead. Performance belongs to a dedicated tool (Gatling, k6, JMeter) with p95/p99 percentiles and a stable environment. In functional tests keep only a coarse guard against hangs (say 10 s) and client timeouts, and record timings in metrics or the report for trends. If you need server processing time, take it from a Server-Timing header or from APM.

Грубий запобіжник і збір часу для трендів
A coarse guard and collecting timings for trends
@Test
void searchDoesNotHang() {
    given().queryParam("q", "phone")
    .when().get("/search")
    .then()
        .statusCode(200)
        .time(lessThan(10L), TimeUnit.SECONDS);       // лише від зависання
}

@Test
void recordSearchLatency() {
    Response r = given().queryParam("q", "phone").when().get("/search");
    r.then().statusCode(200);

    long ms = r.timeIn(TimeUnit.MILLISECONDS);
    Allure.addAttachment("latency-ms", String.valueOf(ms));
    String serverTiming = r.getHeader("Server-Timing");      // наприклад: app;dur=87
}
@Test
void searchDoesNotHang() {
    given().queryParam("q", "phone")
    .when().get("/search")
    .then()
        .statusCode(200)
        .time(lessThan(10L), TimeUnit.SECONDS);       // only against hangs
}

@Test
void recordSearchLatency() {
    Response r = given().queryParam("q", "phone").when().get("/search");
    r.then().statusCode(200);

    long ms = r.timeIn(TimeUnit.MILLISECONDS);
    Allure.addAttachment("latency-ms", String.valueOf(ms));
    String serverTiming = r.getHeader("Server-Timing");      // e.g. app;dur=87
}
🔧 Розбір коду: lessThan(10L) разом з TimeUnit.SECONDS — поріг із великим запасом, який ловить лише зависання. У другому тесті час не перевіряється, а записується у звіт, щоб бачити тренд по прогонах. Server-Timing показує, скільки часу забрала обробка на сервері, без мережі.
🔧 Code breakdown: lessThan(10L) with TimeUnit.SECONDS is a generous threshold that catches only hangs. The second test does not assert the time; it records it in the report to show a trend across runs. Server-Timing shows how long server processing took, network excluded.
💡 Простими словами: Одне вимірювання часу — як один кадр з відео: видно, що щось відбувається, але не видно, як швидко.
💡 In plain words: One timing measurement is a single frame from a video: you see something happening but not how fast.
🗣 Як читати вголос Технічно це просто: then().time(lessThan(2000L)) з Long або з TimeUnit, а значення дає response.time. Але я пояснюю, чому такі перевірки слабкі. Це клієнтський час разом з мережею, TLS і прогрівом JVM, одне вимірювання без статистики на спільному стенді. Жорсткий поріг дає флаки, м'який нічого не ловить. Тому у функціональних тестах я лишаю лише грубий запобіжник від зависань і пишу час у звіт, а продуктивність міряю окремо в k6 чи Gatling з перцентилями.
🗣 How to say it out loud Technically it is simple: then().time(lessThan(2000L)) with a Long or with a TimeUnit, and response.time gives the value. But I explain why such checks are weak. It is client time including the network, TLS and JVM warm-up, a single measurement with no statistics on a shared environment. A tight threshold makes tests flaky, a loose one catches nothing. So in functional tests I keep only a coarse guard against hangs and record timings in the report, and I measure performance separately in k6 or Gatling with percentiles.
51 Як перевіряти ідемпотентність API і як безпечно додавати retry в API-тести? How do you verify API idempotency, and how do you add retries to API tests safely?
Дизайн тестівTest design Senior ▶

Що перевіряти. За HTTP-семантикою GET, PUT і DELETE ідемпотентні: повторний виклик не змінює стан ще раз. PUT двічі — той самий ресурс і та сама версія; DELETE двічі — перший 204, другий 404 або 204, але без побічних ефектів (друге списання, друга подія в черзі). POST за замовчуванням не ідемпотентний, тому платіжні й замовні API додають заголовок Idempotency-Key: повтор із тим самим ключем має повернути ту саму відповідь і не створити другий запис, а той самий ключ з іншим тілом — 409 або 422.

What to verify. By HTTP semantics GET, PUT and DELETE are idempotent: repeating the call does not change state again. PUT twice gives the same resource and the same version; DELETE twice gives 204 then 404 or 204, but no side effects (a second charge, a second event on the queue). POST is not idempotent by default, so payment and ordering APIs add an Idempotency-Key header: a repeat with the same key must return the same response and create no second record, while the same key with a different body should give 409 or 422.

Як перевіряти. Не лише за відповіддю, а за станом: кількість записів, баланс, кількість подій, лог аудиту. Цікавий сценарій — два паралельні запити з тим самим ключем: сервер має обробити лише один.

How to verify it. Not only by the response but by state: record count, balance, number of events, the audit log. An interesting scenario is two parallel requests with the same key: the server must process only one.

Retry в тестах. Повтор запиту в тесті допустимий лише для ідемпотентних операцій і лише на транзитних помилках: 502, 503, 504, 429 з Retry-After, таймаут з'єднання. Повторювати POST без ключа ідемпотентності небезпечно: якщо перший запит дійшов, а відповідь загубилась, ти створиш дубль і отримаєш хибне падіння чи, гірше, хибний успіх. Retry на 4xx чи на провалених assertions маскує баги. Кожна спроба має бути видна у звіті.

Retries in tests. Retrying a request in a test is acceptable only for idempotent operations and only on transient errors: 502, 503, 504, a 429 with Retry-After, a connection timeout. Retrying a POST without an idempotency key is dangerous: if the first request got through and the response was lost, you create a duplicate and get a false failure or, worse, a false success. Retrying on 4xx or on failed assertions hides bugs. Every attempt must be visible in the report.

Retry всього тесту (Surefire rerunFailingTestsCount, JUnit Pioneer @RetryingTest) — тимчасовий пластир: флак ховається, але не лікується. Такі тести варто маркувати й відстежувати як борг.

Retrying a whole test (Surefire rerunFailingTestsCount, JUnit Pioneer @RetryingTest) is a temporary plaster: the flake is hidden, not cured. Mark such tests and track them as debt.

Ідемпотентний POST і безпечний retry для GET
An idempotent POST and a safe retry for GET
@Test
void samePaymentKeyDoesNotChargeTwice() {
    String key = UUID.randomUUID().toString();
    PaymentRequest body = new PaymentRequest(orderId, new BigDecimal("92.00"));

    String first = given().spec(Specs.BASE).header("Idempotency-Key", key).body(body)
        .when().post("/payments").then().statusCode(201).extract().path("id");
    String second = given().spec(Specs.BASE).header("Idempotency-Key", key).body(body)
        .when().post("/payments").then().statusCode(anyOf(is(200), is(201))).extract().path("id");

    assertEquals(first, second, "same key must return the same payment");
    given().spec(Specs.BASE).queryParam("orderId", orderId)
        .when().get("/payments").then().body("items", hasSize(1));
}

static Response getWithRetry(RequestSpecification spec, String path) {
    Set<Integer> transientCodes = Set.of(502, 503, 504);
    Response r = null;
    for (int attempt = 1; attempt <= 3; attempt++) {
        r = given().spec(spec).when().get(path);                 // лише GET: ідемпотентний
        if (!transientCodes.contains(r.statusCode())) return r;
        Allure.step("retry " + attempt + " after " + r.statusCode());
        sleepQuietly(Duration.ofMillis(500L * attempt));
    }
    return r;
}
@Test
void samePaymentKeyDoesNotChargeTwice() {
    String key = UUID.randomUUID().toString();
    PaymentRequest body = new PaymentRequest(orderId, new BigDecimal("92.00"));

    String first = given().spec(Specs.BASE).header("Idempotency-Key", key).body(body)
        .when().post("/payments").then().statusCode(201).extract().path("id");
    String second = given().spec(Specs.BASE).header("Idempotency-Key", key).body(body)
        .when().post("/payments").then().statusCode(anyOf(is(200), is(201))).extract().path("id");

    assertEquals(first, second, "same key must return the same payment");
    given().spec(Specs.BASE).queryParam("orderId", orderId)
        .when().get("/payments").then().body("items", hasSize(1));
}

static Response getWithRetry(RequestSpecification spec, String path) {
    Set<Integer> transientCodes = Set.of(502, 503, 504);
    Response r = null;
    for (int attempt = 1; attempt <= 3; attempt++) {
        r = given().spec(spec).when().get(path);                 // GET only: idempotent
        if (!transientCodes.contains(r.statusCode())) return r;
        Allure.step("retry " + attempt + " after " + r.statusCode());
        sleepQuietly(Duration.ofMillis(500L * attempt));
    }
    return r;
}
🔧 Розбір коду: Перший тест перевіряє і відповідь (той самий id), і стан (рівно один платіж для замовлення). У getWithRetry повторюються лише коди 502–504, пауза росте з кожною спробою, а кожен повтор фіксується кроком Allure. Тест, що використовує хелпер, усе одно перевіряє фінальний статус сам.
🔧 Code breakdown: The first test checks both the response (the same id) and the state (exactly one payment for the order). In getWithRetry only codes 502–504 are retried, the pause grows with each attempt, and each retry is recorded as an Allure step. A test using the helper still asserts the final status itself.
💡 Простими словами: Ідемпотентність — «натиснути двічі те саме, що натиснути раз»; retry безпечний лише там, де це правда.
💡 In plain words: Idempotency means pressing twice equals pressing once; a retry is safe only where that holds.
🗣 Як читати вголос Ідемпотентність я перевіряю не лише за відповіддю, а за станом. PUT двічі дає той самий ресурс, DELETE двічі не створює побічних ефектів, а для POST з Idempotency-Key повтор із тим самим ключем повертає той самий результат і рівно один запис, а інше тіло з тим самим ключем — конфлікт. Retry в тестах дозволяю лише для ідемпотентних запитів і лише на 502–504 чи 429, з видимими спробами у звіті. Повторювати POST без ключа чи падіння assertions — означає ховати баги.
🗣 How to say it out loud I verify idempotency by state, not only by response. PUT twice gives the same resource, DELETE twice creates no side effects, and for a POST with an Idempotency-Key a repeat with the same key returns the same result and exactly one record, while a different body with the same key is a conflict. I allow retries in tests only for idempotent requests and only on 502 to 504 or 429, with every attempt visible in the report. Retrying a POST without a key, or retrying failed assertions, means hiding bugs.
52 Як готувати і прибирати тестові дані через API, щоб тести були незалежними і не засмічували середовище? How do you set up and clean up test data through the API so tests stay independent and do not pollute the environment?
Дизайн тестівTest design Senior ▶

Принцип. Кожен тест створює те, що йому потрібно, і не покладається на дані, які «вже є на стенді». Спільні фікстури ламаються, коли хтось їх змінив, і не дають запускати тести паралельно.

The principle. Each test creates what it needs and does not rely on data that "is already on the environment". Shared fixtures break when someone changes them and prevent parallel runs.

Setup через API, а не через UI чи базу. API — найшвидший шлях, який при цьому проходить бізнес-валідацію. Прямий запис у базу швидший, але обходить правила й ламається при зміні схеми; його залишають для станів, які через API недосяжні (запис, старший за рік). Setup виносять у шар API-клієнтів або фабрик: users.createActive(), а не 15 рядків given/when/then у @BeforeEach.

Setup through the API, not the UI or the database. The API is the fastest path that still goes through business validation. Writing straight to the database is faster but skips the rules and breaks when the schema changes; keep it for states the API cannot reach (a record older than a year). Setup lives in the API client or factory layer: users.createActive(), not 15 lines of given/when/then in @BeforeEach.

Cleanup. Варіанти: @AfterEach з видаленням усього, що тест створив (ідентифікатори складаються в список ще під час setup); JUnit 5 extension, що реєструє ресурси й видаляє їх навіть після падіння; префікс чи тег у даних (qa-run-{id}) плюс нічний прибиральник для того, що вижило; окремий тенант чи ефемерне середовище на кожен прогін, яке просто знищується.

Cleanup. Options: @AfterEach deleting everything the test created (ids are collected in a list during setup); a JUnit 5 extension that registers resources and deletes them even after a failure; a prefix or tag in the data (qa-run-{id}) plus a nightly sweeper for anything that survived; a separate tenant or an ephemeral environment per run that is simply destroyed.

Трейдофи. Видалення після кожного тесту уповільнює прогін і саме може падати; залишені дані ускладнюють налагодження, якщо їх прибирати занадто рано. Найнадійніше — унікальні дані на кожен тест (UUID у ключових полях) і ізоляція за тенантом, тоді cleanup стає гігієною, а не умовою коректності. Помилка cleanup не повинна маскувати падіння самого тесту — її варто логувати окремо.

Trade-offs. Deleting after every test slows the run and can fail itself; leftover data complicates debugging if it is cleaned up too early. The most reliable approach is unique data per test (UUIDs in key fields) and tenant isolation — then cleanup is hygiene rather than a condition for correctness. A cleanup failure must not mask the test's own failure; log it separately.

Реєстр створених ресурсів як JUnit 5 extension
A registry of created resources as a JUnit 5 extension
public final class Cleanup implements AfterEachCallback {
    private final Deque<Runnable> actions = new ArrayDeque<>();

    public void register(Runnable deleteAction) { actions.push(deleteAction); }

    @Override
    public void afterEach(ExtensionContext ctx) {
        while (!actions.isEmpty()) {
            try {
                actions.pop().run();
            } catch (RuntimeException e) {
                LoggerFactory.getLogger(Cleanup.class).warn("cleanup failed", e);   // не маскує падіння тесту
            }
        }
    }
}

class OrderRefundTest {
    @RegisterExtension final Cleanup cleanup = new Cleanup();
    private final UsersClient users = new UsersClient();
    private final OrdersClient orders = new OrdersClient();

    @Test
    void paidOrderCanBeRefunded() {
        long userId = users.createActive("qa-" + UUID.randomUUID());
        cleanup.register(() -> users.delete(userId));
        long orderId = orders.createPaid(userId);
        cleanup.register(() -> orders.delete(orderId));

        given().spec(Specs.BASE).when().post("/orders/{id}/refund", orderId)
        .then().statusCode(200).body("status", equalTo("REFUNDED"));
    }
}
public final class Cleanup implements AfterEachCallback {
    private final Deque<Runnable> actions = new ArrayDeque<>();

    public void register(Runnable deleteAction) { actions.push(deleteAction); }

    @Override
    public void afterEach(ExtensionContext ctx) {
        while (!actions.isEmpty()) {
            try {
                actions.pop().run();
            } catch (RuntimeException e) {
                LoggerFactory.getLogger(Cleanup.class).warn("cleanup failed", e);   // does not mask the test failure
            }
        }
    }
}

class OrderRefundTest {
    @RegisterExtension final Cleanup cleanup = new Cleanup();
    private final UsersClient users = new UsersClient();
    private final OrdersClient orders = new OrdersClient();

    @Test
    void paidOrderCanBeRefunded() {
        long userId = users.createActive("qa-" + UUID.randomUUID());
        cleanup.register(() -> users.delete(userId));
        long orderId = orders.createPaid(userId);
        cleanup.register(() -> orders.delete(orderId));

        given().spec(Specs.BASE).when().post("/orders/{id}/refund", orderId)
        .then().statusCode(200).body("status", equalTo("REFUNDED"));
    }
}
🔧 Розбір коду: ArrayDeque як стек видаляє ресурси у зворотному порядку: спершу замовлення, потім користувача, від якого воно залежить. @RegisterExtension на полі екземпляра дає кожному тесту свій реєстр. Помилка видалення логується, а не кидається, тож справжня причина падіння тесту не губиться.
🔧 Code breakdown: ArrayDeque used as a stack deletes resources in reverse order: the order first, then the user it depends on. @RegisterExtension on an instance field gives each test its own registry. A deletion error is logged rather than thrown, so the real cause of a test failure is not lost.
💡 Простими словами: Хороший тест як турист у горах: приносить усе своє і забирає своє сміття.
💡 In plain words: A good test is like a hiker: it brings everything it needs and carries its rubbish out.
🗣 Як читати вголос Кожен тест у мене створює власні дані через API, бо це швидко і проходить бізнес-валідацію, а спільні фікстури на стенді рано чи пізно хтось змінить. Setup ховаю в API-клієнти й фабрики. Для cleanup реєструю видалення кожного створеного ресурсу в extension, яке виконується навіть після падіння і у зворотному порядку. Але головне — унікальні дані й ізоляція за тенантом, тоді cleanup — гігієна, а не умова коректності. І помилка прибирання не має ховати падіння самого тесту.
🗣 How to say it out loud Each of my tests creates its own data through the API, because it is fast and goes through business validation, while shared fixtures on an environment will sooner or later be changed by someone. I hide setup in API clients and factories. For cleanup I register a delete for every created resource in an extension that runs even after a failure and in reverse order. But the key is unique data and tenant isolation, so cleanup is hygiene rather than a condition for correctness. And a cleanup error must never hide the test's own failure.
53 Як замокати залежність сервісу через WireMock у тестах на REST Assured? How do you mock a service dependency with WireMock in REST Assured tests?
Дизайн тестівTest design Middle ▶

Навіщо. Сервіс під тестом часто викликає інші: платіжний шлюз, курс валют, сервіс доставки. Справжні залежності на стенді бувають недоступні, повільні або не дають відтворити рідкісні відповіді (таймаут, 500, дивний формат). WireMock — HTTP-сервер, який повертає заздалегідь описані відповіді й записує отримані запити.

Why. The service under test often calls others: a payment gateway, an exchange-rate service, a delivery service. Real dependencies on a test environment may be unavailable, slow, or unable to reproduce rare responses (a timeout, a 500, an odd format). WireMock is an HTTP server that returns responses you describe in advance and records the requests it receives.

Як підключити. У JUnit 5 — WireMockExtension з динамічним портом. Сервіс під тестом має брати адресу залежності з конфігурації, щоб у тестовому оточенні вона вказувала на WireMock. Стаб описується через stubFor(get(urlPathEqualTo("/rates")).willReturn(okJson(...))), помилки — serverError(), aResponse().withStatus(503), затримка — withFixedDelay(ms), обрив — withFault(Fault.CONNECTION_RESET_BY_PEER).

How to set it up. In JUnit 5 use a WireMockExtension with a dynamic port. The service under test must read the dependency's address from configuration, so in the test setup it points to WireMock. A stub is described with stubFor(get(urlPathEqualTo("/rates")).willReturn(okJson(...))), errors with serverError() or aResponse().withStatus(503), a delay with withFixedDelay(ms), a dropped connection with withFault(Fault.CONNECTION_RESET_BY_PEER).

Перевірка взаємодії. Після дії REST Assured перевіряємо відповідь нашого сервісу, а WireMock verify(postRequestedFor(...).withRequestBody(matchingJsonPath(...))) — що сервіс надіслав залежності правильний запит.

Verifying the interaction. After the REST Assured call we check our service's response, and with WireMock verify(postRequestedFor(...).withRequestBody(matchingJsonPath(...))) we check that the service sent the dependency the right request.

Пастка з імпортами. І REST Assured, і WireMock мають статичні get() і post(). Імпортувати дві бібліотеки через * в одному класі не вийде — одну бібліотеку викликають з префіксом класу, наприклад WireMock.get(...).

The import trap. Both REST Assured and WireMock have static get() and post(). You cannot import both with * in one class — call one library with a class prefix, for example WireMock.get(...).

Межа. Мок доводить, що наш сервіс правильно працює з нашим уявленням про залежність. Щоб це уявлення не застаріло, потрібні контрактні тести.

The limit. A mock proves our service works correctly with our idea of the dependency. To keep that idea from going stale you need contract tests.

Стаб, відмова залежності й перевірка запиту
A stub, a dependency failure and a request check
@RegisterExtension
static WireMockExtension rates = WireMockExtension.newInstance()
        .options(WireMockConfiguration.wireMockConfig().dynamicPort())
        .build();

@Test
void convertsPriceWithCurrentRate() {
    rates.stubFor(WireMock.get(WireMock.urlPathEqualTo("/rates/EUR"))
            .willReturn(WireMock.okJson("{\"currency\":\"EUR\",\"rate\":45.10}")));

    given().spec(Specs.BASE).queryParam("currency", "EUR")
    .when().get("/products/SKU-1/price")
    .then().statusCode(200).body("amount", equalTo(new BigDecimal("2.22")));

    rates.verify(1, WireMock.getRequestedFor(WireMock.urlPathEqualTo("/rates/EUR")));
}

@Test
void ratesServiceDownGivesGraceful503() {
    rates.stubFor(WireMock.get(WireMock.urlPathMatching("/rates/.*"))
            .willReturn(WireMock.aResponse().withStatus(503).withFixedDelay(2_000)));

    given().spec(Specs.BASE).queryParam("currency", "EUR")
    .when().get("/products/SKU-1/price")
    .then().statusCode(503).body("code", equalTo("RATES_UNAVAILABLE"));
}
🔧 Розбір коду: Усі виклики WireMock написані з префіксом класу, тож статичний given() REST Assured не конфліктує з WireMock.get. dynamicPort() обирає вільний порт, і його треба передати сервісу під тестом через конфіг. Другий тест доводить, що відмова залежності перетворюється на зрозумілу помилку, а не на 500. Порівняння з BigDecimal припускає, що в конфігу ввімкнено BIG_DECIMAL.
🔧 Code breakdown: Every WireMock call uses the class prefix, so REST Assured's static given() does not clash with WireMock.get. dynamicPort() picks a free port, which must reach the service under test through its config. The second test proves a dependency failure turns into a clear error rather than a 500. The BigDecimal comparison assumes the BIG_DECIMAL config is on.
💡 Простими словами: WireMock — це «дублер» сусіднього сервісу, який грає будь-яку роль, яку ти йому напишеш.
💡 In plain words: WireMock is a stand-in for a neighbouring service that plays whatever role you script for it.
🗣 Як читати вголос WireMock я використовую як підмінний HTTP-сервер для залежностей: курсів валют, платежів, доставки. Піднімаю його через WireMockExtension з динамічним портом, а сервіс під тестом бере адресу залежності з конфігу. Стабами відтворюю не лише успіх, а й 503, затримки й обриви з'єднання, які на реальному стенді не отримаєш. Потім REST Assured перевіряє відповідь нашого сервісу, а verify — що він надіслав правильний запит. Статичні get двох бібліотек конфліктують, тому WireMock пишу з префіксом.
🗣 How to say it out loud I use WireMock as a stand-in HTTP server for dependencies: exchange rates, payments, delivery. I start it through WireMockExtension on a dynamic port, and the service under test reads the dependency address from config. With stubs I reproduce not only success but also 503s, delays and dropped connections you cannot get on a real environment. Then REST Assured checks our service's response, and verify checks it sent the right request. The static get methods of the two libraries clash, so I write WireMock calls with the prefix.
54 Як протестувати пагінацію API на REST Assured, щоб знайти пропуски й дублікати між сторінками? How do you test API pagination with REST Assured so you catch gaps and duplicates between pages?
Дизайн тестівTest design Senior ▶

Що може зламатися. Пагінація здається простою, але саме тут живуть неприємні баги: дублікати й пропуски на межі сторінок через нестабільне сортування; неправильні total і totalPages; остання сторінка неповна або порожня; page=0, від'ємні значення й size понад ліміт повертають 500; курсорна пагінація губить записи, якщо дані змінюються між запитами.

What can break. Pagination looks simple, but nasty bugs live here: duplicates and gaps at page boundaries due to unstable sorting; wrong total and totalPages; the last page partial or empty; page=0, negative values and a size above the limit returning 500; cursor pagination losing records when data changes between requests.

Стратегія. Створити відому кількість записів з унікальною міткою (наприклад, 23 записи з префіксом тесту), щоб не залежати від чужих даних. Пройти всі сторінки в циклі, доки не прийде порожня сторінка або next не стане null, і зібрати ідентифікатори. Потім перевірити: кількість дорівнює 23, дублікатів немає, множина збігається зі створеною, сортування тримається через межі сторінок, total дорівнює 23 на кожній сторінці. Окремо — граничні параметри: size=1, максимальний size, size понад максимум, сторінка за межами.

Strategy. Create a known number of records with a unique marker (say 23 records with the test's prefix), so you do not depend on other data. Walk every page in a loop until an empty page arrives or next becomes null, and collect the ids. Then check: the count is 23, there are no duplicates, the set equals the created one, sorting holds across page boundaries, and total is 23 on every page. Separately check boundary params: size=1, the maximum size, a size above the maximum, a page beyond the end.

Курсори. Для cursor-based API (next-токен) варто перевірити, що вставка нового запису посеред обходу не спричиняє дублікатів чи пропусків старих, і що курсор зі зміненими параметрами фільтра відхиляється.

Cursors. For a cursor-based API (a next token) check that inserting a new record mid-walk causes no duplicates or gaps among the old ones, and that a cursor reused with changed filter params is rejected.

Запобіжник. Цикл обходу обов'язково має ліміт ітерацій: баг, коли сервер завжди віддає next, інакше перетворює тест на нескінченний.

Safety net. The walking loop must have an iteration limit: a bug where the server always returns next would otherwise make the test run forever.

Обхід усіх сторінок і перевірки
Walking every page and the checks
@Test
void pagesCoverAllRecordsExactlyOnce() {
    String tag = "qa-" + UUID.randomUUID();
    Set<Long> created = IntStream.range(0, 23)
            .mapToObj(i -> products.create(tag + "-" + i)).collect(Collectors.toSet());

    List<Long> seen = new ArrayList<>();
    int page = 0;
    while (page < 20) {                                          // запобіжник від нескінченного циклу
        JsonPath body = given().spec(Specs.BASE)
                .queryParam("tag", tag).queryParam("page", page).queryParam("size", 5)
                .queryParam("sort", "id,asc")
            .when().get("/products")
            .then().statusCode(200).body("total", equalTo(23))
            .extract().jsonPath();

        List<Long> ids = body.getList("items.id", Long.class);
        if (ids.isEmpty()) break;
        seen.addAll(ids);
        page++;
    }

    assertThat(seen).hasSize(23).doesNotHaveDuplicates().isSorted();
    assertThat(new HashSet<>(seen)).isEqualTo(created);
    assertThat(page).isEqualTo(5);                                // 5+5+5+5+3
}

@ParameterizedTest
@ValueSource(ints = {-1, 0, 1001})
void invalidPageSizeIsRejected(int size) {
    given().spec(Specs.BASE).queryParam("size", size)
    .when().get("/products")
    .then().statusCode(400);
}
@Test
void pagesCoverAllRecordsExactlyOnce() {
    String tag = "qa-" + UUID.randomUUID();
    Set<Long> created = IntStream.range(0, 23)
            .mapToObj(i -> products.create(tag + "-" + i)).collect(Collectors.toSet());

    List<Long> seen = new ArrayList<>();
    int page = 0;
    while (page < 20) {                                          // guard against an endless loop
        JsonPath body = given().spec(Specs.BASE)
                .queryParam("tag", tag).queryParam("page", page).queryParam("size", 5)
                .queryParam("sort", "id,asc")
            .when().get("/products")
            .then().statusCode(200).body("total", equalTo(23))
            .extract().jsonPath();

        List<Long> ids = body.getList("items.id", Long.class);
        if (ids.isEmpty()) break;
        seen.addAll(ids);
        page++;
    }

    assertThat(seen).hasSize(23).doesNotHaveDuplicates().isSorted();
    assertThat(new HashSet<>(seen)).isEqualTo(created);
    assertThat(page).isEqualTo(5);                                // 5+5+5+5+3
}

@ParameterizedTest
@ValueSource(ints = {-1, 0, 1001})
void invalidPageSizeIsRejected(int size) {
    given().spec(Specs.BASE).queryParam("size", size)
    .when().get("/products")
    .then().statusCode(400);
}
🔧 Розбір коду: Мітка tag ізолює тест від чужих записів, тому точні кількості безпечні. getList("items.id", Long.class) гарантує тип елементів для порівняння з множиною. Перевірка isSorted() на склеєному списку ловить порушення порядку саме на межах сторінок, а page == 5 — неправильну кількість сторінок.
🔧 Code breakdown: The tag marker isolates the test from other records, so exact counts are safe. getList("items.id", Long.class) fixes the element type for comparison with the set. isSorted() on the merged list catches ordering breaks exactly at page boundaries, and page == 5 catches a wrong page count.
💡 Простими словами: Пагінацію перевіряють не сторінкою, а всім обходом: склей сторінки і подивись, чи вийшов рівно той набір.
💡 In plain words: You test pagination with a full walk, not one page: glue the pages together and see whether you got exactly the set.
🗣 Як читати вголос Для пагінації я спершу створюю відому кількість записів з унікальною міткою, наприклад 23, щоб не залежати від чужих даних. Потім обходжу всі сторінки в циклі з лімітом ітерацій, збираю id і перевіряю, що їх рівно 23, без дублікатів, з тим самим набором і з сортуванням через межі сторінок, а total правильний на кожній сторінці. Окремо граничні параметри: size один, максимум, понад максимум, від'ємні значення. Для курсорів ще перевіряю вставку нового запису посеред обходу.
🗣 How to say it out loud For pagination I first create a known number of records with a unique marker, say 23, so I do not depend on other data. Then I walk every page in a loop with an iteration limit, collect the ids and check there are exactly 23, no duplicates, the same set, and sorting that holds across page boundaries, with the right total on every page. Separately I test boundary params: size one, the maximum, above the maximum, negative values. For cursors I also check inserting a new record in the middle of the walk.
▶ 9 6
55 Як спроєктувати фреймворк API-тестів на REST Assured: шари, API-клієнти, специфікації, моделі й середовища? How do you design an API test framework on REST Assured: layers, API clients, specs, models and environments?
АрхітектураArchitecture Senior ▶

Проблема «плоских» тестів. Коли кожен тест сам збирає given/when/then з URL, заголовками й JSON-рядками, зміна одного ендпоінта ламає десятки файлів, а тести читаються як HTTP-лог, а не як сценарії.

The problem with "flat" tests. When every test builds its own given/when/then with URLs, headers and JSON strings, changing one endpoint breaks dozens of files, and tests read like an HTTP log rather than scenarios.

Шари. 1) Config: одне місце, де з -Denv=staging чи змінних середовища читаються URL, облікові дані й таймаути. Жодних адрес у коді тестів. 2) Specs: незмінні RequestSpecification і ResponseSpecification для ролей і типових відповідей, з фільтрами (Allure, токени, correlation id). 3) Models: POJO чи records запитів і відповідей плюс фабрики тестових даних. 4) API clients: клас на ресурс (OrdersClient, UsersClient) з методами на кшталт create(OrderRequest), які інкапсулюють шлях, метод і специфікацію та повертають Response або ValidatableResponse. 5) Tests: сценарій і перевірки, без знання про URL.

Layers. 1) Config: one place where URLs, credentials and timeouts are read from -Denv=staging or environment variables. No addresses in test code. 2) Specs: immutable RequestSpecification and ResponseSpecification objects for roles and typical responses, with filters (Allure, tokens, correlation id). 3) Models: request and response POJOs or records plus test data factories. 4) API clients: one class per resource (OrdersClient, UsersClient) with methods such as create(OrderRequest) that encapsulate the path, method and spec and return a Response or ValidatableResponse. 5) Tests: the scenario and the checks, with no knowledge of URLs.

Ключові рішення. Клієнт повертає відповідь, а не робить assertions — інакше неможливо написати негативний тест тим самим методом. Для зручності є пара методів: create() повертає сиру відповідь, createOk() перевіряє 201 і десеріалізує. Стан (токен, сесія) передається явно або через фільтр, а не через статичні поля. Кожен клієнт позначений @Step, тож звіт читається як сценарій.

Key decisions. A client returns the response instead of asserting — otherwise you cannot write a negative test with the same method. For convenience there is a pair of methods: create() returns the raw response, createOk() checks the 201 and deserializes. State (token, session) is passed explicitly or through a filter, never through static fields. Each client method carries @Step, so the report reads like a scenario.

Чого уникати. «Божественний» BaseTest на тисячу рядків, успадкування заради хелперів замість композиції, приховані assertions у хелперах, де незрозуміло, що саме перевірено. Фреймворк має бути нудним: новий інженер додає тест за годину, а не вивчає магію.

What to avoid. A thousand-line "god" BaseTest, inheritance for the sake of helpers instead of composition, hidden assertions inside helpers where nobody can tell what was checked. A framework should be boring: a new engineer adds a test in an hour instead of studying magic.

Структура пакетів
Package structure
src/test/java/com/shop/api/
  config/    Env.java                 (env, urls, secrets)
  specs/     Specs.java               (request/response specs, filters)
  auth/      TokenCache.java, BearerTokenFilter.java
  models/    OrderRequest.java, Order.java, Orders.java (factory)
  clients/   OrdersClient.java, UsersClient.java
  tests/     orders/CreateOrderTest.java, orders/RefundTest.java
src/test/resources/
  schemas/   order.json
  env/       staging.properties, local.properties
Клієнт ресурсу і тест, що ним користується
A resource client and a test that uses it
public class OrdersClient {
    private final RequestSpecification spec;
    public OrdersClient(RequestSpecification spec) { this.spec = spec; }

    @Step("POST /orders")
    public ValidatableResponse create(OrderRequest body) {
        return given().spec(spec).body(body).when().post("/orders").then();
    }

    @Step("Create order and expect 201")
    public Order createOk(OrderRequest body) {
        return create(body).statusCode(201).extract().as(Order.class);
    }
}

class CreateOrderTest {
    private final OrdersClient orders = new OrdersClient(Specs.customer());

    @Test
    void newOrderStartsAsPending() {
        Order order = orders.createOk(Orders.validOrder().build());
        assertThat(order.status()).isEqualTo("PENDING");
    }

    @Test
    void zeroQuantityIsRejected() {
        orders.create(Orders.validOrder().quantity(0).build())
              .spec(Specs.VALIDATION_ERROR);
    }
}
🔧 Розбір коду: Клієнт отримує специфікацію в конструкторі, тож один клас обслуговує різні ролі. create() повертає ValidatableResponse без перевірок, і негативний тест сам вирішує, чого чекати. createOk() — зручна обгортка для позитивного шляху. Тести не містять жодного URL.
🔧 Code breakdown: The client takes a spec in its constructor, so one class serves different roles. create() returns a ValidatableResponse without checks, and the negative test decides what to expect. createOk() is a convenience wrapper for the happy path. The tests contain no URLs at all.
💡 Простими словами: Тест каже «що перевірити», клієнт знає «куди і як відправити», конфіг знає «на яке середовище».
💡 In plain words: The test says what to check, the client knows where and how to send, the config knows which environment.
🗣 Як читати вголос Я будую фреймворк шарами. Конфіг читає середовище й секрети, специфікації тримають спільні налаштування й фільтри, моделі й фабрики дають дані, API-клієнти по одному на ресурс знають шляхи й методи, а тести містять лише сценарій і перевірки. Клієнт повертає відповідь і не робить assertions, інакше негативний тест тим самим методом не напишеш; для щасливого шляху є обгортка на кшталт createOk. Стан передаю явно, не через статику, а методи клієнтів позначаю @Step для звіту.
🗣 How to say it out loud I build the framework in layers. Config reads the environment and secrets, specs hold shared settings and filters, models and factories provide data, API clients, one per resource, know the paths and methods, and tests contain only the scenario and the checks. A client returns the response and does not assert, otherwise you cannot write a negative test with the same method; for the happy path there is a wrapper like createOk. I pass state explicitly, not through statics, and mark client methods with @Step for the report.
56 Як запускати API-тести на REST Assured у CI і перемикати середовища? How do you run REST Assured API tests in CI and switch between environments?
АрхітектураArchitecture Middle ▶

Середовища. Код не знає адрес. Клас Env читає -Denv=staging (або змінну TEST_ENV), підвантажує env/staging.properties, а секрети бере зі змінних середовища, які CI передає із захищеного сховища. Будь-яке значення можна перекрити системною властивістю для локального налагодження.

Environments. The code knows no addresses. An Env class reads -Denv=staging (or a TEST_ENV variable), loads env/staging.properties, and takes secrets from environment variables that CI passes in from a protected store. Any value can be overridden with a system property for local debugging.

Запуск. Maven Surefire (mvn test) або Gradle. Набори розділяють тегами JUnit 5: @Tag("smoke") для швидкої перевірки після деплою, @Tag("regression") для нічного прогону; вибір — -Dgroups=smoke. Паралельність вмикається в junit-platform.properties, але лише після того, як тести доведено незалежними.

Running. Maven Surefire (mvn test) or Gradle. Suites are split with JUnit 5 tags: @Tag("smoke") for a quick post-deploy check, @Tag("regression") for the nightly run; you select with -Dgroups=smoke. Parallelism is switched on in junit-platform.properties, but only after the tests are proven independent.

Порядок у пайплайні. Спершу healthcheck стенду з коротким таймаутом: якщо сервіс не піднявся, немає сенсу ганяти 800 тестів і отримувати 800 однакових падінь. Потім smoke, потім регрес. Результати — JUnit XML для CI і allure-results як артефакт, з якого генерується звіт.

Order in the pipeline. First a healthcheck of the environment with a short timeout: if the service did not come up, there is no point running 800 tests and getting 800 identical failures. Then smoke, then regression. Results: JUnit XML for CI and allure-results as an artifact from which the report is generated.

Що важливо. Тести на staging не повинні залежати від порядку і спільних даних, бо паралельно з ними працюють інші пайплайни й люди. Флаки видно з історії звіту — їх ремонтують або карантинять з тікетом, а не лікують масовим rerun. І секрети ніколи не потрапляють у логи: blacklist заголовків у LogConfig та маскування змінних у CI.

What matters. Tests on staging must not depend on order or shared data, because other pipelines and people work there at the same time. Flaky tests show up in the report history — they get fixed or quarantined with a ticket, not treated with mass reruns. And secrets never reach the logs: header blacklisting in LogConfig plus masked variables in CI.

Клас конфігурації середовища
An environment config class
public final class Env {
    private static final Properties PROPS = load(System.getProperty("env", "local"));

    public static String apiUrl()   { return get("api.url"); }
    public static String authUrl()  { return get("auth.url"); }
    public static String secretFor(String clientId) {
        return Objects.requireNonNull(System.getenv("SECRET_" + clientId.toUpperCase().replace('-', '_')),
                "missing secret for " + clientId);
    }

    private static String get(String key) {
        return System.getProperty(key, PROPS.getProperty(key));      // -Dapi.url=... перекриває файл
    }

    private static Properties load(String env) {
        try (InputStream in = Env.class.getResourceAsStream("/env/" + env + ".properties")) {
            Properties p = new Properties();
            p.load(Objects.requireNonNull(in, "unknown env " + env));
            return p;
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
}
public final class Env {
    private static final Properties PROPS = load(System.getProperty("env", "local"));

    public static String apiUrl()   { return get("api.url"); }
    public static String authUrl()  { return get("auth.url"); }
    public static String secretFor(String clientId) {
        return Objects.requireNonNull(System.getenv("SECRET_" + clientId.toUpperCase().replace('-', '_')),
                "missing secret for " + clientId);
    }

    private static String get(String key) {
        return System.getProperty(key, PROPS.getProperty(key));      // -Dapi.url=... overrides the file
    }

    private static Properties load(String env) {
        try (InputStream in = Env.class.getResourceAsStream("/env/" + env + ".properties")) {
            Properties p = new Properties();
            p.load(Objects.requireNonNull(in, "unknown env " + env));
            return p;
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
}
Крок пайплайна GitHub Actions
A GitHub Actions pipeline step
- name: Healthcheck
  run: curl --fail --max-time 10 https://staging.example.com/health
- name: API smoke
  run: mvn -B test -Denv=staging -Dgroups=smoke
  env:
    SECRET_ORDERS_SERVICE: ${{ secrets.ORDERS_SERVICE_SECRET }}
- name: Upload Allure results
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: allure-results
    path: target/allure-results
🔧 Розбір коду: System.getProperty(key, PROPS.getProperty(key)) дає пріоритет системній властивості над файлом. requireNonNull з повідомленням падає одразу й зрозуміло, якщо секрет не передали, замість 401 у сотні тестів. У YAML if: always() завантажує результати навіть тоді, коли тести впали.
🔧 Code breakdown: System.getProperty(key, PROPS.getProperty(key)) gives a system property priority over the file. requireNonNull with a message fails immediately and clearly if a secret was not passed, instead of a 401 in a hundred tests. In the YAML if: always() uploads the results even when the tests failed.
💡 Простими словами: Один і той самий код тестів — різні середовища лише через параметр запуску.
💡 In plain words: The same test code runs against different environments by changing only a launch parameter.
🗣 Як читати вголос У коді тестів немає жодної адреси: клас Env читає -Denv, підтягує properties для цього середовища, а секрети бере зі змінних, які передає CI. Будь-що можна перекрити системною властивістю. У пайплайні спершу healthcheck, щоб не отримати сотні однакових падінь, потім smoke за тегом, потім регрес. Результати Allure завантажую завжди, навіть якщо тести впали. Паралельність вмикаю лише для незалежних тестів, а секрети ховаю і в логах тестів, і в CI.
🗣 How to say it out loud The test code has no addresses at all: an Env class reads -Denv, loads the properties for that environment, and takes secrets from variables CI provides. Anything can be overridden with a system property. In the pipeline I run a healthcheck first so I do not get hundreds of identical failures, then smoke by tag, then regression. I always upload the Allure results, even when tests fail. I switch on parallelism only for independent tests, and I hide secrets both in the test logs and in CI.
57 Як налаштувати контрактні перевірки через JSON Schema в CI і чим вони відрізняються від consumer-driven contract testing? How do you set up contract checks with JSON Schema in CI, and how do they differ from consumer-driven contract testing?
АрхітектураArchitecture Senior ▶

Схеми в CI. Для кожного ключового ендпоінта є схема відповіді в src/test/resources/schemas, і окремий набір тестів перевіряє реальні відповіді через matchesJsonSchemaInClasspath. Цей набір запускається в пайплайні провайдера на кожен PR, тож зміна, що ламає форму відповіді (видалене поле, змінений тип, новий обов'язковий параметр), блокує злиття. Схеми краще не писати руками, а генерувати з OpenAPI-специфікації або тримати в спільному репозиторії, щоб не було двох джерел правди. Альтернатива схемам — валідація за самою OpenAPI-специфікацією через фільтр REST Assured (наприклад, OpenApiValidationFilter з бібліотеки swagger-request-validator), який перевіряє і запит, і відповідь.

Schemas in CI. Each key endpoint has a response schema in src/test/resources/schemas, and a dedicated test set checks real responses with matchesJsonSchemaInClasspath. The set runs in the provider pipeline on every PR, so a change that breaks the response shape (a removed field, a changed type, a new required parameter) blocks the merge. Schemas are better generated from the OpenAPI spec or kept in a shared repository than written by hand, so there are not two sources of truth. An alternative to schemas is validating against the OpenAPI spec itself with a REST Assured filter (for example OpenApiValidationFilter from the swagger-request-validator library), which checks both the request and the response.

Чим це не є. Перевірка схеми — провайдер-центрична: вона доводить, що відповідь відповідає опублікованому контракту. Вона не знає, які поля реально використовують клієнти, тому або занадто сувора (ламається на безпечних змінах), або занадто м'яка (пропускає видалення поля, яке читає мобільний застосунок).

What it is not. Schema validation is provider-centric: it proves the response matches the published contract. It does not know which fields clients actually use, so it is either too strict (breaks on safe changes) or too loose (misses the removal of a field the mobile app reads).

Consumer-driven contracts. У Pact чи Spring Cloud Contract кожен споживач фіксує свої очікування (які запити шле й які поля читає), контракти публікуються в брокер, а провайдер у своєму CI перевіряє, що задовольняє всіх споживачів. Інструмент can-i-deploy відповідає, чи безпечно викладати версію.

Consumer-driven contracts. In Pact or Spring Cloud Contract each consumer records its expectations (which requests it sends and which fields it reads), contracts are published to a broker, and the provider verifies in its own CI that it satisfies all consumers. The can-i-deploy tool answers whether a version is safe to release.

Коли що. Схеми — дешевий старт і захист публічного API з невідомими клієнтами. Consumer-driven — коли є кілька внутрішніх споживачів і незалежні деплої мікросервісів. Часто поєднують два підходи: схему для форми, Pact для реальних залежностей між командами.

When to use which. Schemas are a cheap start and protect a public API with unknown clients. Consumer-driven contracts fit several internal consumers and independent microservice deployments. Teams often use both: a schema for the shape, Pact for real dependencies between teams.

Набір контрактних перевірок для кількох ендпоінтів
A contract check set for several endpoints
@Tag("contract")
class ResponseContractTest {

    @ParameterizedTest(name = "{0} matches {1}")
    @CsvSource({
        "/orders/1001,        schemas/order.json",
        "/users/7,            schemas/user.json",
        "/products?page=0,    schemas/product-page.json"
    })
    void responseMatchesSchema(String path, String schema) {
        given().spec(Specs.BASE)
        .when().get(path)
        .then()
            .statusCode(200)
            .body(matchesJsonSchemaInClasspath(schema));
    }

    @Test
    void createOrderMatchesOpenApi() {
        given().spec(Specs.BASE)
            .filter(new OpenApiValidationFilter("openapi/shop.yaml"))   // перевіряє і запит, і відповідь
            .body(Orders.validOrder().build())
        .when().post("/orders")
        .then().statusCode(201);
    }
}
@Tag("contract")
class ResponseContractTest {

    @ParameterizedTest(name = "{0} matches {1}")
    @CsvSource({
        "/orders/1001,        schemas/order.json",
        "/users/7,            schemas/user.json",
        "/products?page=0,    schemas/product-page.json"
    })
    void responseMatchesSchema(String path, String schema) {
        given().spec(Specs.BASE)
        .when().get(path)
        .then()
            .statusCode(200)
            .body(matchesJsonSchemaInClasspath(schema));
    }

    @Test
    void createOrderMatchesOpenApi() {
        given().spec(Specs.BASE)
            .filter(new OpenApiValidationFilter("openapi/shop.yaml"))   // checks both request and response
            .body(Orders.validOrder().build())
        .when().post("/orders")
        .then().statusCode(201);
    }
}
🔧 Розбір коду: @CsvSource тримає пари «ендпоінт → схема» в одному місці, і назва кожного кейсу у звіті показує, яка саме схема зламалась. @Tag("contract") дозволяє запускати набір окремим кроком у CI провайдера. OpenApiValidationFilter — фільтр із бібліотеки swagger-request-validator-restassured, який падає, якщо запит чи відповідь не відповідають специфікації.
🔧 Code breakdown: @CsvSource keeps the endpoint-to-schema pairs in one place, and each case name in the report shows which schema broke. @Tag("contract") lets the set run as a separate step in the provider CI. OpenApiValidationFilter is a filter from the swagger-request-validator-restassured library that fails if the request or response does not match the spec.
💡 Простими словами: Схема каже «відповідь має таку форму», consumer-driven контракт каже «ось що саме мені від тебе треба».
💡 In plain words: A schema says the response has this shape; a consumer-driven contract says here is exactly what I need from you.
🗣 Як читати вголос У CI провайдера я тримаю окремий набір з тегом contract: для ключових ендпоінтів реальна відповідь перевіряється через matchesJsonSchemaInClasspath, і зламана форма блокує PR. Схеми генерую з OpenAPI, щоб не було двох джерел правди, або валідую прямо за OpenAPI фільтром. Але пояснюю різницю: схема перевіряє опублікований контракт і не знає, які поля реально читають клієнти. Consumer-driven контракти в Pact фіксують очікування кожного споживача, і провайдер перевіряє, що задовольняє всіх. Часто потрібні два підходи разом.
🗣 How to say it out loud In the provider CI I keep a separate set tagged contract: for key endpoints the real response is checked with matchesJsonSchemaInClasspath, and a broken shape blocks the PR. I generate schemas from OpenAPI so there are not two sources of truth, or validate straight against OpenAPI with a filter. But I explain the difference: a schema checks the published contract and does not know which fields clients really read. Consumer-driven contracts in Pact record each consumer's expectations, and the provider verifies it satisfies all of them. Often you need both.
58 API-тести на REST Assured нестабільні в CI: як знайти причину флаків і що з ними робити? Your REST Assured API tests are flaky in CI: how do you find the cause and what do you do about it?
АрхітектураArchitecture Senior ▶

Спершу дані, не здогадки. Зібрати статистику з історії звіту: які тести падають, як часто, з якими повідомленнями, у який час і на якому середовищі. Флак, що падає лише в паралельному прогоні, і флак, що падає вночі, мають різні причини.

Data first, not guesses. Collect statistics from the report history: which tests fail, how often, with which messages, at what time and on which environment. A flake that fails only in parallel runs and one that fails at night have different causes.

Типові причини в API-тестах. Спільні дані: два тести міняють того самого користувача чи залежать від кількості записів. Статичний стан RestAssured: паралельні класи переписують baseURI, auth чи фільтри. Eventual consistency: запис створено, але читання йде з репліки чи пошукового індексу, де його ще немає, а тест робить GET одразу або з Thread.sleep(1000). Залежність від порядку: тест використовує id, створений іншим тестом. Час і часові пояси: «сьогодні» на агенті CI в UTC, а сервер рахує в Europe/Kyiv; тести біля півночі падають. Нестабільне середовище: 502 від балансувальника під час деплою сусідньої команди, rate limit спільного IdP. Недетермінований порядок у відповіді, перевірений через contains.

Typical causes in API tests. Shared data: two tests change the same user or depend on a record count. Static RestAssured state: parallel classes overwrite baseURI, auth or filters. Eventual consistency: a record is created but the read goes to a replica or a search index that does not have it yet, while the test does a GET immediately or after Thread.sleep(1000). Order dependence: a test uses an id created by another test. Time and time zones: "today" on the CI agent is in UTC while the server counts in Europe/Kyiv, so tests near midnight fail. An unstable environment: a 502 from the load balancer while a neighbouring team deploys, the rate limit of a shared IdP. A non-deterministic order in the response checked with contains.

Діагностика. Відтворити локально через @RepeatedTest(100) і паралельний режим; логувати correlation id і шукати запит у логах сервісу; порівняти запит і відповідь у вкладеннях Allure між зеленим і червоним прогоном.

Diagnosis. Reproduce locally with @RepeatedTest(100) and parallel mode; log a correlation id and find the request in the service logs; compare the request and response in the Allure attachments between a green and a red run.

Лікування. Власні унікальні дані, специфікації замість статики, Awaitility замість sleep, фіксований годинник або явні часові пояси, containsInAnyOrder там, де порядок не гарантований, retry лише на транзитних 5xx для ідемпотентних запитів. Поки причину не усунуто — карантин з тікетом і власником, а не мовчазний rerun, який привчає команду ігнорувати червоний CI.

Cure. Own unique data, specs instead of statics, Awaitility instead of sleep, a fixed clock or explicit time zones, containsInAnyOrder where order is not guaranteed, retries only on transient 5xx for idempotent requests. Until the cause is fixed, quarantine with a ticket and an owner rather than a silent rerun that teaches the team to ignore red CI.

Відтворення і три типові виправлення
Reproducing and three typical fixes
// 1. Відтворити: 100 повторів того самого тесту
@RepeatedTest(100)
void orderAppearsInSearch() { createdOrderIsSearchable(); }

// БУЛО: спільний користувач, sleep і порядок, якого ніхто не обіцяв
@Test
void flaky() throws InterruptedException {
    orders.createOk(Orders.validOrder().customerId("shared-user").build());
    Thread.sleep(1000);
    when().get("/search?customer=shared-user").then()
        .body("items.status", contains("PAID", "PENDING"));
}

// СТАЛО: власні дані, очікування умови, порядок не важливий
@Test
void stable() {
    String customer = "qa-" + UUID.randomUUID();
    Order order = orders.createOk(Orders.validOrder().customerId(customer).build());

    await().atMost(Duration.ofSeconds(15)).pollInterval(Duration.ofMillis(500))
        .untilAsserted(() -> given().queryParam("customer", customer)
            .when().get("/search")
            .then().statusCode(200).body("items.number", hasItem(order.number())));
}
// 1. Reproduce: 100 repetitions of the same test
@RepeatedTest(100)
void orderAppearsInSearch() { createdOrderIsSearchable(); }

// BEFORE: shared user, sleep and an order nobody promised
@Test
void flaky() throws InterruptedException {
    orders.createOk(Orders.validOrder().customerId("shared-user").build());
    Thread.sleep(1000);
    when().get("/search?customer=shared-user").then()
        .body("items.status", contains("PAID", "PENDING"));
}

// AFTER: own data, waiting for a condition, order does not matter
@Test
void stable() {
    String customer = "qa-" + UUID.randomUUID();
    Order order = orders.createOk(Orders.validOrder().customerId(customer).build());

    await().atMost(Duration.ofSeconds(15)).pollInterval(Duration.ofMillis(500))
        .untilAsserted(() -> given().queryParam("customer", customer)
            .when().get("/search")
            .then().statusCode(200).body("items.number", hasItem(order.number())));
}
🔧 Розбір коду: @RepeatedTest(100) перетворює рідкісний флак на відтворюваний. У поганій версії три джерела нестабільності: спільний customerId, фіксована пауза і contains з конкретним порядком. У хорошій — унікальний клієнт, очікування через Awaitility до 15 секунд і hasItem для номера саме цього замовлення.
🔧 Code breakdown: @RepeatedTest(100) turns a rare flake into a reproducible one. The bad version has three sources of instability: a shared customerId, a fixed pause and contains with a specific order. The good one has a unique customer, waiting with Awaitility for up to 15 seconds, and hasItem for this order's number.
💡 Простими словами: Флак — це не «невезіння», а баг у тесті, середовищі чи продукті, який просто рідко проявляється.
💡 In plain words: A flake is not bad luck but a bug in the test, the environment or the product that simply shows up rarely.
🗣 Як читати вголос Я починаю з даних: історія звіту показує, які тести падають, як часто і за яких умов. Потім перевіряю типові причини API-флаків: спільні дані, статичний стан RestAssured у паралельному прогоні, eventual consistency, залежність від порядку, часові пояси й нестабільний стенд. Відтворюю через RepeatedTest і correlation id у логах сервісу. Лікую власними даними, специфікаціями, Awaitility замість sleep і правильними матчерами. А до виправлення — карантин з тікетом і власником, не мовчазний rerun.
🗣 How to say it out loud I start with data: the report history shows which tests fail, how often and under what conditions. Then I check the typical causes of API flakes: shared data, static RestAssured state in parallel runs, eventual consistency, order dependence, time zones and an unstable environment. I reproduce with RepeatedTest and trace correlation ids in the service logs. I cure it with own data, specs, Awaitility instead of sleep and the right matchers. Until it is fixed it goes into quarantine with a ticket and an owner, not a silent rerun.
59 Як тестувати асинхронні API з eventual consistency за допомогою Awaitility і чому Thread.sleep — погана ідея? How do you test asynchronous APIs with eventual consistency using Awaitility, and why is Thread.sleep a bad idea?
АрхітектураArchitecture Senior ▶

Ситуація. POST повертає 202 Accepted, а результат з'являється пізніше: замовлення обробляє черга, пошуковий індекс оновлюється через кілька секунд, звіт генерується у фоні. Одразу після запису GET повертає старі дані або 404, і це не баг, а властивість системи.

The situation. A POST returns 202 Accepted and the result appears later: a queue processes the order, the search index refreshes a few seconds later, a report is generated in the background. Right after the write a GET returns stale data or a 404, and that is not a bug but a property of the system.

Чому не sleep. Thread.sleep(3000) завжди чекає повні три секунди, навіть коли дані готові за 200 мс, — на сотні тестів це хвилини. І він усе одно замалий у поганий день, тож тест флакує. Фіксована пауза — одночасно повільна й ненадійна.

Why not sleep. Thread.sleep(3000) always waits the full three seconds, even when the data is ready in 200 ms — across hundreds of tests that is minutes. And on a bad day it is still too short, so the test flakes. A fixed pause is slow and unreliable at the same time.

Awaitility. Бібліотека org.awaitility:awaitility опитує умову з інтервалом до таймауту: await().atMost(Duration.ofSeconds(20)).pollInterval(Duration.ofMillis(500)).untilAsserted(() -> ...). Всередину кладеться звичайна перевірка REST Assured: поки вона кидає AssertionError, Awaitility чекає й повторює; як тільки проходить — тест іде далі. Корисні доповнення: pollDelay для першої паузи, alias("order is paid") для зрозумілого повідомлення, ignoreExceptions(), якщо поки ресурс не створено, приходить помилка десеріалізації.

Awaitility. The org.awaitility:awaitility library polls a condition at an interval until a timeout: await().atMost(Duration.ofSeconds(20)).pollInterval(Duration.ofMillis(500)).untilAsserted(() -> ...). Inside goes an ordinary REST Assured check: while it throws an AssertionError, Awaitility waits and retries; once it passes, the test moves on. Useful extras: pollDelay for the first pause, alias("order is paid") for a readable message, ignoreExceptions() if a deserialization error comes back until the resource exists.

Що важливо. Таймаут має відповідати SLA асинхронної обробки, а не «щоб точно встигло»: якщо бізнес обіцяє статус за 10 секунд, тест із хвилинним очікуванням не ловить деградацію. Чекати треба кінцевого стану, а не проміжного, і враховувати, що кінцевим може бути й помилка (FAILED) — тоді очікування має завершитися, а тест упасти з поясненням. Якщо система надає вебхук чи подію, іноді надійніше слухати її, ніж опитувати API.

What matters. The timeout should match the SLA of the async processing, not "long enough to be sure": if the business promises a status within 10 seconds, a test that waits a minute will not catch degradation. Wait for the final state, not an intermediate one, and remember the final state may be an error (FAILED) — then the wait should end and the test should fail with an explanation. If the system offers a webhook or an event, listening to it is sometimes more reliable than polling the API.

Очікування кінцевого статусу замовлення
Waiting for the final order status
import static org.awaitility.Awaitility.await;

@Test
void paymentEventuallyCapturesOrder() {
    long orderId = given().spec(Specs.BASE).body(Orders.validOrder().build())
        .when().post("/orders/checkout")
        .then().statusCode(202)
        .extract().path("orderId");

    String finalStatus = await("order " + orderId + " reaches a final status")
        .atMost(Duration.ofSeconds(10))                         // SLA обробки, не «про запас»
        .pollDelay(Duration.ofMillis(200))
        .pollInterval(Duration.ofMillis(500))
        .until(() -> given().spec(Specs.BASE).when().get("/orders/{id}", orderId)
                          .then().statusCode(200).extract().<String>path("status"),
               status -> Set.of("PAID", "FAILED").contains(status));

    assertThat(finalStatus).as("payment result").isEqualTo("PAID");
}
import static org.awaitility.Awaitility.await;

@Test
void paymentEventuallyCapturesOrder() {
    long orderId = given().spec(Specs.BASE).body(Orders.validOrder().build())
        .when().post("/orders/checkout")
        .then().statusCode(202)
        .extract().path("orderId");

    String finalStatus = await("order " + orderId + " reaches a final status")
        .atMost(Duration.ofSeconds(10))                         // the processing SLA, not a safety margin
        .pollDelay(Duration.ofMillis(200))
        .pollInterval(Duration.ofMillis(500))
        .until(() -> given().spec(Specs.BASE).when().get("/orders/{id}", orderId)
                          .then().statusCode(200).extract().<String>path("status"),
               status -> Set.of("PAID", "FAILED").contains(status));

    assertThat(finalStatus).as("payment result").isEqualTo("PAID");
}
🔧 Розбір коду: await("...") задає опис, який з'явиться в повідомленні про таймаут. until(supplier, predicate) опитує статус, доки він не стане кінцевим: і PAID, і FAILED зупиняють очікування. Тому FAILED не «чекає до таймауту», а одразу дає зрозуміле падіння на останньому рядку. .<String>path явно задає тип значення.
🔧 Code breakdown: await("...") sets a description that shows up in the timeout message. until(supplier, predicate) polls the status until it becomes final: both PAID and FAILED stop the wait. So FAILED does not wait out the timeout but produces a clear failure on the last line straight away. .<String>path sets the value type explicitly.
💡 Простими словами: sleep — «почекаю три секунди навмання», Awaitility — «перевірятиму щопівсекунди, доки не станеться, але не довше за обіцяне».
💡 In plain words: sleep means waiting three seconds at random; Awaitility means checking every half second until it happens, but no longer than promised.
🗣 Як читати вголос Коли API асинхронне, одразу після запису GET може повернути старі дані, і це нормальна поведінка. Thread.sleep тут одночасно повільний і ненадійний: завжди чекає повний час, а в поганий день його все одно мало. Я беру Awaitility: await з atMost і pollInterval, а всередині звичайна перевірка REST Assured, яку він повторює, доки вона не пройде. Таймаут ставлю за SLA обробки, чекаю кінцевого стану і враховую, що кінцевим може бути FAILED — тоді тест падає одразу і зрозуміло.
🗣 How to say it out loud When an API is asynchronous, a GET right after a write can return stale data, and that is normal behaviour. Thread.sleep is both slow and unreliable here: it always waits the full time and on a bad day it is still not enough. I use Awaitility: await with atMost and pollInterval, and inside an ordinary REST Assured check that it retries until it passes. I set the timeout from the processing SLA, wait for a final state, and account for FAILED being final too, so the test fails at once and clearly.
60 Як тестувати версіонування API і зворотну сумісність, коли одночасно живуть v1 і v2? How do you test API versioning and backward compatibility when v1 and v2 live side by side?
АрхітектураArchitecture Senior ▶

Способи версіонування. У шляху (/api/v1/orders), у заголовку Accept: application/vnd.shop.v2+json або в окремому заголовку чи query-параметрі. Для тестів це різниця лише в специфікації: basePath або accept(...). Тому специфікація на кожну версію, а тести, спільні для обох, параметризуються версією.

Ways to version. In the path (/api/v1/orders), in an Accept: application/vnd.shop.v2+json header, or in a separate header or query parameter. For tests the difference is only in the spec: a basePath or an accept(...). So there is a spec per version, and tests shared by both are parameterized by version.

Що перевіряти. Зворотна сумісність v1: старі клієнти не повинні помітити появу v2. У відповіді v1 не зникають поля й не змінюються типи; нові поля допустимі; запит v1 без нових полів далі приймається. Найкраще це фіксує набір контрактних тестів і схем для v1, який не змінюється, поки версія підтримується. Ізоляція: зміни в v2 (перейменування, нова валідація) не протікають у v1. Життєвий цикл: застаріла версія повертає заголовки Deprecation і Sunset з датою, а після дати — зрозумілу помилку (410 Gone або 404 з документацією), а не 500. Невідома версія (v9, неправильний Accept) — 404 або 406, а не тиха відповідь найновішої версії.

What to check. v1 backward compatibility: old clients must not notice v2 arriving. No fields disappear from v1 responses and no types change; new fields are acceptable; a v1 request without the new fields is still accepted. A contract and schema set for v1 that stays frozen while the version is supported pins this down best. Isolation: changes in v2 (renames, new validation) do not leak into v1. Lifecycle: a deprecated version returns Deprecation and Sunset headers with a date, and after the date a clear error (410 Gone or a 404 with docs), not a 500. An unknown version (v9, a wrong Accept) gives 404 or 406, not a silent answer from the newest version.

Трейдофи. Повне дублювання наборів для кожної версії множить підтримку. Практичний підхід: параметризувати спільні сценарії, тримати окремі тести лише для відмінностей, а старі версії покривати контрактним набором і smoke, а не повним регресом. Коли дата Sunset минула — видалити тести разом з версією, щоб набір не обростав мертвими гілками.

Trade-offs. Fully duplicating suites per version multiplies maintenance. A practical approach: parameterize shared scenarios, keep separate tests only for the differences, and cover old versions with the contract set and smoke rather than full regression. When the Sunset date passes, delete the tests together with the version so the suite does not collect dead branches.

Спільний сценарій для двох версій і перевірки життєвого циклу
A shared scenario for two versions and lifecycle checks
static RequestSpecification version(String v) {
    return new RequestSpecBuilder().addRequestSpecification(Specs.BASE)
            .setBasePath("/api/" + v).build();
}

@ParameterizedTest
@ValueSource(strings = {"v1", "v2"})
void orderCanBeFetchedInEveryVersion(String v) {
    given().spec(version(v))
    .when().get("/orders/{id}", orderId)
    .then().statusCode(200)
        .body("id", equalTo(orderId))
        .body(matchesJsonSchemaInClasspath("schemas/" + v + "/order.json"));
}

@Test
void v1IsDeprecatedWithSunsetDate() {
    given().spec(version("v1"))
    .when().get("/orders/{id}", orderId)
    .then().statusCode(200)
        .header("Deprecation", notNullValue())
        .header("Sunset", matchesPattern(".*\\d{4} \\d{2}:\\d{2}:\\d{2} GMT"));
}

@Test
void unknownVersionIsNotServedSilently() {
    given().spec(version("v9"))
    .when().get("/orders/{id}", orderId)
    .then().statusCode(anyOf(is(404), is(406)));
}
🔧 Розбір коду: version(v) збирає специфікацію з базової і додає лише basePath. Параметризований тест проганяє один сценарій на обох версіях, але схема для кожної своя — schemas/v1/order.json заморожена. Заголовок Sunset за RFC 8594 містить дату у форматі HTTP-date, тому регулярка перевіряє її закінчення.
🔧 Code breakdown: version(v) builds a spec from the base one and adds only the basePath. The parameterized test runs one scenario on both versions, but each has its own schema — schemas/v1/order.json stays frozen. The Sunset header per RFC 8594 carries an HTTP-date, so the regex checks how it ends.
💡 Простими словами: Нова версія API не має права зламати старих клієнтів — і саме це перевіряють тести версіонування.
💡 In plain words: A new API version has no right to break old clients, and that is exactly what versioning tests check.
🗣 Як читати вголос Для тестів різниця між версіонуванням у шляху й у заголовку Accept — лише в специфікації, тому я роблю специфікацію на версію і параметризую спільні сценарії. Головне — зворотна сумісність v1: поля не зникають, типи не змінюються, старі запити приймаються, і це фіксує заморожений набір схем для v1. Також перевіряю ізоляцію змін v2, заголовки Deprecation і Sunset, поведінку після Sunset і невідому версію. Повний регрес на кожну версію не дублюю: старі покриваю контрактами й smoke.
🗣 How to say it out loud For tests the difference between path versioning and an Accept header lives only in the spec, so I build a spec per version and parameterize shared scenarios. The key is v1 backward compatibility: fields do not disappear, types do not change, old requests are accepted, and a frozen schema set for v1 pins that down. I also check that v2 changes stay isolated, the Deprecation and Sunset headers, behaviour after Sunset and an unknown version. I do not duplicate full regression per version: older ones get contracts and smoke.