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.

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

Singapore Backend Developers Are Expensive and Hard to Retain — The Remote Fix

You finally hired that senior backend engineer. Eight months later, they left for a bank offering 40% more. Now you're starting over.

Read more

Why Clear Acceptance Criteria Matters in Software Projects

The small detail that quietly determines whether your project ships smoothly or turns into endless back-and-forth

Read more

Service Objects in Ruby — How I Structure Business Logic

Service objects are the most argued-about pattern in Rails codebases and the least defined. Here is a concrete structure that handles initialization, result signaling, and error propagation without pulling in a framework.

Read more

Logging Across Microservices Is Useless If You Can't Connect the Dots

Logs from individual services are only as useful as your ability to correlate them across service boundaries. Without structured logging and a consistent correlation ID strategy, your logs are evidence without context.

Read more