Mastering API Testing with Quarkus: From RestAssured to Pact and jqwik
Build confidence in your Java APIs with example-based tests, consumer-provider contracts, and property-driven validation.
APIs are the backbone of modern applications, and their credibility depends entirely on how much clients can trust them. A broken response, an undocumented change, or a missed edge case can ripple across teams and systems in seconds. That’s why testing isn’t optional, it’s the safety net that keeps your services reliable as they evolve. In this hands-on tutorial, we’ll build a simple Quarkus API and put it under a full spectrum of tests: quick example-based checks with RestAssured, container-backed integration using Dev Services, contract verification through Pact, and property-based exploration with jqwik to uncover the unexpected.
Prerequisites, versions, and environment
JDK 21
Maven 3.9+
Podman or Docker installed and running (Dev Services will pull containers automatically)
Project bootstrap and dependencies
Create the project with Maven (or grab it from my Github repository)
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=quarkus-api-testing \
-DclassName="org.acme.todo.TodoResource" \
-Dpath="/api/todos" \
-Dextensions="rest-jackson,hibernate-orm-panache,jdbc-postgresql, hibernate-validator"
cd quarkus-api-testing
Modify pom.xml
Add the test library for jqwik:
<!-- jqwik property-based testing -->
<dependency>
<groupId>net.jqwik</groupId>
<artifactId>jqwik</artifactId>
<version>1.9.3</version>
<scope>test</scope>
</dependency>
Core implementation
We’ll build a minimal Todo API with Panache. It’s small but realistic enough for testing patterns.
Domain
Rename the src/main/java/org/acme/todo/MyEntity.java
to Todo.java
and replace the content with the following:
package org.acme.todo;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Entity
public class Todo extends PanacheEntity {
@NotBlank
@Size(max = 100)
@Column(nullable = false, length = 100)
public String title;
@Column(nullable = false)
public boolean done = false;
}
Key points:
Bean Validation annotations give us rules to probe with jqwik and RestAssured.
PanacheEntity includes an
id
field and CRUD helpers.
Resource
Replace the content of src/main/java/org/acme/todo/TodoResource.java
with the following:
package org.acme.todo;
import java.net.URI;
import java.util.List;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api/todos")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class TodoResource {
@GET
public List<Todo> list() {
return Todo.listAll();
}
@GET
@Path("{id}")
public Todo get(@PathParam("id") Long id) {
Todo todo = Todo.findById(id);
if (todo == null)
throw new NotFoundException();
return todo;
}
@POST
@Transactional
public Response create(@Valid Todo todo) {
todo.id = null;
todo.persist();
return Response.created(URI.create("/api/todos/" + todo.id)).entity(todo).build();
}
@PUT
@Path("{id}")
@Transactional
public Todo update(@PathParam("id") Long id, @Valid Todo changes) {
Todo todo = Todo.findById(id);
if (todo == null)
throw new NotFoundException();
todo.title = changes.title;
todo.done = changes.done;
return todo;
}
@DELETE
@Path("{id}")
@Transactional
public void delete(@PathParam("id") Long id) {
if (!Todo.deleteById(id))
throw new NotFoundException();
}
}
Configuration
src/main/resources/application.properties
# Let Dev Services provision PostgreSQL automatically
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.datasource.db-kind=postgresql
# no URL/username/password -> Dev Services autostarts a container for tests and dev
Dev Services will start PostgreSQL automatically in dev and test modes if it finds the JDBC extension and no manual config.
Verification: write tests and run them
With the API in place, it’s time to prove that it behaves as expected. Verification is not a single technique but a layered process: example-based tests to cover the happy paths, contract tests to keep providers and consumers in sync, and property-based tests to uncover edge cases you didn’t think of. In the following sections we’ll apply each style step by step, running them directly against our Quarkus application and its Dev Services-managed database.
Example-based API tests (RestAssured + @QuarkusTest)
The most direct way to verify an API is by exercising its endpoints with concrete inputs and checking the responses. Quarkus integrates smoothly with RestAssured and @QuarkusTest, letting you spin up the application in test mode and issue real HTTP calls against it. This style of test is easy to read, close to how clients actually use your API, and gives fast feedback whenever you change a resource or validation rule.
Replace the content of src/test/java/org/acme/todo/TodoResourceTest.java
with:
package org.acme.todo;
import static io.restassured.RestAssured.given;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
@QuarkusTest
class TodoResourceTest {
@Test
void create_and_get() {
Long id = given()
.contentType(ContentType.JSON)
.body("{\"title\":\"write tests\"}")
.when()
.post("/api/todos")
.then()
.statusCode(201)
.header("Location", Matchers.containsString("/api/todos/"))
.extract().jsonPath().getLong("id");
given()
.when()
.get("/api/todos/{id}", id)
.then()
.statusCode(200)
.body("title", Matchers.equalTo("write tests"))
.body("done", Matchers.equalTo(false));
}
@Test
void validation_error_on_blank_title() {
given()
.contentType(ContentType.JSON)
.body("{\"title\":\"\"}")
.when()
.post("/api/todos")
.then()
.statusCode(400); // Bean Validation kicks in
}
}
Run tests:
./mvnw test
Expected:
Maven starts a PostgreSQL Dev Service container and runs tests against it. You’ll see Testcontainers logs indicating a Postgres image being pulled and started.
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
Contract testing (Pact consumer)
In a microservices architecture, making sure each microservices works is (relatively) easy. The microservices are usually small, and easy to test. But how do you make sure the microservices work together? How do you know if the system as a whole works?
One answer is contract testing. Contract testing gives more confidence than testing individual services, but the cost is far lower than end-to-end testing.
There are a few different contract-testing frameworks out there, including Pact, Microcks, Spring Cloud Contract. Arguably the most popular contract testing solution is Pact, so it’s where the Quarkiverse support for contract testing has started.
Let’s add the consumer extension:
quarkus ext add io.quarkiverse.pact:quarkus-pact-consumer
We’ll write a consumer contract that expects a provider to return a Todo item at GET /api/todos/1
. Pact spins up a mock server from the contract, your test calls it, and Pact writes the contract file. Later, a provider test verifies the actual API against the contract.
The Quarkus Pact Consumer extension removes boilerplate. You just mark your test with @PactTestFor
and Quarkus will handle Pact’s mock server lifecycle.
src/test/java/org/acme/todo/consumer/TodoConsumerPactTest.java
package org.acme.todo.consumer;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import au.com.dius.pact.core.model.V4Pact;
import au.com.dius.pact.core.model.annotations.Pact;
import au.com.dius.pact.core.model.annotations.PactDirectory;
import io.quarkus.test.junit.QuarkusTest;
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "TodoProvider", port = "8085")
@PactDirectory("target/pacts")
@QuarkusTest // only needed if you inject Quarkus beans/clients
class TodoConsumerPactTest {
@Pact(provider = "TodoProvider", consumer = "TodoConsumer")
V4Pact pact(PactDslWithProvider builder) {
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/json");
return builder
.uponReceiving("get todo by id")
.path("/api/todos/1").method("GET")
.willRespondWith()
.status(200).headers(headers)
.body("{\"id\":1,\"title\":\"write tests\",\"done\":false}")
.toPact(V4Pact.class);
}
@Test
void consumer_parses_response() {
given()
.baseUri("http://localhost:8085")
.when()
.get("/api/todos/1")
.then()
.statusCode(200)
.body(containsString("\"title\""));
}
}
Run the consumer contract test:
mvn test -Dtest=TodoConsumerPactTest
A pact file appears under target/pacts/TodoConsumer-TodoProvider.json
. This file becomes the contract your provider must satisfy.
Contract verification (Pact provider)
Now verify the real Quarkus API satisfies the contract produced above. Let’s add the provider dependency first:
quarkus ext add io.quarkiverse.pact:quarkus-pact-provider
And now create the:
src/test/java/org/acme/todo/provider/TodoProviderPactVerificationTest.java
package org.acme.todo.provider;
import org.acme.todo.Todo;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import au.com.dius.pact.provider.junit5.HttpTestTarget;
import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider;
import au.com.dius.pact.provider.junitsupport.Provider;
import au.com.dius.pact.provider.junitsupport.loader.PactFolder;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.transaction.Transactional;
@Provider("TodoProvider")
@PactFolder("target/pacts")
@QuarkusTest
class TodoProviderPactTest {
@ConfigProperty(name = "quarkus.http.test-port", defaultValue = "8081")
int quarkusPort;
@BeforeEach
void setTarget(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", quarkusPort));
}
@BeforeEach
@Transactional
void seedData() {
if (Todo.findById(1L) == null) {
var t = new Todo();
t.title = "write tests";
t.done = false;
t.persist();
}
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verify(PactVerificationContext context) {
context.verifyInteraction();
}
}
Tell Quarkus to use port 8081 for tests so Pact can hit it:
src/test/resources/application.properties
quarkus.http.test-port=8081
Run the provider verification (it will pick up the pact file from target/pacts
by default when using local files):
mvn test -Dtest=TodoProviderPactTest
Pact JUnit 5 details and annotations are defined in the official docs.
Property-based testing with jqwik
Property-based testing is different from the usual “example-based” tests you write with JUnit and RestAssured. Instead of checking a few handpicked cases, a property-based framework like jqwik generates hundreds of random inputs to see if your code always respects its invariants. For example, if you say “a slug should only contain lowercase letters, digits, and dashes,” jqwik will try strings of varying length, whitespace, emojis, or even null values until it finds a counterexample. If it breaks, jqwik shrinks the failing input to the smallest case that still reproduces the bug, giving you a precise, reproducible test. This approach is especially valuable for APIs, where user input is unpredictable and edge cases often slip through ordinary unit tests.
We’ll test a small utility that generates URL-safe slugs. The property: slug only contains lowercase letters, digits, and dashes, and length is ≤ 100. jqwik will generate hundreds of inputs for you.
src/main/java/org/acme/todo/Slugger.java
package org.acme.todo;
public final class Slugger {
private Slugger() {
}
public static String slugify(String input) {
if (input == null)
return "";
String s = input.trim().toLowerCase();
s = s.replaceAll("[^a-z0-9]+", "-");
s = s.replaceAll("^-+|-+$", "");
return s.length() > 100 ? s.substring(0, 100) : s;
}
}
src/test/java/org/acme/todo/SluggerProperties.java
package org.acme.todo;
import static org.junit.jupiter.api.Assertions.*;
import net.jqwik.api.Arbitraries;
import net.jqwik.api.Arbitrary;
import net.jqwik.api.ForAll;
import net.jqwik.api.Property;
import net.jqwik.api.Provide;
import net.jqwik.api.constraints.StringLength;
class SluggerProperties {
@Property
void onlySafeChars(@ForAll @StringLength(max = 200) String any) {
String slug = Slugger.slugify(any);
assertTrue(slug.matches("[a-z0-9-]*"));
}
@Property
void trimmedAndBounded(@ForAll String any) {
String slug = Slugger.slugify(" " + any + " ");
assertTrue(slug.length() <= 100);
}
@Property
void emptyOrNullBecomesEmpty(@ForAll("empties") String s) {
assertTrue(Slugger.slugify(s).isEmpty());
}
@Provide
Arbitrary<String> empties() {
return Arbitraries.of("", " ", null);
}
}
Run jqwik together with other tests; it’s a JUnit 5 engine and coexists with Jupiter tests. Let’s run all the tests together:
quarkus test
You can see the result in the console:
2025-09-10 11:28:25,969 INFO [io.qua.test] (Test runner thread) Running 1/6. Running: org.acme.todo.TodoResourceTest#create_and_get()
2025-09-10 11:28:25,992 INFO [io.qua.test] (Test runner thread) Running 2/6. Running: org.acme.todo.TodoResourceTest#validation_error_on_blank_title()
2025-09-10 11:28:25,998 INFO [io.qua.test] (Test runner thread) Running 3/6. Running: org.acme.todo.consumer.TodoConsumerPactTest#TodoConsumerPactTest
2025-09-10 11:28:25,998 INFO [io.qua.test] (Test runner thread) Running 3/6. Running: org.acme.todo.consumer.TodoConsumerPactTest#consumer_parses_response()
2025-09-10 11:28:26,110 WARNING [org.jun.jup.eng.des.AbstractExtensionContext] (Test runner thread) Type implements CloseableResource but not AutoCloseable: au.com.dius.pact.consumer.junit5.JUnit5MockServerSupport
2025-09-10 11:28:26,110 INFO [au.com.diu.pac.con.jun.PactConsumerTestExt$Companion] (Test runner thread) Writing pacts out to directory from @PactDirectory annotation
2025-09-10 11:28:26,112 INFO [io.qua.test] (Test runner thread) Running 4/6. Running: org.acme.todo.provider.TodoProviderPactTest#TodoProviderPactTest
2025-09-10 11:28:26,113 INFO [io.qua.test] (Test runner thread) Running 4/6. Running: org.acme.todo.provider.TodoProviderPactTest#verify(PactVerificationContext)
2025-09-10 11:28:26,120 INFO [io.qua.test] (Test runner thread) Running 4/6. Running: org.acme.todo.provider.TodoProviderPactTest#TodoConsumer - get todo by id
Verifying a pact between TodoConsumer and TodoProvider
[Using File target/pacts/TodoConsumer-TodoProvider.json]
get todo by id
returns a response which
has status code 200 (OK)
has a matching body (OK)
2025-09-10 11:28:26,126 WARN [au.com.diu.pac.pro.DefaultTestResultAccumulator] (Test runner thread) Skipping publishing of verification results as it has been disabled (pact.verifier.publishResults is not 'true')
2025-09-10 11:28:26,130 INFO [io.quarkus] (Test runner thread) quarkus-api-testing(test application) stopped in 0.003s
2025-09-10 11:28:26,131 INFO [io.qua.test] (Test runner thread) Running 5/6. Running: #jqwik (JUnit Platform)
2025-09-10 11:28:26,131 INFO [io.qua.test] (Test runner thread) Running 5/6. Running: org.acme.todo.SluggerProperties#SluggerProperties
2025-09-10 11:28:26,131 INFO [io.qua.test] (Test runner thread) Running 5/6. Running: org.acme.todo.SluggerProperties#emptyOrNullBecomesEmpty
timestamp = 2025-09-10T11:28:26.132554, SluggerProperties:emptyOrNullBecomesEmpty =
|-----------------------jqwik-----------------------
tries = 3 | # of calls to property
checks = 3 | # of not rejected calls
generation = EXHAUSTIVE | parameters are exhaustively generated
after-failure = SAMPLE_FIRST | try previously failed sample, then previous seed
when-fixed-seed = ALLOW | fixing the random seed is allowed
edge-cases#mode = MIXIN | edge cases are mixed in
edge-cases#total = 0 | # of all combined edge cases
edge-cases#tried = 0 | # of edge cases tried in current run
seed = -1616141622311026153 | random seed to reproduce generated values
2025-09-10 11:28:26,132 INFO [io.qua.test] (Test runner thread) Running 6/6. Running: org.acme.todo.SluggerProperties#onlySafeChars
timestamp = 2025-09-10T11:28:26.148656, SluggerProperties:onlySafeChars =
|-----------------------jqwik-----------------------
tries = 1000 | # of calls to property
checks = 1000 | # of not rejected calls
generation = RANDOMIZED | parameters are randomly generated
after-failure = SAMPLE_FIRST | try previously failed sample, then previous seed
when-fixed-seed = ALLOW | fixing the random seed is allowed
edge-cases#mode = MIXIN | edge cases are mixed in
edge-cases#total = 3 | # of all combined edge cases
edge-cases#tried = 3 | # of edge cases tried in current run
seed = -2574161603354934649 | random seed to reproduce generated values
2025-09-10 11:28:26,148 INFO [io.qua.test] (Test runner thread) Running 7/6. Running: org.acme.todo.SluggerProperties#trimmedAndBounded
timestamp = 2025-09-10T11:28:26.161475, SluggerProperties:trimmedAndBounded =
|-----------------------jqwik-----------------------
tries = 1000 | # of calls to property
checks = 1000 | # of not rejected calls
generation = RANDOMIZED | parameters are randomly generated
after-failure = SAMPLE_FIRST | try previously failed sample, then previous seed
when-fixed-seed = ALLOW | fixing the random seed is allowed
edge-cases#mode = MIXIN | edge cases are mixed in
edge-cases#total = 3 | # of all combined edge cases
edge-cases#tried = 3 | # of edge cases tried in current run
seed = -425603960744887077 | random seed to reproduce generated values
All 7 tests are passing (0 skipped), 7 tests were run in 516ms.
The log shows the full test suite in action, moving through each layer of verification. First, the example-based RestAssured tests run against the live Quarkus test application: one creates and fetches a Todo, the other checks that invalid input is rejected.
Next, the Pact consumer test spins up a mock provider and writes a contract file, followed by the Pact provider test, which launches Quarkus, seeds the database, and verifies the actual API against the consumer contract.
After that, jqwik property-based tests execute, generating hundreds of inputs to confirm the Slugger
utility never breaks its invariants. The detailed jqwik output shows seeds, generation modes, and how many trials were performed.
Finally, Quarkus reports that all 7 tests passed in just over half a second, proving that the API behaves correctly under fixed examples, cross-service contracts, and randomized input generation.
4.5 Coverage (optional)
You can add Quarkus’ coverage guide instructions to measure unit and integration coverage in one go. Make sure to read below article about it!
Know What You’re Testing: Mastering Code Coverage in Quarkus with JaCoCo
This tutorial will walk you through the essentials of test coverage in Quarkus. You'll learn how to generate coverage reports, understand what they mean, and use them to improve your tests. We'll be using JaCoCo, the de facto standard for code coverage in Java, which integrates smoothly with Quarkus.
Production, performance, and security notes
Keep Pact contracts in CI and fail builds on breaking changes. Prefer a Pact Broker for versioned publishing and verification across teams.
Stabilize test data. Use known seeds for integration tests. Avoid time-dependent assertions.
Dev Services is perfect for dev and CI, but your staging pipeline should also run against managed infrastructure to catch config drift.
Run property-based tests with shorter sample sizes in CI for speed; run extended samples nightly.
For API security tests, add a layer with JWT or OIDC and use RestAssured to attach tokens; Quarkus testing docs show injection patterns you can build on.
Links to docs
Quarkus testing guide (JUnit 5, RestAssured, injection, native).
Dev Services (overview) and for databases.
APIs deserve tests that inspire confidence and catch surprises before users do. By combining example-based checks, contract verification, and property-driven exploration, you cover the predictable paths, enforce trust between services, and expose the hidden edges that only randomized inputs will reveal. This layered approach not only shortens feedback cycles for developers, but also builds lasting reliability into the interfaces your clients depend on every day.