The Best Way to Test Java Applications in 2026

The Best Way to Test Java Applications in 2026

Java has one of the richest testing ecosystems of any language. It also has some of the most elaborate test suites that test almost nothing important. Understanding the difference between tests that provide confidence and tests that provide coverage numbers is the most important skill in Java testing.

The Testing Pyramid (and Why Most Java Projects Invert It)

The testing pyramid describes the ideal distribution of test types:

  • Unit tests: Many, fast, test one thing in isolation
  • Integration tests: Fewer, slower, test component interactions
  • End-to-end tests: Few, slowest, test user flows

Most Java projects, especially Spring Boot applications, invert this pyramid. They have:

  • Hundreds of @SpringBootTest integration tests that spin up the entire application context for every test
  • Heavy mocking of everything except the specific class being tested
  • A handful of actual unit tests

This results in slow test suites, high maintenance overhead, and tests that still miss bugs in the interactions between components.

Unit Testing with JUnit 5 and Mockito

JUnit 5 is the current standard. If you’re still on JUnit 4, migrate. The extension model, parameterized tests, and nested test classes alone are worth the effort.

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private PaymentService paymentService;

    @InjectMocks
    private OrderService orderService;

    @Test
    @DisplayName("should process order and charge payment")
    void shouldProcessOrder() {
        // given
        var order = new Order(UUID.randomUUID(), List.of(
            new LineItem("product-1", 2, BigDecimal.valueOf(9.99))
        ));
        when(paymentService.charge(any())).thenReturn(PaymentResult.success("tx-123"));

        // when
        var result = orderService.processOrder(order);

        // then
        assertThat(result.isSuccessful()).isTrue();
        verify(orderRepository).save(argThat(saved ->
            saved.getStatus() == OrderStatus.CONFIRMED
        ));
    }

    @ParameterizedTest
    @ValueSource(strings = {"", "  ", "\t"})
    void shouldRejectBlankProductIds(String productId) {
        var order = new Order(UUID.randomUUID(), List.of(
            new LineItem(productId, 1, BigDecimal.ONE)
        ));

        assertThrows(ValidationException.class,
            () -> orderService.processOrder(order));
    }
}

Key practices:

  • Use @DisplayName for human-readable test names
  • Follow the Given/When/Then structure (Arrange/Act/Assert)
  • Use @ParameterizedTest to avoid copy-paste test proliferation
  • Prefer AssertJ over JUnit assertions for readability

What to Mock and What Not To

The most important and most contested decision in Java unit testing is what to mock.

Mock external dependencies: Third-party APIs, external services, anything not in your control.

Mock expensive infrastructure: Databases, message queues, caches—in unit tests.

Don’t mock your own domain objects: If you’re mocking Order to test OrderService, you’re hiding behavior, not testing it. Use real domain objects.

Don’t mock things just because they exist: The value of a test is proportional to how closely it resembles real production behavior. Every mock is a lie about how the system actually behaves.

The instinct to mock everything to achieve isolation and speed is understandable, but it produces tests that don’t catch the bugs that actually happen in production—which are usually about how components interact.

Integration Testing with Testcontainers

Testcontainers is the most important development in Java testing in the last several years. It lets you spin up real Docker containers for your test dependencies—actual PostgreSQL, actual Redis, actual Kafka—in your tests.

This eliminates the “it works in tests but breaks in production” problem caused by using H2 as an in-memory substitute for PostgreSQL and then wondering why your native queries don’t work.

@SpringBootTest
@Testcontainers
class OrderRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldPersistAndRetrieveOrder() {
        var order = new Order(UUID.randomUUID(), OrderStatus.PENDING, LocalDateTime.now());
        orderRepository.save(order);

        var retrieved = orderRepository.findById(order.getId());

        assertThat(retrieved).isPresent();
        assertThat(retrieved.get().getStatus()).isEqualTo(OrderStatus.PENDING);
    }
}

Use @Container with static for containers shared across the test class (much faster than spinning up a container per test). Use @DynamicPropertySource to override Spring Boot configuration with the container’s connection details.

