Skip to main content

Java Testing Best Practices

Advanced Java testing patterns using JUnit 5, Mockito, AssertJ, and mutation testing with PITest.

Overview

Effective testing prevents defects from reaching production and enables confident refactoring. This guide covers Java-specific testing patterns including modern JUnit 5 features, fluent assertions with AssertJ, strategic mocking with Mockito, and mutation testing with PITest to verify test quality.

Testing in Java has evolved significantly with JUnit 5's architecture. Unlike JUnit 4's monolithic design, JUnit 5 separates concerns into modules: Jupiter (programming model), Platform (test execution), and Vintage (backward compatibility). This modularity enables better IDE integration, parallel execution, and custom extensions.

The Java testing ecosystem emphasizes several key principles: tests should be fast (no external dependencies for unit tests), isolated (each test independent), repeatable (same result every time), and self-validating (pass/fail without manual inspection). AssertJ's fluent API improves readability, while mutation testing ensures tests actually verify behavior rather than just achieving code coverage.

Testing Philosophy

Follow the Testing Honeycomb model: prioritize integration tests with real dependencies, use unit tests for complex business logic, and maintain high mutation test scores (≥80%). See Testing Strategy for the overall approach.


Core Principles

  • JUnit 5: Use modern testing framework with parameterized tests and nested test classes
  • AssertJ: Fluent assertions for readable test code
  • Mockito: Minimal mocking, prefer real objects when possible
  • Mutation testing: PITest to verify test quality (≥80% mutation score)
  • Test data builders: Create readable, maintainable test data
  • Arrange-Act-Assert: Clear test structure

Dependencies

These dependencies provide the modern Java testing stack. JUnit 5 Jupiter is the core testing framework, Mockito enables test doubles for dependencies, AssertJ provides fluent assertions, and TestContainers allows integration testing with real Docker containers (e.g., PostgreSQL). The testRuntimeOnly dependency on junit-platform-launcher is required for IDE test execution.

build.gradle:

dependencies {
// JUnit 5
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// Mockito
testImplementation 'org.mockito:mockito-core:5.8.0'
testImplementation 'org.mockito:mockito-junit-jupiter:5.8.0'

// AssertJ
testImplementation 'org.assertj:assertj-core:3.25.1'

// TestContainers (for integration tests)
testImplementation 'org.testcontainers:testcontainers:1.19.3'
testImplementation 'org.testcontainers:postgresql:1.19.3'
testImplementation 'org.testcontainers:junit-jupiter:1.19.3'
}

tasks.named('test') {
useJUnitPlatform()
}

JUnit 5 Basics

JUnit 5 (released 2017) is a complete rewrite of JUnit 4 with a modular architecture, lambda support, and better extension mechanisms. The @DisplayName annotation provides readable test names in reports, while @BeforeEach and @AfterEach ensure test isolation by resetting state between tests.

Test Structure

import org.junit.jupiter.api.*;
import static org.assertj.core.api.Assertions.*;

@DisplayName("Payment Service")
class PaymentServiceTest {

private PaymentService paymentService;
private PaymentRepository paymentRepository;

@BeforeEach // Runs before each test method
void setUp() {
paymentRepository = new InMemoryPaymentRepository();
paymentService = new PaymentService(paymentRepository);
}

@AfterEach // Runs after each test method
void tearDown() {
paymentRepository.clear();
}

@Test
@DisplayName("should create payment with valid request") // Readable test report names
void shouldCreatePaymentWithValidRequest() {
// Arrange - set up test data
var request = PaymentRequest.builder()
.customerId("CUST-123")
.amount(new BigDecimal("100.00"))
.currency("USD")
.build();

// Act - execute the operation under test
var result = paymentService.createPayment(request);

// Assert - verify the outcome
assertThat(result).isNotNull();
assertThat(result.getId()).isNotBlank();
assertThat(result.getStatus()).isEqualTo(PaymentStatus.PENDING);
assertThat(result.getAmount()).isEqualByComparingTo("100.00");
}

@Test
@DisplayName("should throw exception when amount is negative")
void shouldThrowExceptionWhenAmountIsNegative() {
// Arrange
var request = PaymentRequest.builder()
.customerId("CUST-123")
.amount(new BigDecimal("-100.00"))
.currency("USD")
.build();

// Act & Assert
assertThatThrownBy(() -> paymentService.createPayment(request))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Amount must be positive");
}
}

