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.