Back to Blog
TestcontainersDeveloper ExperienceContract TestingLocal Development

Testcontainers: Your Local Setup Is a Lie Without It

Your local development environment is a lie. Most teams relegate Testcontainers to the `src/test` directory, treating it as a CI-only isolation tool. This misses its most profound impact: providing a truly consistent, production-like dependency landscape right on your laptop, slashing 'works on my machine' incidents and accelerating daily development.

September 7, 2026
7 min read
RS
Raju Shanigarapu

You're building against a local setup that's fundamentally different from production, and you don't even realize the daily toll it's taking. Most engineering teams shove Testcontainers into src/test, leveraging it solely for isolated JUnit integration tests in CI. This narrow view completely misses its most transformative application: creating a production-mirroring dependency ecosystem right on your developer machine, not just for tests, but for actual development. We've spent years patching application-dev.yml files, spinning up ad-hoc docker-compose stacks, and praying our local PostgreSQL matches the version in staging. It's a fragile house of cards, leading to constant "works on my machine" debugging sessions that erode velocity and trust.

Your application-dev.yml Is a Crutch

Let's be blunt: your application-dev.yml is probably a mess of hardcoded external service URLs, or worse, pointing to a shared development database that's constantly being nuked or modified by a colleague. This practice is not development; it's glorified guesswork. You're building features against an environment that is neither stable nor representative. When your service needs PostgreSQL 14.x, a specific Redis cache, and an S3-compatible object store, relying on a shared dev instance introduces non-determinism. Someone else's test data, schema migration, or even a simple cache flush can break your local feature branch without warning. How many hours have you wasted debugging a NullPointerException only to find out the shared dev database was empty, or a schema migration had rolled back? "Environment reconciliation" quietly eats hours of every developer's week.

The same applies to local docker-compose setups. While better than shared services, they often become stale. Developers forget to docker-compose pull or build, leading to local containers running older versions than what's deployed to CI or staging. This drift is insidious; your code passes local tests against Kafka 2.8 but blows up in CI because the pipeline is running against Kafka 3.2. The problem isn't docker-compose itself; it's the manual orchestration and the mental overhead of keeping it in sync.

The Unseen Cost of "Works On My Machine"

"It works on my machine" isn't a badge of honor; it's a symptom of a broken feedback loop. The friction it creates manifests in several ways:

  1. Extended Debugging Cycles: Features that pass local testing often fail their first CI run, not due to code bugs, but environmental discrepancies. This pushes debugging further down the pipeline, where feedback is slower and more expensive.
  2. Increased Cognitive Load: Developers waste mental cycles managing disparate local dependencies instead of focusing on business logic. "Is my Kafka running? Is it the right version? Did I clear the topic?"
  3. Delayed Releases: Each environment-related failure in CI or staging adds days to release cycles. These aren't "QA found a bug" delays; they're "our setup is inconsistent" delays.

A large share of CI pipeline failures come from environmental inconsistencies rather than actual code defects or test flakiness. That is a massive drain. Playwright 1.4x UI tests randomly fail due to backend service startup issues, not UI interaction problems. Standardizing the dev environment with Testcontainers removes most of those failures.

Testcontainers Beyond JUnit: A Local Dependency Sandbox

The core insight is this: Testcontainers doesn't care where it's run. It's a Docker container lifecycle manager. You can leverage it to spin up an entire suite of production-like dependencies for your local service before you even run a single mvn clean install. Imagine a main method that orchestrates your local environment, completely isolated, fully versioned, and guaranteed to match what CI sees.

Here's a simplified example using Spring Boot and JUnit 5, but the principle applies universally. Instead of a JUnit test, this could be a custom main method or a developer script.

package com.example.qa;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;

import java.util.HashMap;
import java.util.Map;

@SpringBootApplication
public class LocalDevRunner {

    private static PostgreSQLContainer<?> postgres;
    private static KafkaContainer kafka;

