Introduction: Why testing is the foundation of quality Java code
Every Java developer has encountered a situation where fixing one bug creates three new ones. To break this vicious cycle, there is testing. It is not just a checkbox on a checklist, but a professional discipline that saves nerves, money, and the product's reputation.
In this article, we will break down three key levels of testing: unit, integration, and e2e (end-to-end). We will look at them with practical Java examples, using popular frameworks: JUnit 5, Mockito, Spring Boot Test, and Selenium. You will learn how to build the right testing pyramid and not overload your project with useless tests.
1. Unit testing: Checking atomic units of code
Unit tests are the lowest and fastest level. They check the work of a single "unit" (usually a method or class) in isolation from external dependencies (DB, API, file system).
1.1 Tools and frameworks
- JUnit 5 — the de facto standard for writing tests in Java.
- Mockito — a library for creating mocks and stubs.
- AssertJ — a library for fluent assertions.
1.2 Example of a Unit test with JUnit 5 and Mockito
Let's imagine we have an OrderService that depends on a PaymentRepository. We want to test the service logic without calling the real database.
// Class under testpublic class OrderService { private final PaymentRepository paymentRepository;
public OrderService(PaymentRepository paymentRepository) { this.paymentRepository = paymentRepository; }
public String processOrder(Order order) { if (order.getAmount() <= 0) { throw new IllegalArgumentException("Order amount must be positive"); } boolean success = paymentRepository.pay(order); return success ? "Payment successful" : "Payment error"; }}
// Unit testimport org.junit.jupiter.api.Test;import org.junit.jupiter.api.extension.ExtendWith;import org.mockito.InjectMocks;import org.mockito.Mock;import org.mockito.junit.jupiter.MockitoExtension;import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)class OrderServiceTest {
@Mock private PaymentRepository paymentRepository;
@InjectMocks private OrderService orderService;
@Test void testProcessOrder_Success() { // Arrange Order order = new Order(100.0); when(paymentRepository.pay(order)).thenReturn(true);
// Act String result = orderService.processOrder(order);
// Assert assertEquals("Payment successful", result); verify(paymentRepository, times(1)).pay(order); }
@Test void testProcessOrder_NegativeAmount() { // Arrange Order order = new Order(-10.0);
// Act & Assert assertThrows(IllegalArgumentException.class, () -> { orderService.processOrder(order); }); }}Best Practices for Unit tests:
- One test — one assertion (FIRST principle: Fast, Isolated, Repeatable, Self-validating, Timely).
- Use clear names:
shouldReturnSuccess_whenPaymentIsApproved. - Do not test private methods — test the public contract.
- Cover edge cases: null, empty strings, negative numbers.
2. Integration testing: Checking component interaction
Integration tests check how different modules of the system