← Back to DevBytes

Testing Strategies for Java Applications

Introduction to Testing Java Applications

Testing is a cornerstone of professional software development. In the Java ecosystem, a robust testing strategy ensures that your applications behave as expected, remain maintainable over time, and can evolve without introducing regressions. This tutorial explores the full spectrum of testing strategies available to Java developers, from unit tests to end-to-end tests, and provides practical examples you can apply immediately.

What Is a Testing Strategy?

A testing strategy is a structured approach that defines how an application will be tested throughout its lifecycle. It encompasses the types of tests you write, the tools you use, the coverage you aim for, and the processes that govern when and how tests run. In Java, a well-rounded strategy typically follows the testing pyramid: a broad base of fast unit tests, a smaller layer of integration tests, and a narrow top of end-to-end tests.

The Testing Pyramid Explained

Why Testing Matters

Without a deliberate testing strategy, teams inevitably face slower delivery cycles, frequent production bugs, and growing fear of refactoring. Tests provide a safety net that enables confident change. They also serve as executable documentation, describing how the system is expected to behave under various conditions. In Java specifically, strong tooling like JUnit, Mockito, and Testcontainers makes it straightforward to build a reliable, automated test suite that runs on every build.

Beyond catching defects, tests influence design. Writing testable code encourages loose coupling, clear interfaces, and single-responsibility classes. When a class is hard to test, it is usually a sign that it has too many responsibilities or hidden dependencies.

Setting Up Your Java Test Stack

Most modern Java projects use JUnit 5 as the test engine, Mockito for mocking, and AssertJ for fluent assertions. If you are using Maven, add the following dependencies to your pom.xml:

<dependencies>
    <!-- Production -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Test -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>1.19.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <version>1.19.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

The spring-boot-starter-test dependency transitively brings in JUnit 5, Mockito, AssertJ, and Spring Test, giving you everything needed for unit and integration testing out of the box.

Unit Testing in Java

Unit tests focus on the smallest pieces of logic in your application. They should be fast, deterministic, and isolated from external systems. Let's look at a simple service class and its corresponding unit test.

The Class Under Test

public class DiscountCalculator {

    private final CustomerRepository customerRepository;

    public DiscountCalculator(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    public double applyDiscount(String customerId, double orderTotal) {
        if (orderTotal < 0) {
            throw new IllegalArgumentException("Order total cannot be negative");
        }
        Customer customer = customerRepository.findById(customerId);
        if (customer == null) {
            return orderTotal;
        }
        double discountRate = customer.isLoyaltyMember() ? 0.10 : 0.0;
        if (orderTotal > 100.0) {
            discountRate += 0.05;
        }
        return orderTotal * (1 - discountRate);
    }
}

Writing the Unit Test

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

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

class DiscountCalculatorTest {

    private CustomerRepository repository;
    private DiscountCalculator calculator;

    @BeforeEach
    void setUp() {
        repository = mock(CustomerRepository.class);
        calculator = new DiscountCalculator(repository);
    }

    @Test
    void shouldApplyLoyaltyDiscountForMembers() {
        Customer member = new Customer("c1", true);
        when(repository.findById("c1")).thenReturn(member);

        double result = calculator.applyDiscount("c1", 200.0);

        assertThat(result).isEqualTo(170.0);
        verify(repository, times(1)).findById("c1");
    }

    @Test
    void shouldApplyBonusDiscountForLargeOrders() {
        Customer nonMember = new Customer("c2", false);
        when(repository.findById("c2")).thenReturn(nonMember);

        double result = calculator.applyDiscount("c2", 150.0);

        assertThat(result).isEqualTo(142.5);
    }

    @Test
    void shouldReturnFullPriceWhenCustomerNotFound() {
        when(repository.findById("ghost")).thenReturn(null);

        double result = calculator.applyDiscount("ghost", 50.0);

        assertThat(result).isEqualTo(50.0);
    }

