0Pricing

Testing Mastery: Unlocking Advanced Techniques with JUnit, Mockito, and Testcontainers

Dive deep into advanced testing techniques with JUnit 5's powerful features, Mockito's sophisticated mocking capabilities, and robust integration testing using Testcontainers, exploring real-world scenarios to elevate your test suite.

T
Testing Mastery: JUnit, Mockito & Integration Tests · 10 min read · 2,073 words

Welcome back, CoddyKit learners! We're on the fourth leg of our journey through "Testing Mastery: JUnit, Mockito & Integration Tests." In our previous posts, we laid the groundwork, explored best practices, and learned how to sidestep common pitfalls. Now, it's time to level up. This post is all about moving beyond the basics, diving into advanced techniques and real-world use cases that will empower you to build truly robust and resilient test suites.

As applications grow in complexity, so too must our testing strategies. Standard unit and integration tests are crucial, but sometimes you need more power, more flexibility, or a more realistic testing environment. That's where advanced JUnit 5 features, sophisticated Mockito tricks, and powerful tools like Testcontainers come into play.

Advanced JUnit 5: More Power to Your Tests

JUnit 5 isn't just a testing framework; it's a versatile platform with features designed to handle complex testing scenarios with elegance. Let's explore some of its advanced capabilities.

Parameterized Tests: Data-Driven Testing Made Easy

Often, you need to test the same logic with different sets of input data. Instead of writing multiple identical tests, JUnit 5's Parameterized Tests allow you to define a single test method and supply it with various arguments. This makes your tests DRY (Don't Repeat Yourself) and highly readable.

You can use different sources for your parameters, such as @ValueSource, @CsvSource, @MethodSource, or even @CsvFileSource.


import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;

class CalculatorService {
    int add(int a, int b) {
        return a + b;
    }
}

class CalculatorServiceTest {

    private final CalculatorService calculator = new CalculatorService();

    @ParameterizedTest
    @CsvSource({"1, 1, 2", "2, 3, 5", "-1, 1, 0", "0, 0, 0"})
    void add_shouldReturnCorrectSum(int a, int b, int expectedSum) {
        assertEquals(expectedSum, calculator.add(a, b));
    }
}

In this example, the add_shouldReturnCorrectSum test will execute four times, once for each row in the @CsvSource, with different inputs and expected outputs.

Dynamic Tests: Generating Tests at Runtime

Sometimes, the test cases aren't known until runtime. Perhaps they come from a database, a configuration file, or an external API. JUnit 5's Dynamic Tests allow you to generate tests programmatically during test execution. This is incredibly powerful for scenarios like testing API endpoints defined in a JSON file or processing a large dataset.


import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import java.util.Arrays;
import java.util.Collection;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;

class DynamicTestExample {

    @TestFactory
    Collection<DynamicTest> dynamicTestsFromCollection() {
        return Arrays.asList(
            dynamicTest("1st dynamic test", () -> assertTrue(true)),
            dynamicTest("2nd dynamic test", () -> assertTrue(true))
        );
    }

    // More complex example: testing a list of strings
    @TestFactory
    Collection<DynamicTest> testStringsForLength() {
        String[] words = new String[]{"hello", "world", "junit", "coddykit"};
        return Arrays.stream(words)
                .map(word -> dynamicTest("Test length of " + word, 
                                         () -> assertTrue(word.length() > 3)))
                .toList();
    }
}

The @TestFactory method returns a collection of DynamicTest instances, which JUnit then executes. This offers immense flexibility for complex, data-driven test scenarios.

Conditional Test Execution: Running Tests Smarter

Not all tests need to run in all environments. JUnit 5 provides annotations like @EnabledOnOs, @DisabledOnJre, @EnabledIfSystemProperty, and @EnabledIfEnvironmentVariable to conditionally execute tests. This is invaluable for platform-specific tests, performance tests that only run on specific hardware, or integration tests that require certain environment variables to be set.


import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledOnOs;
import org.junit.jupiter.api.condition.OS;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;

class ConditionalTestExample {

    @Test
    @EnabledOnOs(OS.LINUX)
    void onlyRunOnLinux() {
        System.out.println("This test runs only on Linux.");
    }

    @Test
    @EnabledIfSystemProperty(named = "java.vm.name", matches = ".*OpenJDK.*")
    void onlyRunOnOpenJDK() {
        System.out.println("This test runs only if JVM is OpenJDK.");
    }
}

Mockito for Complex Scenarios: Beyond Simple Stubs

Mockito is a powerful mocking framework, and while when().thenReturn() covers most cases, there are advanced features for more intricate mocking needs.

ArgumentCaptor: Capturing Arguments for Detailed Assertions

Sometimes you need to verify not just that a method was called, but also inspect the exact arguments passed to it, especially if those arguments are complex objects. ArgumentCaptor allows you to capture an argument passed to a method and perform assertions on it.


import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.assertEquals;

