El paquete @playwright/experimental-ct-react monta componentes React individuales en un navegador real usando un fixture mount(): puedes testear el comportamiento de la tecla Escape en un dropdown o el estado de error de un formulario sin levantar toda la aplicación. Usa los mismos locators y aserciones que tus tests de Playwright normales, configurados mediante un playwright-ct.config.ts separado. Esta guía cubre la configuración, el testeo de props y event handlers, la re-renderización con props actualizadas en medio de un test, la regresión visual a nivel de componente, y cuándo los tests de componentes reemplazan a los E2E y cuándo no.
Cuándo tiene sentido el testing de componentes
Usa tests de componentes para
- Componentes de UI con lógica de interacción compleja (dropdowns, modals, date pickers)
- Componentes difíciles de alcanzar vía E2E (estados límite, estados de error)
- Componentes de design system que necesitan verificación de consistencia visual
- Testear event handling y props sin la aplicación completa
No reemplaces los tests E2E con tests de componentes
Los tests de componentes no cubren routing, autenticación, integración con API, ni la interacción entre componentes. Necesitas ambos.
Configuración
npm init playwright@latest -- --ct
# O agregar a un proyecto existente:
npm install --save-dev @playwright/experimental-ct-reactEsto crea un playwright-ct.config.ts separado de tu configuración de Playwright normal:
// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';
export default defineConfig({
testDir: './src',
testMatch: '**/*.ct.spec.{ts,tsx}',
use: {
ctPort: 3100, // Puerto para el servidor de tests de componentes
viewport: { width: 1280, height: 720 },
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
});Tu primer test de componente
// components/Button.tsx
interface ButtonProps {
label: string;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
onClick?: () => void;
}
export function Button({ label, variant = 'primary', disabled, onClick }: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
disabled={disabled}
onClick={onClick}
data-testid="button"
>
{label}
</button>
);
}// components/Button.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';
test('renderiza con el label correcto', async ({ mount }) => {
const component = await mount(<Button label="Hacer clic" />);
await expect(component).toContainText('Hacer clic');
});
test('aplica la clase de variante', async ({ mount }) => {
const component = await mount(<Button label="Peligro" variant="danger" />);
await expect(component).toHaveClass(/btn-danger/);
});
test('el botón deshabilitado no puede clickearse', async ({ mount }) => {
const component = await mount(<Button label="Deshabilitado" disabled />);
await expect(component.getByTestId('button')).toBeDisabled();
});
test('llama a onClick cuando se hace clic', async ({ mount }) => {
let clicked = false;
const component = await mount(
<Button
label="Hacer clic"
onClick={() => { clicked = true; }}
/>
);
await component.click();
expect(clicked).toBe(true);
});Ejecuta los tests de componentes:
npx playwright test --config playwright-ct.config.tsTestear un componente de formulario
// components/LoginForm.tsx
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
error?: string;
}
export function LoginForm({ onSubmit, error }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
return (
<form onSubmit={(e) => {
e.preventDefault();
onSubmit(email, password);
}}>
<input
data-testid="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Email"
/>
<input
data-testid="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Contraseña"
/>
{error && <p data-testid="error">{error}</p>}
<button type="submit" data-testid="submit">Iniciar sesión</button>
</form>
);
}// components/LoginForm.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { LoginForm } from './LoginForm';
test('envía con credenciales correctas', async ({ mount }) => {
let emailEnviado = '';
let passwordEnviado = '';
const component = await mount(
<LoginForm
onSubmit={(email, password) => {
emailEnviado = email;
passwordEnviado = password;
}}
/>
);
await component.getByTestId('email').fill('usuario@test.com');
await component.getByTestId('password').fill('ClaveValida1');
await component.getByTestId('submit').click();
expect(emailEnviado).toBe('usuario@test.com');
expect(passwordEnviado).toBe('ClaveValida1');
});
test('muestra el mensaje de error cuando se proporciona', async ({ mount }) => {
const component = await mount(
<LoginForm
onSubmit={() => {}}
error="Credenciales inválidas"
/>
);
await expect(component.getByTestId('error')).toHaveText('Credenciales inválidas');
});
test('no muestra error cuando error es undefined', async ({ mount }) => {
const component = await mount(
<LoginForm onSubmit={() => {}} />
);
await expect(component.getByTestId('error')).not.toBeVisible();
});Testear un componente dropdown
Los componentes complejos con estado son donde los tests de componentes brillan:
// components/Dropdown.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Dropdown } from './Dropdown';
const opciones = [
{ value: 'chrome', label: 'Chrome' },
{ value: 'firefox', label: 'Firefox' },
{ value: 'safari', label: 'Safari' },
];
test('el dropdown muestra opciones al hacer clic', async ({ mount }) => {
const component = await mount(
<Dropdown options={opciones} placeholder="Seleccionar navegador" />
);
// Inicialmente cerrado
await expect(component.getByTestId('dropdown-list')).not.toBeVisible();
// Clic para abrir
await component.getByTestId('dropdown-trigger').click();
// Ahora visible con todas las opciones
await expect(component.getByTestId('dropdown-list')).toBeVisible();
const items = component.getByTestId('dropdown-option');
await expect(items).toHaveCount(3);
await expect(items.nth(0)).toHaveText('Chrome');
});
test('se cierra después de seleccionar una opción', async ({ mount }) => {
let seleccionado = '';
const component = await mount(
<Dropdown
options={opciones}
placeholder="Seleccionar navegador"
onSelect={(value) => { seleccionado = value; }}
/>
);
await component.getByTestId('dropdown-trigger').click();
await component.getByText('Firefox').click();
expect(seleccionado).toBe('firefox');
await expect(component.getByTestId('dropdown-list')).not.toBeVisible();
await expect(component.getByTestId('dropdown-trigger')).toHaveText('Firefox');
});
test('se cierra con la tecla Escape', async ({ mount }) => {
const component = await mount(
<Dropdown options={opciones} placeholder="Seleccionar" />
);
await component.getByTestId('dropdown-trigger').click();
await expect(component.getByTestId('dropdown-list')).toBeVisible();
await component.press('Escape');
await expect(component.getByTestId('dropdown-list')).not.toBeVisible();
});Actualizar componentes en medio de un test
Puedes re-renderizar un componente con diferentes props:
test('el mensaje de error se actualiza cuando cambian las props', async ({ mount }) => {
const component = await mount(
<LoginForm onSubmit={() => {}} />
);
// Sin error al inicio
await expect(component.getByTestId('error')).not.toBeVisible();
// Actualizar props para agregar error
await component.update(
<LoginForm onSubmit={() => {}} error="Sesión expirada" />
);
await expect(component.getByTestId('error')).toHaveText('Sesión expirada');
// Limpiar el error
await component.update(
<LoginForm onSubmit={() => {}} />
);
await expect(component.getByTestId('error')).not.toBeVisible();
});Hooks en tests de componentes
Los tests de componentes de Playwright soportan los mismos hooks que los tests normales:
import { test, expect, beforeEach } from '@playwright/experimental-ct-react';
let contadorClicks = 0;
test.beforeEach(() => {
contadorClicks = 0;
});
test('cuenta los clics correctamente', async ({ mount }) => {
const component = await mount(
<Button label="Clic" onClick={() => { contadorClicks++; }} />
);
await component.click();
await component.click();
await component.click();
expect(contadorClicks).toBe(3);
});Testing visual con componentes
Los tests de componentes funcionan bien con regresión visual:
test('las variantes del botón se ven correctas', async ({ mount }) => {
for (const variant of ['primary', 'secondary', 'danger'] as const) {
const component = await mount(<Button label={variant} variant={variant} />);
await expect(component).toHaveScreenshot(`button-${variant}.png`);
}
});Esto es más confiable que los screenshots de página completa: hay menos piezas en movimiento y es más fácil aislar qué cambió.
Vue y Svelte
Los mismos conceptos aplican a Vue y Svelte:
# Vue
npm install --save-dev @playwright/experimental-ct-vue
# Svelte
npm install --save-dev @playwright/experimental-ct-svelte<!-- Test de Vue -->
<script setup>
import { test, expect } from '@playwright/experimental-ct-vue'
import MiComponente from './MiComponente.vue'
</script>Componente vs E2E: cuándo usar cada uno
| Escenario | Test de componente | Test E2E |
|-----------|-------------------|----------|
| Handler de clic en botón | ✅ | ❌ |
| Dropdown se abre/cierra | ✅ | ❌ |
| Mensajes de validación de formulario | ✅ | ❌ |
| Flujo de login de punta a punta | ❌ | ✅ |
| Navegación entre páginas | ❌ | ✅ |
| Integración con API | ❌ | ✅ |
| Regresión visual de componente | ✅ | A veces |
Resumen de comandos
# Configuración
npm install --save-dev @playwright/experimental-ct-react
# Config: playwright-ct.config.ts
# Tests: NombreComponente.ct.spec.tsx
# Ejecutar
npx playwright test --config playwright-ct.config.tsOperaciones clave:
mount(: renderiza un componente en un navegador real) component.getByTestId(...): locators estándar de Playwrightcomponent.click(): acciones estándar de Playwrightcomponent.update(: re-renderiza con nuevas props) expect(component).toHaveScreenshot(): regresión visual