Back to blog

REST Assured Complete Tutorial (Java): A Comprehensive Guide for API Testing

Kajal12 min readTest Automation
REST Assured Complete Tutorial (Java): A Comprehensive Guide for API Testing

In modern delivery, HTTP APIs are where systems really meet: mobile apps, web backends, partners, and internal services all rely on stable request and response shapes. UI tests are slow and costly; API tests give faster feedback and catch many bugs before they ever hit a screen.

REST Assured is a Java library that lets you write API tests in a short, readable style (you chain method calls instead of writing lots of boilerplate). It uses three main steps: given() sets things up (URL, headers, body), when() sends the HTTP call, then() checks the result. The tests are still normal JUnit or TestNG tests you can run in CI.

This tutorial walks start to finish: add dependencies, write tests, then grow into a tidy project layout. For contracts between services (who expects what from an API), see Contract Testing for Microservices: The 2026 Definitive Guide. For how API tests fit next to unit and UI tests, see Modern Test Pyramid 2026: Complete Strategy. For unstable builds, see Fix Flaky Tests: 2026 Masterclass.

Version note. The examples use REST Assured 5.5.x. Before you freeze versions for production, check the latest patch on Maven Central: io.rest-assured.

Prerequisites

  • JDK 17 or 21 (LTS) recommended; REST Assured 5.x supports Java 8+ but teams should align with your org’s supported runtime.
  • Build tool: Maven or Gradle.
  • IDE: IntelliJ IDEA, Eclipse, or VS Code with Java extensions.
  • Test runner: TestNG or JUnit 5—this guide uses TestNG for @DataProvider and setup methods like @BeforeClass; JUnit 5 with @ParameterizedTest works too.
  • HTTP basics: verbs, status codes, JSON structure, headers like Content-Type and Authorization.

Why REST APIs—and why automate them?

REST is a common way to design web APIs: each resource has a URL, you use HTTP verbs (GET, POST, …), and the data is often JSON. API testing matters because:

  • Most product behavior is reachable faster through the API than through the UI.
  • Defects surface earlier when contracts and status codes are asserted on every merge.
  • Performance and reliability (latency, timeouts, retries) are observable at the HTTP layer.
  • Security-sensitive paths (auth, tokens, PII) should be covered with explicit negative tests.
  • Integration risk between services is concentrated at HTTP boundaries—exactly where REST Assured operates.

REST Assured does not replace contract testing or a schema registry by itself; it works with them. When many teams ship their own services, you still want written-down agreements between caller and callee (contract testing guide). Use REST Assured for automated HTTP checks against real or test-double URLs, with a clear owner (QA, SDET, or platform team).

Step 1 — Add REST Assured to Maven

Add dependencies to pom.xml under <dependencies>. Keep REST Assured and json-schema-validator on the same version number so they stay in sync and you avoid odd mix-and-match errors.

<dependencies> <dependency> <groupId>io.rest-assured</groupId> <artifactId>rest-assured</artifactId> <version>5.5.2</version> <scope>test</scope> </dependency> <dependency> <groupId>io.rest-assured</groupId> <artifactId>json-schema-validator</artifactId> <version>5.5.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.testng</groupId> <artifactId>testng</artifactId> <version>7.10.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.hamcrest</groupId> <artifactId>hamcrest</artifactId> <version>2.2</version> <scope>test</scope> </dependency> </dependencies>

Hamcrest is a small helper library for writing checks in tests. In REST Assured you use it through calls like equalTo("Leanne Graham"), containsString("@"), or greaterThan(0) inside .body(...). REST Assured already depends on Hamcrest internally, and Maven may download it indirectly when you add REST Assured. We still list Hamcrest in pom.xml on purpose: that way your IDE and CI always use one clear version of those matcher classes, and you see fewer “works on my machine” classpath surprises.

Official reference: REST Assured usage.

Step 2 — Add REST Assured to Gradle

dependencies { testImplementation 'io.rest-assured:rest-assured:5.5.2' testImplementation 'io.rest-assured:json-schema-validator:5.5.2' testImplementation 'org.testng:testng:7.10.2' testImplementation 'org.hamcrest:hamcrest:2.2' }

Sync the project and run a single empty test once to verify resolution.

Step 3 — The flow: given, when, then

REST Assured uses chained methods: each step returns an object so you can keep typing .something() on the next line. That keeps tests easy to read.

MethodRole
given()Base URI, headers, query/path params, auth, body, content type
when()HTTP verb: get(), post(), put(), patch(), delete(), …
then()Status, headers, body, time, schema; checks built with Hamcrest matchers (see above)

Some parts of REST Assured lean on Groovy (another JVM language). If a test fails, open the full error stack and use request/response logging (see the logging section) before you blame the server.