    @Test
    void shouldRejectNegativeOrderTotal() {
        assertThatThrownBy(() -> calculator.applyDiscount("c1", -10.0))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("cannot be negative");
    }
}

Notice how the CustomerRepository is mocked using Mockito. This keeps the test fast and focused purely on the logic inside DiscountCalculator. AssertJ's fluent assertions make the expected outcomes readable and expressive.

Integration Testing with Spring Boot

While unit tests verify isolated logic, integration tests confirm that components collaborate correctly. Spring Boot provides annotations that bootstrap a sliced or full application context for testing. The @SpringBootTest annotation loads the complete context, while @WebMvcTest loads only the web layer.

Testing a REST Controller

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturnOrderById() throws Exception {
        Order order = new Order("o1", "c1", 250.0);
        when(orderService.findById("o1")).thenReturn(order);

        mockMvc.perform(get("/api/orders/o1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value("o1"))
            .andExpect(jsonPath("$.total").value(250.0));
    }

    @Test
    void shouldReturn404WhenOrderMissing() throws Exception {
        when(orderService.findById("missing")).thenThrow(new OrderNotFoundException("missing"));

        mockMvc.perform(get("/api/orders/missing"))
            .andExpect(status().isNotFound());
    }
}

The @WebMvcTest slice loads only the controller and its surrounding web infrastructure, keeping the test fast. The OrderService is replaced with a Mockito mock via @MockBean, so no real business logic or database is involved.

Database Integration Testing with Testcontainers

In-memory databases like H2 are convenient, but they behave differently from production databases. Testcontainers solves this by spinning up real database engines in Docker containers during your test run. This gives you confidence that your SQL, migrations, and queries work against the actual database you will use in production.

@SpringBootTest
@Testcontainers
class OrderRepositoryIT {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @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() {
        Order order = new Order("o1", "c1", 300.0);
        orderRepository.save(order);

        Optional<Order> found = orderRepository.findById("o1");

        assertThat(found).isPresent();
        assertThat(found.get().getTotal()).isEqualTo(300.0);
    }
}

Here, the @Container annotation starts a fresh PostgreSQL instance before any tests run. The @DynamicPropertySource method injects the container's connection details into the Spring context, so your repository talks to a real database. Because the container is static, it is shared across all tests in the class, reducing startup overhead.

End-to-End Testing

End-to-end tests exercise the entire application stack, including HTTP, business logic, and persistence. They are slower and more brittle than unit or integration tests, so they should be used sparingly. For Spring Boot applications, @SpringBootTest with a random port combined with TestRestTemplate or WebClient is a common approach.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderFlowE2ETest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void shouldCreateAndRetrieveOrder() {
        String baseUrl = "http://localhost:" + port;
        OrderRequest request = new OrderRequest("c1", 450.0);

        ResponseEntity<Order> createResponse = restTemplate.postForEntity(
            baseUrl + "/api/orders", request, Order.class);

        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        String orderId = createResponse.getBody().getId();

        ResponseEntity<Order> getResponse = restTemplate.getForEntity(
            baseUrl + "/api/orders/" + orderId, Order.class);

        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK());
        assertThat(getResponse.getBody().getTotal()).isEqualTo(450.0);
    }
}

This test starts the full application, sends real HTTP requests, and verifies the entire request-response cycle. It is valuable because it catches wiring issues that unit and slice tests might miss, such as misconfigured beans or broken JSON serialization.

Best Practices for Java Testing

Follow the Arrange-Act-Assert Pattern

Structure each test in three clear phases: set up the preconditions, execute the behavior under test, and verify the outcome. This makes tests easier to read and debug.

Keep Tests Independent and Repeatable

Tests should not depend on execution order or shared mutable state. Use @BeforeEach to reset state, and avoid static variables that leak between tests. A test that passes only when run after another test is a liability.

Name Tests Expressively

Use descriptive method names or @DisplayName annotations that communicate intent. Names like shouldRejectNegativeOrderTotal are far more useful than test1 or applyDiscountTest.

Mock External Dependencies, Not Your Own Code

Mocking is most valuable at the boundaries of your system: databases, HTTP clients, message queues. Over-mocking internal classes leads to brittle tests that break whenever you refactor. Prefer to test real object interactions where practical.

Measure and Monitor Coverage

Use tools like JaCoCo to track code coverage, but treat coverage as a signal, not a goal. Eighty percent coverage with meaningful tests is far more valuable than one hundred percent coverage with trivial assertions. Configure JaCoCo in Maven like this:

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.11</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Run Tests in CI on Every Commit

Automate your test suite in a continuous integration pipeline. Fast feedback prevents regressions from accumulating and keeps the main branch deployable. Consider splitting your pipeline into stages: unit tests first, then integration tests, then E2E, so failures surface quickly and cheaply.

Conclusion

A thoughtful testing strategy is one of the most valuable investments a Java development team can make. By layering fast unit tests, targeted integration tests with Testcontainers, and a small set of end-to-end tests, you create a safety net that catches bugs early, supports confident refactoring, and documents expected behavior. The tools in the Java ecosystem, JUnit 5, Mockito, AssertJ, Spring Test, and Testcontainers, are mature and work together seamlessly. Start by ensuring your unit tests are clean and meaningful, then add integration coverage where it reduces real risk, and reserve end-to-end tests for the most critical user journeys. Over time, this disciplined approach will pay dividends in software quality, team velocity, and developer confidence.

— Ad —

Google AdSense will appear here after approval

← Back to all articles