AssertJ Assertions

AssertJ provides fluent, type-safe assertions that produce readable test code and informative error messages. Unlike JUnit's basic assertions, AssertJ uses method chaining for natural language-like assertions and provides specific assertion methods for common types (collections, strings, numbers).

Fluent Assertions

import static org.assertj.core.api.Assertions.*;

class PaymentAssertionsTest {

@Test
void demonstrateAssertJPatterns() {
var payment = createTestPayment();

// Object assertions with extraction - cleaner than multiple assertions
assertThat(payment)
.isNotNull()
.extracting(Payment::getId, Payment::getStatus)
.containsExactly("PAY-123", PaymentStatus.COMPLETED);

// String assertions
assertThat(payment.getId())
.isNotBlank()
.startsWith("PAY-")
.hasSize(10);

// BigDecimal assertions
assertThat(payment.getAmount())
.isPositive()
.isEqualByComparingTo("100.00")
.isBetween(new BigDecimal("0"), new BigDecimal("1000"));

// Collection assertions
assertThat(payment.getTransactionHistory())
.isNotEmpty()
.hasSize(3)
.extracting(Transaction::getType)
.containsExactly(TransactionType.CREATED,
TransactionType.AUTHORIZED,
TransactionType.COMPLETED);

// Enum assertions
assertThat(payment.getStatus())
.isEqualTo(PaymentStatus.COMPLETED)
.isNotEqualTo(PaymentStatus.FAILED);

// Exception assertions
assertThatThrownBy(() -> payment.refund(new BigDecimal("200")))
.isInstanceOf(RefundException.class)
.hasMessage("Refund amount exceeds payment amount")
.hasNoCause();
}

@Test
void customAssertions() {
var payment = createTestPayment();

// Use custom assertion class
PaymentAssert.assertThat(payment)
.hasPositiveAmount()
.isInStatus(PaymentStatus.COMPLETED)
.wasCreatedAfter(LocalDateTime.now().minusMinutes(5));
}
}

// Custom assertion class
class PaymentAssert extends AbstractAssert<PaymentAssert, Payment> {

public PaymentAssert(Payment payment) {
super(payment, PaymentAssert.class);
}

public static PaymentAssert assertThat(Payment payment) {
return new PaymentAssert(payment);
}

public PaymentAssert hasPositiveAmount() {
isNotNull();
if (actual.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
failWithMessage("Expected payment to have positive amount but was <%s>",
actual.getAmount());
}
return this;
}

public PaymentAssert isInStatus(PaymentStatus status) {
isNotNull();
if (actual.getStatus() != status) {
failWithMessage("Expected payment status to be <%s> but was <%s>",
status, actual.getStatus());
}
return this;
}

public PaymentAssert wasCreatedAfter(LocalDateTime dateTime) {
isNotNull();
if (!actual.getCreatedAt().isAfter(dateTime)) {
failWithMessage("Expected payment to be created after <%s> but was <%s>",
dateTime, actual.getCreatedAt());
}
return this;
}
}

Mockito Patterns

Mockito is a mocking framework that creates test doubles for dependencies. Mocks should be used sparingly - prefer real objects when possible. Use mocks for external systems (databases, HTTP clients) or when the real dependency is slow or has side effects. The @ExtendWith(MockitoExtension.class) annotation enables Mockito's annotations in JUnit 5.

Constructor Injection (Preferred)

import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(MockitoExtension.class) // Enables Mockito annotations
class PaymentServiceTest {

@Mock // Creates mock instance
private PaymentRepository paymentRepository;

@Mock
private AuditService auditService;

@Mock
private NotificationService notificationService;

@InjectMocks // Injects mocks into this instance via constructor
private PaymentService paymentService;

@Test
void shouldSavePaymentAndSendNotification() {
// Arrange
var request = createPaymentRequest();
var payment = Payment.fromRequest(request);

when(paymentRepository.save(any(Payment.class)))
.thenReturn(payment);

// Act
paymentService.createPayment(request);

// Assert
verify(paymentRepository).save(any(Payment.class));
verify(auditService).logPaymentCreated(payment.getId());
verify(notificationService).sendPaymentConfirmation(request.getCustomerId());
}
}

