IKivan-kozenko -aqa
Try yourself as a QA tester
All topics·Advanced·13 / 24

Components (experimental)

Component testing mounts a React/Vue/Svelte component directly in a real browser and runs Playwright assertions against it — no JSDOM, real clicks, real layout. The key limitation: you can't pass live Node.js objects to the component, only plain data. I use it for testing complex UI components in isolation before wiring them into full e2e flows.

What component testing actually is

Standard Playwright tests open a full page in a browser. Component testing is different: I mount a single component (<OrderForm />) in isolation, pass it props, interact with it, and assert its output — without needing a running server or routing.

The component runs in a real browser (not JSDOM), so CSS, layout, hover states, and scroll behavior all work correctly. The test logic runs in Node.js, and Playwright bridges the two. This is different from unit testing with Vitest which uses JSDOM.

ts
// Типовий тест компонента — монтуємо OrderForm і перевіряємо кнопку Submit
test('submit button fires event', async ({ mount }) => {
  let submitted = false

  const component = await mount(
    <OrderForm onSubmit={() => { submitted = true }} />
  )

  // Всі Playwright локатори і assertions працюють на змонтованому компоненті
  await expect(component).toContainText('Submit')
  await component.getByRole('button', { name: 'Submit' }).click()
  expect(submitted).toBeTruthy()
})

Getting started

I install the framework-specific package (not @playwright/test). For React:

The install creates a few files: playwright-ct.config.ts, playwright/index.html (the HTML scaffold where components mount), and playwright/index.ts (where I add global styles and theme setup). Tests go in src/ alongside component files with .spec.tsx extension.

The index files look like this:

bash
# React
npm install --save-dev @playwright/experimental-ct-react

# Vue
npm install --save-dev @playwright/experimental-ct-vue

# Svelte
npm install --save-dev @playwright/experimental-ct-svelte
ts
// playwright/index.ts — глобальна ініціалізація для всіх тестів компонентів
import '../src/styles/global.css'
import { theme } from '../src/theme'

// beforeMount запускається перед кожним mount()
// afterMount — після
export const parameters = {
  // передати конфігурацію
}
bash
# Запустити компонентні тести
npm run test-ct

# Або напряму
npx playwright test --config=playwright-ct.config.ts

The Node/browser boundary — the key limitation

The most important thing to understand: the test runs in Node.js, the component runs in the browser. Serializable data (strings, numbers, plain objects, arrays) crosses the boundary fine. Non-serializable things (live objects, functions as callbacks that need to return complex data) cannot.

The workaround for complex props: create a test wrapper component that accepts simple props and internally converts them to the complex format the real component needs. This keeps the test boundary clean.

ts
// Не спрацює — Media це складний браузерний об'єкт
test('this will not work', async ({ mount }) => {
  const component = await mount(
    <ImagePicker onChange={(media: Media) => { /* Media - браузерний об'єкт */ }} />
  )
})

// Спрацює — обгортка передає лише рядок (ім'я файлу)
type ImagePickerForTestProps = {
  onMediaChange(fileName: string): void
}

function ImagePickerForTest({ onMediaChange }: ImagePickerForTestProps) {
  return <ImagePicker onChange={(media) => onMediaChange(media.name)} />
}

test('records selected file name', async ({ mount }) => {
  let selected = ''
  const component = await mount(
    <ImagePickerForTest onMediaChange={(name) => { selected = name }} />
  )
  await component.getByTestId('file-input').setInputFiles('logo.png')
  await expect.poll(() => selected).toBe('logo.png')
})

beforeMount hooks — router, store, theme

For components that need a router or Pinia store, I configure them in playwright/index.ts using beforeMount. I can pass per-test configuration from the mount() call via hooksConfig.

ts
// playwright/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { createPinia } from 'pinia'

export type HooksConfig = {
  routing?: boolean
  initialStore?: Record<string, unknown>
}

beforeMount(async ({ app, hooksConfig }) => {
  if (hooksConfig?.routing) {
    app.use(createRouter({ history: createWebHistory(), routes: [] }))
  }
  if (hooksConfig?.initialStore) {
    const pinia = createPinia()
    app.use(pinia)
    // ініціалізувати стор з тестовими даними
  }
})
ts
// Тест передає hooksConfig в mount()
test('renders with router', async ({ mount }) => {
  const component = await mount(OrdersPage, {
    hooksConfig: {
      routing: true,
      initialStore: { orders: [{ id: 1, name: 'Test order' }] }
    }
  })
  await expect(component.getByText('Test order')).toBeVisible()
})

Practical tips

Mount inside each test, not in `beforeEach`. It makes tests self-contained and easier to debug. When mount() is in beforeEach, I have to look at two places to understand what the test is working with.

Module mocks (`vi.mock()`, `jest.mock()`) don't affect the component. They run in Node.js but the component runs in the browser. To mock API calls, use router fixture or context.route() instead.

Don't access component instance or its methods. Test from the user's perspective — clicks and visibility checks. If a test breaks when seen from the user's angle, it's a real bug.