Static imports keep tests concise:

import static io.restassured.RestAssured.*; import static org.hamcrest.Matchers.*;

Step 4 — Your first test (JSONPlaceholder)

JSONPlaceholder is a read-only fake API—good for practicing JSON shape and JSONPath without changing real data.

import org.testng.annotations.Test; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.equalTo; public class FirstApiTest { @Test public void getSingleUser_returnsExpectedPayload() { given() .baseUri("https://jsonplaceholder.typicode.com") .when() .get("/users/1") .then() .assertThat() .statusCode(200) .body("name", equalTo("Leanne Graham")) .body("email", equalTo("Sincere@april.biz")) .body("address.city", equalTo("Gwenborough")); } }

Run with:

mvn -q test

If you use JUnit 5, replace @Test with org.junit.jupiter.api.Test and keep the same chained calls.

Step 5 — HTTP verbs in practice

GET: collection, query params, path params

@Test public void getAllUsers_nonEmptyArray() { given() .baseUri("https://jsonplaceholder.typicode.com") .when() .get("/users") .then() .statusCode(200) .body("size()", org.hamcrest.Matchers.greaterThan(0)); } @Test public void getPosts_filteredByUserId() { given() .baseUri("https://jsonplaceholder.typicode.com") .queryParam("userId", 1) .when() .get("/posts") .then() .statusCode(200) .body("[0].userId", equalTo(1)); } @Test public void getUser_byPathParam() { int userId = 5; given() .baseUri("https://jsonplaceholder.typicode.com") .pathParam("id", userId) .when() .get("/users/{id}") .then() .statusCode(200) .body("id", equalTo(userId)); }

POST / PUT / PATCH / DELETE (ReqRes sample API)

ReqRes returns sample JSON for create/update demos—good for practicing 201 and 200 responses and body checks.

@Test public void post_createsUser() { String body = """ {"name":"John Doe","job":"Software Developer"} """; given() .baseUri("https://reqres.in/api") .header("Content-Type", "application/json") .body(body) .when() .post("/users") .then() .statusCode(201) .body("name", equalTo("John Doe")) .body("job", equalTo("Software Developer")) .body("id", notNullValue()); } @Test public void put_replacesUser() { String body = """ {"name":"John Doe Updated","job":"Senior Developer"} """; given() .baseUri("https://reqres.in/api") .contentType("application/json") .body(body) .when() .put("/users/2") .then() .statusCode(200) .body("name", equalTo("John Doe Updated")) .body("updatedAt", notNullValue()); } @Test public void patch_partialUpdate() { given() .baseUri("https://reqres.in/api") .contentType("application/json") .body("{\"job\":\"Lead Developer\"}") .when() .patch("/users/2") .then() .statusCode(200) .body("job", equalTo("Lead Developer")); } @Test public void delete_returnsNoContent() { given() .baseUri("https://reqres.in/api") .when() .delete("/users/2") .then() .statusCode(204); }

Prefer .contentType(ContentType.JSON) instead of typing "application/json" by hand when everyone on the team uses JSON.

Step 6 — Response validation: status, headers, body, arrays, time

Status codes

