Running tests in CI means executing each test type in a headless, reproducible, non-interactive environment — installing dependencies deterministically, providing any services the tests need, and producing machine-readable results and artifacts for every run.
Local test runs make assumptions that break in CI: an already-installed browser, a running database, a warm cache, an interactive terminal. In CI you make everything explicit. Use deterministic installs (npm ci, not npm install) so the lockfile is honored. Run test runners in non-interactive/CI mode (many watch by default). Install browser binaries and their system libraries for E2E (npx playwright install --with-deps). Provide dependencies for integration/API tests via service containers or mocks. And always emit results in a machine-readable format (JUnit/JSON) plus failure artifacts (screenshots, traces, logs), because when a CI test fails you can't just re-run it interactively — the artifacts are your only window into what happened.
A suite passes locally but hangs forever on CI. The cause: the test script ran in watch mode waiting for keypresses. Adding CI=true (which puts the runner in single-run mode) and switching npm install to npm ci makes the job run deterministically and exit cleanly.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'npm' }
- run: npm ci # deterministic install
- run: npm test -- --ci --reporters=default --reporters=jest-junit # non-interactive + JUnit
# E2E: install browsers + OS deps before running
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with: { name: test-results, path: 'test-results/' }Scenario: A test suite is green locally but on CI it (a) sometimes installs different dependency versions, (b) occasionally hangs, and (c) when it fails, no one can tell why. Address all three problems.