s. Integration tests must use ephemeral, version-pinned dependencies. E2E tests must validate user journeys against contract-stable interfaces, not implementation details.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
// Sharding enables parallel execution across CI runners
shard: process.env.CI ? { current: Number(process.env.SHARD_ID), total: Number(process.env.SHARD_COUNT) } : undefined,
// Isolate each test file to prevent shared state
isolate: true,
// Timeout thresholds aligned with scope
testTimeout: 5000,
hookTimeout: 10000,
// Coverage only on unit/integration, disabled for E2E
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'cobertura'],
exclude: ['**/e2e/**', '**/test-utils/**', '**/*.d.ts'],
},
// Custom reporters for CI artifact generation
reporters: process.env.CI ? ['default', 'junit'] : ['default'],
outputFile: {
junit: './test-results/junit.xml',
},
},
});
Step 2: Implement Test Impact Analysis (TIA)
Running the entire suite on every commit is computationally wasteful. TIA maps changed files to affected tests using dependency graphs. In TypeScript/Node.js, this is achieved via AST parsing or build tool metadata.
// scripts/tia-runner.ts
import { execSync } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import path from 'path';
const CHANGED_FILES = process.env.CHANGED_FILES?.split('\n').filter(Boolean) ?? [];
const TEST_DIR = path.resolve('src');
// Simple dependency resolver: if a changed file is imported by a test, run it
function resolveAffectedTests(): string[] {
const affected = new Set<string>();
for (const changed of CHANGED_FILES) {
if (!changed.startsWith(TEST_DIR)) continue;
// Find tests importing the changed module
const testFiles = execSync(`grep -rl "from ['\"].*${path.basename(changed, '.ts')}" src/**/*.test.ts`, { encoding: 'utf-8' })
.split('\n')
.filter(Boolean);
testFiles.forEach(t => affected.add(t));
}
return affected.size > 0 ? Array.from(affected) : ['src/**']; // fallback to full suite if graph breaks
}
const targetFiles = resolveAffectedTests();
console.log(`TIA resolved ${targetFiles.length} test files`);
execSync(`npx vitest run ${targetFiles.join(' ')}`, { stdio: 'inherit' });
Step 3: Ephemeral Integration Environments
Never reuse databases or message brokers across test runs. Containerized dependencies with deterministic seeding eliminate state drift.
# docker-compose.test.yml
version: '3.8'
services:
test-db:
image: postgres:15-alpine
environment:
POSTGRES_DB: app_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test"]
interval: 5s
timeout: 5s
retries: 5
test-redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --appendonly no
Step 4: CI Workflow Orchestration
Structure the pipeline to fail fast, cache aggressively, and parallelize by shard.
# .github/workflows/ci-test.yml
name: CI Test Pipeline
on:
pull_request:
branches: [main]
push:
branches: [main]
env:
NODE_VERSION: '20'
SHARD_COUNT: 4
jobs:
unit-tests:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run test:unit
env:
SHARD_ID: ${{ matrix.shard }}
SHARD_COUNT: ${{ env.SHARD_COUNT }}
CI: true
- uses: actions/upload-artifact@v4
if: always()
with:
name: unit-results-${{ matrix.shard }}
path: test-results/
integration-tests:
needs: unit-tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_DB: app_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run test:integration
env:
DATABASE_URL: postgresql://test:test@localhost:5432/app_test
REDIS_URL: redis://localhost:6379
CI: true
- uses: actions/upload-artifact@v4
if: always()
with:
name: integration-results
path: test-results/
Architecture Decisions and Rationale
- Sharding over Runner Scaling: Distributing test files across fixed runner counts reduces variance. Scaling runners without sharding creates resource contention and unpredictable queue times.
- Deterministic Seeding: Integration tests must initialize databases with identical fixtures per run.
pg_dump/pg_restore or migration-driven seeding eliminates state leakage.
- Artifact Isolation: JUnit XML and coverage reports are uploaded per shard. Merging happens downstream in the reporting layer, preventing race conditions during CI upload.
- Conditional E2E Execution: End-to-end suites trigger only on main branch merges or explicit
workflow_dispatch. This preserves developer velocity while maintaining regression safety for releases.
Pitfall Guide
-
Normalizing Flaky Tests as "Pipeline Noise"
Flaky tests corrupt trust in the CI system. Developers bypass gates, merge broken code, and eventually disable automation. Quarantine flaky tests immediately, tag them with @flaky, and enforce a 48-hour resolution SLA. Root causes are typically timing assumptions, shared state, or non-deterministic external APIs.
-
Running Full Suites on Every Commit
Test volume scales linearly with codebase growth; pipeline latency scales exponentially when executed sequentially. Implement test impact analysis or at minimum, path-based filtering (src/ changes trigger unit/integration, docs/ changes skip tests entirely).
-
Shared Mutable State Across Test Runs
Databases, caches, or filesystem directories reused between tests cause cross-contamination. Each test file must assume a clean slate. Use transaction rollbacks, in-memory stores, or container recreation per shard.
-
Hardcoding Environment-Specific Values
Tests that embed production URLs, API keys, or region-specific endpoints break in isolated CI environments. Use .env.test overrides and mock external services via contract stubs (e.g., Pact, MSW) rather than live endpoints.
-
Treating Coverage Percentage as a Quality Metric
95% coverage with trivial assertions provides false confidence. Track mutation score, branch coverage, and critical path execution instead. Enforce coverage gates only on new code, not legacy codebases.
-
Blocking Production Deployments on E2E Flakiness
End-to-end suites are inherently fragile due to browser rendering, network latency, and third-party dependencies. Use them for release validation, not PR gates. Implement retry logic with exponential backoff and quarantine persistent failures.
-
Ignoring CI Runner Resource Exhaustion
Memory leaks in test runners, unclosed database connections, or unbounded logging degrade subsequent jobs. Enforce --max-old-space-size limits, close client pools in afterAll, and rotate runners periodically.
Best Practice Summary: Treat tests as production code. Version them, review them, profile them, and deprecate obsolete assertions. CI is not a dumping ground for validation logic; it is a feedback distribution system.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup / Solo Developer | Sequential unit + manual E2E | Low overhead, fast iteration, minimal CI complexity | Minimal compute cost, higher manual validation time |
| Mid-Size Team (5-20 devs) | Parallelized sharded unit/integration + TIA | Balances velocity and safety; reduces merge conflicts | Moderate CI minutes; 60% reduction in failed PRs |
| Enterprise / Compliance | Shift-Left + Contract Split + Ephemeral E2E | Enforces audit trails, deterministic releases, zero state drift | Higher initial setup cost; 40% lower long-term CI spend due to precision |
| Legacy Monolith | Quarantine flaky β migrate to sharding β add TIA | Prevents pipeline collapse during transition | Temporary compute spike during migration; net savings post-stabilization |
Configuration Template
# .github/workflows/test-pipeline.yml
name: Automated Testing Pipeline
on:
pull_request:
branches: [main, release/*]
push:
branches: [main]
env:
NODE_VERSION: '20'
SHARD_COUNT: 6
jobs:
validate:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4, 5, 6]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci --ignore-scripts
- name: Run Sharded Tests
run: npm run test:ci
env:
SHARD_ID: ${{ matrix.shard }}
SHARD_COUNT: ${{ env.SHARD_COUNT }}
CI: true
NODE_OPTIONS: '--max-old-space-size=4096'
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-shard-${{ matrix.shard }}
path: |
test-results/
coverage/
retention-days: 7
report:
needs: validate
runs-on: ubuntu-latest
if: always()
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: test-results-shard-*
path: artifacts/
- name: Merge & Publish Reports
run: |
npm ci
npx junit-merge artifacts/**/junit.xml > test-results/merged.xml
npx istanbul report --root artifacts/**/coverage-final.json text lcov
- uses: actions/upload-artifact@v4
with:
name: final-test-report
path: test-results/
Quick Start Guide
- Initialize Test Runner Configuration: Install Vitest or Jest, create
vitest.config.ts, enable isolate: true, and configure JUnit output for CI.
- Add Sharding Environment Variables: Export
SHARD_ID and SHARD_COUNT in your CI workflow. Update test scripts to read these variables and pass them to the runner.
- Containerize Integration Dependencies: Create
docker-compose.test.yml with PostgreSQL/Redis. Reference in CI via services: block or local execution with docker compose up -d --wait.
- Wire Artifact Collection: Add
upload-artifact steps targeting test-results/ and coverage/. Ensure if: always() guarantees report generation even on failure.
- Validate Locally: Run
SHARD_ID=1 SHARD_COUNT=4 npx vitest run to verify sharding. Commit. Watch CI execute in parallel. Merge when green.