Modern Quarkus Testing in 2026: What Java Developers Missed
The classloading rewrite, CDI component tests, and scoped infrastructure that changed how serious Java teams write test suites.
Most developers think testing in Quarkus is already solved. You write a few @QuarkusTest classes, start the application in test mode, and verify your endpoints or services. For small applications this works fine. The mental model is simple: tests boot Quarkus, run assertions, and shut everything down again.
The problem shows up when the codebase grows. Large enterprise projects easily accumulate hundreds or thousands of tests. Startup time becomes noticeable. Containers start unnecessarily. Infrastructure leaks between test classes. Suddenly your CI pipeline takes fifteen minutes to run a test suite that should take two.
The second problem is architectural. Developers mix integration tests, component tests, and infrastructure tests in the same environment. A simple service test spins up a message broker, PostgreSQL, and an HTTP server. Not because it needs them, but because the testing model makes it convenient.
Quarkus quietly fixed many of these issues in recent releases. Since Quarkus 3.22, the testing architecture changed in several important ways. The classloading model underneath @QuarkusTest was rewritten. Lightweight CDI component tests became production-ready. And the new @WithTestResource model replaced the global infrastructure approach many teams struggled with.
This tutorial walks through those changes and shows how to use them in practice. We will build a small Quarkus service and test it using three different approaches: full application tests, fast CDI component tests, and scoped infrastructure tests.
Prerequisites
To follow this tutorial you need a working Java and Quarkus development environment. We assume you already know how to write REST endpoints and basic JUnit tests.
Java 21 installed
Maven 3.9+
Quarkus CLI installed
Basic knowledge of CDI and REST endpoints
Project Setup
Create a new Quarkus project or start from the full example in my Github repository:
quarkus create app com.example:quarkus-testing-2026 \
--extension=quarkus-rest-jackson,quarkus-jdbc-postgresql,quarkus-hibernate-orm-panache,quarkus-redis-client
cd quarkus-testing-2026Extensions explained:
quarkus-rest-jackson – REST endpoints used in integration tests
quarkus-jdbc-postgresql – PostgreSQL access via Dev Services
quarkus-hibernate-orm-panache – simple entity persistence
quarkus-redis-client – Redis client for a lightweight audit trail (we use it instead of a heavier message broker)
Quarkus Dev Services automatically starts PostgreSQL for dev and test mode. No container setup required. We do not explicitly have to add any test dependencies. quarkus-junit will be included in the scaffolding. Check the pom.xml.
Implementing a Simple Domain
Before writing tests we need a minimal application. We’ll build a small order service. This keeps the focus on the testing model instead of application complexity.
Order Entity
Create a Panache entity by renaming the scaffolded “MyEntity” and replacing it with the following:
package com.example;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "orders")
public class Order extends PanacheEntity {
@Column(nullable = false)
public String customerId;
@Column(nullable = false)
public String product;
public Order() {
}
public Order(String customerId, String product) {
this.customerId = customerId;
this.product = product;
}
}
Panache entities provide an active-record style API. Methods like persist() or listAll() are injected via bytecode enhancement during build time. This detail matters later when we discuss the classloading rewrite.
OrderRepository
Create a repository class.
package com.example;
import java.util.List;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class OrderRepository implements PanacheRepository<Order> {
public List<Order> findByCustomer(String customerId) {
return list("customerId", customerId);
}
}This repository extends PanacheRepository<Order>. Quarkus generates most persistence methods automatically:
persist()findById()listAll()delete()
We only add the domain-specific query findByCustomer.
The important detail is the CDI scope. @ApplicationScoped turns the repository into a CDI bean. This allows us to inject it into services and replace it with a mock in component tests.
Order Service
Now we implement the service layer. Create OrderService.java:
package com.example;
import java.util.List;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class OrderService {
@Inject
OrderRepository orderRepository;
public List<Order> findByCustomer(String customerId) {
return orderRepository.findByCustomer(customerId);
}
}The @Transactional annotation creates a transaction boundary around the persist() method. If persistence fails, Quarkus rolls back automatically.
We also integrate a lightweight side effect: an audit trail. When an order is created, we append its id to a Redis list so other systems (or tests) can observe it without coupling to a heavy message broker.
Order Audit (Redis)
Create a small bean that records each created order id in Redis:
package com.example;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.redis.datasource.RedisDataSource;
import io.quarkus.redis.datasource.list.ListCommands;
@ApplicationScoped
public class OrderAudit {
private static final String LIST_KEY = "orders:created";
private final ListCommands<String, String> listCommands;
public OrderAudit(RedisDataSource redis) {
this.listCommands = redis.list(String.class);
}
public void recordCreated(Order order) {
listCommands.rpush(LIST_KEY, String.valueOf(order.id));
}
}
Inject OrderAudit into OrderService and call orderAudit.recordCreated(order) after persist(order). This gives us a second integration point we can test: in component tests we mock OrderAudit; in full application tests we assert against Redis.
Updated OrderService snippet (only the create path):
@Inject
OrderAudit orderAudit;
@Transactional
public Order create(String customerId, String product) {
Order order = new Order(customerId, product);
orderRepository.persist(order);
orderAudit.recordCreated(order);
return order;
}
REST Endpoint
Finally we expose the service via HTTP. Rename the scaffolded GreetingResource and replace with the following:
package com.example;
import java.util.List;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
@Path("/orders")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OrderResource {
@Inject
OrderService orderService;
@POST
public Order create(Order order) {
return orderService.create(order.customerId, order.product);
}
@GET
public List<Order> list(@QueryParam("customerId") String customerId) {
return orderService.findByCustomer(customerId);
}
}This endpoint allows us to test both service logic and HTTP integration.
The Classloading Rewrite in @QuarkusTest
For years @QuarkusTest relied on a complex workaround. Test classes executed in JUnit’s default classloader. Quarkus then serialized the test instance and transferred it into the runtime classloader using XStream.
This worked until Java 17 tightened reflection rules. Serialization became fragile and extension behaviour became inconsistent. Some bytecode transformations worked in production but failed in tests.
Quarkus 3.22 replaced this architecture completely.
Today @QuarkusTest classes run inside the same classloader as the Quarkus runtime. The result is simple but powerful: test classes behave exactly like production classes.
We do have to add the AssertJ test dependency before we can implement our tests:
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>Let’s create a full application test. Rename the scaffolded GreetingResourceTest and replace with the following:
package com.example;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
@QuarkusTest
class OrderServiceTest {
@Inject
OrderService orderService;
@Test
void persistAndReloadOrder() {
orderService.create("customer-1", "laptop");
List<Order> orders = orderService.findByCustomer("customer-1");
assertThat(orders).hasSize(1);
}
}The test looks ordinary. But something important changed internally.
Panache enhancement, CDI proxies, and interceptors now run exactly the same way they do in production. The test class itself is also subject to extension bytecode transformations.
This removes a whole category of edge cases. Previously you might see behaviour differences between production and tests. Now both environments share the same runtime classloader.
One side effect of the rewrite was that all Dev Services started during JUnit’s discovery phase, so multiple test classes with different configurations could cause duplicate containers, port conflicts, and config cross-talk. In Quarkus 3.25 a new Dev Services API changed the lifecycle: Dev Services now start after augmentation but before the application launches. Containers no longer all spin up at discovery time; they start when needed, one after the other. The Redis (and Kafka, Lambda, Narayana) extensions have been converted, so with 3.25+ you avoid that regression for this tutorial’s stack.
Breaking Changes You Need to Know
This rewrite introduced a few constraints.
The first one is Maven Surefire compatibility. Older Surefire versions do not support the classloader isolation Quarkus now requires.
Update your build plugin.
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>The second change affects nested test profiles. Each @TestProfile requires its own Quarkus instance and classloader. Mixing profiles inside nested test classes no longer works.
The third change concerns parallel test execution. Running @QuarkusTest classes concurrently was never officially supported. The new architecture makes interference more likely. Treat full application tests as sequential integration tests.
Things to watch: Test execution order can change compared to pre-3.22, and there is less “gap” time between tests (classloading is front-loaded), so tests that relied on implicit order or slow async cleanup may need to be fixed. See the test classloading rewrite blog for full details and known regressions.
Fast CDI Tests with @QuarkusComponentTest
Full application tests are correct for integration scenarios. But starting Quarkus for every small service test is expensive.
This is where @QuarkusComponentTest becomes useful.
Instead of starting the whole application, it boots only two subsystems:
the CDI container
the configuration system
No HTTP server. No database. No messaging infrastructure.
Add the dependency first.
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit-component</artifactId>
<scope>test</scope>
</dependency>Now we create a component test for the service. We mock both OrderRepository and OrderAudit so the test runs without a database or Redis.
package com.example;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.Test;
import io.quarkus.test.InjectMock;
import io.quarkus.test.component.QuarkusComponentTest;
import jakarta.inject.Inject;
@QuarkusComponentTest
class OrderServiceComponentTest {
@Inject
OrderService orderService;
@InjectMock
OrderRepository repository;
@InjectMock
OrderAudit orderAudit;
@Test
void findOrdersForCustomer() {
when(repository.findByCustomer("customer-42"))
.thenReturn(List.of(new Order("customer-42", "monitor")));
List<Order> orders = orderService.findByCustomer("customer-42");
assertThat(orders).hasSize(1);
}
@Test
void createOrder_persistsAndRecordsAudit() {
doAnswer(invocation -> {
Order o = invocation.getArgument(0);
o.id = 1L;
return null;
}).when(repository).persist(any(Order.class));
Order order = orderService.create("customer-1", "tablet");
assertThat(order.id).isEqualTo(1L);
assertThat(order.customerId).isEqualTo("customer-1");
assertThat(order.product).isEqualTo("tablet");
verify(repository).persist(any(Order.class));
verify(orderAudit).recordCreated(order);
}
}This test starts in milliseconds. The CDI container injects the service normally, while @InjectMock replaces the dependencies with Mockito mocks. For persist() we use doAnswer to simulate Panache setting the entity id, so the audit receives an order with a non-null id.
The result is a real dependency injection environment without application startup overhead.
Container Lifecycle
The lifecycle follows JUnit’s @TestInstance configuration.
The default mode is PER_METHOD. The CDI container starts before each test and stops afterwards. This guarantees isolation.
If the container startup becomes expensive you can switch to PER_CLASS.
@QuarkusComponentTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ReportingServiceTest {
}The container now starts once and remains active for all tests in the class.
Bytecode Transformations Now Work
Before Quarkus 3.22, component tests could not apply extension bytecode transformations. Panache enhancement and constructor injection behaved differently.
The classloading rewrite removed this limitation.
Now component tests support the same transformations as the runtime. This means you can test Panache entities or CDI interceptors directly inside component tests.
Scoped Infrastructure with @WithTestResource
Infrastructure tests create another common problem. Containers start globally and remain active for the entire test suite.
The old @QuarkusTestResource annotation caused this behaviour. A PostgreSQL container started once and affected every test. This made suites slow and sometimes unpredictable.
Quarkus introduced @WithTestResource to solve this.
The annotation defines how infrastructure resources are shared across tests.
Restricted Scope
The first option is RESTRICTED_TO_CLASS.
@QuarkusTest
@WithTestResource(
value = PostgresTestResource.class,
scope = TestResourceScope.RESTRICTED_TO_CLASS
)
class MigrationTest {
@Test
void verifyDatabaseMigration() {
}
}The container starts before this test class runs and stops immediately afterwards. No other test sees it.
This is useful for tests that require special database states or dedicated mock servers.
Matching Resources
The default scope is MATCHING_RESOURCES.
Quarkus groups test classes that share the same resource configuration. They run inside the same Quarkus instance.
@QuarkusTest
@WithTestResource(PostgresTestResource.class)
class OrderRepositoryTest {
}
@QuarkusTest
@WithTestResource(PostgresTestResource.class)
class CustomerRepositoryTest {
}Both classes reuse the same PostgreSQL container. After both tests finish, Quarkus shuts down the container and moves to the next resource group.
This reduces startup time without creating global side effects.
Global Resources
The final scope is GLOBAL.
@QuarkusTest
@WithTestResource(
value = KeycloakTestResource.class,
scope = TestResourceScope.GLOBAL
)
class OidcIntegrationTest {
}The resource starts once and remains active for the entire test suite.
Use this only when infrastructure truly must be shared across all tests.
Compose Dev Services for Complex Integration Tests
Modern applications rarely depend on a single service. A realistic test topology often includes a database and another backing service—for example a cache or a lightweight audit sink instead of a full message broker.
Quarkus Compose Dev Services lets you describe that environment in a single compose file. We use PostgreSQL and Redis: Redis is much lighter than Kafka and fits our audit-trail use case.
Create the file.
# src/main/compose/compose.yaml
# Exposed ports are required so Quarkus ComposeLocator can discover the services (port 5432 → datasource, 6379 → Redis).
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: test
POSTGRES_DB: quarkus
ports:
- "5432"
redis:
image: redis:7
ports:
- "6379"Enable Compose Dev Services in the test profile (e.g. in application.properties or a test profile):
quarkus.compose.devservices.files=src/main/compose/compose.yaml
quarkus.datasource.db-kind = postgresql
quarkus.datasource.username = postgres
quarkus.datasource.password = test
# Create or update schema when DB is empty (Compose starts with no tables/sequences)
%dev.quarkus.hibernate-orm.schema-management.strategy=update
# Test profile: drop-and-create so each run gets a clean schema
%test.quarkus.hibernate-orm.schema-management.strategy=drop-and-createWhen your tests start, Quarkus launches the entire topology. When the test suite ends, Quarkus shuts everything down.
The same compose topology works in dev mode, integration tests, and CI pipelines. Redis Dev Services will configure the connection when Redis is present in the topology. With the new Dev Services lifecycle (Quarkus 3.25+), multiple test classes no longer cause every container to start at once during discovery; services start when needed and run one after the other, reducing port conflicts and resource use.
Tying the audit into tests
You can verify the Redis audit trail in a full application test. Inject OrderService and RedisDataSource, create an order, then assert that the orders:created list contains the new order id:
package com.example;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import io.quarkus.redis.datasource.RedisDataSource;
import io.quarkus.redis.datasource.list.ListCommands;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
@QuarkusTest
class OrderAuditRedisTest {
@Inject
OrderService orderService;
@Inject
RedisDataSource redis;
@Test
void createOrder_appendsIdToRedisAuditList() {
ListCommands<String, String> listCommands = redis.list(String.class);
String key = "orders:created";
Order order = orderService.create("audit-customer", "desk");
assertThat(order.id).isNotNull();
assertThat(listCommands.lrange(key, 0, -1)).contains(String.valueOf(order.id));
}
}This test runs against the full stack (PostgreSQL and Redis from Compose) and proves the audit path end to end without adding a heavy broker.
Production Hardening
Testing infrastructure becomes critical in large projects. Several failure modes appear when test suites grow.
Test Startup Latency
Full application tests take seconds to start. Hundreds of them slow down CI pipelines significantly.
Use @QuarkusComponentTest for service-level logic. Reserve @QuarkusTest for real integration boundaries like HTTP or persistence.
Infrastructure Contention
Global containers cause hidden coupling between tests. One test modifies the database state and another test fails unexpectedly.
Scoped resources prevent this. Use RESTRICTED_TO_CLASS when database state must remain isolated.
Parallel Execution Risks
Parallel execution sounds attractive but can break infrastructure assumptions.
For example two @QuarkusTest classes may compete for the same port or container resource. Sequential execution remains the safest model for integration tests.
CI Pipeline Stability
Unstable tests usually indicate shared infrastructure. If failures appear randomly, investigate global resources first.
Scoped test resources and component tests reduce this risk dramatically.
Verification
Start the application in test mode.
quarkus testYou should see output similar to this:
Tests run: 4, Failures: 0, Errors: 0(One @QuarkusTest for the service with persistence, two @QuarkusComponentTest methods for the service with mocks, and one @QuarkusTest for the Redis audit.)
Now verify the HTTP endpoint manually. Stop the test mode.
Start the application.
quarkus devCreate an order.
curl -X POST http://localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{"customerId":"customer-99","product":"keyboard"}'Expected output:
{
"id": 1,
"customerId": "customer-99",
"product": "keyboard"
}Fetch orders for the customer.
curl 'http://localhost:8080/orders?customerId=customer-99'Expected result:
[
{
"id": 1,
"customerId": "customer-99",
"product": "keyboard"
}
]This confirms that persistence, REST endpoints, and test infrastructure all work correctly.
Conclusion
Quarkus testing changed more than most developers noticed. The classloading rewrite removed fragile serialization tricks, @QuarkusComponentTest provides fast CDI unit tests, and @WithTestResource gives precise control over test infrastructure. Together these changes allow large test suites to remain fast, isolated, and predictable.
Using a lightweight integration like Redis for an audit trail—instead of a full message broker—keeps the topology simple and the tests fast. You still get a real second service to integrate with and verify in @QuarkusTest, while component tests mock it away and run in milliseconds.
For Java teams building serious systems, this matters because slow or unreliable tests eventually break CI pipelines and slow down delivery.


