Testing 8 min read 1,117 views

React Testing in 2026: Unit, Integration and E2E Examples

Working examples for testing React apps in 2026 — Vitest and React Testing Library for unit and integration tests, Playwright and Cypress for end-to-end, plus CI sharding and the causes of flaky tests.

Software testing

Most React test suites fail for the same two reasons: they test implementation details that break on every refactor, and their end-to-end tests are flaky enough that people stop trusting them. Both are avoidable.

This guide is worked examples rather than theory. Versions used at the time of writing: Vitest 4.1, React Testing Library 16.3, Playwright 1.62 and Cypress 15.20.

The three layers, and what each is for

LayerWhat it testsToolsSpeedHow many
UnitOne function or hook in isolationVitest, JestMillisecondsMany
IntegrationA component with its real children and stateVitest + Testing LibraryFastMost of your suite
End-to-endA real browser against the running appPlaywright, CypressSecondsFew, critical paths only

The middle row is where the value is. Integration tests render real components with real state and assert on what a user would see, so they survive refactors and still catch genuine bugs. If you write only one kind of test, write these.

Setup: Vitest and Testing Library

npm i -D vitest @vitejs/plugin-react jsdom \
  @testing-library/react @testing-library/user-event @testing-library/jest-dom
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.ts',
  },
})
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'

Unit test example

Pure logic needs no rendering. Test the function, not the component that calls it.

// src/lib/cart.test.ts
import { describe, it, expect } from 'vitest'
import { cartTotal } from './cart'

describe('cartTotal', () => {
  it('sums price times quantity', () => {
    const items = [
      { price: 10.5, qty: 2 },
      { price: 3.25, qty: 4 },
    ]
    expect(cartTotal(items)).toBe(34)
  })

  it('returns 0 for an empty cart', () => {
    expect(cartTotal([])).toBe(0)
  })
})

Integration test example

Here is the rule that matters: query the way a user would. Use roles and labels, not test IDs or class names. A test that finds a button by its accessible name keeps working after a refactor — and fails if the button stops being reachable, which is a bug worth catching.

// src/components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { LoginForm } from './LoginForm'

describe('LoginForm', () => {
  it('submits the entered credentials', async () => {
    const user = userEvent.setup()
    const onSubmit = vi.fn()

    render(<LoginForm onSubmit={onSubmit} />)

    await user.type(screen.getByLabelText(/email/i), 'dev@example.com')
    await user.type(screen.getByLabelText(/password/i), 'hunter2')
    await user.click(screen.getByRole('button', { name: /sign in/i }))

    expect(onSubmit).toHaveBeenCalledWith({
      email: 'dev@example.com',
      password: 'hunter2',
    })
  })

  it('shows a validation error for a bad email', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={vi.fn()} />)

    await user.type(screen.getByLabelText(/email/i), 'not-an-email')
    await user.click(screen.getByRole('button', { name: /sign in/i }))

    expect(await screen.findByRole('alert')).toHaveTextContent(/valid email/i)
  })
})

Note findByRole in the second test. getBy throws immediately, findBy retries until the element appears. Using findBy for anything asynchronous removes most of the arbitrary waiting people add to tests.

Mocking the network with MSW

Do not mock fetch. Intercept at the network layer so the component runs its real data-fetching code.

// src/test/server.ts
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'

export const server = setupServer(
  http.get('/api/projects', () => {
    return HttpResponse.json([
      { id: 1, name: 'Apollo' },
      { id: 2, name: 'Borealis' },
    ])
  }),
)
// src/components/ProjectList.test.tsx
import { render, screen } from '@testing-library/react'
import { beforeAll, afterAll, afterEach, it, expect } from 'vitest'
import { server } from '../test/server'
import { ProjectList } from './ProjectList'

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

it('renders projects returned by the API', async () => {
  render(<ProjectList />)

  expect(await screen.findByText('Apollo')).toBeVisible()
  expect(screen.getByText('Borealis')).toBeVisible()
})

End-to-end example: Playwright

npm init playwright@latest
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
})
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test'

test('a user can sign in and complete checkout', async ({ page }) => {
  await page.goto('/login')

  await page.getByLabel('Email').fill('dev@example.com')
  await page.getByLabel('Password').fill('hunter2')
  await page.getByRole('button', { name: 'Sign in' }).click()

  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()

  await page.getByRole('link', { name: 'Cart' }).click()
  await page.getByRole('button', { name: 'Checkout' }).click()

  await expect(page.getByText('Order confirmed')).toBeVisible()
  await expect(page).toHaveURL(/\/orders\/\d+/)
})

Playwright waits for elements to be visible, stable and enabled before acting, so explicit sleeps are almost never needed. trace: 'on-first-retry' is the single most useful setting here: when a test fails in CI you get a recording of the run rather than a stack trace.

