Integration Tests in CI/CD: Where to Put Them Without Slowing Everything Down

by Arif Ikhsanudin, Backend Developer

The Test That's Always "Too Slow for CI"

Integration tests are the first thing cut when pipeline speed becomes a concern. They require real infrastructure — a database, a message broker, an HTTP service — and that infrastructure takes time to start, warm up, and execute against. A suite of 50 integration tests that runs in 12 minutes locally runs in 18 minutes in CI due to container startup overhead, and suddenly someone proposes moving them to "run nightly."

"Run nightly" is how integration tests die. They break silently, nobody fixes them because they're not blocking anyone's work, and after six months the suite is so broken it gets deleted entirely. The codebase loses its best layer of defect detection, and the team doesn't notice until production starts breaking in ways that unit tests can't catch.

There's a better structural answer than "fast so run everywhere" vs "slow so skip them."

The Three-Tier Architecture That Works

Tier 1: Fast path (every commit, under 5 minutes)

Unit tests only. No I/O, no containers, no external services. These run on every push to every branch and every pull request. They catch logic errors, regressions in isolated components, and simple integration issues between classes.

Tier 2: Integration gate (every PR to main, under 15 minutes)

Integration tests and API-level tests. Real database (via Testcontainers), mocked external HTTP services (via WireMock). This is the gate that must pass before code merges to main.

Tier 3: System tests (post-merge, non-blocking for individual developers)

Full end-to-end tests, performance tests, contract tests. These run against a staging environment after merge to main. They're slow, they're comprehensive, and a failure triggers immediate investigation — but they don't block the individual PR workflow.

# .github/workflows/fast-path.yml
on:
  push:
    branches: ['**']

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'gradle'
      - run: ./gradlew test -Pgroups=unit

---

# .github/workflows/integration-gate.yml
on:
  pull_request:
    branches: [main]

jobs:
  integration-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'gradle'
      - run: ./gradlew integrationTest

Making Integration Tests Faster Without Removing Coverage

The goal is not to make integration tests unnecessary — it's to make them fast enough that they belong in the PR gate, not just a nightly run.

Share the application context. Spring Boot's @SpringBootTest starts a full application context. Starting it 20 times for 20 test classes is expensive. Use @SpringBootTest once per test configuration, and use @Nested classes or shared context with @DirtiesContext only where state isolation requires it.

// One context for many tests: fast
@SpringBootTest
@AutoConfigureMockMvc
@Transactional  // Each test rolls back — no need to recreate the database
class PaymentIntegrationTest {

    @Test void shouldChargeCard() { ... }

    @Test void shouldRefundCharge() { ... }

    @Test void shouldRejectExpiredCard() { ... }
}

Transaction rollback instead of schema recreation. The default pattern of dropping and recreating the test schema between tests is extremely slow at scale. Using @Transactional on each test rolls back all database changes at the end of each test without schema recreation. This requires that tests don't commit explicitly — which is usually true for Spring-managed tests.

Container reuse across test runs. Testcontainers supports a TESTCONTAINERS_REUSE_ENABLE=true environment variable that keeps containers running between test executions instead of starting fresh each time. In CI, where you want isolation, use it cautiously — but within a single pipeline run, container reuse across test classes significantly reduces startup overhead.

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
    .withReuse(true);  // Reuse this container across test class restarts in same JVM

Parallel test execution within the integration suite. JUnit 5's parallel execution mode works for integration tests as long as tests are independently isolated (transactions roll back, no shared mutable state). Parallelizing a 12-minute integration suite across 4 threads typically reduces it to 4–5 minutes.

The Placement Decision

For each integration test, ask: what is the earliest point in the pipeline where I need this signal? If a test verifies behavior that's core to the PR being merged, it belongs in the integration gate. If it verifies system behavior that's only meaningful after assembly (full end-to-end flows, performance under load), it belongs post-merge.

The placement is a team decision that should be revisited as the suite grows. A test that starts in the post-merge tier can be promoted to the integration gate if the failure mode it catches starts causing production incidents that the gate would have prevented.

The goal is a gate that's comprehensive enough to be trusted and fast enough to run on every PR. Most teams can reach both.

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

What Happens When Your Cache and Your Database Disagree

Cache-database inconsistency is not a theoretical concern — it is a production condition that every caching system will eventually reach. How you handle it determines whether it is a brief anomaly or a data integrity problem.

Read more

What to Do When a Software Project Fails

It shipped late. Or worse — it never shipped at all. Every team hits this moment. What matters is what you do next.

Read more

Why Mandatory Camera Meetings Are Often Unproductive

Being on camera all day sounds professional, but it can actually kill focus and morale. Here’s why forcing cameras on every meeting might be doing more harm than good.

Read more

How to Run Your Spring Boot App and Database Together With Docker Compose

Getting a Spring Boot application and PostgreSQL to start together correctly in Docker Compose requires more than just listing both services — you need health checks, proper dependency ordering, and connection URL configuration that works inside a container network.

Read more