Running Tests in CI
A workflow that type-checks, lints and tests every push, plus the branch rule that makes a red run block the merge.
npx tsc --noEmit && npx eslint . && npx jest --ciContinuous integration runs your checks on a clean machine for every push. The value is not the test run itself, which you could do locally, but that nobody can forget to do it, and that the machine has none of your laptop's leftovers.
The three commands
Type-check with tsc --noEmit, lint with eslint, then run jest --ci. The --ci flag matters more than it looks: without it, a component whose snapshot is missing gets a new one written and the test passes. With it, the run fails instead.
Pin the Node version in the workflow — React Native Testing Library 14 requires Node 22.13 or newer — and pin TZ as well. Runners are UTC and laptops are not, and a date test that passes in one place and fails in the other costs an afternoon.
Making it block
A workflow that reports a failure blocks nothing by itself. The merge is only stopped once the job's name is added as a required status check in the branch's protection rules. Until then, a red cross is a suggestion.
Keeping it fast
Unit and component tests on every push take a minute or two, which people will tolerate. End-to-end tests need a built binary and a simulator, so put those on a nightly schedule or behind a label, not on every commit.
Example
// __tests__/dueDate.test.tsx
import { formatDueDate } from '../lib/dueDate';
describe('formatDueDate', () => {
beforeAll(() => {
// CI runners sit in UTC; laptops do not. Pin "now" so the same test
// cannot pass on your machine and fail on the runner.
jest.useFakeTimers({ now: new Date('2026-03-01T09:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
it('counts whole days from today', () => {
expect(formatDueDate('2026-03-01')).toBe('Due today');
expect(formatDueDate('2026-03-02')).toBe('Due tomorrow');
expect(formatDueDate('2026-03-08')).toBe('Due in 7 days');
});
it('names a date in the past as overdue', () => {
expect(formatDueDate('2026-02-27')).toBe('Overdue by 2 days');
// Output: 'Overdue by 2 days'
});
});When to use it
- Stop a pull request that breaks the type-check from being merged at all.
- Find out which of three checks failed without opening the log.
- Keep a date-sensitive test honest across a UTC runner and a developer's laptop.
- Run the end-to-end smoke suite nightly without slowing down every commit.
More examples
The workflow itself
Each run step is a separate line in the log, so a failure says which check broke without anyone reading the output. npm ci rather than npm install is what makes the run reproducible: it installs the lockfile and fails if package.json has drifted from it. The job name is the string you will later add to the branch's required status checks, so keep it stable once pull requests depend on it.
# .github/workflows/ci.yml
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
checks:
name: type-check, lint, test
runs-on: ubuntu-latest
env:
TZ: UTC # same clock as the fake timers in the date tests
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22 # testing library 14 needs >= 22.13
cache: npm
- run: npm ci # install exactly what the lockfile says
- run: npx tsc --noEmit
- run: npx eslint .
- run: npx jest --ci --coverageScripts so CI and your terminal run the same thing
Put the commands in package.json and let the workflow call npm run verify. Then a developer can reproduce a CI failure with one command instead of reading the YAML, and changing a flag means editing one file rather than two. test:changed is the version worth running while working: --changedSince compares against the main branch and only runs the files your work can have broken.
{
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "jest",
"test:changed": "jest --watch --changedSince=origin/main",
"test:ci": "jest --ci --coverage",
"verify": "npm run typecheck && npm run lint && npm run test:ci"
}
}End-to-end on a schedule, not on every push
Separating the workflows keeps pull requests fast while still getting device coverage daily. workflow_dispatch adds a manual trigger, which is what you press before a release. Running the flows on Maestro Cloud avoids maintaining emulators on the runner; the local equivalent is maestro test .maestro/ on a machine with the Android SDK installed, which is simpler to reason about but slower and serial.
# .github/workflows/e2e.yml
name: e2e
on:
schedule:
- cron: '0 3 * * *' # 03:00 UTC, nightly
workflow_dispatch: # and a button for when you want it now
jobs:
maestro:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
# Build an Android APK on EAS, then hand the binary to Maestro Cloud.
- run: npx eas-cli build --platform android --profile preview --non-interactive
- uses: mobile-dev-inc/action-maestro-cloud@v3
with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
app-file: build/app-preview.apk
Discussion