Advanced-Level Testing in Java: Unit, Integration, E2E

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

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

Blogs

Book Recommendations