Testing Spring Boot Slices

@SpringBootTest loads the entire application context. For testing specific layers, use slice tests:

@WebMvcTest for controllers:

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturnOrderById() throws Exception {
        var order = new Order(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"),
            OrderStatus.CONFIRMED, LocalDateTime.now());
        when(orderService.findById(any())).thenReturn(Optional.of(order));

        mockMvc.perform(get("/api/orders/123e4567-e89b-12d3-a456-426614174000"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.status").value("CONFIRMED"));
    }
}

@DataJpaTest for repositories (use with Testcontainers instead of H2).

@JsonTest for JSON serialization/deserialization.

These slice tests are faster than full @SpringBootTest and more focused.

Architecture Testing with ArchUnit

One of my favorite underused tools. ArchUnit lets you write tests that enforce architectural constraints:

@AnalyzeClasses(packages = "com.example.myapp")
class ArchitectureTest {

    @ArchTest
    static final ArchRule servicesShouldNotDependOnControllers =
        noClasses()
            .that().resideInAPackage("..service..")
            .should().dependOnClassesThat()
            .resideInAPackage("..controller..");

    @ArchTest
    static final ArchRule repositoriesShouldNotBeUsedInControllers =
        noClasses()
            .that().resideInAPackage("..controller..")
            .should().dependOnClassesThat()
            .resideInAPackage("..repository..");

    @ArchTest
    static final ArchRule servicesShouldBeAnnotated =
        classes()
            .that().resideInAPackage("..service..")
            .should().beAnnotatedWith(Service.class);
}

Architecture tests run in milliseconds and catch the drift that happens over time when teams are moving fast. Put them in your pipeline and enforce your layered architecture automatically.

Contract Testing with Spring Cloud Contract

If you have microservices, contract testing prevents the “consumer expects field X, producer removed field X” class of bugs:

// Contract defined by producer
Contract.make {
    description "should return order by id"
    request {
        method GET()
        url "/api/orders/123"
    }
    response {
        status OK()
        body([
            id: "123",
            status: "CONFIRMED"
        ])
        headers {
            contentType(applicationJson())
        }
    }
}

Spring Cloud Contract generates tests from these contracts that run on the producer side, and stubs that run on the consumer side. If the producer breaks the contract, the test fails before it ever reaches the consumer’s pipeline.

Test Data Management

Random test data is a code smell. Use builders:

public class OrderTestBuilder {
    private UUID id = UUID.randomUUID();
    private OrderStatus status = OrderStatus.PENDING;
    private List<LineItem> items = List.of(LineItemTestBuilder.defaults().build());

    public static OrderTestBuilder defaults() {
        return new OrderTestBuilder();
    }

    public OrderTestBuilder withStatus(OrderStatus status) {
        this.status = status;
        return this;
    }

    public Order build() {
        return new Order(id, status, items);
    }
}

// In tests:
var order = OrderTestBuilder.defaults()
    .withStatus(OrderStatus.CONFIRMED)
    .build();

Libraries like Instancio can generate realistic random data while letting you control the fields that matter for a specific test.

The 10-Minute Rule

If your full test suite takes more than 10 minutes, you need to fix it. Developers stop running tests that take 10 minutes. They run them before merging and then don’t catch issues until CI runs.

Profile your test suite. Find the slow tests (almost certainly @SpringBootTest integration tests). Refactor to slices, use Testcontainers with shared containers, and parallelize with @TestMethodOrder and JUnit 5’s parallel execution support.

Good test suites are fast, informative, and trustworthy. If your tests are slow, ignored, or frequently produce false positives, they’re not providing value—they’re providing coverage metrics that fool management into thinking the codebase is well-tested.

Share this post: LinkedIn Reddit WhatsApp Mastodon
Jesse Borden

Jesse Borden

Software Engineer with an interest in hands on learning

I have several years of professional Information Technology (IT) experience leading staff and projects within the Department of War (DOW). I have managed Service Desk, Web Application Development, and System Administration teams. My two greatest passions are learning and conti...