Skip to main content

Testing React, Vue, and Angular Apps with Playwright

April 23, 2026

</>

Playwright works brilliantly with any modern JavaScript framework — but each framework has its own quirks that affect how you write and structure tests. React's async rendering, Vue's reactivity system, and Angular's change detection all behave differently under test conditions.

Here's a practical guide to testing all three effectively.

The Foundation: What All Three Share

Before getting into framework-specific advice, the fundamentals are the same across React, Vue, and Angular.

Use user-facing locators. Don't select by CSS class or internal implementation details. Select by role, label, or test ID:

// Good
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Email address')
page.getByTestId('checkout-form')

// Avoid
page.locator('.btn-primary')
page.locator('#app > div > form > button:nth-child(3)')

Trust auto-waiting. Playwright waits for elements automatically. Don't add waitForTimeout() calls — they make tests slow and mask real problems.

Test user flows, not implementation. A test that clicks through a checkout flow is more valuable than a test that checks whether a specific function was called.

Testing React Apps

React's main challenge for testers is async state updates and concurrent rendering.

Waiting for State Updates

React 18+ uses concurrent rendering, which means state updates can be batched and applied asynchronously. Playwright's auto-waiting handles most cases, but for complex state flows, use waitFor:

await expect(page.getByText('Order confirmed')).toBeVisible()

This waits until the element appears, retrying until it does or the timeout is hit. Much better than a fixed sleep.

Testing React Router

For apps using React Router, test that navigation updates the URL and renders the correct component:

await page.getByRole('link', { name: 'About' }).click()
await expect(page).toHaveURL('/about')
await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible()

Testing Context and State

If your app has global state (Redux, Zustand, Context), test the user-visible outcome of state changes rather than the state itself. Log in as a user, perform an action, and assert what the UI shows — not what's in the store.

Server Components (Next.js)

For Next.js apps with React Server Components, test the rendered output just like any other HTML. RSC is transparent to Playwright — it sees the final DOM regardless of whether it was rendered on the server or client.

Testing Vue Apps

Vue's reactivity system is generally easier to test than React because updates are synchronous by default in Vue 3. But there are specific patterns to watch for.

Waiting for Vue's Nextick

In some cases, particularly with v-if and v-show toggling, you may need to wait for Vue to finish updating the DOM. Playwright's auto-waiting handles this in most cases, but for complex transitions:

await page.getByRole('button', { name: 'Toggle' }).click()
await expect(page.getByRole('dialog')).toBeVisible()

Testing Vue Router

Same approach as React Router — test URL changes and rendered content:

await page.getByRole('link', { name: 'Products' }).click()
await expect(page).toHaveURL('/products')

Nuxt-Specific Testing

For Nuxt apps, pay attention to hydration. The page may render server-side HTML first, then hydrate on the client. Test interactions after hydration by waiting for an interactive element to be ready before clicking:

const button = page.getByRole('button', { name: 'Add to cart' })
await expect(button).toBeEnabled()
await button.click()

Testing Angular Apps

Angular has the most complex testing surface of the three due to its dependency injection system, zones, and reactive forms. But for end-to-end testing with Playwright, most of that complexity is invisible — you're testing the rendered DOM.

Zone.js and Async Operations

Angular uses Zone.js to track async operations. This means Angular knows when all pending operations have completed. Playwright doesn't know about Zone.js, but its auto-waiting still works because it waits for elements to appear and stabilise in the DOM.

For apps that use async/await with Angular's HttpClient, wait for the network response to complete:

const responsePromise = page.waitForResponse('**/api/products')
await page.getByRole('button', { name: 'Load Products' }).click()
await responsePromise
await expect(page.getByRole('list')).toBeVisible()

Testing Reactive Forms

Angular's reactive forms are a common source of bugs. Test the validation behaviour:

// Submit without filling required fields
await page.getByRole('button', { name: 'Submit' }).click()
await expect(page.getByText('Email is required')).toBeVisible()

// Fill with invalid email
await page.getByLabel('Email').fill('notanemail')
await page.getByRole('button', { name: 'Submit' }).click()
await expect(page.getByText('Invalid email format')).toBeVisible()

Angular Router Guards

Test that route guards work correctly — that unauthenticated users are redirected and authenticated users can access protected routes:

// Unauthenticated - should redirect to login
await page.goto('/dashboard')
await expect(page).toHaveURL('/login')

// After login - should reach dashboard
await page.getByLabel('Email').fill('user@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Login' }).click()
await expect(page).toHaveURL('/dashboard')

Running Tests in CI

All three frameworks work identically in CI with Playwright. The key configuration:

// playwright.config.ts
export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    headless: true,
  },
  webServer: {
    command: 'npm run build && npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

This starts your app, waits for it to be ready, runs all tests, then tears everything down. It works with React (Next.js), Vue (Nuxt), and Angular out of the box.

The Bottom Line

Playwright treats all three frameworks the same — it sees the final DOM and interacts with it like a user would. The framework-specific knowledge mostly comes into play when debugging why a test fails. Understanding how React batches updates, how Vue's reactivity triggers re-renders, and how Angular's change detection works helps you write better assertions and diagnose flaky tests faster.

Start with the user-facing locators, trust the auto-waiting, and test full user flows rather than implementation details. That approach works regardless of which framework your team chose.

Recommended Posts