
September 10, 2026
11 min read
By Kokil Thapa | Last reviewed: September 2026
Your unit tests pass locally, then production breaks on a payment callback or a migration edge case. Integration Testing in CI Pipelines closes that gap by exercising real database connections, HTTP routes, queues, and third-party boundaries inside the same pipeline that builds your artefact. On production Laravel applications I maintain, integration tests sit between fast unit checks and slow browser suites. They run on every push through a GitLab CI pipeline for Laravel before Deployer swaps the release symlink. This guide shows exactly how to wire that stage for PHP 8.3+, Laravel 12 or 13, MySQL, and Redis in 2026.
What is Integration Testing in CI Pipelines and why does it matter?
Integration tests verify that modules work together with real infrastructure. Unit tests mock the database; integration tests hit it. In CI, that means spinning up service containers beside your app image and running PHPUnit or Pest against a disposable schema.
The payoff is practical. I've seen Laravel apps pass 400 unit tests, then fail in staging because a foreign key migration order was wrong. Integration tests in CI would have caught that in four minutes. They also validate database migrations in CI/CD pipelines against a fresh database every run—exactly what production does on first deploy.
Teams that skip this stage often rely on manual QA after merge. That works until traffic grows or payment webhooks multiply. For booking systems like Adventure Third Pole Trek, integration tests around reservation holds and supplier notifications saved repeated staging fire drills.
What belongs in the integration layer
- HTTP feature tests that POST to routes and assert database state
- Queue job dispatch with Redis and
Queue::fake()only where isolation demands it - Migration runs against an empty schema on every pipeline execution
- Payment gateway sandbox calls with credentials from CI variables
- File upload flows using the local disk driver inside the job container
How do integration tests differ from unit and E2E tests in CI?
The test pyramid still applies in 2026. Unit tests stay fast and isolated. End-to-end browser tests are expensive and flaky in CI. Integration tests occupy the middle: slower than units, cheaper than Dusk or Playwright, and far more realistic than mocks alone.
| Test type | CI runtime (typical Laravel app) | Dependencies | Best for |
|---|---|---|---|
| Unit | 30–90 seconds | None (mocked) | Pure logic, validators, DTOs |
| Integration | 2–8 minutes | MySQL, Redis, mail fake | Routes, Eloquent, jobs, migrations |
| E2E / browser | 10–30+ minutes | Full stack + Chrome | Critical checkout or login flows |
Laravel's official testing docs distinguish Unit and Feature test directories. In CI terminology, most Feature tests are integration tests when they use RefreshDatabase or hit external HTTP with Http::fake(). Pure unit tests never boot the full application kernel. See the Laravel 12 testing documentation for the framework's own taxonomy.
For deeper patterns on HTTP assertions, read Laravel feature testing best practices. For Pest syntax in pipelines, see Laravel testing with Pest in CI/CD.
How do you configure GitLab CI for Laravel integration tests?
GitLab CI service containers share a Docker network with the job image. Your app connects to hostname mysql and redis, not 127.0.0.1. That single detail causes more pipeline failures than any assertion mismatch.
Below is a production-ready .gitlab-ci.yml fragment for Laravel 12 on PHP 8.3 with Composer 2.10. Adjust image tags to match your runner's available registry mirrors.
stages:
- build
- test
- deploy
variables:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: laravel_test
DB_HOST: mysql
DB_CONNECTION: mysql
REDIS_HOST: redis
APP_ENV: testing
APP_KEY: base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
integration_tests:
stage: test
image: php:8.3-cli
services:
- name: mysql:8.4
alias: mysql
- name: redis:8
alias: redis
cache:
key: ${CI_COMMIT_REF_SLUG}-composer
paths:
- vendor/
before_script:
- apt-get update -qq && apt-get install -y -qq git unzip libzip-dev libpng-dev
- docker-php-ext-install pdo_mysql zip gd pcntl
- pecl install redis && docker-php-ext-enable redis
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer install --no-interaction --prefer-dist --no-progress
- cp .env.testing .env
- php artisan key:generate --force
- php artisan migrate --force --seed
script:
- php artisan test --parallel --testsuite=Feature
artifacts:
when: always
reports:
junit: storage/logs/junit.xml
expire_in: 7 days
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH Enable JUnit output in phpunit.xml so GitLab surfaces failing test names in the merge request UI. The GitLab CI services documentation explains alias networking and health-check timing.
Wait for database readiness
MySQL may accept TCP connections before it accepts queries. Add a retry loop in before_script:
- |
for i in $(seq 1 30); do
php artisan db:show && break
echo "Waiting for MySQL..."
sleep 2
done On sister sites sharing Deployer 7 + GitLab CI, this retry block eliminated intermittent "Connection refused" failures during Dashain-week merge bursts when runners were under load.
Parallel execution and caching
Laravel's --parallel flag cuts integration runtime roughly in half on multi-core runners. Pair it with Composer vendor caching described in build caching to speed up CI builds. Do not cache bootstrap/cache across branches; stale config breaks tests silently.
- Cache
vendor/keyed bycomposer.lockhash - Run
php artisan config:clearat the start of every test job - Split Feature and Unit into separate jobs for clearer failure signals
- Gate deploy on the integration job, not only on unit tests
What test data and secrets patterns work in integration pipelines?
Integration tests need predictable data without polluting shared state. RefreshDatabase or LazilyRefreshDatabase truncates tables between test classes. For large seed datasets, use factory states instead of loading production dumps—never commit real customer rows.
CI variables hold sandbox API keys for Khalti, eSewa, or Stripe test mode. Mask and protect them per handling secrets in CI/CD pipelines safely. Integration tests should call sandbox endpoints or use Http::fake() with recorded response fixtures stored in tests/Fixtures/.
Validate fixture JSON with a JSON formatter and validator before committing. One malformed webhook payload can make an entire suite fail with opaque parse errors.
For advanced scenarios—restoring anonymised dumps to verify migration paths—see database restore testing you should actually do and test data management for pipelines.
Example integration test for a booking endpoint
<?php
namespace Tests\Feature;
use App\Models\Trip;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BookingIntegrationTest extends TestCase
{
use RefreshDatabase;
public function test_guest_can_hold_seats_until_payment(): void
{
$trip = Trip::factory()->create(['seats' => 10]);
$response = $this->postJson("/api/trips/{$trip->id}/hold", [
'seats' => 2,
'email' => 'guest@example.com',
]);
$response->assertStatus(201)
->assertJsonStructure(['hold_id', 'expires_at']);
$this->assertDatabaseHas('seat_holds', [
'trip_id' => $trip->id,
'seats' => 2,
]);
$this->assertEquals(8, $trip->fresh()->seats_available);
}
} This test fails if migrations, model accessors, or route middleware drift—all without a browser. That is the core value of Integration Testing in CI Pipelines.
How do you enforce quality gates and fix common CI integration failures?
Passing tests alone are not enough for mature pipelines. Add coverage thresholds on integration suites separately from units, as covered in code coverage gates in CI. Contract tests against consumer-driven schemas belong in the same stage; see API contract testing with Pact for microservice boundaries.
Common failure modes and fixes:
- SQLSTATE connection refused — wrong
DB_HOST; must be service aliasmysql, not localhost - Migration already exists — stale volume; ensure ephemeral DB or drop schema in
before_script - Class not found after deploy — run
composer dump-autoloadand clear config cache before tests - Redis connection timeout — confirm
REDIS_CLIENT=phpredisand extension installed in job image - Flaky time assertions — use
Carbon::setTestNow()instead of comparing raw timestamps
Bitbucket and Jenkins teams can mirror the same service-container pattern; platform syntax differs but topology does not. Compare runner options in Bitbucket Pipelines CI/CD guide and Jenkins CI/CD practical tutorial.
PHPUnit's own guidance on test doubles versus real collaborators aligns with this split. Review the PHPUnit 11 manual when deciding what to mock inside integration jobs.
When to run the full suite
Run integration tests on every merge request and on default-branch pushes. Schedule heavy E2E suites nightly. For Nepal-based teams with limited runner minutes—often Rs 3,000–8,000/month (~USD 22–60) on shared GitLab SaaS—parallel jobs plus caching keep costs predictable. Deeper pipeline design belongs in build pipeline automation best practices.
If you lack dedicated DevOps staff, outsourcing pipeline hardening through testing and optimization services or Linux system administration often pays back after the first prevented outage. Custom Laravel platforms benefit from web development teams that treat CI as part of delivery, not an afterthought.
Key Takeaways
- Place Integration Testing in CI Pipelines after unit tests and before deploy, using real MySQL and Redis service containers.
- Set
DB_HOST=mysqlandREDIS_HOST=redisto match GitLab service aliases, never localhost inside job containers. - Use
RefreshDatabase, factories, and masked CI variables—never production dumps or unmasked secrets. - Split unit and integration jobs, enable JUnit artefacts, and add a MySQL readiness retry loop.
- Gate merges on integration results and reserve browser E2E tests for nightly or pre-release pipelines.
- Cache
vendor/by lockfile hash and runconfig:clearevery job to avoid stale bootstrap cache.
People Also Ask
Should integration tests run in the same job as unit tests?
Separate jobs give clearer failure signals and let unit tests finish in under a minute while integration work proceeds in parallel. Merge requests then show exactly which layer broke without scrolling a single long log.
Can you run integration tests without Docker?
Yes, but Docker-based service containers are the standard on GitLab, GitHub Actions, and Bitbucket. Shell runners can point at local MySQL instances, yet that reintroduces environment drift—the problem CI is meant to eliminate.
How many integration tests are enough?
Cover every critical write path: checkout, registration, document upload, payment callback, and scheduled job dispatch. Aim for high confidence on business rules, not 100% line coverage. Pair with shift-left security checks on the same pipeline for auth and input-validation regressions.
Does Laravel 13 change CI integration testing?
Laravel 13 requires PHP 8.3 minimum and keeps Pest and PHPUnit support unchanged at the pipeline level. Update your job image tag, run composer update, and re-run migrations in CI before merging framework bumps.
Ship integration tests before your next deploy
Integration Testing in CI Pipelines is the cheapest insurance against migration mistakes, queue misconfiguration, and payment webhook regressions. Start with one GitLab job, MySQL and Redis services, and a handful of Feature tests on your highest-risk routes. Expand coverage as failures teach you where mocks lied.
If you want a pipeline audit on an existing Laravel or legal-tech platform, contact us for a focused review. You can also browse the portfolio for production systems that run on Deployer 7 + GitLab CI, or read more on the blog about CI/CD pipeline setup. For background on how I approach delivery, see about me.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

