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.
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.--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.vitest --changed and Playwright --only-changed speed up pull requests; the main branch still runs everything, because dependency changes have non-local effects.server/, shared/), fail on decrease rather than on an absolute number, and never treat 100% as a goal.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.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.toHaveScreenshot with committed baselines per platform, maxDiffPixelRatio, and a review flow for intentional changes; or a hosted service when many people touch the UI.vitest --typecheck for generic helpers and public types; mutation testing (Stryker) occasionally on the most critical logic to find tests that assert nothing.setup({ host }) or Playwright's baseURL.A pull-request pipeline with the gates in cost order and sharded tests:
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:
[
{
"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:
coverage: {
provider: 'v8',
include: ['app/**', 'shared/**', 'server/**'],
thresholds: {
'shared/**': { statements: 90, branches: 85 }, // the business rules
'server/**': { statements: 80 },
autoUpdate: false,
},
}
// 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 }) => { /* ... */ })
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.
"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.
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.
Math.random() assertion), run with retries: 2, and make the pipeline report the retried pass."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."
Nothing yet. Notes filed here show up on this page and the section overview.
Server and API tests
Testing Nitro routes at three levels — handler logic in isolation, routes through @nuxt/test-utils/e2e, and contracts against an OpenAPI document — with databases in tests (SQLite in memory, Testcontainers, transaction rollback), seeding and isolation, auth in tests, and validation and error-shape assertions.
Module authoring
What a Nuxt module really is, the three worlds every line of module code lives in, the three primitives everything reduces to, and why a Layers role is a module role in disguise.