Use httpbin.org status URLs (for example https://httpbin.org/status/200) or your own test endpoints so you always get a known status code (good for teaching and smoke tests):

@Test public void statusCodes_examples() { given().when().get("https://httpbin.org/status/200").then().statusCode(200); given().when().get("https://httpbin.org/status/404").then().statusCode(404); }

Headers

@Test public void responseHeaders_json() { given() .baseUri("https://jsonplaceholder.typicode.com") .when() .get("/users/1") .then() .header("Content-Type", org.hamcrest.Matchers.containsString("application/json")) .headers( "Content-Type", org.hamcrest.Matchers.containsString("json"), "Cache-Control", notNullValue()); }

Nested JSON and arrays

@Test public void complexBody_andArrayAssertions() { given() .baseUri("https://jsonplaceholder.typicode.com") .when() .get("/users/1") .then() .body("address.geo.lat", equalTo("-37.3159")) .body("company.name", org.hamcrest.Matchers.containsString("Group")); given() .baseUri("https://jsonplaceholder.typicode.com") .when() .get("/users") .then() .body("size()", equalTo(10)) .body("email", everyItem(org.hamcrest.Matchers.containsString("@"))) .body("id", everyItem(org.hamcrest.Matchers.greaterThan(0))); }

Response time

@Test public void responseTime_sla() { given() .baseUri("https://jsonplaceholder.typicode.com") .when() .get("/users") .then() .time(org.hamcrest.Matchers.lessThan(3000L)); // milliseconds; pick a loose limit for your environment }

Caution: how long a call takes depends on the network and the CI machine. For real load testing, use tools like k6, Gatling, or JMeter. In REST Assured, treat time limits as a light sanity check with a generous number, not a strict production SLA.

Step 7 — Authentication patterns

Basic auth

@Test public void basicAuth_httpbin() { given() .auth().preemptive().basic("user", "pass") .when() .get("https://httpbin.org/basic-auth/user/pass") .then() .statusCode(200) .body("authenticated", equalTo(true)); }

preemptive() sends the username and password on the first request. The default style waits for the server to ask for auth first, which can mean one extra round trip. Use whichever style your API documents.

Bearer / OAuth2 helper

@Test public void bearerToken_header() { String token = System.getenv("API_TOKEN"); // never hard-code secrets given() .header("Authorization", "Bearer " + token) .when() .get("https://api.example.com/v1/me") .then() .statusCode(200); }

given().auth().oauth2(token) is a shortcut for Bearer tokens in many setups. Always match what your login server (OAuth / OpenID provider) actually expects (REST Assured authentication).

API keys

given().queryParam("api_key", System.getenv("API_KEY")).when().get("/data"); // or given().header("X-API-Key", System.getenv("API_KEY")).when().get("/data");

Step 8 — Turning Java objects into JSON (and back)

POJO just means a plain Java class with fields (and getters/setters)—no fancy framework magic. REST Assured can turn Java objects into JSON for the request body if you add a JSON library to the project—usually Jackson or Gson. Add one yourself so the behavior is clear:

<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.18.2</version> <scope>test</scope> </dependency>

Example class and POST:

public class User { private String name; private String job; public User() {} public User(String name, String job) { this.name = name; this.job = job; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getJob() { return job; } public void setJob(String job) { this.job = job; } }
@Test public void post_withPojoBody() { given() .baseUri("https://reqres.in/api") .contentType(io.restassured.http.ContentType.JSON) .body(new User("Alice Johnson", "Product Manager")) .when() .post("/users") .then() .statusCode(201) .body("name", equalTo("Alice Johnson")); }

Read JSON back into a Java object (ReqRes wraps user under data)

This test parses part of the JSON into a User object:

@Test public void get_deserializesNestedData() { User user = given() .baseUri("https://reqres.in/api") .when() .get("/users/2") .then() .statusCode(200) .extract() .jsonPath() .getObject("data", User.class); org.testng.Assert.assertNotNull(user.getName()); }

Make sure your Java field names (or Jackson annotations like @JsonProperty("first_name")) match the real JSON keys from the API. ReqRes wraps user fields under data with names like first_name, so a tiny demo User class may not match without extra mapping.

Step 9 — JSONPath extraction (and optional Groovy-style filters)

JsonPath is documented in the REST Assured wiki: JSON.

import io.restassured.response.Response; import java.util.List; @Test public void jsonPath_extractListsAndFilters() { Response response = given() .baseUri("https://jsonplaceholder.typicode.com") .when() .get("/users"); String firstName = response.jsonPath().getString("[0].name"); List<String> allNames = response.jsonPath().getList("name"); List<String> emails = response.jsonPath().getList("findAll { it.id > 5 }.email"); org.testng.Assert.assertNotNull(firstName); org.testng.Assert.assertEquals(allNames.size(), 10); org.testng.Assert.assertTrue(emails.stream().allMatch(e -> e.contains("@"))); }

In then().body(...) you can also use Groovy-style one-liners for filters (if your team is comfortable with them):

.then() .body("find { it.id == 1 }.name", equalTo("Leanne Graham")) .body("findAll { it.id > 5 }.size()", greaterThan(0));

If those one-liners feel hard to read, use extract to pull values out, then assert with normal Java code.

Step 10 — Reusable RequestSpecification and ResponseSpecification

Centralize defaults to avoid copy-paste URIs and headers.

import io.restassured.builder.RequestSpecBuilder; import io.restassured.builder.ResponseSpecBuilder; import io.restassured.http.ContentType; import io.restassured.specification.RequestSpecification; import io.restassured.specification.ResponseSpecification; import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; public class SpecExample { private RequestSpecification requestSpec; private ResponseSpecification responseSpec; @BeforeClass public void setup() { requestSpec = new RequestSpecBuilder() .setBaseUri("https://jsonplaceholder.typicode.com") .setContentType(ContentType.JSON) .addHeader("Accept", "application/json") .build(); responseSpec = new ResponseSpecBuilder() .expectStatusCode(200) .expectContentType(ContentType.JSON) .expectResponseTime(lessThan(5000L)) .build(); } @Test public void reuseSpecs() { given() .spec(requestSpec) .when() .get("/users/1") .then() .spec(responseSpec) .body("id", equalTo(1)); } }

Global response spec: RestAssured.responseSpecification = responseSpec; applies defaults to every then()—handy in a base class, but easy to break tests that expect an error status (404, 409, …). Prefer explicit .spec(responseSpec) unless every test in the suite expects the same happy path.

Step 11 — JSON Schema validation

You catch breaking JSON shape changes early when you compare the response to a JSON Schema file. Put schema files under src/test/resources/schemas/.

import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath; @Test public void matchesClasspathSchema() { given() .baseUri("https://jsonplaceholder.typicode.com") .when() .get("/users/1") .then() .assertThat() .body(matchesJsonSchemaInClasspath("schemas/user-schema.json")); }

Example src/test/resources/schemas/user-schema.json:

{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["id", "name", "email"], "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "username": { "type": "string" }, "email": { "type": "string", "format": "email" }, "address": { "type": "object", "properties": { "street": { "type": "string" }, "city": { "type": "string" }, "zipcode": { "type": "string" } } } } }

When many teams own different services, pair JSON Schema checks in REST Assured with a contract tool like Pact and a broker (contract testing guide): schema files describe today’s JSON layout; Pact-style contracts describe who agreed to what as APIs change over time.

Step 12 — Logging and failure diagnosis

@Test public void logAll_onDemand() { given() .log() .all() .baseUri("https://jsonplaceholder.typicode.com") .when() .get("/users/1") .then() .log() .all() .statusCode(200); } @Test public void logOnlyIfValidationFails() { given() .log() .ifValidationFails() .baseUri("https://jsonplaceholder.typicode.com") .when() .get("/users/1") .then() .log() .ifValidationFails() .body("id", equalTo(999)); // intentional fail → logs appear }

Use ifValidationFails() in CI so logs stay small on green builds but full request/response appears when something breaks. Save Surefire or TestNG HTML/XML output from the build as downloadable build attachments so people can open them after the job ends.

Step 13 — Framework layout teams can maintain

A structure that scales past a single class:

src/test/java/com/example/api/ base/BaseApiTest.java // request/response specs, RestAssured.reset() clients/UserClient.java // small methods that call the API and return Response models/User.java // fields that match your JSON tests/users/UserContractIT.java src/test/resources/ schemas/user.json config/staging.properties
  • BaseApiTest: set baseUri from env (API_BASE_URL) or properties; avoid hard-coded prod hosts.
  • Clients: one method per area of the API (getUser(id), createUser(user)); tests check the Response or reuse shared matchers.
  • Config: never commit tokens; use env vars or CI secret stores.

Data-driven tests (TestNG)

@DataProvider(name = "userIds") public Object[][] userIds() { return new Object[][] {{1}, {2}, {3}}; } @Test(dataProvider = "userIds") public void users_exist(int id) { given() .baseUri("https://jsonplaceholder.typicode.com") .pathParam("id", id) .when() .get("/users/{id}") .then() .statusCode(200) .body("id", equalTo(id)); }

CI/CD and hygiene

  • Run: mvn -B -q test (or ./mvnw) on every PR that touches API clients or shared Java types used in tests.
  • Parallel runs: Running many TestNG threads at once can overload a shared test API—lower the thread count or give each CI job its own API key where the vendor allows it.
  • Isolation: reset state with RestAssured.reset() in @AfterMethod if tests mutate static filters or specs.
  • Retries: prefer fixing flakiness over blind HTTP retries; if you must retry, use RestAssured filters intentionally and log retry count.

Common pitfalls

  • Leaking secrets in logs when .log().all() is left on for auth headers.
  • Time limits set too tight on public APIs or slow CI machines.
  • Groovy-style JSONPath inside then() without team agreement—new teammates get lost; move complex checks into Java helpers.
  • Ignoring redirects (www vs non-www)—set baseUri to the same host your app uses in real life.
  • Schema without versioning—check schemas into Git and review diffs like application code.

Conclusion

REST Assured gives Java teams a readable way to automate REST checks—from a single smoke test up to a larger setup with shared request settings, JSON Schema files, and pulling fields out of JSON for reuse. Used well, it shortens the loop on the same HTTP surfaces your services expose, which is where a lot of bugs show up first.

Takeaways

  • Use given / when / then in a steady pattern; put shared URL and headers in one reused request setup (request spec).
  • Cover verbs, status, headers, body, and schema; use time checks only as a loose warning unless you control the server and network.
  • Add Jackson or Gson when you send Java objects as JSON; match JSON field names to the API.
  • Prefer log().ifValidationFails() in CI; keep tokens in environment variables.
  • Grow toward small client classes + shared Java types + schema files; add contract testing when many teams change the same APIs.

Official hub: rest-assured.io · Source and wiki: rest-assured/rest-assured.