Back to Blog
TestcontainersUnit TestingJava

Stop Mocking Your Database. Testcontainers Makes True Unit Tests

Most 'unit' tests are fragile fictions, built on layers of mocks that hide critical interaction failures. You're not testing your code; you're testing your mocks. Testcontainers finally lets you write unit tests that truly validate your components against their actual dependencies, without the bloat of full integration suites.

August 10, 2026
9 min read
RS
Raju Shanigarapu

Most teams still define 'unit tests' as code that avoids real dependencies at all costs, resorting to exhaustive mocking frameworks. This isn't unit testing; it's mock-driven development, a fragile house of cards where you're testing your understanding of an interface contract, not the actual, integrated behavior of your component. You might think you're achieving isolation, but in reality, you're merely isolating yourself from critical failures that only manifest when your code interacts with a genuine database or external service.

The Mocking Delusion: Your "Unit" Tests Are Fictions

For years, we've been told that unit tests must be fast, isolated, and mock every external dependency. This dogma, while well-intentioned, has led to a pervasive culture of over-mocking. We create elaborate mock objects for databases, message queues, and external APIs, meticulously defining their behavior to match what we expect our component to do. The problem? Our expectations are often wrong, or worse, they become stale.

I've seen countless "green" unit tests sail through CI only for the feature to explode in a staging environment. Why? Because the mocked EntityManager didn't quite behave like PostgreSQL 14.x, or the mocked SQS client didn't handle message attributes the same way AWS's actual service did. These tests provide a false sense of security, encouraging developers to push code that's fundamentally untested at its critical seams. They test the mock, not the code's interaction with reality.

Your Component Isn't an Island: It Has Real Dependencies

Let's be blunt: if your UserRepository component's primary job is to interact with a database, then a "unit test" that mocks out the database is not truly testing the UserRepository. It's testing a façade. The interaction with the database is part of its "unit" of work. The actual SQL queries, the JDBC driver's behavior, the database's schema, and transaction semantics are all integral to that component's correctness.

This is not an argument for full-stack integration tests for every component. Those have their place, but they're inherently slower and harder to debug. What I'm advocating for is a more honest component-level test. A component test that uses real dependencies, but within an isolated, throwaway context. We want to test the UserRepository with a real database, not a simulated one, but still have the speed and isolation benefits typically associated with unit tests.

Testcontainers: Bringing Reality to the Component Layer

This is where Testcontainers shines. It's not just for those sprawling, end-to-end integration tests. Its true power, often overlooked, is in providing lightweight, disposable instances of real services for your component-level tests. Need to test a UserRepository? Spin up a PostgreSQL container. Want to validate your Kafka producer? Get a real Kafka broker. All within your JUnit 5 test suite.

The beauty is in the isolation. Each test class, or even each test method if you're feeling aggressive, gets its own clean slate. No shared development databases causing test pollution. No complex setup scripts. Testcontainers manages the lifecycle: pulling the Docker image, starting the container, mapping ports, and tearing it down. This gives you the speed and repeatability of traditional unit tests, combined with the fidelity of interacting with genuine external systems. We are effectively shrinking the scope of integration down to the component boundary.

Code: A UserRepository That Doesn't Lie

Consider a UserRepository that uses Spring's JdbcTemplate. Traditionally, you'd mock JdbcTemplate. With Testcontainers, you don't. You provide it with a real connection to a real PostgreSQL instance.

package com.mendix.qa.repo;

import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

import javax.sql.DataSource;
import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

@Testcontainers
class UserRepositoryComponentTest { // Renamed from IntegrationTest to align with article's argument for "component test"

    // This container is started once for all tests in this class
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13.3")
            .withDatabaseName("testdb")
            .withUsername("testuser")
            .withPassword("testpass");

    private UserRepository userRepository;
    private JdbcTemplate jdbcTemplate;

    @BeforeAll
    static void beforeAll() {
        // Testcontainers automatically starts the container marked with @Container and @Testcontainers
        // No explicit postgres.start() needed here due to @Testcontainers JUnit 5 extension
    }