class User {
    String name;
    String email;
    // Getters and Setters
    public User(String name, String email) { this.name = name; this.email = email; }
    public String getName() { return name; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}

interface UserRepository {
    void save(User user);
}

class UserService {
    private final UserRepository userRepository;
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    public void registerUser(String name, String email) {
        User newUser = new User(name, email);
        // Potentially some business logic before saving
        userRepository.save(newUser);
    }
}

class UserServiceTest {

    @Test
    void registerUser_shouldSaveCorrectUser() {
        UserRepository mockRepository = mock(UserRepository.class);
        UserService userService = new UserService(mockRepository);

        userService.registerUser("John Doe", "john.doe@example.com");

        ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
        verify(mockRepository).save(userCaptor.capture());

        User capturedUser = userCaptor.getValue();
        assertEquals("John Doe", capturedUser.getName());
        assertEquals("john.doe@example.com", capturedUser.getEmail());
    }
}

Here, we verify that the save method was called and then inspect the User object that was passed to it.

Custom Answers: Defining Complex Mock Behavior

While thenReturn() is great for simple return values, sometimes you need a mocked method to perform some logic, modify an argument, or return different values based on complex conditions. thenAnswer() allows you to provide a custom implementation for a mocked method using a lambda or an Answer object.


import org.junit.jupiter.api.Test;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.assertEquals;

interface EmailService {
    String sendEmail(String recipient, String subject, String body);
}

class NotificationService {
    private final EmailService emailService;
    public NotificationService(EmailService emailService) {
        this.emailService = emailService;
    }
    public String notifyUser(String recipient, String message) {
        return emailService.sendEmail(recipient, "Notification", message);
    }
}

class NotificationServiceTest {

    @Test
    void notifyUser_shouldReturnConfirmationMessage() {
        EmailService mockEmailService = mock(EmailService.class);

        when(mockEmailService.sendEmail(anyString(), anyString(), anyString()))
            .thenAnswer(new Answer<String>() {
                @Override
                public String answer(InvocationOnMock invocation) throws Throwable {
                    String recipient = invocation.getArgument(0);
                    String subject = invocation.getArgument(1);
                    String body = invocation.getArgument(2);
                    return "Email to " + recipient + " with subject '" + subject + "' and body '" + body + "' sent successfully.";
                }
            });

        NotificationService notificationService = new NotificationService(mockEmailService);
        String result = notificationService.notifyUser("test@example.com", "Hello there!");

        assertEquals("Email to test@example.com with subject 'Notification' and body 'Hello there!' sent successfully.", result);
    }
}

Spying: Partial Mocking of Real Objects

Unlike mocks which are entirely fake objects, a spy is a real object that you can partially mock. This means you can call real methods on the object by default, but you can also stub specific methods if needed. Spying is useful when dealing with legacy code, complex objects, or when you want to test one method of a class while allowing its dependencies to function normally.

Use spies with caution! They can make tests less isolated and harder to understand than pure mocks.


import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.assertEquals;

class ReportingService {
    public String generateReportHeader() {
        return "--- Daily Report ---";
    }

    public String generateReportBody(String data) {
        // Complex logic to process data
        return "Data processed: " + data.toUpperCase();
    }

    public String getFullReport(String data) {
        return generateReportHeader() + "\n" + generateReportBody(data);
    }
}

class ReportingServiceTest {

    @Test
    void getFullReport_withSpying() {
        ReportingService realService = new ReportingService();
        ReportingService spyService = spy(realService);

        // Stub only the header method, let body execute normally
        when(spyService.generateReportHeader()).thenReturn("--- MOCKED HEADER ---");

        String report = spyService.getFullReport("sales data");

        verify(spyService).generateReportHeader();
        verify(spyService).generateReportBody("sales data");

        assertEquals("--- MOCKED HEADER ---\nData processed: SALES DATA", report);

        // You can still call real methods that weren't stubbed
        assertEquals("Data processed: OTHER DATA", spyService.generateReportBody("other data"));
    }
}

Robust Integration Testing with Testcontainers

Integration tests often require external dependencies like databases, message queues, or caching systems. Setting these up manually for each test run is tedious, error-prone, and can lead to inconsistent results. Enter Testcontainers – a powerful library that allows you to spin up lightweight, throwaway instances of databases, message brokers, web browsers, or anything else that can run in a Docker container, directly from your tests.

This ensures your integration tests run against a real dependency, not a mocked one, providing high confidence without the setup hassle.

Real-World Example: Testing a Data Repository with PostgreSQL

Let's imagine we have a ProductRepository that interacts with a PostgreSQL database. We want to test its save and findById methods.


// pom.xml (or build.gradle) would include:
// <dependency>
//     <groupId>org.testcontainers</groupId>
//     <artifactId>postgresql</artifactId>
//     <version>YOUR_TESTCONTAINERS_VERSION</version> 
//     <scope>test</scope>
// </dependency>
// <dependency>
//     <groupId>org.testcontainers</groupId>
//     <artifactId>junit-jupiter</artifactId> // For JUnit 5 integration
//     <version>YOUR_TESTCONTAINERS_VERSION</version>
//     <scope>test</scope>
// </dependency>

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;

// Simple Product POJO
class Product {
    private Long id;
    private String name;
    private double price;

