Integration Testing Anti-Patterns to Avoid
Integration tests fail for predictable reasons: shared mutable state, timing assumptions, over-mocking, wrong scope, and ordering dependencies. Most integration test suites that "work most of the time" are failing for these reasons. This guide identifies the most common anti-patterns and shows how to fix them.
Key Takeaways
Shared test database = shared test failure. Tests that run against a shared database fail when parallel tests interfere. Use isolated databases per test class or transaction rollback to restore clean state.
Thread.sleep() is not a timing strategy. Fixed sleeps are too short in slow environments (CI) and too long in fast environments (local). Use polling with timeouts.
Mocking everything defeats the purpose. Integration tests exist to test that components work together. If you mock the database, the HTTP client, and the queue, you're writing a unit test with extra steps.
Test pollution kills trust. Tests that pass in isolation but fail when run together indicate shared state. Find and eliminate the shared state — don't disable the failing tests.
End-to-end tests are not integration tests. Calling 8 services to test one behavior is an E2E test. Integration tests should test one service with its direct dependencies.
Why Integration Tests Are So Often Broken
Unit tests are straightforward: mock the dependencies, call the function, assert the result. They're fast, reliable, and deterministic.
Integration tests are harder. They involve real infrastructure (databases, message queues, HTTP services), real timing, and real shared state. Without careful design, they become the most expensive and least reliable part of the test suite: slow to run, frequently flaky, and requiring constant maintenance.
Most teams with integration test problems have the same set of problems, just in different proportions. This guide covers the most common ones.
Anti-Pattern 1: Shared Mutable State Between Tests
The problem:
class OrderRepositoryTest {
// WARNING: Shared static database connection
static DataSource dataSource = createDataSource();
static OrderRepository repo = new OrderRepository(dataSource);
@Test
void createsOrder() {
Order order = repo.create(new CreateOrderRequest("user-1", List.of(...)));
assertThat(order.getId()).isNotNull();
}
@Test
void findsOrdersByUser() {
List<Order> orders = repo.findByUserId("user-1");
// PROBLEM: How many orders does "user-1" have?
// Depends on whether createsOrder() ran first
assertThat(orders).hasSize(1); // Fails randomly
}
}Tests that share a database without cleanup are order-dependent. They pass when run in the right order and fail when run in parallel or in a different order.
The fix:
@SpringBootTest
@Transactional // Each test runs in a transaction that's rolled back afterward
class OrderRepositoryTest {
@Autowired
private OrderRepository repo;
@Test
void createsOrder() {
// Transaction started, rolled back after test
Order order = repo.create(new CreateOrderRequest("user-1", List.of(...)));
assertThat(order.getId()).isNotNull();
}
@Test
void findsOrdersByUser() {
// Fresh transaction, no data from createsOrder()
repo.create(new CreateOrderRequest("user-1", List.of(...)));
List<Order> orders = repo.findByUserId("user-1");
assertThat(orders).hasSize(1); // Reliable
}
}Or, with explicit cleanup:
@BeforeEach
void cleanDatabase() {
jdbcTemplate.execute("TRUNCATE orders, order_items CASCADE");
}Anti-Pattern 2: Thread.sleep() for Async Waiting
The problem:
@Test
void consumesMessageFromKafka() {
kafkaTemplate.send("orders", new OrderEvent("ORDER-123"));
Thread.sleep(2000); // Hope this is enough
assertThat(orderRepository.findById("ORDER-123")).isPresent();
}Two seconds is too long in most cases (slow tests) and too short in CI during high load (flaky tests). This is the #1 source of flakiness in async integration tests.
The fix:
@Test
void consumesMessageFromKafka() {
kafkaTemplate.send("orders", new OrderEvent("ORDER-123"));
await()
.atMost(10, TimeUnit.SECONDS)
.pollInterval(100, TimeUnit.MILLISECONDS)
.untilAsserted(() ->
assertThat(orderRepository.findById("ORDER-123")).isPresent()
);
}Awaitility polls every 100ms, up to 10 seconds. It passes the moment the condition is true (no unnecessary waiting) and fails with a clear message if the timeout is reached.
Anti-Pattern 3: Mocking What You Should Test
The problem:
@Test
void savesUserToDatabase() {
UserRepository mockRepo = mock(UserRepository.class);
UserService service = new UserService(mockRepo);
service.createUser(new CreateUserRequest("Alice", "alice@example.com"));
verify(mockRepo).save(any(User.class));
}This isn't an integration test. It verifies that service.createUser() calls repository.save(). It doesn't verify that the save works, that the data is correctly serialized, that constraints are honored, or that queries return the right data.
Integration tests should test real integration. If you're testing a repository, test against a real database (TestContainers). If you're testing a service that calls a repository, test the service with a real repository pointing at a real database.
The fix:
@SpringBootTest
@Testcontainers
class UserServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
}
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void createsUserAndPersistsToDatabase() {
userService.createUser(new CreateUserRequest("Alice", "alice@example.com"));
Optional<User> saved = userRepository.findByEmail("alice@example.com");
assertThat(saved).isPresent();
assertThat(saved.get().getName()).isEqualTo("Alice");
assertThat(saved.get().getCreatedAt()).isNotNull();
}
}Counterpoint: Mock external services you don't own (third-party APIs, payment processors). Use WireMock or similar. Don't mock your own databases, queues, or in-process services.
Anti-Pattern 4: Test Order Dependencies
The problem:
class UserWorkflowTest {
static String createdUserId; // Shared between tests
@Test
@Order(1)
void step1_createUser() {
User user = userService.create(new CreateUserRequest("Alice"));
createdUserId = user.getId(); // Pass to next test
assertThat(createdUserId).isNotNull();
}
@Test
@Order(2)
void step2_activateUser() {
// PROBLEM: If step1 failed or ran out of order, createdUserId is null
userService.activate(createdUserId);
assertThat(userService.findById(createdUserId).getStatus()).isEqualTo("ACTIVE");
}
@Test
@Order(3)
void step3_deleteUser() {
userService.delete(createdUserId);
assertThat(userService.findById(createdUserId)).isEmpty();
}
}When step 1 fails, all subsequent tests fail too — with confusing errors unrelated to what actually broke. Running tests in parallel breaks this entirely. The test suite becomes an integration test of the test suite.
The fix: Make each test self-contained. Each test creates its own data.
class UserWorkflowTest {
@Test
void activatesUser() {
// Arrange: create the user needed for this specific test
User user = userService.create(new CreateUserRequest("Alice-" + UUID.randomUUID()));
// Act
userService.activate(user.getId());
// Assert
User refreshed = userService.findById(user.getId()).orElseThrow();
assertThat(refreshed.getStatus()).isEqualTo("ACTIVE");
}
@Test
void deletesUser() {
User user = userService.create(new CreateUserRequest("Bob-" + UUID.randomUUID()));
userService.delete(user.getId());
assertThat(userService.findById(user.getId())).isEmpty();
}
}Using UUID.randomUUID() in names prevents collisions when running tests in parallel.
Anti-Pattern 5: Wrong Test Scope
The problem: Testing business workflows that span multiple services in integration tests.
// This isn't an integration test — it's an E2E test
@Test
void completeOrderWorkflow() {
// Creates user in user-service
userServiceClient.createUser("Alice");
// Adds products to cart in cart-service
cartServiceClient.addItem("product-1");
// Processes payment in payment-service
paymentServiceClient.charge("card-token", 9999);
// Creates order in order-service
orderServiceClient.createOrder();
// Sends notification via notification-service
assertThat(notificationServiceClient.getLastEmail()).contains("ORDER-");
}This test requires 5 services to be running and correctly configured. It fails for any of 5 independent reasons. When it fails, you don't know which service is broken without investigation.
The fix: Test each service's own behavior in isolation. Use mocks or stubs for other services.
// Order-service integration test — tests order-service with its own dependencies only
@Test
void createsOrderWhenPaymentSucceeds() {
// WireMock stubs payment-service
paymentServiceMock.stubFor(post(urlEqualTo("/charges"))
.willReturn(okJson("""{"id": "ch_123", "status": "succeeded"}""")));
// Direct call to order-service, not through API gateway
Order order = orderService.createOrder(new CreateOrderRequest(
"user-123",
List.of(new Item("product-1", 1, new BigDecimal("99.99"))),
"card-token"
));
// Assert order-service behavior
assertThat(order.getStatus()).isEqualTo("CONFIRMED");
assertThat(order.getPaymentId()).isEqualTo("ch_123");
// Verify order-service called payment-service correctly
paymentServiceMock.verify(postRequestedFor(urlEqualTo("/charges"))
.withRequestBody(matchingJsonPath("$.amount", equalTo("9999"))));
}Test the full workflow in E2E tests that run less frequently.
Anti-Pattern 6: Ignoring Flaky Tests
The problem:
# .github/workflows/ci.yml
- name: Run integration tests
run: ./gradlew integrationTest || true # "It's probably fine"Or:
@Test
@Disabled("Flaky, fix later")
void processesOrderEventFromKafka() {
// Never fixed, never removed
}Flaky tests erode trust. When the CI passes "most of the time," every failure becomes suspect. Teams start ignoring failures, missing real regressions.
The fix: Treat flakiness as a production bug. Investigate every flaky test. The root causes are almost always:
- Shared mutable state — fix isolation
- Timing — fix with polling/await
- External service dependency — mock it
- Resource exhaustion in CI — fix the environment
Track flakiness explicitly. Tools like BuildKite Analytics or Gradle Test Retry let you measure and trend flakiness.
// gradle: retry flaky tests up to 3 times, but track them
test {
retry {
maxRetries = 3
maxFailures = 10
failOnPassedAfterRetry = false // Pass but report as flaky
}
}A test that passes after retry is still a flaky test. Fix it.
Anti-Pattern 7: Not Cleaning Up Resources
The problem:
@Test
void uploadFileToS3() {
String key = "test-file-123.txt";
s3Client.putObject("test-bucket", key, "content");
String retrieved = s3Client.getObject("test-bucket", key);
assertThat(retrieved).isEqualTo("content");
// File left in bucket forever
}Test resources that aren't cleaned up accumulate. Buckets fill up. Database tables grow. Connection pools deplete. CI costs increase. Eventually something breaks in a way that's hard to trace back to this test.
The fix:
@Test
void uploadFileToS3() {
String key = "test-file-" + UUID.randomUUID() + ".txt";
try {
s3Client.putObject("test-bucket", key, "content");
String retrieved = s3Client.getObject("test-bucket", key);
assertThat(retrieved).isEqualTo("content");
} finally {
// Always clean up, even if the test fails
s3Client.deleteObject("test-bucket", key);
}
}Or with JUnit 5's @AfterEach:
private String uploadedKey;
@AfterEach
void cleanup() {
if (uploadedKey != null) {
s3Client.deleteObject("test-bucket", uploadedKey);
}
}For database resources, transaction rollback is cleaner than explicit cleanup.
Anti-Pattern 8: Using Production Data in Tests
The problem: Anonymizing production data is a common suggestion for realistic test data. In practice, it's often done incompletely, and real personal data ends up in test environments.
Beyond the privacy issue, production data creates test dependencies: tests pass because the production data happens to have a specific record, and fail when that record changes.
The fix: Generate test data explicitly. AI tools (covered in our AI test data generation guide) make this fast. Use factory functions or libraries (Faker.js, Python Faker) for volume. Every test should create exactly the data it needs.
@Test
void calculatesCorrectOrderTotal() {
// Don't load from database — create exactly what this test needs
Order order = Order.builder()
.userId("test-user")
.items(List.of(
new Item("product-A", 2, new BigDecimal("10.00")),
new Item("product-B", 1, new BigDecimal("25.00"))
))
.discount(new Discount(10)) // 10% discount
.build();
BigDecimal total = orderCalculator.calculateTotal(order);
// (10*2 + 25*1) * 0.9 = 40.50
assertThat(total).isEqualByComparingTo(new BigDecimal("40.50"));
}Summary
Integration test quality comes down to two principles: isolation and determinism.
Isolation: Each test gets a clean environment. Tests don't share state (database rows, in-memory caches, global variables). Cleanup happens after every test.
Determinism: Tests produce the same result regardless of run order, parallel execution, or time of day. No Thread.sleep(). No external service dependencies that can be down. No reliance on data that might have changed.
Most integration test anti-patterns are violations of one or both principles. Fix the violation; the flakiness goes away.
The investment is worth it. A reliable integration test suite that catches real bugs is one of the highest-value engineering investments a team can make. An unreliable one is a liability.