Stop Avoiding Integration Tests Because They Are Hard to Set Up

by Arif Ikhsanudin, Backend Developer

Where the Reputation Comes From

For a long time, integration tests required a shared test database that was perpetually out of sync with the schema, seeded with data that accumulated mysterious state, and shared between developers who stepped on each other's test results. Setting up a new developer's machine meant running a migration script that half-worked and required manual intervention. CI had its own database instance that drifted from local over time.

This was genuinely painful. The avoidance was rational given the tooling available ten years ago.

The tooling has changed substantially. The avoidance has not kept pace.

What the Modern Stack Looks Like

Testcontainers eliminates the shared database problem entirely. The library (available for Java, Python, Go, .NET, Node.js, and Rust) starts a real Docker container — PostgreSQL, MySQL, Redis, Kafka, whatever you need — as part of the test suite setup, runs the migrations against it, executes the tests, and tears it down. Each developer and each CI run gets an identical, isolated database. Schema drift is impossible. Data contamination between test runs is impossible.

@Testcontainers
class UserRepositoryIntegrationTest {

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

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private UserRepository userRepository;

    @Test
    @Transactional
    void findByEmail_shouldReturnUser_whenEmailExists() {
        userRepository.save(new User("alice@example.com"));
        Optional<User> result = userRepository.findByEmail("alice@example.com");
        assertTrue(result.isPresent());
        assertEquals("alice@example.com", result.get().getEmail());
    }
}

The container starts once for the test class, not once per test. On a machine with Docker installed, the PostgreSQL 16 image (if cached) starts in under two seconds. The entire setup is in the test file — no external configuration, no shared infrastructure.

WireMock (or MockServer, or msw for Node) eliminates the problem of depending on real external APIs in tests. You declare the HTTP responses your test needs, and the library runs a local server that returns them. Your code under test hits localhost:8080 instead of api.partner.com. The test runs without network access, without API keys, and without being affected by the upstream service's availability.

# Python with responses library (WireMock equivalent for requests)
import responses

@responses.activate
def test_payment_gateway_timeout_triggers_retry():
    responses.add(responses.POST, "https://payments.example.com/charge",
                  body=requests.exceptions.Timeout())
    responses.add(responses.POST, "https://payments.example.com/charge",
                  json={"status": "approved", "transaction_id": "txn_123"},
                  status=200)

    result = payment_service.charge(amount=50.00, card_token="tok_test")

    assert result.approved is True
    assert len(responses.calls) == 2  # Verify retry happened

Flyway and Liquibase make schema management deterministic. Migrations run in order, versioned, as part of the test setup. The database schema in tests is always exactly what it is in production.

The Remaining Genuine Costs

Integration tests are still slower than unit tests. A test suite with Testcontainers will add 5–30 seconds of container startup overhead. Each test that hits the database takes milliseconds, not microseconds. A suite of 200 integration tests might take 2–3 minutes instead of 10 seconds.

This is a real cost. The mitigation is to keep the integration suite separate from the unit suite and to run it on a different trigger — on pull requests and before deploys, but not on every save. The unit suite runs constantly. The integration suite runs at boundaries.

The cost of not having integration tests is also real: production bugs in the database layer, ORM mapping failures, query correctness issues under production data, and serialization mismatches — all of which are invisible to unit tests.

The practical starting point: pick one repository class or one external API client that you know has untested behavior in the infrastructure layer. Write three integration tests for it this sprint. The setup will take an hour the first time. The second test file will take fifteen minutes. By the fifth, it will be routine.

The hard setup problem is mostly solved. What remains is inertia.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

How to Write a Proposal That Gets a Response

Most contractor proposals are ignored not because the work is wrong for the client, but because the document is written for the contractor, not the reader.

Read more

Setting Boundaries as a Remote Contractor Is Not Unprofessional. It Is Required.

Without boundaries, remote contracting does not become flexible — it becomes borderless. And borderless work has a well-known consequence: burnout.

Read more

Distributed Tracing: How to Find Where Your Request Actually Failed

Without distributed tracing, debugging failures in a microservices system means correlating timestamps across disconnected log files from multiple teams. Distributed tracing makes request flows visible and failure points obvious.

Read more

How Remote Teams Manage Projects Without Chaos

Managing projects remotely can feel like herding cats. With the right approach, teams stay organized, aligned, and stress-free.

Read more