Testing, error handling and observability

Component tests with Vitest, end-to-end flows with Playwright, error boundaries that users can recover from, and reporting real metrics.

Unit and component tests

// tests/GuideCard.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import GuideCard from '@/components/GuideCard'

describe('GuideCard', () => {
  it('links to the guide and shows its title', () => {
    render(<GuideCard guide={{ slug: 'intro', title: 'Intro' }} />)
    expect(screen.getByRole('link', { name: /intro/i }))
      .toHaveAttribute('href', '/guides/intro')
  })
})
  • Test client components with Testing Library and query by role or accessible name, so restyling does not break the test.
  • Async server components are not rendered by a plain unit renderer. Test the plain functions they call out to, and cover the rendered result with an end-to-end test.
  • Mock next/navigation and next/image in the test setup: they are framework modules with no meaning outside the app.
  • Keep the setup file small — jsdom, the Testing Library matchers and those module mocks. A setup that grows is usually a component that should be split.
  • Assert on props and rendered output, never on call counts to a private fetch: that couples the test to an implementation the user never sees.

End-to-end flows and error boundaries

// e2e/guides.spec.ts
import { expect, test } from '@playwright/test'

test('a visitor can open a guide', async ({ page }) => {
  await page.goto('/guides')
  await page.getByRole('link', { name: 'Intro' }).click()
  await expect(page).toHaveURL(/\/guides\/intro$/)
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
})
// app/guides/error.tsx — must be a client component
'use client'

export default function Error({ error, reset }) {
  return (
    <div role="alert">
      <p>We could not load the guides.</p>
      <button onClick={reset}>Try again</button>
      {process.env.NODE_ENV !== 'production' && <pre>{error.message}</pre>}
    </div>
  )
}
  • Run the end-to-end suite against a production build. Development rendering, caching and error handling all differ from what users get.
  • Wait for a visible condition instead of a fixed timeout. A test that fails once a week is worse than no test, because the team stops reading the failures.
  • error.tsx catches render and data errors in its segment and receives a reset function, so the user can retry without reloading.
  • not-found.tsx handles missing resources; without it a notFound() call falls through to a default page you did not design.
  • Exercise the failure path at least once — a forced error route proves the user sees a message rather than a blank screen.
⚠️
error.message is useful in development and often leaks internals in production. Gate it on the environment; a raw message can disclose table names, file paths or the shape of a query.

Instrumentation, logging and Core Web Vitals

// instrumentation.ts — project root, runs once when the server starts
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { initTracing } = await import('./lib/tracing')
    await initTracing()
  }
}
import { after } from 'next/server'

export default async function Guides() {
  const guides = await getGuides()
  after(() => reportRenderDuration('guides', guides.length))   // runs after the response
  return <GuideList guides={guides} />
}
  • instrumentation.ts hooks into server startup for tracing, logging and error reporting. It must sit at the project root, and register() is the entry point.
  • Use the Node.js runtime for a tracing SDK that depends on native modules. The Edge runtime cannot load them.
  • after() schedules work that should not delay the response, such as analytics or cache warming. Nothing the user is waiting for belongs inside it.
  • Report real Core Web Vitals from a client component and send them to your own endpoint. Lab measurements and field data disagree, and field data is what users experience.
  • Log structured fields — request id, route, duration, status — so a slow route is found by a query rather than by reading prose.
  • Alert on user-visible signals: error rate, p95 latency and LCP. Alerting on individual log lines produces noise and then silence.

FAQ

Vitest or Playwright?
Both, with different jobs. Vitest and Testing Library cover a component's contract and the logic you extract from it, in milliseconds. Playwright covers the flows a user performs against a real server, in seconds — keep that suite small and focused on the paths that matter.
Where should a global error handler live?
error.tsx handles render and data errors for its segment, and a root-level one catches the rest. For server-side reporting, initialise your error tracker in instrumentation.ts so it also covers route handlers and server actions.

Streaming, Suspense and partial prerendering Server actions and forms in depth

Last refreshed 2026-09-18.