Argument Captors

import org.mockito.ArgumentCaptor;

class PaymentServiceTest {

@Test
void shouldCreatePaymentWithCorrectDetails() {
// Arrange
ArgumentCaptor<Payment> paymentCaptor = ArgumentCaptor.forClass(Payment.class);
var request = PaymentRequest.builder()
.customerId("CUST-123")
.amount(new BigDecimal("150.00"))
.currency("USD")
.build();

// Act
paymentService.createPayment(request);

// Assert
verify(paymentRepository).save(paymentCaptor.capture());

Payment capturedPayment = paymentCaptor.getValue();
assertThat(capturedPayment.getCustomerId()).isEqualTo("CUST-123");
assertThat(capturedPayment.getAmount()).isEqualByComparingTo("150.00");
assertThat(capturedPayment.getCurrency()).isEqualTo("USD");
assertThat(capturedPayment.getStatus()).isEqualTo(PaymentStatus.PENDING);
}
}

Behavior Verification

import static org.mockito.Mockito.*;

class PaymentProcessorTest {

@Test
void shouldRetryOnTransientFailure() {
// Arrange
PaymentGateway gateway = mock(PaymentGateway.class);
when(gateway.processPayment(any()))
.thenThrow(new TransientGatewayException())
.thenThrow(new TransientGatewayException())
.thenReturn(new PaymentResult("TXN-123", PaymentStatus.COMPLETED));

var processor = new PaymentProcessor(gateway);

// Act
var result = processor.processWithRetry(createPaymentRequest());

// Assert
assertThat(result.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
verify(gateway, times(3)).processPayment(any());
}

@Test
void shouldNotCallGatewayWhenAmountIsZero() {
// Arrange
PaymentGateway gateway = mock(PaymentGateway.class);
var processor = new PaymentProcessor(gateway);
var request = createPaymentRequest(BigDecimal.ZERO);

// Act
assertThatThrownBy(() -> processor.process(request))
.isInstanceOf(IllegalArgumentException.class);

// Assert
verifyNoInteractions(gateway);
}
}

Parameterized Tests

Parameterized tests (JUnit 5) run the same test logic with different inputs, reducing duplication and improving test coverage. They're particularly useful for testing boundary conditions, input validation, and equivalence classes. Each parameter set creates a separate test execution with its own report entry.

Parameterized tests solve the problem of repetitive test code where the same logic is tested with different values. Instead of writing dozens of similar test methods differing only in input data, you write one test method and provide multiple parameter sets. The test framework executes the method once per parameter set, reporting each execution separately.

JUnit 5 provides several parameter sources: @ValueSource for simple values, @MethodSource for complex objects, @CsvSource for tabular data, and @EnumSource for enum values. Choose the source that best matches your data structure - simple primitives use @ValueSource, while complex domain objects benefit from @MethodSource that can construct them programmatically.

@ParameterizedTest with @ValueSource

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;

class PaymentValidationTest {

@ParameterizedTest // Runs this test multiple times with different arguments
@ValueSource(strings = {"USD", "EUR", "GBP", "JPY"}) // Simple value source
void shouldAcceptValidCurrencies(String currency) {
var validator = new PaymentValidator();
assertThat(validator.isValidCurrency(currency)).isTrue();
}

@ParameterizedTest
@ValueSource(strings = {"", " ", "INVALID", "US", "123"})
void shouldRejectInvalidCurrencies(String currency) {
var validator = new PaymentValidator();
assertThat(validator.isValidCurrency(currency)).isFalse();
}
}

@MethodSource for Complex Parameters

import java.util.stream.Stream;
import org.junit.jupiter.params.provider.Arguments;

class PaymentAmountValidationTest {

@ParameterizedTest
@MethodSource("validPaymentAmounts")
void shouldAcceptValidAmounts(BigDecimal amount, String currency, String expectedResult) {
var validator = new PaymentValidator();
var result = validator.validateAmount(amount, currency);

assertThat(result.isValid()).isTrue();
assertThat(result.getMessage()).isEqualTo(expectedResult);
}

static Stream<Arguments> validPaymentAmounts() {
return Stream.of(
Arguments.of(new BigDecimal("0.01"), "USD", "Valid amount"),
Arguments.of(new BigDecimal("100.00"), "EUR", "Valid amount"),
Arguments.of(new BigDecimal("999999.99"), "GBP", "Valid amount")
);
}

@ParameterizedTest
@MethodSource("invalidPaymentAmounts")
void shouldRejectInvalidAmounts(BigDecimal amount, String currency, String expectedError) {
var validator = new PaymentValidator();
var result = validator.validateAmount(amount, currency);

assertThat(result.isValid()).isFalse();
assertThat(result.getMessage()).contains(expectedError);
}

static Stream<Arguments> invalidPaymentAmounts() {
return Stream.of(
Arguments.of(new BigDecimal("-10.00"), "USD", "Amount must be positive"),
Arguments.of(BigDecimal.ZERO, "EUR", "Amount must be greater than zero"),
Arguments.of(new BigDecimal("0.001"), "USD", "Too many decimal places")
);
}
}

@CsvSource for Tabular Data

class CurrencyConversionTest {

@ParameterizedTest
@CsvSource({
"100.00, USD, EUR, 85.00",
"200.00, EUR, USD, 235.29",
"50.00, GBP, USD, 63.50"
})
void shouldConvertCurrencies(BigDecimal amount, String from, String to, BigDecimal expected) {
var converter = new CurrencyConverter();
var result = converter.convert(amount, from, to);

assertThat(result).isEqualByComparingTo(expected);
}
}

Nested Test Classes

Nested test classes (JUnit 5's @Nested annotation) organize related tests hierarchically, improving test organization and readability. They allow you to group tests by feature or scenario, with each nested class potentially having its own setup/teardown methods. This creates a clear narrative in test reports - the outer class name combined with nested class names describes the testing context.

Nested classes can access outer class fields, enabling shared test setup while each nested class can add specific setup for its scenario. Test reports show the hierarchy, making it easy to understand which scenario failed. This is particularly useful for testing state machines, workflows, or classes with multiple distinct behaviors.

@DisplayName("Payment Service")
class PaymentServiceTest {

private PaymentService paymentService;

@BeforeEach
void setUp() {
paymentService = new PaymentService(
mock(PaymentRepository.class),
mock(AuditService.class)
);
}

@Nested
@DisplayName("when creating payments")
class CreatingPayments {

@Test
@DisplayName("should create payment with valid request")
void shouldCreatePaymentWithValidRequest() {
var request = createValidPaymentRequest();
var result = paymentService.createPayment(request);

assertThat(result).isNotNull();
assertThat(result.getStatus()).isEqualTo(PaymentStatus.PENDING);
}

@Test
@DisplayName("should throw exception when customer not found")
void shouldThrowExceptionWhenCustomerNotFound() {
var request = createPaymentRequestWithInvalidCustomer();

assertThatThrownBy(() -> paymentService.createPayment(request))
.isInstanceOf(CustomerNotFoundException.class);
}
}

@Nested
@DisplayName("when processing payments")
class ProcessingPayments {

@Test
@DisplayName("should transition to completed status on success")
void shouldTransitionToCompletedOnSuccess() {
var payment = createPendingPayment();
paymentService.processPayment(payment.getId());

var result = paymentService.getPayment(payment.getId());
assertThat(result.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
}

@Test
@DisplayName("should transition to failed status on gateway error")
void shouldTransitionToFailedOnGatewayError() {
var payment = createPendingPayment();
when(paymentGateway.process(any()))
.thenThrow(new GatewayException("Gateway unavailable"));

paymentService.processPayment(payment.getId());

var result = paymentService.getPayment(payment.getId());
assertThat(result.getStatus()).isEqualTo(PaymentStatus.FAILED);
}
}
}

Test Data Builders

Test data builders solve the problem of creating complex test objects with many fields. Without builders, tests become cluttered with object construction code that obscures the actual test logic. Builders provide a fluent API for constructing test data, with sensible defaults for fields you don't care about and explicit setters for fields relevant to the test.

The builder pattern for tests differs from production builders - test builders should have default values for all fields so you can create a valid object with minimal code, then override only the fields relevant to your specific test case. This makes tests more maintainable because adding new required fields to a class only requires updating the builder's defaults, not every test.

Builder Pattern for Test Data

// Test data builder with sensible defaults
class PaymentTestBuilder {

private String id = "PAY-123";
private String customerId = "CUST-123";
private BigDecimal amount = new BigDecimal("100.00");
private String currency = "USD";
private PaymentStatus status = PaymentStatus.PENDING;
private LocalDateTime createdAt = LocalDateTime.now();

public static PaymentTestBuilder aPayment() {
return new PaymentTestBuilder();
}

public PaymentTestBuilder withId(String id) {
this.id = id;
return this;
}

public PaymentTestBuilder withCustomerId(String customerId) {
this.customerId = customerId;
return this;
}

public PaymentTestBuilder withAmount(String amount) {
this.amount = new BigDecimal(amount);
return this;
}

public PaymentTestBuilder withCurrency(String currency) {
this.currency = currency;
return this;
}

public PaymentTestBuilder withStatus(PaymentStatus status) {
this.status = status;
return this;
}

// Convenience methods for common scenarios
public PaymentTestBuilder completed() {
this.status = PaymentStatus.COMPLETED;
return this;
}

public PaymentTestBuilder failed() {
this.status = PaymentStatus.FAILED;
return this;
}

// Build method constructs the final object
public Payment build() {
var payment = new Payment();
payment.setId(id);
payment.setCustomerId(customerId);
payment.setAmount(amount);
payment.setCurrency(currency);
payment.setStatus(status);
payment.setCreatedAt(createdAt);
return payment;
}
}

// Usage in tests - notice how readable this is
class PaymentServiceTest {

@Test
void shouldProcessCompletedPayment() {
// Only specify what's relevant to this test
var payment = aPayment()
.withCustomerId("CUST-456")
.withAmount("250.00")
.completed()
.build();

// Test logic is clear - not cluttered with object construction
var result = paymentService.process(payment);
assertThat(result).isNotNull();
}

@Test
void shouldRejectSmallPayments() {
// Different test, different focused data
var payment = aPayment()
.withAmount("0.01") // Only care about amount for this test
.build();

assertThatThrownBy(() -> paymentService.validate(payment))
.isInstanceOf(ValidationException.class);
}
}

Mutation Testing with PITest

Mutation testing verifies test quality by introducing small code changes (mutations) and checking if tests catch them. A mutation that doesn't cause test failures indicates weak tests. PITest automates this by modifying bytecode, running tests, and reporting which mutations survived. High mutation scores (≥80%) indicate tests actually verify behavior, not just achieve code coverage.

Configuration

build.gradle:

plugins {
id 'info.solidsoft.pitest' version '1.15.0'
}

pitest {
targetClasses = ['com.bank.payments.*'] // Classes to mutate
targetTests = ['com.bank.payments.*'] // Tests to run
excludedClasses = [
'com.bank.payments.config.*', // Skip configuration classes
'com.bank.payments.dto.*', // Skip simple DTOs
'**.*Application' // Skip main application class
]

mutators = ['STRONGER'] // More aggressive mutations beyond defaults

threads = 4 // Parallel execution for speed
outputFormats = ['HTML', 'XML']
timestampedReports = false

// Quality thresholds - build fails if not met
mutationThreshold = 80 // 80% of mutations must be killed
coverageThreshold = 85 // 85% line coverage required

// Incremental analysis for faster builds
enableDefaultIncrementalAnalysis = true

// JUnit 5 support
junit5PluginVersion = '1.2.1'
}

Running Mutation Tests

# Run mutation tests
./gradlew pitest

# View results
open build/reports/pitest/index.html

Example: Improving Mutation Score

Boundary mutations are common - PITest might change > to >=, < to <=, etc. Tests must verify behavior at boundaries to kill these mutations.

// BAD: Mutation will survive (changing > to >= still passes this test)
public boolean isEligibleForRefund(Payment payment) {
return payment.getAmount().compareTo(new BigDecimal("10.00")) > 0;
}

@Test
void shouldBeEligibleForRefund() {
var payment = aPayment().withAmount("100.00").build();
assertThat(service.isEligibleForRefund(payment)).isTrue();
// PITest mutates > to >= - test still passes! Mutation survives.
}

// GOOD: Add boundary tests to kill the mutation
@Test
void shouldNotBeEligibleForRefundAtBoundary() {
var payment = aPayment().withAmount("10.00").build();
assertThat(service.isEligibleForRefund(payment)).isFalse();
// This test fails if > is mutated to >= - mutation killed
}

@Test
void shouldBeEligibleForRefundAboveBoundary() {
var payment = aPayment().withAmount("10.01").build();
assertThat(service.isEligibleForRefund(payment)).isTrue();
}

Testing Exceptions

Testing exception scenarios is crucial - exceptions represent error cases that must be handled correctly. AssertJ provides assertThatThrownBy() which makes exception testing more readable than JUnit's @Test(expected=...) annotation. It allows you to verify not just the exception type, but also its message, cause, and any custom properties.

When testing exceptions, verify both that the exception is thrown in the right circumstances and that it contains useful diagnostic information. Exception messages should be specific enough to help developers diagnose the problem - generic messages like "Invalid input" are less useful than "Amount must be between 0.01 and 100000.00".

Expected Exceptions

class PaymentExceptionTest {

@Test
void shouldThrowExceptionWithCorrectMessage() {
var service = new PaymentService();
var invalidRequest = createInvalidRequest();

assertThatThrownBy(() -> service.createPayment(invalidRequest))
.isInstanceOf(PaymentValidationException.class)
.hasMessage("Amount must be positive")
.hasNoCause();
}

@Test
void shouldThrowExceptionWithCause() {
var service = new PaymentService();

assertThatThrownBy(() -> service.processPayment("INVALID-ID"))
.isInstanceOf(PaymentProcessingException.class)
.hasMessageContaining("Failed to process payment")
.hasCauseInstanceOf(GatewayException.class);
}

@Test
void shouldNotThrowException() {
var service = new PaymentService();
var validRequest = createValidRequest();

assertThatCode(() -> service.createPayment(validRequest))
.doesNotThrowAnyException();
}
}

Testing Asynchronous Code

Asynchronous code introduces complexity in testing because the test must wait for async operations to complete before making assertions. Java's CompletableFuture provides a get() method that blocks until the future completes, making it suitable for testing. Always specify a timeout to prevent tests from hanging indefinitely if the async operation never completes.

Testing async code requires careful attention to thread safety and timing. The test thread must wait for the async operation, but not forever. Use get(timeout, TimeUnit) to fail fast if something goes wrong. Also verify that exceptions in async code are properly propagated - they should be wrapped in ExecutionException when calling get(). See Java Concurrency for more patterns.

CompletableFuture Testing

class AsyncPaymentServiceTest {

@Test
void shouldProcessPaymentAsynchronously() throws Exception {
var service = new AsyncPaymentService();
var request = createPaymentRequest();

CompletableFuture<Payment> future = service.createPaymentAsync(request);

// Wait for completion (with timeout)
var payment = future.get(5, TimeUnit.SECONDS);

assertThat(payment).isNotNull();
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
}

@Test
void shouldHandleAsyncException() {
var service = new AsyncPaymentService();
var invalidRequest = createInvalidRequest();

CompletableFuture<Payment> future = service.createPaymentAsync(invalidRequest);

assertThatThrownBy(() -> future.get(5, TimeUnit.SECONDS))
.isInstanceOf(ExecutionException.class)
.hasCauseInstanceOf(PaymentValidationException.class);
}
}

Testing with Virtual Threads (Java 21+; prefer Java 25)

class VirtualThreadPaymentTest {

@Test
void shouldProcessMultiplePaymentsConcurrently() throws Exception {
var service = new PaymentService();
var requests = List.of(
createPaymentRequest("CUST-1"),
createPaymentRequest("CUST-2"),
createPaymentRequest("CUST-3")
);

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Payment>> futures = requests.stream()
.map(request -> executor.submit(() -> service.createPayment(request)))
.toList();

List<Payment> payments = futures.stream()
.map(future -> {
try {
return future.get();
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList();

assertThat(payments).hasSize(3);
assertThat(payments).allMatch(p -> p.getStatus() == PaymentStatus.PENDING);
}
}
}

Testing Best Practices

Good tests serve as living documentation for your code's behavior. Test names should describe what the test verifies in plain language, not just restate the method name. A well-named test makes failures self-explanatory - when "shouldCreatePaymentWhenRequestIsValid" fails, you immediately know what broke without reading the test code.

The structure of test methods follows the Arrange-Act-Assert pattern: set up test data (Arrange), execute the code under test (Act), and verify the outcome (Assert). This structure makes tests easy to read and understand. Each section should be clearly delineated, often with blank lines separating them.

Test Naming Conventions

Use descriptive names that explain the scenario and expected outcome. The pattern "should[Expected Behavior]When[Condition]" clearly communicates what the test verifies. Avoid technical jargon in test names - they should be readable by non-developers.

// GOOD: Descriptive test names that explain behavior
@Test
void shouldCreatePaymentWhenRequestIsValid() { }

@Test
void shouldThrowExceptionWhenAmountIsNegative() { }

@Test
void shouldRetryThreeTimesWhenGatewayIsUnavailable() { }

// BAD: Vague test names
@Test
void testPayment() { }

@Test
void test1() { }

@Test
void createPayment() { }

One Assertion per Test (When Possible)

Single-assertion tests make failures easier to diagnose - when a test fails, you know exactly what went wrong. However, this guideline shouldn't be applied dogmatically. Related assertions on the same object are acceptable, as splitting them into separate tests often requires duplicating setup code.

The key is conceptual focus: each test should verify one behavior or outcome, even if that requires multiple assertions. Testing that a payment has the correct ID, status, and amount is one conceptual assertion (payment created correctly), even though it's three technical assertions.

// GOOD: Focused tests verifying one behavior each
@Test
void shouldSetStatusToPending() {
var payment = service.createPayment(request);
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING);
}

@Test
void shouldGenerateUniqueId() {
var payment = service.createPayment(request);
assertThat(payment.getId()).isNotBlank();
}

// ACCEPTABLE: Related assertions for same object
@Test
void shouldCreatePaymentWithCorrectDetails() {
var payment = service.createPayment(request);

assertThat(payment)
.extracting(Payment::getId, Payment::getStatus, Payment::getAmount)
.containsExactly("PAY-123", PaymentStatus.PENDING, new BigDecimal("100.00"));
}

Avoid Test Interdependence

Tests must be independent - each should set up its own data and clean up after itself. Tests that depend on execution order are brittle (fail when run in different order) and difficult to debug (failures in one test cause cascading failures). JUnit doesn't guarantee test execution order, and parallel test execution (for speed) breaks order-dependent tests.

Shared mutable state between tests is the primary cause of interdependence. Avoid static fields, class-level mutable state, or relying on database state from previous tests. Each test should start with a clean slate, either by creating fresh objects or by clearing shared state in @BeforeEach.

// BAD: Tests depend on execution order and share mutable state
class BadPaymentTest {
private static Payment sharedPayment;

@Test
void test1_createPayment() {
sharedPayment = service.createPayment(request);
}

@Test
void test2_processPayment() {
service.processPayment(sharedPayment.getId()); // Depends on test1
}
}

// GOOD: Independent tests
class GoodPaymentTest {

@Test
void shouldCreatePayment() {
var payment = service.createPayment(request);
assertThat(payment).isNotNull();
}

@Test
void shouldProcessPayment() {
var payment = createAndSavePayment(); // Setup within test
service.processPayment(payment.getId());
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
}
}

Further Reading

External Resources:


Summary

Key Takeaways:

  1. JUnit 5: Use modern testing framework with @DisplayName, nested classes, parameterized tests
  2. AssertJ: Fluent assertions for readable, maintainable test code
  3. Minimal mocking: Prefer real objects, use mocks for external dependencies only
  4. Test data builders: Create readable test data with builder pattern
  5. Mutation testing: Maintain ≥80% mutation score with PITest
  6. Parameterized tests: Test multiple scenarios with @ParameterizedTest
  7. Arrange-Act-Assert: Clear test structure for maintainability
  8. Custom assertions: Create domain-specific assertions for complex objects
  9. Independent tests: Each test should be runnable in isolation
  10. Boundary testing: Test edge cases to kill boundary mutations