Los entornos de DOM virtual (jsdom y happy-dom) son excelentes para verificar con rapidez la lógica de negocio y las estructuras jerárquicas de componentes. Sin embargo, son estructuralmente incapaces de validar la capa visual de la interfaz, ya que carecen de un motor gráfico de renderizado:
getBoundingClientRect() devuelven ceros o estimaciones rudimentarias en lugar de píxeles físicos reales de pantalla.@container ni media queries de esquemas de color.:hover, :active, :focus-visible ni transiciones aceleradas por hardware.El patrón Playwright Component Testing (CT) cierra esta brecha: el componente se monta directamente en una instancia de navegador real, con acceso directo a la pila gráfica del sistema operativo y a la GPU.
Esto permite automatizar pruebas de captura visual píxel a píxel (Visual Regression Testing), garantizando una interfaz libre de defectos visuales en todos los dispositivos.
A diferencia de las pruebas E2E convencionales, donde el navegador carga una aplicación completa con backend (page.goto('/dashboard')), en Playwright CT un servidor Vite ultraligero compila y monta un componente React aislado en un host HTML limpio en tan solo 20–50 ms:
[Runner de pruebas Playwright Node.js]
│
▼ (Comando mount(<Button variant="primary" />))
[Servidor Dev Local Vite CT]
│ ──► Compila un host HTML mínimo (playwright/index.html)
│ ──► Inyecta estilos Tailwind CSS v4 y tokens de tema
▼
[Proceso de navegador real: Chromium / Firefox / WebKit]
│
├──► Cálculo de geometría exacta (BoundingBox, z-index)
├──► Rasterizado acelerado por hardware GPU
├──► Comprobación de estados hover y transiciones CSS
│
▼ (Comparación del buffer de fotogramas)
[Visual Regression Engine: toHaveScreenshot()]
│
└──► Diffing píxel a píxel contra el Golden MasterInstala el paquete del runner y descarga los binarios de navegadores con pnpm exec playwright install.
Configura playwright/index.html y playwright/index.ts, conectando Tailwind CSS v4 y los temas de la aplicación.
Importa test y expect de Playwright CT, monta el componente mediante mount() y declara las interacciones.
Ejecuta pnpm exec playwright test -u para crear las capturas de referencia en la carpeta __snapshots__.
Ejecuta la suite en la imagen oficial de Docker de Playwright para neutralizar diferencias de renderizado de fuentes.
¿Por qué es imposible verificar la precisión de maquetación visual (CSS Grid, apilamiento con z-index y desbordamiento de viewport) utilizando Vitest y jsdom?
position: sticky y filtros SVG complejos en Safari (WebKit) y Chrome (Blink).focus-visible: ring-2 ring-accent-cyan) al navegar mediante la tecla Tab.Compilación de JSX, inyección de tokens de Tailwind v4 y montaje en el host HTML de prueba.
Chromium/WebKit arranca con valores fijos de densidad de píxeles (devicePixelRatio) y métricas de fuentes.
Despacho de hover físico (.hover()), tecleado de texto o navegación por teclado.
Captura de fotograma del elemento y comparación píxel a píxel con enmascaramiento dinámico.
El archivo playwright-ct.config.ts define los motores de navegación, ubicaciones de capturas y la configuración del servidor Vite:
import { defineConfig, devices } from '@playwright/experimental-ct-react';
import path from 'path';
export default defineConfig({
testDir: './src',
testMatch: /.*\.ct\.(ts|tsx)$/,
snapshotDir: './__snapshots__',
timeout: 15 * 1000,
fullyParallel: true,
use: {
trace: 'on-first-retry',
ctPort: 3100,
ctViteConfig: {
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
},
],
});// Conectar estilos globales de Tailwind CSS v4
import '@/app/globals.css';
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
// Hook previo al montaje de cada componente (fija el tema oscuro)
beforeMount(async ({ App }) => {
document.documentElement.classList.add('dark');
return <App />;
});El matcher toHaveScreenshot() captura el buffer visual del componente y efectúa la comparación. Para evitar fallos indeseados por el suavizado subpíxel de fuentes en distintos sistemas operativos, se definen umbrales de tolerancia:
await expect(component).toHaveScreenshot('button-primary-hover.png', {
maxDiffPixelRatio: 0.01, // Permitir hasta un 1% de píxeles diferentes (absorbe antialiasing)
threshold: 0.2, // Distancia de color permitida por píxel
animations: 'disabled', // Detener animaciones CSS para asegurar capturas deterministas
});Una prueba toHaveScreenshot() pasa con éxito en el macOS de un desarrollador, pero en GitHub Actions sobre Linux falla constantemente por un 0.8% de desajuste en el contorno del texto. ¿Cuál es el origen y cómo se soluciona?
A continuación se presenta una prueba de producción con Playwright CT para una tarjeta de perfil (UserProfileCard.ct.tsx), que evalúa estilos de Tailwind v4, estados hover, reflow móvil y diff visual con máscaras:
import React from 'react';
import { test, expect } from '@playwright/experimental-ct-react';
import { UserProfileCard, type UserProfileData } from '../UserProfileCard';
const MOCK_USER: UserProfileData = {
id: 'usr-9812',
name: 'Alejandro Arquitecto',
role: 'Principal Staff Engineer',
avatarUrl: '/avatars/sample.webp',
reputation: 9840,
joinedDate: '2026-03-15T12:00:00Z',
isVerified: true,
};
test.describe('<UserProfileCard /> Visual & Interaction CT', () => {
test('coincide con la referencia visual aprobada en vista de escritorio', async ({ mount }) => {
const component = await mount(
<div className="p-8 bg-[#0B0B11]">
<UserProfileCard user={MOCK_USER} />
</div>
);
// Enmascarar imagen de avatar para evitar falsos positivos por carga de red
await expect(component).toHaveScreenshot('user-card-desktop-idle.png', {
mask: [component.locator('[data-testid="user-avatar"]')],
maxDiffPixelRatio: 0.01,
animations: 'disabled',
});
});
test('renderiza correctamente los efectos hover de Tailwind y mutaciones activas', async ({ mount }) => {
const component = await mount(
<div className="p-8 bg-[#0B0B11]">
<UserProfileCard user={MOCK_USER} />
</div>
);
const followButton = component.getByRole('button', { name: /seguir/i });
// Emular desplazamiento físico del cursor sobre el botón
await followButton.hover();
// Comprobar estilo computado y cursor
await expect(followButton).toHaveCSS('cursor', 'pointer');
// Capturar snapshot del botón en estado hover
await expect(followButton).toHaveScreenshot('user-card-follow-button-hover.png');
// Hacer clic para validar la mutación de estado
await followButton.click();
await expect(component.getByRole('button', { name: /siguiendo/i })).toBeVisible();
});
test('se adapta al viewport móvil (375x667) sin desbordamiento horizontal', async ({
mount,
page,
}) => {
// Definir dimensiones de pantalla móvil vertical
await page.setViewportSize({ width: 375, height: 667 });
const component = await mount(
<div className="p-4 bg-[#0B0B11]">
<UserProfileCard user={MOCK_USER} />
</div>
);
// Verificar que los límites no excedan el ancho de la pantalla móvil
const boundingBox = await component.boundingBox();
expect(boundingBox).not.toBeNull();
if (boundingBox) {
expect(boundingBox.width).toBeLessThanOrEqual(375);
}
// Capturar snapshot móvil responsivo
await expect(component).toHaveScreenshot('user-card-mobile-layout.png', {
mask: [component.locator('[data-testid="user-avatar"]')],
maxDiffPixelRatio: 0.02,
});
});
});Chromium emplea diferentes motores de suavizado subpíxel en macOS (CoreText) frente a Linux (FreeType). Ejecutar toHaveScreenshot() con tolerancia 0% romperá las builds de CI cuando los desarrolladores trabajen en macOS.
// ❌ Antipatrón: 0% de tolerancia (fallará en los agentes Linux de CI)
await expect(component).toHaveScreenshot('modal.png');
// ✅ Solución correcta: configurar umbrales realistas de tolerancia
await expect(component).toHaveScreenshot('modal.png', {
maxDiffPixelRatio: 0.02, // 2% de margen absorbe divergencias de renderizado de texto
threshold: 0.2,
});Realizar capturas visuales mientras los componentes ejecutan animaciones infinitas (animate-pulse o spinners animate-spin) genera fotogramas en momentos arbitrarios, causando fallos constantes.
// ❌ Antipatrón: captura de un spinner animado en movimiento
await expect(component).toHaveScreenshot();
// ✅ Solución correcta: congelar animaciones o enmascarar los nodos dinámicos
await expect(component).toHaveScreenshot({
animations: 'disabled',
mask: [component.locator('.animate-pulse')],
});Arrancar contextos reales de navegador requiere entre 20 y 60 ms por prueba. Trasladar 1.000 pruebas unitarias puras de lógica a Playwright CT infla la ejecución de CI de 10 segundos a más de 25 minutos. Solución: Emplear Vitest para el 80% de pruebas unitarias y de estado, reservando Playwright CT para tokens de diseño, cuadrículas CSS complejas y regresión visual.