Your Integration Tests Are Too Slow Because You Are Testing Too Much at Once
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Suite That Takes 20 Minutes
Your integration suite started at 3 minutes. Six months of new features later, it is at 20 minutes. CI queues back up because it takes four times as long as the deploys it is supposed to gate. Developers start skipping it locally ("I'll just let CI catch it") and merging over failures ("it is probably unrelated to my change").
The instinct is to parallelize — add more CI workers, run test files concurrently. That helps, but it treats a symptom. The cause is that each individual test is doing too much, so the suite grows in proportion to the feature count rather than being capped by test consolidation.
The Three Ways Tests Expand Scope
1. Every test starts the full stack.
A common pattern in Spring Boot applications: every integration test class is annotated with @SpringBootTest, which starts the entire application context — all beans, all configuration, all scheduled tasks, all listeners. Most of these are irrelevant to what the test is actually testing.
For a test that only exercises the database repository layer, you do not need the web layer, the security layer, the messaging layer, or the scheduled job infrastructure. @DataJpaTest starts only the JPA slice. @WebMvcTest starts only the web layer. Slicing the application context can reduce startup time per test class from 10 seconds to under 1 second.
// Starts everything — 10-15 seconds of context startup per class
@SpringBootTest
class UserRepositoryTest { ... }
// Starts only JPA infrastructure — under 1 second
@DataJpaTest
class UserRepositoryTest { ... }
2. Each test case sets up its own database state from scratch.
When every test case runs a full schema creation and seed data load, the overhead compounds rapidly. 100 test cases × 500ms of schema setup = 50 seconds just in setup, before any test logic runs.
The fix: use transactions that roll back after each test case. The schema exists once, created at the start of the test class or test run. Each test case operates within a transaction that is rolled back at the end, leaving the database in the same state for the next test. No schema recreation, no data cleanup.
# pytest with SQLAlchemy: transaction-per-test pattern
@pytest.fixture
def db_session(engine):
connection = engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
yield session # Test runs here, within the transaction
session.close()
transaction.rollback() # Clean state for next test
connection.close()
3. Single tests assert too many behaviors.
A test that creates a user, updates their profile, places an order, processes a refund, and checks the audit log is testing six different behaviors in one test case. When it fails, you do not know which step failed without reading the stack trace carefully. And because it is doing so much, it takes longer to run and is harder to make fast.
Integration tests, like unit tests, should be focused. One test, one behavior under test. The infrastructure setup (database container, seeded reference data) is shared. The behavioral assertion is narrow.
Infrastructure Sharing vs. Test Isolation
The key to making integration tests fast is distinguishing between what should be shared and what should be isolated.
Share: the database container, the schema, reference/seed data that is read-only across all tests.
Isolate: the data created within each test, the application state modified by each test. Use transactions to roll back, or use uniquely-keyed data per test to avoid conflicts.
// Testcontainers: start once, share across all tests in the package
func TestMain(m *testing.M) {
ctx := context.Background()
container, _ := postgres.Run(ctx, "postgres:16",
postgres.WithDatabase("testdb"),
)
defer container.Terminate(ctx)
runMigrations(container.ConnectionString(ctx))
os.Exit(m.Run()) // All tests in the package use this container
}
// Each test uses isolated data
func TestCreateOrder(t *testing.T) {
// Use a unique customer ID per test to avoid conflicts
customerID := uuid.New()
// ... test logic
}
With this pattern, 200 integration tests might share a single container startup cost of 5 seconds and each test might take 20–50 milliseconds. Total: under 15 seconds instead of 20 minutes.
Before adding CI workers, audit your tests for these patterns. The performance recovery from narrowing scope is typically an order of magnitude larger than the recovery from parallelization.