Modern Java Testing with JUnit 6 and Quarkus
From unified versioning to fail-fast execution, discover how JUnit 6 makes testing faster, safer, and cleaner for Quarkus developers.
JUnit 6.0.0 is a major release that finally brings everything under one roof. All components now share a single version number, the baseline is Java 17, and the focus is on speed, null-safety, and a smoother developer experience.
I’ll admit it: I usually delete the default test classes that come with a new project. Most of my demos are small and short-lived, and stability isn’t the goal. But that habit also hides an uncomfortable truth: good tests are what make real software reliable, refactorable, and safe to evolve.
In this hands-on guide, we’ll look at what’s new in JUnit 6 through a Quarkus application. You’ll see how to upgrade your existing JUnit 5 tests, and maybe even find a reason to keep a few of those scaffolded tests around next time.
Prerequisites
You’ll need:
Java 17 or higher (JUnit 6 baseline)
Maven 3.9+
Quarkus CLI 3.15+ (optional)
Basic knowledge of JUnit and Quarkus testing
Bootstrap the Project
Create a new Quarkus app with REST support:
mvn io.quarkus:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=junit6-demo \
-Dextensions="rest-jackson"
cd junit6-demoIf you just want to take a quick peek, feel free to look at my Github repository.
Add JUnit 6 Dependencies
Edit your pom.xml:
<properties>
<junit.version>6.0.0</junit.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- JUnit 6 BOM -->
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Quarkus BOM or other framework BOMs below this -->
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<!-- Override to JUnit 6 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>Quarkus currently depends on JUnit 5, so overriding to 6 ensures your test engine runs the latest APIs while maintaining full Quarkus Test integration.
Null-Safety with JSpecify
JUnit 6 is the first version that directly supports JSpecify annotations, which improve nullability checking in IDEs and tools like NullAway or ErrorProne. I am deliberately not showing this here. It is. a pita to set up and I did cover it in an older post. Go, check that out. It integrates seamlessly now.
Create a Simple Service
package com.example;
import java.util.Optional;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class UserService {
public String getUserName(Long id) {
if (id == null)
return null;
return id == 1L ? "Alice" : null;
}
public Optional<String> getUserEmail(Long id) {
if (id == null || id != 1L)
return Optional.empty();
return Optional.of("alice@example.com");
}
}Write the Tests
package com.example;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
@QuarkusTest
class UserServiceTest {
@Inject
UserService userService;
@Test
void validId_returnsUser() {
var user = userService.getUserName(1L);
assertNotNull(user);
assertEquals(”Alice”, user);
}
@Test
void nullId_returnsNull() {
assertNull(userService.getUserName(null));
}
@Test
void invalidId_returnsEmptyEmail() {
assertTrue(userService.getUserEmail(99L).isEmpty());
}
}Your IDE now recognizes null contracts automatically. If you annotate parameters with @Nullable or @NonNull from JSpecify, it can warn about unsafe dereferences at compile time.
Fail-Fast Execution with CancellationToken
Large test suites often waste time continuing after a major failure. JUnit 6 introduces fail-fast mode and a CancellationToken API that stops execution immediately after the first failure.
Configure Surefire
This isn’t much different, as Surefire had the option to “fail fast” since forever (skipAfterFailureCount). But if you use the ConsoleLauncher, you now also have the new --fail-fast mode. This is helpful with CI integrations. We stick with the surefire way here and configure skipAfterFailureCount in the pom.xml:
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<configuration>
<argLine>--add-opens java.base/java.lang=ALL-UNNAMED</argLine>
<skipAfterFailureCount>1</skipAfterFailureCount>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
</systemPropertyVariables>
</configuration>
</plugin>
Example Suite
package com.example;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class FailFastDemoTest {
@Test
@Order(1)
void success() {
assertEquals(2, 1 + 1);
}
@Test
@Order(2)
void failsAndStops() {
fail("This failure triggers fail-fast mode");
}
@Test
@Order(3)
void willNotRun() {
System.out.println("Should not execute");
}
}Run:
mvn test mvn test -Dsurefire.skipAfterFailureCount=1Only the first two tests execute.
Faster Parameterized Tests with FastCSV
JUnit 6 switches to FastCSV, replacing the outdated univocity-parsers.
Parsing is faster, more compliant, and provides clearer error messages.
Calculator Service
package com.example;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int multiply(int a, int b) {
return a * b;
}
public double divide(int a, int b) {
if (b == 0)
throw new ArithmeticException("Division by zero");
return (double) a / b;
}
}CSV Tests
JUnit 5 made parameterized tests mainstream with @ParameterizedTest, letting you run one test across multiple data sets.
JUnit 6 refines that idea. @CsvSource is now more flexible, cleaner to read, and fits better with modern Java syntax.
The concept stays the same: each line in @CsvSource represents a separate test run, with values separated by commas.
Starting with JUnit 6, you can define custom string delimiters using delimiterString.
For example, delimiterString = “::” works well when your data doesn’t fit simple comma-separated formats.
Just note that delimiter and delimiterString can’t be used at the same time.
JUnit 6 supports Java text blocks, making multi-line CSV data easier to read and maintain.
This is especially useful when you have several columns or want to include inline comments for clarity.
package com.example;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
@QuarkusTest
class CalculatorTest {
@Inject
Calculator calc;
@ParameterizedTest(name = “{0} + {1} = {2}”)
@CsvSource({
“1,1,2”,
“2,3,5”,
“-5,5,0”
})
void addition(int a, int b, int expected) {
assertEquals(expected, calc.add(a, b));
}
@ParameterizedTest(name = “{0} * {1} = {2}”)
@CsvSource(delimiter = ‘|’, textBlock = “”“
2 | 3 | 6
5 | 4 | 20
-2 | 3 | -6
“”“)
void multiplication(int a, int b, int expected) {
assertEquals(expected, calc.multiply(a, b));
}
}External CSV File
Text blocks now support comments starting with #.
You can document your data sets directly inside the block without affecting test execution.
Create src/test/resources/division.csv:
#----------------------------------
# dividend,divisor,expected
#----------------------------------
dividend,divisor,expected
10,2,5.0
20,4,5.0
100,10,10.0Add the following method to CalculatorTest:
@ParameterizedTest
@org.junit.jupiter.params.provider.CsvFileSource(resources = “/division.csv”, numLinesToSkip = 1)
void divisionFromFile(int dividend, int divisor, double expected) {
assertEquals(expected, calc.divide(dividend, divisor), 0.001);
}JRE-Range Conditions
JUnit 6 raises the default baseline to Java 17 and updates its version conditions accordingly.
package com.example;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.*;
import static org.junit.jupiter.api.condition.JRE.*;
@QuarkusTest
class JreVersionTest {
@Test
@EnabledOnJre({JAVA_17, JAVA_21})
void supportedVersions() {
System.out.println(”Runs on Java 17 or 21”);
}
@Test
@DisabledForJreRange(min = JAVA_17, max = JAVA_19)
void skippedOnOldJres() {
System.out.println(”Skipped on 17–19”);
}
}
Deterministic Nested Class Ordering
JUnit 6 makes nested class execution predictable (deterministic ordering), but you should still be explicit with @Order annotations if the sequence matters to your tests.
@TestMethodOrdercontrols the order of@Testmethods within a class@TestClassOrdercontrols the order of@Nestedclasses within a parent classBoth must be configured at the appropriate level for ordering to work
Without
@TestClassOrder, JUnit 6 uses its deterministic-but-nonobvious orderingContainer classes (those with nested classes) don’t execute their own
@Testmethods
package com.example;
import org.junit.jupiter.api.ClassOrderer;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestClassOrder;
import org.junit.jupiter.api.TestMethodOrder;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class) // ← For test methods
@TestClassOrder(ClassOrderer.OrderAnnotation.class) // ← For nested classes
class NestedOrderingTest {
@Nested
@Order(1) // ← Controls when this nested class runs
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class TopLevel {
@Test
void topLevelTest() {
System.out.println(”1. Top level”);
}
}
@Nested
@Order(2) // ← InnerA runs second
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class InnerA {
@Test
@Order(1)
void a1() {
System.out.println(”2. InnerA.a1”);
}
@Test
@Order(2)
void a2() {
System.out.println(”3. InnerA.a2”);
}
}
@Nested
@Order(3) // ← InnerB runs third
@TestClassOrder(ClassOrderer.OrderAnnotation.class) // ← For its own nested classes
class InnerB {
@Nested
@Order(1) // ← Within InnerB, BTests runs before Deep
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BTests {
@Test
void b1() {
System.out.println(”4. InnerB.b1”);
}
}
@Nested
@Order(2) // ← Deep runs last
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class Deep {
@Test
void deepTest() {
System.out.println(”5. Deep.deepTest”);
}
}
}
}This ensures consistent ordering for reproducible CI pipelines. Also remeber: If ordering is critical, avoid deep nesting.
Record Test Classes
Records are valid test classes too. Great for lightweight test utilities. BUT despite that, I am having some second thoughts around this. The whole point of records is to be transparent carriers for immutable data. Using them for test classes feels like using a hammer as a screwdriver - it works, but it’s not what the tool was designed for.
package com.example;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
record CalculatorRecordTest() {
@Test
void addsCorrectly() {
var calc = new Calculator();
assertEquals(4, calc.add(2, 2));
}
@Test
void multipliesCorrectly() {
var calc = new Calculator();
assertEquals(6, calc.multiply(2, 3));
}
}
Running Your Tests
# Run everything
mvn test
# Run specific test
mvn test -Dtest=CalculatorTest
# Run in Quarkus dev mode
mvn quarkus:dev
# Press ‘r’ to re-run tests interactivelyMigration Checklist (from JUnit 5)
✅ Use Java 17+
✅ Update all JUnit dependencies to 6.0.0
✅ Upgrade Surefire/Failsafe to 3.0+
✅ Replace deprecated or removed APIs
✅ Verify CSV tests (FastCSV is stricter)
✅ Adjust JRE conditions (JAVA_8–16 removed)
✅ Remove JUnit Vintage dependencies
Key Takeaways
Unified versioning reduces dependency drift.
Built-in JSpecify support improves static analysis.
Fail-fast mode saves CI time on large test suites.
FastCSV provides cleaner, faster parameterized tests.
Java 17 baseline unlocks new language features.
Deterministic nested ordering improves reproducibility.
References
JUnit 6 modernizes the testing stack for the Java 17 era and Quarkus developers are ready to take full advantage of it.




The unified versioning approach in JUnit 6 really simplifies dependency managment. I've wasted too much time debugging version mismatches in the past, so having everything under one version number is a huge improvment for team consistancy.
Ordering is the sign of the bad design.