Testing strategy

Quality gates and CI

Designing the pipeline — stage order, caching, sharding and parallelism, what blocks a merge — plus coverage policy, flake management, accessibility and performance budgets, visual regression, type and mutation tests, preview deployments, and the test-data and environment story.

Tests only matter if they run on every change and block the wrong ones. "Describe your CI" is a senior question because the answer shows how you balance speed, confidence and cost, and how you handle the human side: flaky tests, coverage politics, and what happens when the build is red on Friday evening.

Know

  • Stage order by cost and signal: install (cached) → lint and typecheck (seconds, catches the most) → unit and component tests (fast, parallel) → build → end-to-end on the built app (sharded) → deploy preview → budgets on the preview (Lighthouse, axe) → deploy. Fail fast: the cheap stages gate the expensive ones.
  • Caching: the pnpm store keyed by the lockfile (actions/setup-node with cache: pnpm), Nuxt's build cache where safe, Playwright browsers cached by version; --frozen-lockfile so a cache never hides a resolution change.
  • Parallelism: Vitest runs files in parallel by default and supports --shard=1/4 across machines; Playwright fullyParallel plus --shard with blob reports merged into one HTML report; keep e2e to Chromium on pull requests and run the full browser matrix nightly.
  • Only what changed, sometimes: vitest --changed and Playwright --only-changed speed up pull requests; the main branch still runs everything, because dependency changes have non-local effects.
  • What blocks a merge: lint, typecheck, unit and component tests, the build, and the e2e critical journeys; budgets (performance, accessibility, bundle size) block when they regress beyond a threshold, not on noise. Required checks are configured on the branch, and nobody merges around them.
  • Coverage policy: report coverage for visibility, set thresholds as a floor per critical directory (server/, shared/), fail on decrease rather than on an absolute number, and never treat 100% as a goal.
  • Flake management: retries only in CI and only for e2e, with a report of retried passes; a quarantine label (test.fixme with an owner and a date) so the suite stays green without lying; root causes fixed within the sprint. Track flake rate; a rising rate is a product signal (races in hydration or data fetching) as often as a test problem.
  • Budgets: Lighthouse CI with budgets.json (LCP, INP proxy TBT, CLS, script size) run against the preview URL with several runs and the median; @axe-core/playwright at wcag22aa on key pages; bundle-size checks (nuxt analyze output diff or a size-limit action) so an accidental dependency shows up in the PR.
  • Visual regression: Playwright toHaveScreenshot with committed baselines per platform, maxDiffPixelRatio, and a review flow for intentional changes; or a hosted service when many people touch the UI.
  • Type and mutation tests: vitest --typecheck for generic helpers and public types; mutation testing (Stryker) occasionally on the most critical logic to find tests that assert nothing.
  • Environments and data: a preview deployment per pull request (Cloudflare, Vercel, or a Nuxt server preset on a review environment) with test data seeded and third-party services in sandbox mode; secrets scoped per environment; e2e tests point at the preview via setup({ host }) or Playwright's baseURL.
  • Trunk-based flow: small pull requests, feature flags for incomplete work, main always deployable, a revert culture over hotfix heroics; the pipeline is the definition of done.

How it works

A pull-request pipeline with the gates in cost order and sharded tests:

.github/workflows/ci.yml
name: CI
on:
  pull_request:
  push: { branches: [main] }
permissions: { contents: read, pull-requests: write }
concurrency: { group: ci-${{ github.ref }}, cancel-in-progress: true }

jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha>
      - uses: pnpm/action-setup@<sha>
      - uses: actions/setup-node@<sha>
        with: { node-version-file: .node-version, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm vitest run --project unit --project nuxt --coverage --shard=${{ matrix.shard }}/2
    strategy: { matrix: { shard: [1, 2] } }

  build:
    needs: checks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha>
      - uses: pnpm/action-setup@<sha>
      - uses: actions/setup-node@<sha>
        with: { node-version-file: .node-version, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm nuxt build
      - uses: actions/upload-artifact@<sha>
        with: { name: output, path: .output }

  e2e:
    needs: build
    runs-on: ubuntu-latest
    strategy: { fail-fast: false, matrix: { shard: [1, 2, 3, 4] } }
    steps:
      - uses: actions/checkout@<sha>
      - uses: pnpm/action-setup@<sha>
      - uses: actions/setup-node@<sha>
        with: { node-version-file: .node-version, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - uses: actions/download-artifact@<sha>
        with: { name: output, path: .output }
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm exec playwright test --shard=${{ matrix.shard }}/4
        env: { NUXT_TEST_PREBUILT: 'true' }
      - uses: actions/upload-artifact@<sha>
        if: always()
        with: { name: blob-report-${{ matrix.shard }}, path: blob-report }

  budgets:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha>
      - run: npx @lhci/cli autorun --collect.staticDistDir=.output/public --assert.budgetsFile=budgets.json

Budgets that fail the build only on real regressions:

budgets.json
[
  {
    "path": "/*",
    "timings": [
      { "metric": "largest-contentful-paint", "budget": 2500 },
      { "metric": "total-blocking-time", "budget": 300 },
      { "metric": "cumulative-layout-shift", "budget": 0.1 }
    ],
    "resourceSizes": [
      { "resourceType": "script", "budget": 350 },
      { "resourceType": "total", "budget": 1200 }
    ]
  }
]

Coverage as a floor, and quarantine that stays honest:

vitest.config.ts (excerpt)
coverage: {
  provider: 'v8',
  include: ['app/**', 'shared/**', 'server/**'],
  thresholds: {
    'shared/**': { statements: 90, branches: 85 },   // the business rules
    'server/**': { statements: 80 },
    autoUpdate: false,
  },
}
test/e2e/reports.spec.ts
// Quarantined: fails intermittently when the PDF service is slow. Owner: @martin, due 2026-10-01, tracked in #482.
test.fixme('exports the monthly report', async ({ page, goto }) => { /* ... */ })
Gotcha· Retries as a fix

retries: 3 makes the suite green and hides a race that is also in production. Keep retries small, only for e2e, report every retried pass, and treat a test that needs retries as a bug with a deadline.

Gotcha· A single global coverage number

"Coverage must be ≥ 80%" leads to tests that call functions and assert nothing, and discourages deleting dead code because coverage drops. Per-directory floors on the code that matters and a fail-on-decrease rule keep the metric useful.

Verify before the interview:

The current blob-report merge command (playwright merge-reports) and Vitest's sharding/--changed flags in the 5.x line; also the Lighthouse CI autorun assertion options.

docs ↗

Exercise

Exercise
  • Draw your current pipeline as stages with durations; reorder it by cost and signal and estimate the time saved on a failing lint.
  • Add a Lighthouse CI run with the budget file above against this app's generated output; find which budget is closest to failing.
  • Introduce a deliberately flaky e2e test (a Math.random() assertion), run with retries: 2, and make the pipeline report the retried pass.

Be able to say

Be able to say· Describe the CI pipeline you would set up for a Nuxt project.

"Stages ordered by cost and signal, each gating the next: a cached install with a frozen lockfile, then lint and nuxt typecheck, which take seconds and catch the most, then unit and component tests sharded across two runners with coverage reported and floors on the business-rule directories, then the build, whose output feeds sharded Playwright runs on Chromium with blob reports merged, then a preview deployment where Lighthouse budgets and axe checks run against the real thing. Required checks block the merge; budgets block only on regressions beyond a threshold. Retries are for e2e in CI only and every retried pass is reported, flaky tests are quarantined with an owner and a date, and the full browser matrix runs nightly rather than on every pull request. The pipeline is the definition of done, so main stays deployable."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.