I once spent four hours debugging a "syntax error near RETURNING" that only appeared in production. My local tests were green, my mock objects were satisfied, and my SQLite-based CI environment was blissfully unaware that I was using Postgres-specific features. It was a classic "it works on my machine" moment that could have been avoided if my tests actually touched a real database.
The Mocking Trap
We’ve been told for years that "unit tests should be isolated" and "databases are too slow for CI." So, we reach for mocks or in-memory equivalents. The problem? Mocks are liars. They represent what you *think* the infrastructure does, not what it actually does.
When you mock your database, you aren't testing your data layer; you’re testing your ability to write a mock that mimics a database. You miss out on constraint violations, permission issues, and those subtle dialect differences that turn a Friday afternoon deployment into a weekend-long incident.
Enter Service Containers
If you’re using GitHub Actions, you don't need to mock your infra. You can run real, disposable instances of Postgres, Redis, or RabbitMQ right alongside your code using Service Containers. This is "Infrastructure as a Sidecar."
Instead of your code talking to a fake object, it talks to a real Docker container started specifically for that workflow run. When the job finishes, the container vanishes.
A Practical Example: Postgres in GitHub Actions
Let's look at how to wire this up. This is a standard .github/workflows/test.yml setup. Notice the services block—this is where the magic happens.
name: Integration Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: test_db
# Map the container port 5432 to the host port 5432
ports:
- 5432:5432
# Crucial: Wait until postgres is actually ready to receive connections
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run Migrations and Tests
env:
# Point your app to the sidecar
DATABASE_URL: postgres://user:password@localhost:5432/test_db
run: |
npm run migrate
npm testWhy the Health Check Matters
You’ll notice the options field with pg_isready. Don't skip this.
Docker containers take a few seconds to boot up. If your test suite starts before the Postgres engine has finished initializing its internal file system, your tests will fail with a "Connection Refused" error. The health check ensures that GitHub Actions waits for the service to be "healthy" before moving on to your steps.
Dealing with Multiple Services
The sidecar pattern scales. If your application relies on Redis for caching and Postgres for storage, just add another service.
services:
postgres:
image: postgres:15
# ... config ...
redis:
image: redis
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5Now, your integration tests are actually testing the integration. You’re catching that one weird Redis command that doesn't behave like the library mock said it would.
The Trade-offs
Is this slower than mocks? Yes. You're pulling Docker images and waiting for engines to start. We’re talking about an extra 20–40 seconds per run.
But I'd argue that 30 seconds of CI time is significantly cheaper than an hour of developer time spent chasing a bug that only exists in production.
A few tips for the road:
1. Use specific tags: Don't just use postgres:latest. Use the exact version you run in production (e.g., postgres:15.4).
2. Clean up your data: If your test suite isn't idempotent, wrap your tests in transactions or use a global teardown to truncate tables between suites.
3. Mind the ports: If you run multiple jobs on the same runner (uncommon in hosted GHA but common in self-hosted), ensure you don't have port collisions.
Stop pretending your software lives in a vacuum. Use a sidecar, run the real infra, and sleep better during deployments.