E2E in Docker and Ephemeral Environments
Reproducible test environments are the foundation of reliable CI/CD. Learn how to use Docker Compose for local and CI E2E testing, and how to run smoke tests against ephemeral preview deployments.
This post is part 3 of the CI/CD Quality series. Part 2 covered GitHub Actions — workflows, caching, and Playwright integration.
Introduction — The “Works on My Machine” Problem
A test suite that passes locally but fails in CI is worse than no test suite. It trains developers to ignore CI failures.
The root cause is environmental inconsistency. Your local machine has different:
- Node version
- Database version
- Environment variables
- Network latency
- File system behaviour
Docker solves this by packaging the application and its dependencies into a container that runs identically everywhere.
This post covers how to use Docker Compose for reproducible E2E testing locally and in CI, and how to run smoke tests against ephemeral preview environments.
Why Docker for E2E Tests
Reproducibility — Same container runs on developer laptops, CI runners, and production servers. No “works on my machine” failures.
Isolation — Each test run starts from a clean state. No leftover data from previous runs.
Parallelisation — Run multiple isolated environments simultaneously for parallel test execution.
Simplicity — Developers run docker compose up instead of manually installing Postgres, Redis, and Elasticsearch.
:::tip[Docker vs. Mocks] Mocking external dependencies (databases, message queues) in tests is fast but fragile. Mocks drift from real behaviour. Docker lets you test against the real database with minimal overhead. :::
Docker Compose for Local E2E
Docker Compose orchestrates multi-container applications. Define your app, database, and dependencies in docker-compose.yml.
Example: Web App + Postgres
# docker-compose.yml
version: '3.8'
services:
db:
image: postgres:16
environment:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U testuser"]
interval: 5s
timeout: 5s
retries: 5
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://testuser:testpass@db:5432/testdb
NODE_ENV: test
depends_on:
db:
condition: service_healthy
Key features:
dbservice runs Postgres 16healthcheckensures Postgres is ready before starting the appappservice waits fordbto be healthy (depends_onwithcondition)- Environment variables configured for test mode
Running E2E Tests with Docker Compose
# Start services
docker compose up -d
# Wait for app to be ready
npx wait-on http://localhost:3000
# Run Playwright tests
npm run test:e2e
# Stop services
docker compose down
Alternatively, script this in package.json:
{
"scripts": {
"test:e2e:docker": "docker compose up -d && npx wait-on http://localhost:3000 && npm run test:e2e; docker compose down"
}
}
:::warning[Data Persistence]
By default, Docker Compose persists data in volumes. For tests, you want a clean slate every run. Use docker compose down -v to remove volumes and reset state.
:::
Docker Compose in CI
The same docker-compose.yml runs in GitHub Actions.
GitHub Actions Workflow with Docker Compose
name: E2E Tests
on: [pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Start services with Docker Compose
run: docker compose up -d
- name: Wait for app to be ready
run: npx wait-on http://localhost:3000 --timeout 60000
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npm run test:e2e
- name: Stop services
if: always()
run: docker compose down -v
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
Why this works:
docker compose up -dstarts services in the backgroundwait-onblocks until the app responds onhttp://localhost:3000- Playwright runs tests against
http://localhost:3000 docker compose down -vcleans up containers and volumes (if: always()ensures cleanup even if tests fail)
:::info[CI Performance] Starting Docker containers adds 20–40 seconds to CI runs. This is acceptable for E2E tests but too slow for unit tests. Use Docker only for integration and E2E test suites. :::
Seeding Test Data
E2E tests need realistic data. Seed the database before running tests.
Database Seeding Script
// scripts/seed-test-db.ts
import { Client } from 'pg';
const client = new Client({
connectionString: process.env.DATABASE_URL,
});
async function seed() {
await client.connect();
await client.query(`
INSERT INTO users (email, name, role)
VALUES
('admin@test.com', 'Admin User', 'admin'),
('user@test.com', 'Regular User', 'user');
`);
await client.query(`
INSERT INTO products (name, price, stock)
VALUES
('Product A', 29.99, 100),
('Product B', 49.99, 50);
`);
await client.end();
console.log('Test database seeded');
}
seed().catch(console.error);
Run seeding after services start:
- name: Start services
run: docker compose up -d
- name: Wait for app
run: npx wait-on http://localhost:3000
- name: Seed test data
run: npm run seed:test
- name: Run E2E tests
run: npm run test:e2e
:::tip[Idempotent Seeds]
Make seeding scripts idempotent — safe to run multiple times. Use INSERT ... ON CONFLICT DO NOTHING or DELETE FROM table before inserting. This prevents failures when running tests repeatedly during development.
:::
Ephemeral Preview Environments
Ephemeral environments are temporary deployments created for each pull request. They allow you to test the application in a production-like environment before merging.
Services like Vercel, Netlify, and Heroku Review Apps automatically deploy preview environments for every PR.
Vercel Preview Deployments
Vercel automatically deploys every push to a unique URL:
https://my-app-pr123-abc1234.vercel.app
This URL is posted as a comment on the pull request.
Running Smoke Tests Against Preview Deployments
Wait for the preview deployment to complete, then run smoke tests against it.
name: Preview E2E
on:
pull_request:
jobs:
preview-smoke-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Wait for Vercel deployment
uses: UnlyEd/github-action-await-vercel@v1
with:
deployment-url: ${{ github.event.pull_request.head.sha }}
timeout: 300
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Run smoke tests against preview
env:
BASE_URL: ${{ steps.await-vercel.outputs.url }}
run: npm run test:e2e:smoke
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: preview-smoke-report
path: playwright-report/
Key features:
await-vercelaction waits for Vercel deployment to completeBASE_URLenvironment variable points tests at the preview URL- Smoke tests run against the deployed preview, not localhost
Configuring Playwright for Dynamic Base URL
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
],
});
Now tests use the preview URL when BASE_URL is set:
test('homepage loads', async ({ page }) => {
await page.goto('/'); // Uses baseURL from config
await expect(page.locator('h1')).toContainText('Welcome');
});
:::warning[Preview Environment Limitations] Preview environments often use separate databases or mock data. Do not assume data parity with production. Test UI rendering, navigation, and authentication — not production data integrity. :::
Comparing Local Docker vs. Preview Environments
| Aspect | Local Docker | Preview Environment |
|---|---|---|
| Speed | 20–40s startup | 60–180s deployment |
| Isolation | Full control over services | Cloud-managed, limited control |
| Data | Seed scripts, full control | Mock data or separate test DB |
| Use case | Integration/E2E tests in CI | Smoke tests on real infra |
| Cost | Free (runs on CI runner) | Depends on platform tier |
Recommendation: Use Docker Compose for full E2E test suites in CI. Use preview environments for lightweight smoke tests that validate deployment and routing.
Practical Example — Full E2E CI Workflow
Combine Docker Compose for local testing and preview smoke tests in a single workflow.
name: E2E Tests
on: [pull_request]
jobs:
e2e-local:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Start services
run: docker compose up -d
- name: Wait for app
run: npx wait-on http://localhost:3000 --timeout 60000
- name: Seed test data
run: npm run seed:test
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Run full E2E suite
run: npm run test:e2e
- name: Stop services
if: always()
run: docker compose down -v
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-report
path: playwright-report/
preview-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Wait for Vercel
id: await-vercel
uses: UnlyEd/github-action-await-vercel@v1
with:
deployment-url: ${{ github.event.pull_request.head.sha }}
timeout: 300
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Run smoke tests
env:
BASE_URL: ${{ steps.await-vercel.outputs.url }}
run: npm run test:e2e:smoke
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: preview-smoke-report
path: playwright-report/
Two jobs:
- e2e-local — Full E2E suite against Docker Compose services
- preview-smoke — Smoke tests against Vercel preview deployment
Both run in parallel. Total CI time: ~5–7 minutes.
Conclusion — Environments as Code
Reproducible test environments are the foundation of reliable CI/CD. Docker Compose makes it trivial to package your application, dependencies, and test data into a portable, versioned environment definition.
The principles:
- Use Docker Compose for local and CI E2E tests
- Seed test data in a repeatable, idempotent way
- Clean up containers and volumes after every run
- Run smoke tests against preview deployments to validate real infrastructure
- Separate concerns — full E2E in Docker, smoke tests in preview
When “works on my machine” becomes “works in the container,” CI failures become actionable and trustworthy.
Action for this week: Create a docker-compose.yml for your project with your app and its primary dependency (database, cache, etc.). Run one E2E test against it locally. Measure the time from docker compose up to test completion.
Next in this series: Part 4 — Reports, Artifacts, and Failure Triage in CI