    public static void main(String[] args) {
        System.out.println("Starting local development environment...");

        // Define containers with specific versions
        postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:14.5"))
                .withDatabaseName("app_db")
                .withUsername("app")
                .withPassword("password");
        postgres.start();

        kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));
        kafka.start();

        // Configure Spring Boot with container properties
        Map<String, Object> properties = new HashMap<>();
        properties.put("spring.datasource.url", postgres.getJdbcUrl());
        properties.put("spring.datasource.username", postgres.getUsername());
        properties.put("spring.datasource.password", postgres.getPassword());
        properties.put("spring.kafka.bootstrap-servers", kafka.getBootstrapServers());
        properties.put("server.port", "8080"); // Your service port

        SpringApplication app = new SpringApplication(LocalDevRunner.class);
        app.setDefaultProperties(properties);
        ConfigurableApplicationContext context = app.run(args);

        System.out.println("Local service started on port 8080 with Testcontainers-managed dependencies.");
        System.out.println("PostgreSQL JDBC URL: " + postgres.getJdbcUrl());
        System.out.println("Kafka Bootstrap Servers: " + kafka.getBootstrapServers());
        System.out.println("Press Ctrl+C to stop...");

        // Add shutdown hook to stop containers gracefully
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            System.out.println("Stopping containers...");
            if (kafka != null) kafka.stop();
            if (postgres != null) postgres.stop();
            System.out.println("Containers stopped.");
        }));
    }
}

This runnable LocalDevRunner effectively replaces your docker-compose up and your application-dev.yml for critical dependencies. Every developer runs the exact same PostgreSQL 14.5 and Kafka 7.4.0. No more version drift. No more shared dev database contention. This simple pattern also shortens CI, because fewer builds fail on environmental setup.

Contract-Driven Development with Live Dependencies

This approach also fundamentally changes how you approach contract testing. Instead of purely mocking external services, you can run integration tests against real instances of those services, spun up by Testcontainers, and orchestrated with tools like WireMock 2.35.x for specific response stubbing.

Consider a scenario where your service integrates with a third-party payment gateway. Instead of only mocking the gateway, you can:

  1. Spin up a WireMock server using Testcontainers.
  2. Configure your service to point to this WireMock instance.
  3. Load WireMock with a baseline set of contract stubs from the external service's OpenAPI spec (if available).
  4. Execute your tests against your service, which is now talking to a "live" stubbed version of the external dependency.

This provides a higher fidelity test than a pure unit test with hardcoded mocks, ensuring your service correctly interacts with a networked service, even if that service is locally stubbed. It's not about replacing E2E; it's about shifting left, catching integration issues before they hit a full E2E environment. We use this extensively with our AI model integrations, ensuring our service correctly formats requests and parses responses with Claude claude-sonnet-4-6, even if the actual model call is stubbed out via WireMock.

Where This Breaks Down

While powerful, this approach isn't a silver bullet. The primary limitation is resource consumption. Spinning up multiple containers for every developer on every feature branch can quickly consume RAM and CPU, especially for complex microservice architectures or memory-intensive services like Elasticsearch or large language models. A developer with 16GB of RAM might struggle if their local service plus a PostgreSQL, Kafka, Redis, and an S3 mock container push past 80% usage.

Furthermore, relying on Docker Desktop 4.22.x means you're tied to its stability and performance. Docker daemon issues, slow image pulls, or network peculiarities can introduce their own brand of "it works on my machine" problems, albeit at a lower frequency than traditional ad-hoc setups. Complex setups might also incur a significant startup penalty, potentially making the initial boot of your local environment take several minutes. You need to be pragmatic about which dependencies truly warrant this level of isolation and which can be acceptably mocked or run as shared services in a well-managed dev environment. Don't spin up a full Kubernetes cluster in Testcontainers if your local machine groans under the load.

Your Local Setup Deserves Better

Your development velocity is directly tied to the fidelity and reliability of your local environment. Stop treating Testcontainers as a peripheral tool for CI. It's a foundational component for a robust developer experience. The "works on my machine" problem isn't a developer failing; it's a systemic failure of environment management. Testcontainers offers a direct, pragmatic solution.

This week, pick one key dependency that frequently causes local development headaches – maybe your database or a critical message queue. Instead of configuring it via application-dev.yml, create a simple main method in your service that uses Testcontainers to spin up a versioned, isolated instance of that dependency before launching your Spring Boot application. Start migrating your local setup to this pattern, one dependency at a time.

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.

Sent with MailerLite. See the privacy policy.