End-to-end example: Cypress

// cypress/e2e/checkout.cy.ts
describe('checkout', () => {
  it('lets a signed-in user complete an order', () => {
    cy.visit('/login')

    cy.findByLabelText(/email/i).type('dev@example.com')
    cy.findByLabelText(/password/i).type('hunter2')
    cy.findByRole('button', { name: /sign in/i }).click()

    cy.findByRole('heading', { name: /dashboard/i }).should('be.visible')

    cy.findByRole('link', { name: /cart/i }).click()
    cy.findByRole('button', { name: /checkout/i }).click()

    cy.contains('Order confirmed').should('be.visible')
    cy.url().should('match', /\/orders\/\d+/)
  })
})

Those findBy* commands come from @testing-library/cypress, which keeps your queries consistent with the integration tests above. Without it you end up with CSS selectors that break on every styling change.

Playwright or Cypress?

Playwright 1.62Cypress 15.20
BrowsersChromium, Firefox, WebKitChromium, Firefox, WebKit
ParallelismBuilt in, freeBuilt in; dashboard for orchestration
CI shardingOne flagVia Cypress Cloud or manual split
DebuggingTrace viewer after the runTime-travel runner, live
Multi-tab / multi-originSupportedLimited
Visual comparisonBuilt inPlugin
Learning curveMediumLow

Choose Playwright for speed, cross-browser coverage and CI scale — it is the stronger default in 2026. Choose Cypress if your team values the interactive time-travel runner, which is still the most pleasant local debugging experience available.

Do not run both. Two E2E suites means two sets of flaky tests and nobody owning either.

Running E2E tests in CI

# .github/workflows/e2e.yml
name: e2e
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test --shard=${{ matrix.shard }}/4
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report-${{ matrix.shard }}
          path: playwright-report/

Four shards turn a twelve-minute suite into roughly three. Uploading the report on failure matters more than it sounds — without it, a CI-only failure is nearly impossible to diagnose.

Why tests go flaky

  • Fixed sleeps. waitForTimeout(2000) is a guess. It is too long on a fast machine and too short on a loaded CI runner. Wait for a condition instead.
  • Shared state between tests. If test B only passes after test A has run, they are one test. Seed the state each test needs.
  • Real third-party calls. An external API in an E2E test means someone else's downtime fails your build. Stub it at the network layer.
  • Selectors tied to styling. .btn-primary-2 breaks on the next redesign. Roles and labels do not.
  • Time and randomness. Freeze the clock and seed random generators, or a test will fail at midnight or once every hundred runs.

What not to test

Do not assert on internal state, prop names or the number of times a hook rendered — that is testing React, not your application. Do not snapshot large component trees; those snapshots get regenerated without being read.

A useful check before writing any test: if this test fails, will it tell me something is broken for a user? If not, it is maintenance cost without a safety benefit.

Frequently asked questions

What is a good React E2E test example?

A single critical user journey run in a real browser — sign in, add to cart, check out — asserting only on what the user sees. The Playwright example above is a complete, runnable version.

Should I use Playwright or Cypress for React in 2026?

Playwright is the stronger default: faster, genuinely cross-browser, with free parallelism and one-flag CI sharding. Cypress remains excellent if your team values its interactive time-travel debugger. Pick one.

Is Vitest better than Jest for React?

For Vite projects, yes — it reuses your Vite config, starts faster and needs almost no setup. For an existing Jest suite that works, migration is optional; Jest 30 is still actively maintained.

How many E2E tests should I write?

Few. Cover the journeys that lose money or block users if broken — authentication, checkout, publishing. Everything else belongs in integration tests, which are faster and far less flaky.

Why do my React tests break on every refactor?

They are querying implementation details — test IDs, class names, component internals. Query by role and label instead, and tests survive refactors while still catching real breakage.

How do I test components that fetch data?

Intercept at the network layer with MSW rather than mocking fetch. The component then runs its real data-fetching code, so you test what actually ships.

How do I stop flaky end-to-end tests?

Remove fixed sleeps, isolate state between tests, stub third-party calls, use role-based selectors, and freeze time and randomness. Those five cover the large majority of flakiness.

What test coverage percentage should I target?

No specific number. Coverage shows which lines ran, not whether behaviour is correct. A suite at 60% covering real user journeys beats one at 95% asserting on internals.

Share this article:
ES
Written by

Edrees Salih

Full-stack software engineer with 9 years of experience. Passionate about building scalable solutions and sharing knowledge with the developer community.

View Profile

Comments (0)

Leave a Comment

Your email will not be published.

No comments yet. Be the first to share your thoughts!

Related Articles

Related Articles

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.