Master REST API Testing with REST-assured in Spring Boot
Learn REST-assured testing in Spring Boot with production-tested patterns, debugging tips, and real incident stories from senior Java developers..
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Basic knowledge of Spring Boot and REST APIs
- ✓Java 17+ and Maven/Gradle
- ✓Familiarity with JUnit 5
- Use REST-assured for fluent, readable REST API tests in Spring Boot.
- Leverage @SpringBootTest with webEnvironment and @LocalServerPort for integration tests.
- Validate JSON responses with JsonPath for precise assertions.
- Mock external services with WireMock to isolate API tests.
- Integrate with Testcontainers for database or message broker dependencies.
Think of REST-assured as a postman inside your code. Instead of clicking buttons, you write Java code that sends HTTP requests and checks the responses automatically. It's like having a robot that can test your API endpoints every time you make a change.
If you're building REST APIs with Spring Boot, you need to test them. Not just unit tests that mock everything, but real integration tests that hit actual endpoints. That's where REST-assured comes in. It's a Java DSL that makes HTTP testing feel like natural language. You write something like given().when().get("/api/orders").then().statusCode(200) and it just works.
I've seen teams spend weeks debugging API contracts that broke because they only tested with Postman manually. The moment you automate with REST-assured, you catch regressions fast. But there's a catch: REST-assured has its own pitfalls. Port conflicts, SSL issues, and flaky tests due to async processing are common.
In this article, I'll show you how to set up REST-assured in a Spring Boot project the right way. We'll cover configuration, request/response validation, authentication, and advanced patterns like mocking external services. I'll also share war stories from production where REST-assured tests saved us (or failed us). By the end, you'll be able to write robust API tests that you can trust.
Setting Up REST-assured in Spring Boot
Let's get REST-assured into your project. I prefer using the latest version, currently 5.4.0, but check Maven Central. Add the dependency to your pom.xml:
``xml <dependency> <groupId>io.rest-assured</groupId> <artifactId>rest-assured</artifactId> <scope>test</scope> </dependency> ``
If you're using Spring Boot's dependency management, you don't need a version. But watch out: Spring Boot might bring an older version. I always override to the latest stable. Also add the JSON path support:
``xml <dependency> <groupId>io.rest-assured</groupId> <artifactId>json-path</artifactId> <scope>test</scope> </dependency> ``
Now, create a test class. The key is to use @SpringBootTest with a random port. Why random? Because fixed ports cause conflicts in CI. Here's a typical setup:
```java @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) class OrderControllerTest {
@LocalServerPort private int port;
@BeforeEach void setUp() { RestAssured.port = port; RestAssured.baseURI = "http://localhost"; } // tests... } ```
I've seen people forget to set the port and then wonder why tests fail. The @LocalServerPort annotation injects the actual random port. Don't hardcode it.
One more thing: if you have multiple test classes, consider using @DirtiesContext to avoid context caching issues. This is especially important when you have stateful beans.
Writing Your First REST-assured Test
Let's write a simple test for a GET endpoint. Suppose we have an endpoint that returns a list of orders. I'll show you the test, then break it down.
``java @Test void shouldReturnOrders() { ``given() .param("page", 0) .param("size", 10) .when() .get("/api/orders") .then() .statusCode(200) .body("content.size()", greaterThan(0)) .body("content[0].customerId", equalTo(1)); }
Notice the static methods: , given(), when(). This is the BDD style. then() sets up request parameters, headers, body. given() executes the HTTP method. when() asserts on the response.then()
For query parameters, use .param(). For path parameters, use .pathParam(). For headers, .header(). For authentication, .auth().
Now, what if you need to extract a value from the response for subsequent tests? Use .extract():
``java int orderId = ``given() .contentType(JSON) .body(orderJson) .when() .post("/api/orders") .then() .statusCode(201) .extract() .path("orderId");
This is powerful for chaining requests. But be careful: extracted values can be null if the path is wrong. Always validate the response structure first.
One mistake I often see: using for debugging but leaving it in the final code. Remove logging assertions in production tests to keep logs clean.then().log().all()
extract() to get the newly created invoice ID and then immediately fetch it to verify persistence. This caught a race condition where the invoice was created but not yet indexed.extract() to capture response data for chained tests, but validate the response first.Validating JSON Responses with JsonPath
REST-assured uses JsonPath under the hood. You can write complex assertions like:
``java .body("orders.find { it.status == 'PENDING' }.total", greaterThan(100.0)) ``
This filters the orders array for pending orders and checks the total. JsonPath syntax is powerful but can be tricky. I recommend testing your JsonPath expressions in isolation first using a tool like JsonPath Tester.
Common gotcha: when using with Hamcrest matchers, the matcher must match the entire value. For partial matches, use body()containsString() or hasItem().
For arrays, you can assert size:
``java .body("orders.size()", equalTo(5)) ``
If the response has nested objects, use dot notation:
``java .body("customer.name", equalTo("John")) ``
But what if the key contains special characters? Escape them with brackets:
``java .body("['customer-name']", equalTo("John")) ``
One more thing: if you need to validate the entire response structure, consider using JSON Schema validation. REST-assured supports it via body(matchesJsonSchemaInClasspath("order-schema.json")). This is great for contract testing.
containsInAnyOrder() for array assertions.Handling Authentication and Headers
Most APIs require authentication. REST-assured supports various auth schemes out of the box:
- Basic Auth:
given().auth().preemptive().basic("user", "pass") - OAuth2:
given().auth().oauth2("token") - Custom header:
given().header("Authorization", "Bearer " + token)
I prefer using preemptive basic auth for tests because it avoids an extra challenge request. But in production, you'd use OAuth2.
For session-based auth, you can use filters:
``java ``given().filter(new SessionFilter()).get("/api/orders")
But that's less common.
Now, a real-world scenario: you have an API that requires a CSRF token. You need to first fetch the token, then include it in subsequent requests. Here's how I do it:
```java // First, get CSRF token String csrfToken = given() .get("/api/csrf") .then() .extract() .path("token");
// Then, use it in POST given() .header("X-CSRF-TOKEN", csrfToken) .contentType(JSON) .body(orderJson) .when() .post("/api/orders") .then() .statusCode(201); ```
This pattern is common in Spring Security setups. But be careful: if the CSRF token changes per session, you need to reuse the same session. Use SessionFilter to maintain cookies.
What the Official Docs Won't Tell You
Here's the hard truth: REST-assured documentation is good but leaves out several production gotchas.
- Static port configuration is global. If you set
RestAssured.portin one test, it affects all tests running in the same JVM. Always reset it in@BeforeEachor use a freshRequestSpecificationper test class. @DirtiesContextis your friend. Without it, Spring caches the application context across test classes. If one test class modifies the database, other tests see dirty data. Use@DirtiesContext(classMode = AFTER_CLASS)to force a fresh context.- REST-assured doesn't automatically follow redirects. If your API returns 302, you need to disable redirects or handle it manually. Use
.given().redirects().follow(false) - Multipart uploads are tricky. The API for file uploads is not intuitive. You need to use
. But if you forget to set the content type, it defaults togiven().multiPart("file", new File("test.txt"))application/octet-stream, which may not be what your server expects. - SSL in tests. If you're testing HTTPS locally with a self-signed certificate, use
. But never use this in production tests.given().relaxedHTTPSValidation() - Parallel test execution. REST-assured is not thread-safe by default. If you run tests in parallel, each thread must have its own
RestAssuredinstance or you'll get port conflicts. Use@TestInstance(Lifecycle.PER_CLASS)and reset state in@BeforeEach. - Logging can mask failures. I've seen teams add
to every test and then ignore the output. Only log when a test fails. Uselog().all()to automatically log on failure.given().log().ifValidationFails()
@DirtiesContext on every test method. We moved it to class level and reduced execution time by 80%.Mocking External Services with WireMock
Your API probably calls external services. In tests, you don't want to hit real endpoints. WireMock is the perfect companion. It runs a mock HTTP server that you can program to return specific responses.
Add WireMock dependency:
``xml <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> <scope>test</scope> </dependency> ``
But I prefer using WireMock standalone. Here's an example:
```java @SpringBootTest(webEnvironment = RANDOM_PORT) @WireMockTest(httpPort = 8089) class PaymentServiceTest {
@Test void shouldProcessPayment() { stubFor(post(urlEqualTo("/payments")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") .withBody("{\"status\":\"SUCCESS\"}")));
given() .contentType(JSON) .body(paymentRequest) .when() .post("/api/payments") .then() .statusCode(200) .body("status", equalTo("SUCCESS")); } } ```
You need to configure your Spring Boot app to use the WireMock port for external calls. I usually set the external service URL in application-test.properties:
``properties payment.service.url=http://localhost:8089 ``
And activate the test profile in your test class.
WireMock also supports request matching, delays, and fault injection. Use withFixedDelay(2000) to simulate slow responses.
Integrating with Testcontainers for Database Tests
If your API interacts with a database, you need a real database in your tests. Testcontainers spins up a Docker container with your database. This is much better than using an in-memory H2, which behaves differently from production databases.
Add Testcontainers dependency:
``xml <dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <scope>test</scope> </dependency> ``
Then, use @Testcontainers and @Container:
```java @Testcontainers @SpringBootTest(webEnvironment = RANDOM_PORT) class OrderRepositoryTest {
@Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15") .withDatabaseName("testdb") .withUsername("test") .withPassword("test");
@DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); }
@Test void shouldSaveOrder() { // REST-assured test that uses the real database } } ```
This ensures your tests run against the same database dialect as production. The container is reused across tests, but you can clean data between tests using @Sql or @DirtiesContext.
One gotcha: Testcontainers requires Docker. In CI, you need a Docker daemon. Many CI providers support it, but if not, you can fall back to H2. I recommend using a Maven profile to conditionally use Testcontainers.
The Silent Port Conflict That Took Down CI
BindException: Address already in use when running REST-assured tests.@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) without @DirtiesContext, causing the same port to be reused across test suites in parallel runs.@DirtiesContext(classMode = ClassMode.AFTER_CLASS) to each test class and configured Maven Surefire to fork per test class.- Always use random ports in integration tests, never fixed ports.
- Use @DirtiesContext to reset context between test classes to avoid port conflicts.
- Configure test execution to fork per class or use unique port ranges.
- Monitor CI logs for BindExceptions—they are often a sign of port contention.
- Consider using Testcontainers to manage containerized dependencies, which also helps with port isolation.
given().relaxedHTTPSValidation().lsof -i :<port>ps aux | grep java| File | Command / Code | Purpose |
|---|---|---|
| OrderControllerTest.java | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) | Setting Up REST-assured in Spring Boot |
| OrderApiTest.java | @Test | Writing Your First REST-assured Test |
| JsonPathAssertionTest.java | given() | Validating JSON Responses with JsonPath |
| AuthTest.java | given() | Handling Authentication and Headers |
| AdvancedConfigTest.java | given() | What the Official Docs Won't Tell You |
| WireMockTest.java | stubFor(post(urlEqualTo("/payments")) | Mocking External Services with WireMock |
| TestcontainersTest.java | @DynamicPropertySource | Integrating with Testcontainers for Database Tests |
Key takeaways
Interview Questions on This Topic
How does REST-assured differ from MockMvc in Spring Boot testing?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Spring Boot. Mark it forged?
5 min read · try the examples if you haven't