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.

Syntaxnpx tsc --noEmit && npx eslint . && npx jest --ci

Continuous 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

Example · tsx
// __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.

Example · yaml
# .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 --coverage

Scripts 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.

Example · json
{
  "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.

Example · yaml
# .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

  • Be the first to comment on this lesson.