Testing in Java: Unit, Integration, E2E — Advanced Level
In modern Java development, testing is not just a formality but a fundamental practice that ensures code stability and predictability. Basic tests, such as simple @Test with Assert.assertEquals, have long given way to complex multi-layered strategies. In this article, we will break down advanced techniques for each level of the testing pyramid: Unit, Integration, and End-to-End (E2E). You will learn how to write maintainable, fast, and reliable tests that turn your code into a well-tuned mechanism.
1. Advanced Unit Tests: Mocks, Stubs, and Parameterization
Unit tests verify an isolated piece of code (usually a single class or method). At an advanced level, we move from simple scenarios to complex interactions and edge cases. The key tool here is the Mockito library.
1.1. Using ArgumentCaptor and Complex Answers (Answer)
It is often necessary not only to verify that a method was called but also which arguments were passed to it. For this, ArgumentCaptor is used.
// Example: Verifying that the correct message was sent to the service@ExtendWith(MockitoExtension.class)public class NotificationServiceTest { @Mock private EmailSender emailSender; @InjectMocks private NotificationService notificationService;
@Test void shouldSendEmailWithCorrectContent() { // Arrange User user = new User("test@example.com", "Test User"); // Act notificationService.sendWelcomeEmail(user); // Assert ArgumentCaptor<EmailMessage> captor = ArgumentCaptor.forClass(EmailMessage.class); verify(emailSender).send(captor.capture()); EmailMessage capturedMessage = captor.getValue(); assertEquals("test@example.com", capturedMessage.getTo()); assertTrue(capturedMessage.getBody().contains("Test User")); }}For dynamically creating responses based on input data, use the Answer interface:
@Testvoid testDynamicResponse() { when(dataRepository.findById(anyLong())).thenAnswer(invocation -> { Long id = invocation.getArgument(0); return new DataEntity(id, "Entity with ID: " + id); }); assertEquals("Entity with ID: 5", dataService.getDataById(5L).getName());}1.2. Parameterized Tests (JUnit 5)
Parameterization allows you to run the same test with different data sets, avoiding code duplication. JUnit 5 provides powerful annotations such as @CsvSource and @MethodSource.
@ParameterizedTest@CsvSource({ "10, 20, 30", "0, 0, 0", "-5, 5, 0"})void testSum(int a, int b, int expectedSum) { Calculator calculator = new Calculator(); assertEquals(expectedSum, calculator.add(a, b));}2. Integration Testing: Working with Databases and External Services
Integration tests verify the interaction between system components, for example, with a real database or queues. The main challenge is managing the environment state. Here, Testcontainers comes to the rescue.
2.1. Testcontainers for an Isolated Database
Testcontainers spins up Docker containers with the necessary software (PostgreSQL, Redis, Kafka) right during the tests, ensuring cleanliness and reproducibility.
@SpringBootTest@Testcontainersclass UserRepositoryIntegrationTest {
@Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15") .withDatabaseName("testdb") .withUsername("test") .withPassword("test");
@DynamicPropertySource static void configureProperties(DynamicPropertyR