    public Product(Long id, String name, double price) { this.id = id; this.name = name; this.price = price; }
    public Product(String name, double price) { this(null, name, price); }

    // Getters and Setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public double getPrice() { return price; }
    public void setPrice(double price) { this.price = price; }
}

// Basic Repository Interface
interface ProductRepository {
    Product save(Product product);
    Optional<Product> findById(Long id);
}

// JDBC Implementation
class JdbcProductRepository implements ProductRepository {
    private final String jdbcUrl;
    private final String username;
    private final String password;

    public JdbcProductRepository(String jdbcUrl, String username, String password) {
        this.jdbcUrl = jdbcUrl;
        this.username = username;
        this.password = password;
    }

    private Connection getConnection() throws SQLException {
        return DriverManager.getConnection(jdbcUrl, username, password);
    }

    @Override
    public Product save(Product product) {
        String sql = "INSERT INTO products (name, price) VALUES (?, ?) RETURNING id";
        try (Connection conn = getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {
            pstmt.setString(1, product.getName());
            pstmt.setDouble(2, product.getPrice());
            ResultSet rs = pstmt.executeQuery();
            if (rs.next()) {
                product.setId(rs.getLong(1));
            }
            return product;
        } catch (SQLException e) {
            throw new RuntimeException("Error saving product", e);
        }
    }

    @Override
    public Optional<Product> findById(Long id) {
        String sql = "SELECT id, name, price FROM products WHERE id = ?";
        try (Connection conn = getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {
            pstmt.setLong(1, id);
            ResultSet rs = pstmt.executeQuery();
            if (rs.next()) {
                return Optional.of(new Product(rs.getLong("id"), rs.getString("name"), rs.getDouble("price")));
            }
            return Optional.empty();
        } catch (SQLException e) {
            throw new RuntimeException("Error finding product", e);
        }
    }
}

@Testcontainers
class JdbcProductRepositoryIntegrationTest {

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

    private static ProductRepository productRepository;

    @BeforeAll
    static void setUp() throws SQLException {
        // Initialize repository with Testcontainers details
        productRepository = new JdbcProductRepository(
                postgres.getJdbcUrl(),
                postgres.getUsername(),
                postgres.getPassword()
        );

        // Create schema
        try (Connection conn = postgres.createConnection("");
             PreparedStatement pstmt = conn.prepareStatement(
                     "CREATE TABLE products (id SERIAL PRIMARY KEY, name VARCHAR(255), price NUMERIC)"
             )) {
            pstmt.execute();
        }
    }

    @Test
    void saveAndFindById_shouldWorkCorrectly() {
        Product newProduct = new Product("Laptop", 1200.00);
        Product savedProduct = productRepository.save(newProduct);

        assertNotNull(savedProduct.getId());
        assertEquals("Laptop", savedProduct.getName());
        assertEquals(1200.00, savedProduct.getPrice(), 0.001);

        Optional<Product> foundProduct = productRepository.findById(savedProduct.getId());

        assertTrue(foundProduct.isPresent());
        assertEquals(savedProduct.getId(), foundProduct.get().getId());
        assertEquals("Laptop", foundProduct.get().getName());
        assertEquals(1200.00, foundProduct.get().getPrice(), 0.001);
    }
}

With @Testcontainers and @Container, JUnit will automatically start a PostgreSQL container before all tests in the class and stop it afterwards. Each test run gets a clean, isolated database instance, eliminating environmental inconsistencies and making your integration tests reliable and repeatable.

Putting It All Together: Real-World Scenarios

These advanced techniques aren't just academic exercises; they solve real problems in professional development:

  • Testing Complex Business Rules: Use Parameterized Tests to cover all edge cases of a discount calculation service.
  • Validating API Integrations: Combine Dynamic Tests with a `Testcontainers` instance of a mock API server to validate various request/response scenarios defined in an external config.
  • Handling Legacy Code: Employ Spies to introduce tests into existing, tightly coupled code without a full refactor, gradually improving test coverage.
  • Ensuring Data Integrity: Use `ArgumentCaptor` in conjunction with a mocked data access layer to verify that complex data transformations result in the correct object being persisted.
  • Platform-Specific Optimizations: Use Conditional Test Execution to test platform-dependent code paths, such as file system operations optimized for Windows vs. Linux.

By mastering these advanced capabilities, you can write more efficient, comprehensive, and maintainable tests that truly reflect the real-world behavior of your application.

Conclusion

We've journeyed far beyond the basics in this post, exploring the power of advanced JUnit 5 features, the flexibility of Mockito's sophisticated mocking techniques, and the game-changing capabilities of Testcontainers for robust integration testing. These tools equip you to tackle even the most challenging testing scenarios, ensuring higher quality software and greater confidence in your deployments.

As you continue your learning path with CoddyKit, remember that testing is an art as much as a science. Experiment with these techniques, integrate them into your workflow, and watch your testing mastery grow. Stay tuned for our final post in this series, where we'll look at future trends and the broader testing ecosystem!

ProgrammingTutorialCoddyKit

Enjoyed this article?

Explore more tutorials and insights to level up your coding skills.

Browse All Articles →