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.