If you are new to Java testing, you might wonder: are JUnit 6 and Mockito competitors? Do I need both? When does Mockito replace JUnit 6? The answer is that they are not alternatives β they are complementary tools with completely different jobs. JUnit 6 runs your tests. Mockito creates the fake objects your tests use. This guide clarifies their distinct roles, shows how they integrate, and answers every question developers have about which tool does what.
What JUnit 6 Does
JUnit 6 is a test runner and test framework. Its responsibilities are:
Discovering test classes and test methods on the classpath
Managing the test lifecycle (@BeforeEach, @AfterEach, etc.)
Executing test methods and reporting pass/fail/skip
Supporting parameterized tests, dynamic tests, and parallel execution
Integrating with build tools (Maven, Gradle) and IDEs
What Mockito Does
Mockito is a mocking framework. Its responsibilities are:
Creating fake (mock) implementations of interfaces and classes
Defining return values for method calls on mocks (stubbing)
Verifying that methods were called with expected arguments
Creating spies that wrap real objects with selective overrides
Capturing method arguments for detailed inspection
Mockito does NOT run tests. JUnit 6 does NOT create mocks. They work together.
The Division of Labour
// This test uses BOTH JUnit 6 and Mockito β each doing its own job
import org.junit.jupiter.api.*; // JUnit 6: @Test, @BeforeEach, @DisplayName
import org.mockito.*; // Mockito: @Mock, @InjectMocks
import org.mockito.junit.jupiter.*; // Bridge: connects the two
import static org.junit.jupiter.api.Assertions.*; // JUnit 6: assertions
import static org.mockito.Mockito.*; // Mockito: when(), verify()
// JUnit 6 discovers and runs this class
@ExtendWith(MockitoExtension.class) // Mockito registers as a JUnit 6 extension
class OrderServiceTest {
// ---- Mockito's job: create fake dependencies ----
@Mock
private OrderRepository orderRepository; // fake repository
@Mock
private EmailService emailService; // fake email service
@InjectMocks
private OrderService orderService; // real class under test
// ---- JUnit 6's job: lifecycle management ----
@BeforeEach
void printTestStart() {
System.out.println("Test starting...");
}
// ---- JUnit 6's job: discover and execute this test ----
@Test
@DisplayName("Placing an order persists it and sends a confirmation email")
void placingOrderPersistsItAndSendsEmail() {
// ---- Mockito's job: define stub behaviour ----
Order savedOrder = new Order(1L, "[email protected]", 99.99, OrderStatus.CONFIRMED);
when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);
// ---- The actual call under test ----
Order result = orderService.placeOrder("[email protected]", 99.99);
// ---- JUnit 6's job: assert the result ----
assertNotNull(result.getId(), "Order ID must be assigned");
assertEquals(OrderStatus.CONFIRMED, result.getStatus());
// ---- Mockito's job: verify interactions ----
verify(orderRepository, times(1)).save(any(Order.class));
verify(emailService, times(1)).sendConfirmation("[email protected]");
}
}
JUnit 6 and TestNG are the two most widely used Java testing frameworks, and many teams face the choice between them when starting a new project or evaluating a migration. This guide gives you a detailed, honest, side-by-side comparison of every major feature β with real code examples for each β so you can make an informed decision based on your teamβs actual needs.
Quick Summary: Which Should You Choose?
If you need…
Choose
Standard Java unit + integration testing
JUnit 6
Complex test grouping and dependency between tests
TestNG
Spring Boot project
JUnit 6 (native support)
Parallel execution with fine-grained thread control
TestNG (more mature)
Large existing JUnit 4 codebase
JUnit 6 (Vintage engine migration)
Complex data-driven tests with flexible XML configuration
Writing tests is the most time-consuming part of software development for many Java teams. AI-powered test generation is changing that. Tools like GitHub Copilot, JetBrains AI Assistant, Diffblue Cover, and EvoSuite can generate JUnit 6 test skeletons, suggest edge cases, and even produce fully runnable test methods from your production code. This guide explores what these tools can and cannot do, shows real examples of AI-generated tests, and gives you a practical workflow for using AI assistance while maintaining test quality.
What AI Test Generation Can Do Today
Capability
Quality level
Best tool
Generate test method skeletons from class signatures
High
GitHub Copilot, JetBrains AI
Suggest edge cases for simple methods
High
GitHub Copilot, Claude
Generate parameterized test data
Medium
GitHub Copilot, Diffblue
Auto-generate full test classes with assertions
Medium
Diffblue Cover, EvoSuite
Understand complex business domain rules
Low
Requires human guidance
Generate integration tests requiring context
Low
Human-written is still better
Workflow 1: GitHub Copilot in IntelliJ IDEA
With GitHub Copilot installed, start typing a test method and Copilot suggests completions based on your production code context:
// Production class:
public class EmailValidator {
public boolean isValid(String email) {
if (email == null || email.isBlank()) return false;
return email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,}$");
}
}
// You type the class declaration and first @Test, Copilot fills in the rest:
class EmailValidatorTest {
private final EmailValidator validator = new EmailValidator();
// -- GitHub Copilot generated the following tests --
@Test
@DisplayName("Valid email address returns true")
void validEmailAddressReturnsTrue() {
assertTrue(validator.isValid("[email protected]"));
}
@Test
@DisplayName("Null email returns false")
void nullEmailReturnsFalse() {
assertFalse(validator.isValid(null));
}
@Test
@DisplayName("Empty email returns false")
void emptyEmailReturnsFalse() {
assertFalse(validator.isValid(""));
}
@Test
@DisplayName("Email without @ returns false")
void emailWithoutAtSignReturnsFalse() {
assertFalse(validator.isValid("userexample.com"));
}
// Copilot also suggested:
@ParameterizedTest(name = "Valid: {0}")
@ValueSource(strings = {
"[email protected]",
"[email protected]",
"[email protected]"
})
void validEmailFormatsReturnTrue(String validEmail) {
assertTrue(validator.isValid(validEmail),
validEmail + " should be a valid email");
}
@ParameterizedTest(name = "Invalid: {0}")
@NullAndEmptySource
@ValueSource(strings = {"plainaddress", "@missing-local.com", "missing-tld@example"})
void invalidEmailFormatsReturnFalse(String invalidEmail) {
assertFalse(validator.isValid(invalidEmail),
invalidEmail + " should be an invalid email");
}
}
JUnit 6 is excellent for verifying correctness, but measuring how fast your code runs requires a different approach. A naive long start = System.currentTimeMillis() before and after a method call is not a reliable benchmark β JVM warm-up, JIT compilation, and garbage collection all introduce noise that makes single-run timing meaningless. This guide covers professional performance testing techniques for Java: from JUnit 6βs @Timeout for basic bounds, to full JMH micro-benchmarks integrated into a Maven build.
Three Levels of Performance Testing
Level
Tool
Purpose
Accuracy
Basic bounds
JUnit 6 @Timeout
Fail if a test exceeds a time limit
Low β single cold run
Regression detection
JUnit 6 + assertTimeout
Catch performance regressions in CI
Medium
Micro-benchmarking
JMH (Java Microbenchmark Harness)
Precise throughput and latency measurement
High β warmed JVM, statistics
Level 1: @Timeout β Simple Time Bounds
Use @Timeout to fail a test if it takes too long. This catches catastrophic performance regressions (infinite loops, accidental N+1 queries, missing index):
import org.junit.jupiter.api.Timeout;
import java.util.concurrent.TimeUnit;
class OrderSearchPerformanceTest {
// @Timeout: test fails if it takes longer than the specified duration
// Applied at method level
@Test
@Timeout(value = 2, unit = TimeUnit.SECONDS)
@Tag("performance")
@DisplayName("Searching orders by customer email returns within 2 seconds")
void searchingOrdersByEmailReturnsWithinTwoSeconds() {
List<Order> results = orderRepository.findByEmail("[email protected]");
assertNotNull(results);
// If this takes >2s, test fails with:
// org.opentest4j.AssertionFailedError: method timed out after 2 seconds
}
// Apply @Timeout at class level to set a default for ALL tests in the class
@Timeout(5) // 5 seconds default for every test
class AllSearchesMustBeFast {
@Test
void searchByEmailIsWithin5Seconds() { /* ... */ }
@Test
@Timeout(1) // override: this specific test must complete in 1 second
void searchByIdIsWithin1Second() { /* ... */ }
}
}
// Global timeout: set in junit-platform.properties
// junit.jupiter.execution.timeout.default=30s (safety net for all tests)
Code coverage tells you which lines your tests execute. It does not tell you whether your tests would catch a bug if one was introduced. Mutation testing answers the harder question: if I intentionally break the code in small ways, do my tests detect it? PIT (Pitest) is the leading mutation testing tool for Java, and it integrates directly with JUnit 6. This guide shows you how to set it up, read the reports, and use the results to genuinely improve your test suite quality.
What Is Mutation Testing?
PIT automatically modifies your production code in small ways called mutations β changing a + to a -, a > to a >=, removing a method call, negating a condition. Each modified version of the code is a mutant. PIT then runs your test suite against each mutant:
Killed mutant β at least one test failed against this mutant. β Good: your tests caught the bug.
Surviving mutant β all tests passed against this mutant. β Bad: your tests did not catch this bug.
Mutation score β percentage of killed mutants. Higher is better.
A mutation score of 85% means 85% of introduced bugs would be caught by your test suite. The surviving 15% represent genuine gaps in your tests.
Setup: PIT with Maven
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.16.1</version>
<dependencies>
<!-- JUnit 5/6 Platform support for PIT -->
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.1</version>
</dependency>
</dependencies>
<configuration>
<!-- Target only your application code, not generated or config classes -->
<targetClasses>
<param>com.example.service.*</param>
<param>com.example.domain.*</param>
</targetClasses>
<!-- Only run fast unit tests (tagged 'unit') for mutation analysis -->
<targetTests>
<param>com.example.*Test</param>
</targetTests>
<!-- Mutation operators to apply -->
<mutators>
<mutator>STRONGER</mutator> <!-- recommended set for Java -->
</mutators>
<!-- Fail build if mutation score drops below 80% -->
<mutationThreshold>80</mutationThreshold>
<!-- Output formats: HTML (human), XML (CI integration) -->
<outputFormats>
<outputFormat>HTML</outputFormat>
<outputFormat>XML</outputFormat>
</outputFormats>
<!-- Verbose: show each mutant result -->
<verbose>false</verbose>
</configuration>
</plugin>
Running PIT
# Run mutation tests and generate report
mvn test-compile org.pitest:pitest-maven:mutationCoverage
# Or bind to verify phase: mvn verify
# Report generated at: target/pit-reports/YYYYMMDDHHMI/index.html
Example-based tests verify that your code behaves correctly for the specific inputs you think of. Property-based testing takes a different approach: you define the properties your code must satisfy β invariants that should hold for all valid inputs β and the framework generates hundreds or thousands of inputs automatically to try to falsify them. This finds edge cases you would never think to write yourself. jqwik is the leading property-based testing library for the JUnit Platform, and this guide covers everything you need to use it effectively.
Example-Based vs Property-Based Testing
Example-Based (@ParameterizedTest)
Property-Based (jqwik)
Inputs
You provide specific values
Framework generates random values
Coverage
Only what you thought to test
Wide random sweep + edge cases
Reproducibility
Always the same inputs
Same seed = same inputs (stored on failure)
Finding bugs
Finds bugs in known scenarios
Finds bugs in unexpected corner cases
Best for
Known rules, specific business logic
Algorithms, parsers, data transformations
Setup: Adding jqwik
<dependency>
<groupId>net.jqwik</groupId>
<artifactId>jqwik</artifactId>
<version>1.9.0</version>
<scope>test</scope>
</dependency>
<!-- jqwik runs on the JUnit Platform β ensure Surefire is 3.x -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
Your First Property: The Basics
Replace @Test with @Property and add annotated parameters. jqwik generates values for them automatically:
import net.jqwik.api.*;
import static org.junit.jupiter.api.Assertions.*;
class StringUtilsPropertyTest {
// @Property runs this test ~1000 times with different random String values
// @ForAll means jqwik generates arbitrary values of that type
@Property
void reversingAStringTwiceYieldsOriginal(@ForAll String anyString) {
// Property: reverse(reverse(s)) == s for ANY string
String reversed = StringUtils.reverse(anyString);
String doubleReversed = StringUtils.reverse(reversed);
assertEquals(anyString, doubleReversed,
"Reversing any string twice must return the original string");
}
@Property
void reversedStringHasSameLength(@ForAll String anyString) {
// Property: reversing a string never changes its length
assertEquals(anyString.length(), StringUtils.reverse(anyString).length(),
"Reversed string must have same length as original");
}
}
Everyone makes testing mistakes β but the same mistakes get made over and over by developers at every experience level. This guide collects the most damaging and most common JUnit testing anti-patterns, explains exactly why each is harmful, and shows the correct approach with before/after code. Work through this list once and you will never make these mistakes again.
Mistake 1: Testing Implementation, Not Behaviour
The most fundamental mistake. Tests that verify how code works internally break every time the implementation changes, even if the behaviour stays correct.
// WRONG: tests internal implementation details
@Test
void testOrderService() {
orderService.placeOrder(order);
// Verifying internal calls β breaks if you refactor without changing behaviour
verify(orderRepository).findByCustomerId(anyLong());
verify(cacheService).invalidate("orders:customer:1");
verify(metricsService).increment("orders.placed");
verify(auditLogger).log(any(AuditEvent.class));
// This test breaks if you rename a method, add a cache layer, or change logging
}
// CORRECT: tests observable behaviour
@Test
@DisplayName("Placing an order persists it and confirms via email")
void placingOrderPersistsItAndSendsConfirmation() {
Order placed = orderService.placeOrder(order);
// Verify what the consumer of this service observes
assertNotNull(placed.getId(), "Order must be assigned an ID");
assertEquals(OrderStatus.CONFIRMED, placed.getStatus());
verify(emailService).sendConfirmation(order.getCustomerEmail()); // external contract
// Internal caching, metrics, auditing: tested in their own unit tests
}
Mistake 2: Assertions Without Messages
// WRONG: failure message is cryptic
assertEquals("CONFIRMED", order.getStatus().name());
// Failure: expected: <CONFIRMED> but was: <PENDING>
// No context: WHICH order? After WHAT operation? In WHAT test scenario?
// CORRECT: failure message explains context
assertEquals(OrderStatus.CONFIRMED, order.getStatus(),
"Order should be CONFIRMED after successful payment authorisation, "
+ "got: " + order.getStatus()
+ ", payment result: " + order.getPaymentResult());
// Failure message now contains everything needed to diagnose without running the test again