import static io.restassured.RestAssured.*; і import static org.hamcrest.Matchers.*;. Наостанок закрий текст і розкажи відповідь уголос за блоком 🗣 Як читати вголос — інтерв'юер чує, як ти пояснюєш, а не бачить, що ти прочитав.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.Що це. 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.
<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-код ці бібліотеки не потрапляють.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.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.
@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.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.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.
// Статичне скорочення: повертає 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.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.Методи. Кожен 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.
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 сам по собі ще нічого не гарантує.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.Глобально. Статичні поля 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.
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 прибирає глобальний стан після класу.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.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.
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 і закодувати пари в тілі.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.Один заголовок. 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.
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 відповіді. Останній рядок змінює глобальний конфіг кодування.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.Надіслати. 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.
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") додається поверх сесійної.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.Перевірка в ланцюжку. 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.
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() для заголовків з кількома значеннями.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.Що це. 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.
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 додається поверх специфікації.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.Що це. 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.
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 з кожного елемента масиву.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.Що це. 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.
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 ставимо вручну, бо без декодерів бібліотека його не додає.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.Проблема. За замовчуванням 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.
@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);
}
CoreConnectionPNames), тож опечатка в імені просто нічого не змінить. Параметр менеджера з'єднань очікує Long, звідси 3_000L. Другий тест перевизначає лише таймаут читання для одного запиту.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.За замовчуванням. 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).
@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 не прив'язує тест до хоста середовища.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.Для одного запиту. 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.
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 лише один запит.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.Що робить. 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.
@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.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.Суть проблеми. 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.
// ПОГАНО: два класи паралельно переписують спільну статику
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.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() не ділиться між потоками. Файл властивостей вмикає паралельність і для методів, і для класів.@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.Рядок. 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.
// 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"));
Map.of дозволяє покласти рядок туди, де контракт чекає число. Jackson 2.12+ серіалізує Java record за іменами компонентів, тож User перетвориться на {"name":"Olena","age":30}.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}.Що це. 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.
// 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 — для всіх.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.Як підключити. Через 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.
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 в обох напрямках.(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.Один об'єкт. 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.
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 перевіряє умову для всіх.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.Форма. 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.
@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-мегабайтний масив у пам'яті, щоб не тримати великий файл у репозиторії.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.Отримати байти. 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.
@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.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.Проблема масштабу. На 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.
@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"));
}
@Builder на record генерує OrderRequest.builder() і вкладений клас OrderRequestBuilder. NON_NULL не надсилає promoCode, якщо тест його не задав. Параметризований тест змінює лише quantity, а решта полів валідні й унікальні завдяки UUID і AtomicInteger.@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.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.
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.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.Окремо від запиту. 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.
@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"), створеним з рядка.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.Що це. 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].
{
"data": {
"user": { "name": "Olena", "address": { "city": "Kyiv" } },
"items": [ { "name": "Pen", "price": 12 }, { "name": "Cup", "price": 80 } ],
"headers": { "x-trace-id": "a1b2" }
}
}
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 потрібні через дефіси.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 підтримує замикання 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.
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 доступний у замиканні як змінна.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.Отримати список. Є два рівноцінні шляхи. 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.
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, якої немає серед простих матчерів ланцюжка.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.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.
<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>
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) конвертує рядкові значення в числа.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.Базові матчери. 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.
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")));
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 показав обидві розбіжності разом, а не зупинився на першій.allOf requires both conditions, anyOf at least one. The output below shows REST Assured reporting both mismatches together rather than stopping at the first.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.
// Відповідь: {"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 — лише до кількості та складу.["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.Проблема. Коли 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.
// Відповідь: {"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, а не на саме поле. "$" — корінь відповіді.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.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.
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 на порожньому списку.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.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.
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());
"\\d{4}" — це регулярка \d{4}. Вираз номера картки \*{12}\d{4} вимагає рівно дванадцять зірочок і чотири цифри. body(emptyString()) без шляху перевіряє тіло цілком."\\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.Чому не 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.
@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) не дає тесту пройти на порожньому чи одноелементному списку. У другому тесті копія сортується, а потім порівнюється з оригіналом.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.Що вже є. Усередині одного 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.
@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, ігноруючи масштаб.assertSoftly runs the lambda, collects every failure and throws one error. isEqualByComparingTo compares BigDecimals with compareTo, ignoring scale.Підключення. Потрібен окремий артефакт 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.
{
"$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"] }
}
}
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") — конкретне значення для цього користувача.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.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.
@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-режим.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.Два способи. 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.
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(...) — хелпери проєкту, що генерують чи отримують токени з потрібними властивостями. Останній тест перевіряє, що токен тільки на читання не дає видаляти.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.Як отримати. Для сервісних тестів зазвичай використовують 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.
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 проявиться одним зрозумілим падінням.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.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.
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() приймає шлях до сховища ключів і пароль, який тут береться зі змінної середовища.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.Навіщо. На великому наборі тести, що отримують токен у кожному @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.
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, тому окремий тест може передати власний токен.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.Запит. 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.
@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() — перший відповідає за запит, другий за відповідь.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.Що це. Фільтр — перехоплювач навколо кожного запиту: він бачить запит до відправлення і відповідь після, може їх змінити, записати або навіть не відправляти запит і повернути свою відповідь. Фільтри утворюють ланцюжок: кожен викликає 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.
@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 й статус, а для помилок — повна відповідь. Другий тест додає фільтр лише до одного запиту.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.Контракт. Інтерфейс 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.
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 незмінне після створення, тож один екземпляр безпечно ділити між потоками. Фільтр повертає ту саму відповідь, не змінюючи її.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.Підключення. Залежність 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.
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} підставляє поле аргументу в назву кроку.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.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.
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 лише з одним контролером, куди передано мок сервісу, тож тест ізольований від бази й мережі.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.Навіщо. Щасливий шлях зазвичай перевіряють розробники. Основна цінність 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.
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() — хелпер, що доводить: відхилений запит нічого не створив.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.Як. 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.
@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 показує, скільки часу забрала обробка на сервері, без мережі.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.Що перевіряти. За 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.
@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. Тест, що використовує хелпер, усе одно перевіряє фінальний статус сам.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.Принцип. Кожен тест створює те, що йому потрібно, і не покладається на дані, які «вже є на стенді». Спільні фікстури ламаються, коли хтось їх змінив, і не дають запускати тести паралельно.
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.
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 на полі екземпляра дає кожному тесту свій реєстр. Помилка видалення логується, а не кидається, тож справжня причина падіння тесту не губиться.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.Навіщо. Сервіс під тестом часто викликає інші: платіжний шлюз, курс валют, сервіс доставки. Справжні залежності на стенді бувають недоступні, повільні або не дають відтворити рідкісні відповіді (таймаут, 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.
@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"));
}
given() REST Assured не конфліктує з WireMock.get. dynamicPort() обирає вільний порт, і його треба передати сервісу під тестом через конфіг. Другий тест доводить, що відмова залежності перетворюється на зрозумілу помилку, а не на 500. Порівняння з BigDecimal припускає, що в конфігу ввімкнено BIG_DECIMAL.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.Що може зламатися. Пагінація здається простою, але саме тут живуть неприємні баги: дублікати й пропуски на межі сторінок через нестабільне сортування; неправильні 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.
@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 — неправильну кількість сторінок.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.Проблема «плоских» тестів. Коли кожен тест сам збирає 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.
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
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.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.Середовища. Код не знає адрес. Клас 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.
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);
}
}
}
- 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() завантажує результати навіть тоді, коли тести впали.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.Схеми в 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.
@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, який падає, якщо запит чи відповідь не відповідають специфікації.@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.Спершу дані, не здогадки. Зібрати статистику з історії звіту: які тести падають, як часто, з якими повідомленнями, у який час і на якому середовищі. Флак, що падає лише в паралельному прогоні, і флак, що падає вночі, мають різні причини.
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.
// 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 для номера саме цього замовлення.@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.Ситуація. 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.
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 явно задає тип значення.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.Способи версіонування. У шляху (/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.
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, тому регулярка перевіряє її закінчення.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.