Production engineering guide for test pyramid implementation covering patterns, implementation strategies, and operational best practices.
Test pyramid implementation is the disciplined approach to structuring automated tests across layers of system complexity, ensuring fast, reliable, and maintainable feedback. It matters when teams need to validate changes rapidly without waiting for slow end-to-end runs, especially in continuous delivery pipelines. The pyramid prioritizes unit tests at the base, integration tests in the middle, and end-to-end tests at the apex—each layer balancing speed, coverage, and cost.
The test pyramid is not a diagram. It is a deliberate configuration of execution, scope, and ownership that determines when and how tests run. The goal is to minimize time-to-feedback by ensuring that the majority of tests are fast and focused, while only a smaller number of expensive, high-level tests validate system-wide behavior.
Each test layer must be distinguishable by its execution strategy: what runs, when, and how often.
main, or with every service deployment.Use a CI configuration file (e.g., .github/workflows/ci.yml) to define these layers explicitly.
name: CI Pipeline
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install
- run: npm test -- --coverage --watchAll=false
- uses: actions/upload-artifact@v3
with:
name: unit-test-coverage
path: coverage/
integration-tests:
needs: unit-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker-compose up -d --build
- run: npm run test:integration
- run: psql -U app -d testdb -c "SELECT * FROM migrations"
e2e-tests:
needs: integration-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker-compose up -d --build
- run: npx cypress run --record --parallel --group "release"
- run: curl -X POST https://api.example.com/health
Each job defines a layer. The unit-tests job runs on every push, while integration-tests runs only after the unit layer passes and a merge occurs. The e2e-tests job runs last and is only triggered when the integration suite passes.
The scope of a test defines what it covers, and isolation determines how independent it is from external systems.
Use docker-compose.yml to define the test environment for integration and E2E layers.
version: '3.8'
services:
db:
image: postgres:15
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: testdb
volumes:
- ./test-data:/docker-entrypoint-initdb.d
ports:
- "5432:5432"
api:
build: .
ports:
- "3000:3000"
depends_on:
- db
environment:
DATABASE_URL: postgres://app:secret@db:5432/testdb
command: ["npm", "run", "start:dev"]
cypress:
image: cypress/browsers:node18.17.0-chrome115
volumes:
- ./cypress:/e2e
depends_on:
- api
command: ["npx", "cypress", "run"]
This setup ensures that integration and E2E tests run against a consistent, reproducible environment. The depends_on key in docker-compose ensures services start in the correct order, and volumes mount test data into the database container.
The success of the test pyramid hinges on how well the pipeline orchestrates test execution, failure reporting, and feedback delivery.
Parallel execution is essential for keeping the pyramid efficient. Use --parallel in Cypress, --jobs in Jest, and --max-concurrent in GitHub Actions.
{
"e2e": {
"baseUrl": "http://localhost:3000",
"parallel": true,
"record": true,
"group": "release",
"reporter": "junit",
"reporterOptions": {
"mochaFile": "test-results.xml"
}
}
}
Cypress uses parallel to split test runs across machines. The record flag uploads results to a dashboard, and reporterOptions emits XML for integration with CI systems.
In Jest, use --maxWorkers to scale unit tests across CPU cores.
{
"jest": {
"maxWorkers": 4,
"testEnvironment": "node",
"coverageDirectory": "<rootDir>/coverage",
"collectCoverageFrom": [
"src/**/*.ts"
],
"coverageReporters": [
"json",
"lcov",
"text"
]
}
}
The collectCoverageFrom field ensures coverage is calculated across all source files. The coverageReporters output multiple formats for analysis.
A test failure is not just a red box. It must be actionable.
Use --verbose and --debug flags to increase diagnostic detail.
npx jest --verbose --debug --coverage --updateSnapshot
In integration tests, capture logs from all services.
docker-compose logs --tail=100 --timestamps db api
For E2E failures, use Cypress’s screenshots and videos directories.
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
screenshotsFolder: 'cypress/screenshots',
videosFolder: 'cypress/videos',
reporter: 'junit',
reporterOptions: {
mochaFile: 'test-results.xml'
},
setupNodeEvents(on, config) {
on('task', {
log(message) {
console.log(message);
return null;
}
});
}
}
});
When a test fails, the CI system uploads videos and screenshots. The cypress/screenshots directory contains each failed test case, and videos contains full recordings of the test execution.
The pyramid is not a one-time setup. It requires continuous refinement to prevent drift and decay.
The pyramid is defined by the ratio of test counts across layers. A common baseline is 70% unit, 20% integration, 10% E2E tests.
Use a script to validate this ratio.
#!/bin/bash
# check-test-ratio.sh
UNIT_COUNT=$(find src -name "*.spec.ts" | wc -l)
INTEGRATION_COUNT=$(find tests/integration -name "*.test.ts" | wc -l)
E2E_COUNT=$(find cypress/e2e -name "*.spec.ts" | wc -l)
TOTAL=$((UNIT_COUNT + INTEGRATION_COUNT + E2E_COUNT))
UNIT_RATIO=$(echo "scale=2; $UNIT_COUNT / $TOTAL" | bc -l)
INTEGRATION_RATIO=$(echo "scale=2; $INTEGRATION_COUNT / $TOTAL" | bc -l)
E2E_RATIO=$(echo "scale=2; $E2E_COUNT / $TOTAL" | bc -l)
echo "Test Ratio Summary:"
echo "Unit: $UNIT_COUNT ($UNIT_RATIO)"
echo "Integration: $INTEGRATION_COUNT ($INTEGRATION_RATIO)"
echo "E2E: $E2E_COUNT ($E2E_RATIO)"
if (( $(echo "$UNIT_RATIO < 0.6" | bc -l) )); then
echo "ERROR: Unit tests below 60% threshold"
exit 1
fi
if (( $(echo "$INTEGRATION_RATIO < 0.15" | bc -l) )); then
echo "ERROR: Integration tests below 15% threshold"
exit 1
fi
if (( $(echo "$E2E_RATIO < 0.08" | bc -l) )); then
echo "ERROR: E2E tests below 8% threshold"
exit 1
fi
Run this script as a job in CI:
jobs:
test-ratio:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: chmod +x check-test-ratio.sh
- run: ./check-test-ratio.sh
If the ratio drifts, the CI pipeline fails, prompting engineers to rebalance tests.
Test data must be reproducible and isolated.
seed scripts to populate databases before integration and E2E runs.docker-compose exec db psql -U app -d testdb -c "SELECT * FROM users" to inspect state.#!/bin/bash
# seed-db.sh
set -e
echo "Seeding test database..."
psql -U app -d testdb -c "
INSERT INTO users (id, name, email) VALUES
(1, 'Alice', '[email protected]'),
(2, 'Bob', '[email protected]');
"
psql -U app -d testdb -c "
INSERT INTO posts (user_id, title, body) VALUES
(1, 'First Post', 'Hello, world!'),
(2, 'Second Post', 'Testing the pyramid.');
"
echo "Seeding complete."
Run this script before starting integration and E2E tests.
- run: bash seed-db.sh
working-directory: ./scripts
Flaky tests are the silent killers of the pyramid. They pass on one run but fail on the next, eroding trust.
Use --retries in Jest and --retry in Cypress.
// jest.config.js
module.exports = {
retries: 2,
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/test/setup.js']
};
In Cypress, use --retry with --retry-only to re-run only failed tests.
npx cypress run --retry --retry-only --record --parallel
The retry flag retries all tests on failure, while retry-only runs only the tests that failed in the previous run. This reduces feedback latency during development.
A common failure: integration tests depend on a real database, but the database is not reset between runs.
The result: tests interfere with each other. A test that creates a user affects another test that expects a clean database.
Solution: use docker-compose down and docker-compose up to reset the environment.
- run: |
docker-compose down
docker-compose up -d --build
docker-compose exec db psql -U app -d testdb -c "TRUNCATE TABLE users, posts RESTART IDENTITY"
This ensures a clean state before each integration or E2E run.
A test is labeled integration but is actually end-to-end. It uses a real frontend, API, and database, but also makes external HTTP calls.
The fix: define boundaries explicitly. Use --tag or --category in test runners.
// In a test file
describe('User Registration', () => {
it('should create a user and send welcome email', async () => {
// This test touches database, API, and external email service
await api.post('/users', { name: 'Charlie' });
await emailService.waitForEmail('[email protected]');
expect(await db.get('users', { name: 'Charlie' })).toBeTruthy();
});
});
Mark such tests as e2e, and use a test:e2e script to run only them.
{
"scripts": {
"test:e2e": "jest --tags=e2e",
"test:integration": "jest --tags=integration"
}
}
A test suite runs, but no coverage report is generated. The coverage directory is missing, or the coverage.json file is not generated.
Check the coverageDirectory and collectCoverageFrom settings.
{
"jest": {
"coverageDirectory": "coverage",
"collectCoverageFrom": [
"src/**/*.ts",
"!src/**/*.test.ts"
]
}
}
The !src/**/*.test.ts excludes test files from coverage, ensuring that only production code is measured.
The CI pipeline runs npm install, but docker-compose up fails due to missing image.
The docker-compose.yml file references an image, but the build step is missing.
Fix:
- run: docker-compose build
- run: docker-compose up -d --build
Without --build, the services use cached images, which may be outdated.
The test pyramid is not a shape. It is a discipline. It is the commitment to running the right tests at the right time, with the right data, and the right feedback. It is not about having tests, but about making them work.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy