An Isolated Docker Test Environment So E2E Tests Don't Touch Prod Data
docker-compose.test.yml spins up a second, port-shifted copy of the whole stack just for Playwright, with global.setup.js seeding test users and global.teardown.js cleaning them up.
Every port in e2e/docker-compose.test.yml is shifted by one digit from its production equivalent: Postgres on 5433 instead of 5432, backend on 5001 instead of 5000, frontend on 3001 instead of 80. That's not an aesthetic choice — it's what makes it possible to run the full E2E suite on the same machine as a running production or development stack without a single port collision, and it's the mechanism that guarantees the tests physically cannot reach real data, because there's a completely separate set of containers.
global.setup.js seeds test users; global.teardown.js drops them — prod data is never in the loop.
Four services, one throwaway network
services:
test-postgres:
image: postgres:15-alpine
ports: ["5433:5432"]
volumes:
- ../init.sql:/docker-entrypoint-initdb.d/01-init.sql
- ./init-test-users.sql:/docker-entrypoint-initdb.d/02-test-users.sql
test-backend:
build: { context: ../backend }
environment:
DB_HOST: test-postgres
JWT_SECRET: e2e-test-jwt-secret-key
ports: ["5001:5000"]
depends_on:
test-postgres: { condition: service_healthy }
test-frontend:
build: { context: ../frontend, args: { REACT_APP_API_URL: http://localhost:5001/api } }
ports: ["3001:80"]
playwright:
image: mcr.microsoft.com/playwright:v1.42.0-jammy
depends_on:
test-frontend: { condition: service_healthy }
command: bash -c "sleep 10 && npx playwright test --reporter=html,json"init-test-users.sql runs as a second init script alongside the real init.sql, mounted into Postgres's docker-entrypoint-initdb.d directory — the same schema as production, seeded with known test accounts, in a database that's been running for exactly as long as this one test session. There's no migration path from test data to prod data because there's no shared database at all; deleting the test-postgres container deletes everything it ever knew.
Setup and teardown are code, not a manual step
// global.setup.js
const loginUser = async (page, request, user, authFile, label) => {
await request.post(`${API_URL}/auth/register`, { data: user }).catch(() => {});
await page.goto('/login');
await page.fill('input[type="email"]', user.email);
await page.fill('input[type="password"]', user.password);
await page.click('button[type="submit"]');
await page.waitForURL(/\/dashboard/, { timeout: 10000 });
await page.context().storageState({ path: authFile });
};Registration is attempted and its failure silently ignored — the idempotent "create if not exists" pattern that lets setup run repeatedly against a container that might already have the test user from a previous run. The resulting storageState files (playwright/.auth/user.json and admin.json) are what let the "authenticated" test project (from the previous post) skip a UI login for every single spec — one real login, reused across the whole authenticated test project. global.teardown.js runs the inverse at the end: deleting the trips, events, and any data those tests created, so a second run starts from a clean baseline rather than an accumulating pile of "E2E Test User's" old fishing logs.
Why a UI-first, API-fallback login helper
loginUser tries the UI login flow first and only falls back to a direct API call if that fails within its timeout. That ordering is deliberate: a UI login exercises the actual login form, which is itself useful coverage (a broken login page fails setup loudly, for every subsequent test, rather than being silently bypassed). The API fallback exists purely for resilience — if the frontend is slow to boot inside CI, setup shouldn't fail the entire run over a timing race it isn't actually testing.
The tradeoff this buys
Running docker-compose -f docker-compose.test.yml up costs a few minutes of build time on a cold cache. What it buys back is that "run the E2E suite" and "risk corrupting real user data" are structurally unrelated events — there is no code path in this configuration that can reach the production database, because the container that would need to exist for that to happen was never started.
Series: Fishing Tracker Pro. Next: the marketing side of the project — a Python script that draws its own social media graphics.