Testing REST APIs in Quarkus: Faster, Real, and Production-Ready
A hands-on guide for Java developers moving beyond Spring’s MockMvc to Quarkus and RestAssured
Picture this: your team’s user management service powers half the company’s microservices. Friday afternoon, a patch goes live. Minutes later, dashboards light up with 500 errors. You trace it down: a missing validation rule in the controller. A five-line bug that slipped through because nobody tested the failure path.
Spring developers often solve this with MockMvcTester, a declarative way to test controllers without spinning up the whole stack. Quarkus takes a different path. Thanks to its blazing fast startup and test integration, you don’t need mocks or slices. You run your tests against real HTTP endpoints in-process. That means you validate the same runtime stack that runs in production—serialization, filters, exception mappers, headers, and all.
That’s what we’ll explore here. We’ll build a small Quarkus User API, then write tests that cover happy paths, validation failures, exception handling, content negotiation, security, and even ETags. Along the way we’ll reflect on how this differs from Spring, and why it matters in enterprise projects.
Let’s Start with a new Quarkus Project
You can follow the tutorial step by step or directly jump to my Github repository and grab the complete, running example from there.
quarkus create app com.example:rest-testing-demo:1.0.0 \
--no-code \
-x 'rest-jackson, hibernate-validator, quarkus-elytron-security-properties-file'
cd rest-testing-demo
Why --no-code
?
By default, quarkus create app
scaffolds a sample GreetingResource
and a test class. With --no-code
, you start with a clean project. That way, the only code in your repo is what the tutorial walks you through. No distractions.
Extensions explained
rest-jackson
This is the backbone of your REST API. It brings in REST for JAX-RS endpoints and integrates with Jackson for JSON serialization and deserialization. It’s the Quarkus equivalent of having Spring MVC + Jackson.hibernate-validator
Enables Jakarta Bean Validation (@NotBlank
,@Email
, etc.) for your DTOs. In this tutorial, it ensures invalid requests (like missing fields or malformed emails) trigger structured 400 responses.elytron-security-properties-file
Adds support for authentication and authorization based on two property files:application-users.properties
: defines usernames and passwords.application-roles.properties
: maps users to roles.
This is perfect for demos, tutorials, or lightweight deployments. In production you’d probably switch to LDAP, OIDC, or SAML, but Elytron Properties keeps things simple.
We need to add two more extension to the pom.xml explicitly
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-security</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.javacrumbs.json-unit</groupId>
<artifactId>json-unit</artifactId>
<version>4.1.1</version>
<scope>test</scope>
</dependency>
test-security
Gives you the@TestSecurity
annotation. This makes it trivial to simulate authenticated users with specific roles when testing protected endpoints like/admin
.json-unit
This brings in thejsonEquals
matcher. It’s the closest Quarkus equivalent to Spring’sisLenientlyEqualTo
fromMockMvcTester
.
A little Configuration before we start
We tell Quarkus where to find the Elytron user and role files and which realm to use:
# src/main/resources/application.properties
# Security configuration
quarkus.security.users.file.enabled=true
quarkus.security.users.file.plain-text=true
quarkus.security.users.file.realm=ApplicationRealm
quarkus.security.users.file.users=application-users.properties
quarkus.security.users.file.roles=application-roles.properties
A few notes:
plain-text=true
means we don’t hash passwords for the tutorial. In production, you’d set up salted hashes.ApplicationRealm
is just a name. You could call it anything.
Create the user file src/main/resources/application-users.properties
admin=admin123
user=user123
And the roles file src/main/resources/application-roles.properties
admin=ADMIN
user=USER
The Domain
Let’s start with something simple: a User
record and a DTO for user creation.
package com.example.user;
public record User(Long id, String name, String email, String role) {}
package com.example.user;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record UserCreate(
@NotBlank String name,
@Email String email,
@NotBlank String role
) {}
We’ll validate name
, email
, and role
at the boundary. Invalid input should never slip deeper into our system.
A Tiny Repository
For the sake of the tutorial, we’ll use an in-memory repo. In production, this could be Hibernate Panache or a real database.
package com.example.user;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class UserRepo {
private final Map<Long, User> db = new ConcurrentHashMap<>();
public Optional<User> find(long id) {
return Optional.ofNullable(db.get(id));
}
public User save(long id, UserCreate in) {
var u = new User(id, in.name(), in.email(), in.role());
db.put(id, u);
return u;
}
}
Handling Errors the Quarkus Way
We’ll return structured Problem Details for both validation errors and custom exceptions. Quarkus integrates ExceptionMapper
naturally. You might remember an older tutorial here that introduces the Problem dependency. This is a light(er) approach.
package com.example.error;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Problem(String type, String title, int status, String detail, String instance) {
}
package com.example.error;
public class NotFoundException extends RuntimeException {
public NotFoundException(String msg) {
super(msg);
}
}
package com.example.error;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
@Provider
@ApplicationScoped
public class ProblemMappers implements ExceptionMapper<Throwable> {
@Override
public Response toResponse(Throwable ex) {
final int status = (ex instanceof NotFoundException) ? 404 : 400;
final String title = (status == 404) ? "Not Found" : "Bad Request";
var problem = new Problem("about:blank", title, status, ex.getMessage(), null);
return Response.status(status)
.type(MediaType.APPLICATION_JSON_TYPE)
.entity(problem)
.build();
}
}
With this in place, any unhandled exception turns into a simple RFC 9457 response.
The Resource
Here’s our JAX-RS resource. Notice how we define both JSON and text/plain endpoints, and add conditional ETag handling.
package com.example.user;
import com.example.error.NotFoundException;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.EntityTag;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Request;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
@Path("/users")
public class UserResource {
@Inject
UserRepo repo;
@Context
Request request;
@GET
@Path("/{id}")
@Produces(MediaType.APPLICATION_JSON)
public User get(@PathParam("id") long id) {
return repo.find(id).orElseThrow(() -> new NotFoundException("User " + id + " not found"));
}
@GET
@Path("/{id}")
@Produces(MediaType.TEXT_PLAIN)
public String getText(@PathParam("id") long id) {
var u = get(id);
return "%s <%s> [%s]".formatted(u.name(), u.email(), u.role());
}
@POST
@Path("/{id}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response create(@PathParam("id") long id, @Valid UserCreate in, @Context UriInfo uri) {
var saved = repo.save(id, in);
var etag = new EntityTag(Integer.toHexString(saved.hashCode()));
return Response.created(uri.getAbsolutePath())
.tag(etag)
.entity(saved)
.build();
}
@GET
@Path("/{id}/etag")
@Produces(MediaType.APPLICATION_JSON)
public Response getWithEtag(@PathParam("id") long id) {
var u = get(id);
var etag = new EntityTag(Integer.toHexString(u.hashCode()));
var pre = request.evaluatePreconditions(etag);
if (pre != null)
return pre.build(); // 304 Not Modified
return Response.ok(u).tag(etag).build();
}
}
A Trace Header for Every Response
It’s common in enterprise APIs to include correlation headers. Let’s add one with a response filter:
package com.example.common;
import java.util.UUID;
import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.ContainerResponseContext;
import jakarta.ws.rs.container.ContainerResponseFilter;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.ext.Provider;
@Provider
@ApplicationScoped
@Priority(Priorities.HEADER_DECORATOR)
public class TraceHeaderFilter implements ContainerResponseFilter {
@Override
public void filter(jakarta.ws.rs.container.ContainerRequestContext req, ContainerResponseContext res) {
MultivaluedMap<String, Object> headers = res.getHeaders();
headers.putSingle("X-Trace-Id", UUID.randomUUID().toString());
}
}
A Protected Endpoint
Finally, let’s add a simple admin-only endpoint:
package com.example.user;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
@Path("/admin")
public class AdminResource {
@GET
@RolesAllowed("ADMIN")
public Response adminOnly() {
return Response.ok("secret").build();
}
}
Writing the Tests
Quarkus makes it painless to test REST endpoints: you annotate your test classes with @QuarkusTest
and fire real HTTP calls with RestAssured. Unlike in Spring, splitting tests into multiple classes has no performance penalty, because Quarkus boots your application once for the entire test suite. That means you can organize tests by concern instead of cramming everything into one big test class.
A good practice is:
UserResourceTest
→ happy paths and JSON comparisonsUserValidationTest
→ invalid input and 400 errorsUserNotFoundTest
→ custom exceptions and 404sContentNegotiationTest
→ media types and 406/415EtagTest
→ conditional requestsHeadersTest
→ Checking for HeadersAdminResourceAuthTest
→ role-based security
Each of these is annotated with @QuarkusTest
, but the application only starts once. That’s a big win over Spring Boot, where every @SpringBootTest
can reload the context unless cached.
Happy Path with Lenient JSON Equality
package com.example.user;
import static io.restassured.RestAssured.given;
import static net.javacrumbs.jsonunit.JsonMatchers.jsonEquals;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
@QuarkusTest
class UserResourceTest {
@Test
void create_and_get_json_leniently() {
given().contentType(ContentType.JSON)
.body(new UserCreate("Markus", "markus@jboss.org", "ROLE_USER"))
.when().post("/users/42")
.then().statusCode(201)
.body(jsonEquals("""
{ "id": 42, "name": "Markus", "email": "markus@jboss.org", "role": "ROLE_USER" }
"""));
}
}
Here RestAssured provides the HTTP plumbing, while JsonUnit checks structural equality. Extra whitespace and field order don’t matter.
Validation Failures
package com.example.user;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
@QuarkusTest
class UserValidationTest {
@Test
void create_with_invalid_email_returns_problem_details() {
given().contentType(ContentType.JSON)
.body(new UserCreate("Markus", "not-an-email", "ROLE_USER"))
.when().post("/users/99")
.then().statusCode(400)
.body("title", equalTo("Bad Request"))
.body("detail", containsString("must be a well-formed email"));
}
}
Not Found Exception
package com.example.user;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
@QuarkusTest
class UserNotFoundTest {
@Test
void get_unknown_user_yields_problem_404() {
given().accept(ContentType.JSON)
.when().get("/users/404")
.then().statusCode(404)
.body("title", equalTo("Not Found"))
.body("detail", containsString("not found"));
}
}
Content Negotiation
package com.example.user;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
@QuarkusTest
class ContentNegotiationTest {
@Test
void plain_text_when_requested() {
// First create a user
given().contentType(ContentType.JSON)
.body(new UserCreate("Markus", "markus@jboss.org", "ROLE_USER"))
.when().post("/users/5")
.then().statusCode(201);
// Test content negotiation for plain text
given().accept("text/plain")
.when().get("/users/5")
.then().statusCode(200)
.contentType("text/plain")
.body(containsString("Markus <markus@jboss.org> [ROLE_USER]"));
}
}
ETags
package com.example.user;
import static io.restassured.RestAssured.given;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
@QuarkusTest
class EtagTest {
@Test
void conditional_get_returns_304_when_etag_matches() {
// First create a user
given().contentType(ContentType.JSON)
.body(new UserCreate("Test User", "test@example.com", "ROLE_USER"))
.when().post("/users/11")
.then().statusCode(201);
// Get the ETag
var etag = given().accept(ContentType.JSON)
.when().get("/users/11/etag")
.then().statusCode(200)
.extract().header("ETag");
// Test conditional GET with matching ETag
given().accept(ContentType.JSON)
.header("If-None-Match", etag)
.when().get("/users/11/etag")
.then().statusCode(304);
}
}
Headers
package com.example.user;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.emptyOrNullString;
import static org.hamcrest.Matchers.not;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class HeadersTest {
@Test
void x_trace_id_header_is_set_on_every_response() {
// We don't assert a specific status code on purpose.
// The ContainerResponseFilter runs for success and error responses alike.
given()
.when().get("/users/1")
.then()
.header("X-Trace-Id", not(emptyOrNullString()));
}
}
Why this works: the TraceHeaderFilter
is a global ContainerResponseFilter
, so Quarkus adds X-Trace-Id
to all responses, including 2xx, 4xx, and 5xx. This test keeps things simple by only asserting header presence, independent of the endpoint’s status.
Security
package com.example.user;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class AdminResourceAuthTest {
@Test
void anonymous_is_unauthorized() {
given().when().get("/admin").then().statusCode(401);
}
@Test
void user_with_wrong_role_is_forbidden() {
given().auth().preemptive().basic("user", "user123")
.when().get("/admin")
.then().statusCode(403);
}
@Test
void admin_with_correct_role_can_access() {
given().auth().preemptive().basic("admin", "admin123")
.when().get("/admin")
.then().statusCode(200)
.body(equalTo("secret"));
}
}
Start quarkus in dev mode,
quarkus dev
navigate to the Continuous Testing page (http://localhost:8080/q/dev-ui/continuous-testing
) and run all tests:
When to Use @QuarkusIntegrationTest
All the above use @QuarkusTest
, which is perfect for daily TDD. But if you want to confirm your packaged JAR or native image behaves the same, add one extra class:
// src/test/java/com/example/user/UserResourceIT.java
package com.example.user;
import io.quarkus.test.junit.QuarkusIntegrationTest;
@QuarkusIntegrationTest
class UserResourceIT extends UserResourceTest {
// runs the same tests against the packaged app
}
Spring vs Quarkus: The Real Differences
Let’s step back and compare.
In Spring, you often reach for MockMvcTester
or @WebMvcTest
because starting the full context is too slow. That introduces complexity: you slice the app, decide what beans to load, mock others, and test against a simulated dispatcher servlet. It’s fast enough, but not the same as production.
In Quarkus, tests run against the real stack by default. Startup is measured in milliseconds, so there’s no need for slices or mocks. What you test is what you’ll deploy.
Error handling differs too. In Spring, consistent Problem responses usually require @ControllerAdvice
and custom handlers. In Quarkus, you just provide an ExceptionMapper
. It’s picked up automatically in both prod and tests.
Security follows the same pattern. Spring’s @WithMockUser
works, but it couples you to Spring Security’s internals. In Quarkus, @TestSecurity
declares users and roles inline in the test. Expressing 401, 403, and 200 scenarios becomes natural.
Finally, the developer experience. Spring developers often wait seconds for a context reload between test runs. Quarkus has continuous testing built in. Change a line of code, and tests rerun instantly. This makes test-driven development feel effortless.
The result is a shift in mindset. With Spring, you optimize for speed by cutting realism. With Quarkus, you get speed and realism together.
Confidence Without Compromise
Controller tests are not just for edge cases. They are the safety net that keeps small mistakes from turning into outages. With Quarkus and RestAssured, you can test the real runtime, cover happy paths and failure paths, validate headers and security. All with tests that feel as fast as unit tests.
Spring developers will find familiar testing idioms here, but also discover a new rhythm: fewer mocks, fewer slices, less ceremony. Just real endpoints, tested quickly and confidently.
Once you’ve written tests this way, it’s hard to go back.