| name | web-testing |
| description | The most common testing mistake in a React/TypeScript web app is either testing implementation details — internal state, component method calls, class names — that shatter on every refactor, or having a thin unit layer a |
| source_type | skill |
| source_file | skills/web-testing.md |
web-testing
Migrated from skills/web-testing.md.
Codex packaging notes
- Claude/Telar source files remain the source of truth; this file is the generated Codex adapter.
- Skill-local support files from the original Telar skill, such as
references/... or workflow/..., are packaged beside this SKILL.md.
- Repo-root references from the original Telar file, such as
agents/..., commands/..., scripts/..., resources/..., rules/..., hooks/..., or templates/..., are packaged at this plugin root.
- The original Telar orchestration source (
skills/orchestration/...) is packaged under source/skills/orchestration/... for exact-reference lookups; all other Telar skills exist here only as the generated adapters under the plugin-root skills/ directory.
- Resolve plugin-root paths from this generated skill directory via
../.. when reading support files or running packaged scripts.
Build a React/TS Web Test Suite That's Fast at the Base and Honest at the Top
The most common testing mistake in a React/TypeScript web app is either testing implementation details — internal state, component method calls, class names — that shatter on every refactor, or having a thin unit layer and a massive Playwright suite that is slow and flaky. This skill covers the four layers that work well together: Vitest + Testing Library for fast component behavior, MSW for network boundaries, integration tests wiring real stores and routers, and Playwright for the critical journeys that genuinely need a browser — plus axe-core for accessibility regressions.
Problem
it('calls handleSubmit on click', () => {
const { getByTestId } = render(<LoginForm />)
fireEvent.click(getByTestId('submit-btn'))
expect(wrapper.instance().handleSubmit).toHaveBeenCalled()
})
await page.click('#submit')
await page.waitForTimeout(3000)
expect(await page.textContent('.result')).toBe('Done')
Solution
Layer 1: component tests with Vitest + Testing Library
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
it('shows a field error when email is blank on submit', async () => {
const user = userEvent.setup()
render(<LoginForm onSuccess={vi.fn()} />)
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(screen.getByRole('alert')).toHaveTextContent('Email is required')
})
it('calls onSuccess with the email after a valid submission', async () => {
const onSuccess = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSuccess={onSuccess} />)
await user.type(screen.getByLabelText(/email/i), 'user@example.com')
await user.type(screen.getByLabelText(/password/i), 'secret')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(onSuccess).toHaveBeenCalledWith('user@example.com')
})
Use getByRole / getByLabelText / getByText — never getByTestId or class selectors. This layer should be the largest by count and run in milliseconds with no network or browser involved.
Layer 2: network mocking with MSW
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
const server = setupServer(
http.post('/api/auth/login', () =>
HttpResponse.json({ token: 'tok_abc' }, { status: 200 })
)
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
it('shows welcome text after a successful login', async () => {
const user = userEvent.setup()
render(<LoginForm onSuccess={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'user@example.com')
await user.type(screen.getByLabelText(/password/i), 'secret')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(await screen.findByText(/welcome/i)).toBeInTheDocument()
})
it('shows an error when the API returns 401', async () => {
server.use(http.post('/api/auth/login', () => HttpResponse.json({}, { status: 401 })))
const user = userEvent.setup()
render(<LoginForm onSuccess={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'bad@example.com')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(/invalid credentials/i)
})
onUnhandledRequest: 'error' fails loudly when a new API call is added to the component but not covered by a handler — preventing silent drift where the call returns undefined and the test coincidentally passes anyway.
Layer 3: integration with real store and router
import { RouterProvider, createMemoryHistory, createRouter } from '@tanstack/react-router'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { routeTree } from '@/routeTree.gen'
it('navigates to the profile page after login', async () => {
const history = createMemoryHistory({ initialEntries: ['/login'] })
const router = createRouter({ routeTree, history })
const user = userEvent.setup()
render(<RouterProvider router={router} />)
await user.type(screen.getByLabelText(/email/i), 'user@example.com')
await user.type(screen.getByLabelText(/password/i), 'secret')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(
await screen.findByRole('heading', { name: /your profile/i })
).toBeInTheDocument()
})
Layer 4: E2E with Playwright and @axe-core/playwright
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test('checkout flow: product → cart → order confirmation', async ({ page }) => {
await page.goto('/products/widget-pro')
await page.getByRole('button', { name: /add to cart/i }).click()
await page.getByRole('link', { name: /view cart/i }).click()
await page.getByRole('button', { name: /checkout/i }).click()
await expect(page.getByRole('heading', { name: /order confirmed/i })).toBeVisible()
})
test('product page has no critical accessibility violations', async ({ page }) => {
await page.goto('/products/widget-pro')
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze()
expect(results.violations).toEqual([])
})
Set trace: 'on-first-retry' in playwright.config.ts — the trace viewer gives a step-by-step DOM/network replay that cuts flaky-test diagnosis time dramatically versus log tailing.
E2E engine: official Playwright agents vs MCP live-verify
Two different tools cover two different moments in the workflow — generating/maintaining the durable spec suite versus verifying a change works right now against a running app.
Official Playwright test agents (Planner → Generator → Healer) generate and self-heal the durable spec suite committed to the repo. Init with:
npx playwright init-agents --loop=claude
Hard prerequisite: this requires Playwright 1.56+ — the agent system was introduced in 1.56, and a project on an older version must upgrade first. The harness patterns in this skill (locators, fixtures, axe scans) work on any recent Playwright version; only the official-agent generator itself needs 1.56+.
Playwright MCP is for live, interactive verification: driving a running localhost app and asserting against the accessibility tree, not screenshots. Install:
claude mcp add playwright npx @playwright/mcp@latest
When to use which: reach for MCP during the build loop to answer "does my change work right now" against a live app. Reach for the official agents (or hand-authored specs, per this skill's Layer 4 patterns above) to produce and maintain the durable regression suite that runs in CI.
For the stack-specific pieces of an E2E suite, see [[supabase-e2e-harness]] for Supabase test data setup (Arrange/Act), [[web-e2e-locators]] for stable locator and waiting strategy, [[web-e2e-catalog]] for how the suite itself should be structured, and [[web-e2e-review]] before declaring any E2E suite done.
Which layer for what
| What you are testing | Layer | Tool |
|---|
| Validation logic, utility functions | Unit | Vitest (plain) |
| Component renders correct roles / text | Component | Vitest + Testing Library |
| Component + network calls (success / error paths) | Component + network | Vitest + MSW |
| Multi-route flow with real store | Integration | Vitest + router + store |
| Critical end-to-end user journey | E2E | Playwright |
| Accessibility compliance per page / state | E2E | @axe-core/playwright |
Pyramid shape: most tests live in the component and MSW layers; a handful of integration tests cover cross-route flows; E2E tests cover only the 3–5 journeys that would be critical if broken in production.
Why This Works
getByRole / getByLabelText queries are stable across refactors because they target the accessible tree, not internal DOM structure — renaming a class or extracting a subcomponent doesn't break them, but removing the label does (a real regression worth catching).
- MSW intercepts at the
fetch/XMLHttpRequest boundary, not at the module import level, so the component runs its real data-fetching code; only the HTTP response is controlled. This means a vi.mock('axios') call is never needed, and the test remains valid across fetch-library changes.
- Playwright locators wait for the element to be visible and stable before interacting, so
await page.getByRole('button', { name: /submit/i }).click() cannot click before the button renders — eliminating the entire class of waitForTimeout flakes without any developer discipline required.
@axe-core/playwright runs axe in the real browser DOM after navigation, catching contrast, missing labels, and ARIA errors that no amount of component-level testing can surface.
Edge Cases & Pitfalls
Common Mistakes
- Querying by
data-testid everywhere: reserve it for elements with no accessible role or label. Every getByTestId is a missed chance to verify the accessible tree — and a hook that will break on DOM refactors.
- Snapshot tests without behavioral assertions: snapshots catch visual drift but don't verify the component works; add at least one interaction assertion alongside each snapshot so a failure points to something meaningful.
- Over-mocking with
vi.mock: mocking every collaborator proves nothing about the integration; prefer MSW for network boundaries and the real module for everything internal to the feature.
- Calling
waitForTimeout in Playwright: replace every instance with a locator assertion (await expect(locator).toBeVisible()) — if the right locator doesn't exist, the timeout is masking a real problem, not solving it.
- Forgetting
afterEach(() => server.resetHandlers()): a test that overrides an MSW handler without resetting pollutes every subsequent test in the file.
- Running axe only on the static landing page: accessibility regressions most often appear on modal dialogs, error states, and post-submit confirmations — run axe after the interaction that surfaces each state.
Verification
npx vitest run
npx playwright test --headed
npx playwright show-report
References