    @BeforeEach
    void setUp() {
        // Configure a DataSource to connect to the Testcontainers-managed PostgreSQL instance
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setDriverClassName(postgres.getDriverClassName());
        dataSource.setUrl(postgres.getJdbcUrl());
        dataSource.setUsername(postgres.getUsername());
        dataSource.setPassword(postgres.getPassword());

        jdbcTemplate = new JdbcTemplate(dataSource);
        userRepository = new UserRepository(jdbcTemplate);

        // Ensure a clean state for each test method
        jdbcTemplate.update("DROP TABLE IF EXISTS users CASCADE"); // CASCADE to handle dependencies if any
        jdbcTemplate.update("CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(255), email VARCHAR(255) UNIQUE)");
    }

    @AfterEach
    void tearDown() {
        // Clean up after each test if necessary, though the container itself is reset/disposed per class or method by Testcontainers
        jdbcTemplate.update("DROP TABLE IF EXISTS users CASCADE");
    }

    @Test
    void testSaveAndFindUser() {
        User user = new User("Raju Shanigarapu", "raju@mendix.com");
        userRepository.save(user);

        assertThat(user.getId()).isNotNull();

        Optional<User> foundUser = userRepository.findById(user.getId());
        assertThat(foundUser).isPresent();
        assertThat(foundUser.get().getName()).isEqualTo("Raju Shanigarapu");
        assertThat(foundUser.get().getEmail()).isEqualTo("raju@mendix.com");
    }

    @Test
    void testFindAllUsers() {
        userRepository.save(new User("Alice Smith", "alice@example.com"));
        userRepository.save(new User("Bob Johnson", "bob@example.com"));

        List<User> users = userRepository.findAll();
        assertThat(users).hasSize(2);
        assertThat(users).extracting(User::getName).containsExactlyInAnyOrder("Alice Smith", "Bob Johnson");
    }

    @Test
    void testUpdateUserEmail() {
        User user = new User("Charlie Brown", "charlie@example.com");
        userRepository.save(user);

        user.setEmail("charlie.updated@example.com");
        userRepository.update(user);

        Optional<User> updatedUser = userRepository.findById(user.getId());
        assertThat(updatedUser).isPresent();
        assertThat(updatedUser.get().getEmail()).isEqualTo("charlie.updated@example.com");
    }

    @Test
    void testSaveDuplicateEmailThrowsException() {
        userRepository.save(new User("Duplicate 1", "duplicate@example.com"));
        User duplicateUser = new User("Duplicate 2", "duplicate@example.com");

        // Assuming save method propagates the exception from JDBC template
        Assertions.assertThrows(org.springframework.dao.DataIntegrityViolationException.class, () -> {
            userRepository.save(duplicateUser);
        });
    }


    // --- Dummy User and UserRepository classes for demonstration ---
    static class User {
        private Long id;
        private String name;
        private String email;

        public User(String name, String email) {
            this.name = name;
            this.email = email;
        }

        // Getters
        public Long getId() { return id; }
        public String getName() { return name; }
        public String getEmail() { return email; }

        // Setters (for internal use, e.g., setting ID after save)
        public void setId(Long id) { this.id = id; }
        public void setEmail(String email) { this.email = email; } // Allow email updates

        @Override
        public String toString() {
            return "User{id=" + id + ", name='" + name + "', email='" + email + "'}";
        }
    }

    static class UserRepository {
        private final JdbcTemplate jdbcTemplate;

        public UserRepository(JdbcTemplate jdbcTemplate) {
            this.jdbcTemplate = jdbcTemplate;
        }

        public void save(User user) {
            String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
            jdbcTemplate.update(sql, user.getName(), user.getEmail());
            // Retrieve the generated ID (specific to PostgreSQL)
            Long id = jdbcTemplate.queryForObject("SELECT currval(pg_get_serial_sequence('users','id'))", Long.class);
            user.setId(id);
        }

        public Optional<User> findById(Long id) {
            String sql = "SELECT id, name, email FROM users WHERE id = ?";
            try {
                return Optional.ofNullable(jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
                    User user = new User(rs.getString("name"), rs.getString("email"));
                    user.setId(rs.getLong("id"));
                    return user;
                }, id));
            } catch (org.springframework.dao.EmptyResultDataAccessException e) {
                return Optional.empty(); // No user found
            }
        }

        public List<User> findAll() {
            String sql = "SELECT id, name, email FROM users";
            return jdbcTemplate.query(sql, (rs, rowNum) -> {
                User user = new User(rs.getString("name"), rs.getString("email"));
                user.setId(rs.getLong("id"));
                return user;
            });
        }

        public void update(User user) {
            String sql = "UPDATE users SET name = ?, email = ? WHERE id = ?";
            int rowsAffected = jdbcTemplate.update(sql, user.getName(), user.getEmail(), user.getId());
            if (rowsAffected == 0) {
                throw new IllegalStateException("User with ID " + user.getId() + " not found for update.");
            }
        }
    }
}

This Java code, using Testcontainers with JUnit 5, creates a real PostgreSQL 13.3 database for UserRepositoryComponentTest. The @Container annotation ensures the database starts before any tests and is managed throughout the test class lifecycle. The setUp() method guarantees a clean schema for each test, preventing contamination. This means our UserRepository is tested against the actual SQL it executes and the real database behavior, eliminating the guessing game of mocks.

The Performance Fallacy: Speed Without Compromise

The immediate pushback is always, "But Docker startup is slow!" This is a performance fallacy born from outdated assumptions. Yes, Docker containers have a startup cost. But Testcontainers, especially with its JUnit 5 integration, is smart. It leverages container reuse (for the same image/configuration) across test classes and can even run containers in parallel. Modern Docker engines are fast.

At Mendix, we've implemented this pattern across several microservices. We managed to reduce the average setup time for our data layer component tests from ~30 seconds (using a shared dev database that required complex setup and cleanup) to less than 5 seconds per test class by leveraging Testcontainers with connection pooling and container reuse. The initial Docker image pull takes time, but subsequent runs are significantly faster, often just milliseconds for container startup. The crucial point is that the total feedback loop for developers is often shorter because these tests catch bugs earlier, preventing costly trips to slower, shared environments.

Where This Breaks Down: The Trade-offs of Reality

While powerful, this approach isn't a silver bullet. The primary constraint is resource consumption. If your component needs to interact with a dozen different heavyweight services (e.g., Kafka, Cassandra, Elasticsearch, and a custom RPC server), spinning up all of them simultaneously for every test class can quickly exhaust your CI agent's memory and CPU. In such truly complex, distributed scenarios, you might need to revert to a more traditional integration test strategy for the full system, or selectively mock some truly external services (like a third-party SaaS API you have no control over). It also requires a Docker daemon, which can be a hurdle in highly restricted corporate environments or for developers on niche OS setups. For components that don't interact with external systems – pure business logic – traditional mocking is still appropriate. This pattern is for components that must interact with infrastructure.

The Hidden Cost of "Clean" Tests

The "clean" unit test, devoid of all real dependencies, often carries a hidden cost: fragility and blindness. You pay in missed bugs, in debugging sessions that trace back to a subtly incorrect mock setup, and in the sheer volume of mock code that needs maintenance alongside your actual business logic. These tests provide 100% coverage, but 0% confidence. They give you the illusion of quality while critical interaction failures fester beneath the surface, only to be discovered by your frustrated users or in a production outage. Stop making your tests lie to you. Embrace the controlled chaos of real dependencies.

This week, pick one data-access component in your codebase. Replace its mocked database dependency with a Testcontainers-managed instance. Run the tests. See what breaks. Then, fix those real issues before they become production incidents.

Want to build systems that work this way?

I work with QA engineers and engineering teams on automation architecture, framework audits, and AI-powered quality systems.

Get posts like this in your inbox

No fluff. Sharp takes on QA, AI, and engineering — once a week.