Las pruebas funcionales de integración y extremo a extremo (E2E) analizan las aplicaciones mediante el árbol de accesibilidad (Accessibility Tree) y la estructura del DOM. Las aserciones del tipo expect(locator).toBeVisible() únicamente comprueban que el nodo exista en el documento, cuente con dimensiones no nulas y no esté oculto por display: none o visibility: hidden. Sin embargo, son incapaces de detectar problemas de degradación estética y estructural:
z-index e isolation: isolate);overflow: hidden;line-height) o desplazamientos de márgenes y rellenos (padding, margin).Las pruebas de regresión visual (Visual Regression Testing) resuelven esta limitación realizando una comparación píxel a píxel entre el estado visual renderizado actualmente y una captura de referencia previamente aprobada (Golden Master).
En los pipelines modernos, la herramienta principal es el motor nativo de capturas de Playwright, impulsado por el algoritmo optimizado de diferenciación pixelmatch y el acceso directo al búfer de fotogramas del navegador (Blink, Gecko, WebKit).
Las pruebas visuales transforman el árbol DOM y las reglas computadas de CSS en un mapa de bits ráster (Bitmap), comparando de forma determinista dos búferes de bytes en formato RGBA.
+-------------------------------------------------------------------------+
| Playwright Test Engine (Node.js Runner) |
+-------------------------------------------------------------------------+
|
v CDP / BiDi
+-------------------------------------------------------------------------+
| Headless Browser (Chromium / WebKit) |
| 1. Freeze DOM & Time (page.clock.setFixedTime) |
| 2. Disable Animations & Caret (CSS: * { animation: none !important }) |
| 3. Wait for Web Fonts (document.fonts.ready) |
| 4. Mask Dynamic Elements (mask: [locator]) -> Fill Rect #FF00FF |
| 5. Capture Framebuffer (Skia / CoreGraphics Rasterizer) |
+-------------------------------------------------------------------------+
|
v Búfer actual (actual.png)
+-------------------------------------------------------------------------+
| Motor de diferenciación Pixelmatch |
| |
| PNG Actual [RGBA] <--- Comparar ---> PNG Golden Master [RGBA] |
| | | |
| +-------------------+-------------------+ |
| | |
| v |
| Distancia de color Delta-E por píxel |
| ¿Distancia > threshold (0.2)? |
| / \ |
| [Sí] [No] |
| | | |
| Marcar rojo (#FF0000) Mantener gris |
| Incrementar contador diff Sin cambios |
| | |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| Validación de umbrales |
| |
| diffPixels / totalPixels <= maxDiffPixelRatio (ej. 0.001)? |
| / \ |
| [Sí] [No] |
| v v |
| PASS (Verde) FAIL (diff.png guardado en artefactos)|
+-------------------------------------------------------------------------+mask: Locator[].Button, Input, Select, Modal) en todos sus estados (hover, focus-visible, disabled, error) y temas (Claro, Oscuro, Alto Contraste).playwright.config.ts#Para prevenir fallos intermitentes (flakiness), la configuración global de Playwright maximiza la estabilidad del renderizado: inhabilita animaciones, oculta el cursor de texto y fija la densidad de píxeles (deviceScaleFactor: 1).
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
export default defineConfig({
testDir: './e2e/visual',
snapshotDir: './e2e/__snapshots__',
// Separación limpia de capturas por sistema operativo y plataforma
snapshotPathTemplate: '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}-{platform}{ext}',
timeout: 30_000,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [
['html', { open: 'never' }],
['list'],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
viewport: { width: 1280, height: 720 },
deviceScaleFactor: 1,
},
expect: {
toHaveScreenshot: {
// Sensibilidad perceptiva a diferencias de color (0..1)
threshold: 0.2,
// Proporción máxima permitida de píxeles modificados (0.1% del área)
maxDiffPixelRatio: 0.001,
// Desactivación forzada de animaciones CSS y Web Animations API
animations: 'disabled',
// Ocultar cursor de inserción de texto parpadeante
caret: 'hide',
// Escalar según píxeles CSS físicos
scale: 'css',
},
},
projects: [
{
name: 'chromium-desktop',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
name: 'chromium-mobile',
use: {
...devices['Pixel 7'],
},
},
],
});Las aplicaciones web reales contienen datos volátiles: marcas temporales actuales, temporizadores regresivos, IDs de transacción y avatares cargados desde CDNs externas. Sin máscaras, estos elementos generan fallos visuales constantes.
import { test, expect } from '@playwright/test';
test.describe('Verificación visual del panel de facturación', () => {
test.beforeEach(async ({ page }) => {
// 1. Congelar el reloj del sistema del navegador para fechas deterministas
await page.clock.setFixedTime(new Date('2026-09-10T12:00:00.000Z'));
// 2. Mockear respuestas dinámicas de la API de balance y cuenta
await page.route('**/api/v1/billing/summary', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
balance: 14250.0,
currency: 'USD',
planName: 'Enterprise Scale',
renewalDate: '2026-10-10',
}),
});
});
await page.goto('/dashboard/billing');
// 3. Esperar a que las fuentes web terminen de cargarse antes del render
await page.evaluate(async () => {
await document.fonts.ready;
});
});
test('captura completa del panel con enmascaramiento de nodos dinámicos', async ({ page }) => {
// Selección de elementos inestables para superponer la máscara
const dynamicTimestamp = page.getByTestId('server-uptime-clock');
const userAvatar = page.getByTestId('header-user-avatar');
const externalBanner = page.locator('[data-sponsored-ad]');
await expect(page).toHaveScreenshot('billing-dashboard-full.png', {
fullPage: true,
mask: [dynamicTimestamp, userAvatar, externalBanner],
maskColor: '#FF00FF', // Color magenta visible para inspección en reportes
maxDiffPixelRatio: 0.0005,
});
});
test('captura aislada del diálogo modal de cambio de plan', async ({ page }) => {
const upgradeButton = page.getByRole('button', { name: 'Cambiar plan' });
await upgradeButton.click();
const modalWindow = page.getByRole('dialog', { name: 'Seleccionar plan de suscripción' });
await expect(modalWindow).toBeVisible();
// Captura restringida estrictamente al localizador del modal
await expect(modalWindow).toHaveScreenshot('modal-plan-selector.png', {
animations: 'disabled',
threshold: 0.15,
});
});
});El motivo principal de inestabilidad en suites visuales en equipos distribuidos son las diferencias entre motores de renderizado de fuentes del sistema operativo:
El mismo glifo de la fuente Inter a 16px en macOS y Ubuntu tendrá contornos ligeramente dispares en los píxeles adyacentes. Esta variación genera entre un 3% y un 7% de discrepancia, provocando que las capturas generadas en local fallen en CI en Linux.
[Estación local (macOS)] --> Renderizado mediante CoreText --> Golden Snapshot A
[Runner CI (Ubuntu Linux)] --> Renderizado mediante FreeType --> Actual Snapshot B
|
[Discrepancia pixelmatch]
Diff: 4.8% (Fail)La solución arquitectónica: estandarizar la generación de capturas en la imagen oficial de Docker de Playwright. Tanto la creación de las imágenes base como su validación se ejecutan dentro del mismo contenedor Linux.
# La etiqueta debe coincidir exactamente con @playwright/test en package.json
FROM mcr.microsoft.com/playwright:v1.49.0-noble
WORKDIR /app
# Instalar fuentes del sistema para una tipografía predecible
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-liberation \
fonts-noto-color-emoji \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Copiar manifiestos de dependencias
COPY package.json pnpm-lock.yaml ./
# Habilitar pnpm vía Corepack
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm install --frozen-lockfile
COPY . .
# Comando por defecto para ejecutar la suite visual
CMD ["pnpm", "exec", "playwright", "test", "e2e/visual"]services:
playwright-visual:
build:
context: .
dockerfile: Dockerfile.playwright
ipc: host # Evita el agotamiento de memoria en /dev/shm para Chromium
volumes:
- .:/app
- /app/node_modules
environment:
- CI=true
network_mode: host# Ejecutar las pruebas visuales en el entorno estandarizado de Docker
docker compose -f docker-compose.visual.yml run --rm playwright-visual
# Actualizar las capturas de referencia (Golden Master) tras un rediseño intencionado
docker compose -f docker-compose.visual.yml run --rm playwright-visual \
pnpm exec playwright test e2e/visual --update-snapshotsCrear las capturas en entornos locales de desarrollo con macOS y subirlas a Git provoca fallos automáticos en GitHub Actions (donde los runners ejecutan Ubuntu).
// ❌ Generado en macOS:
// e2e/__snapshots__/dashboard-chromium-darwin.png
// En CI en Linux Playwright busca:
// e2e/__snapshots__/dashboard-chromium-linux.png -> La prueba falla por discrepancias de fuentes
// ✅ Solución: Generar y actualizar capturas SIEMPRE dentro del contenedor Docker de Playwright:
// docker compose -f docker-compose.visual.yml run --rm playwright-visual pnpm exec playwright test --update-snapshotsLlamar a toHaveScreenshot() antes de que las fuentes web hayan finalizado su carga (evitando FOUT/FOIT) causa que el test capture la fuente de reserva del sistema, fallando aleatoriamente según la latencia de red.
// ❌ Inestable: navegación completada, pero la tipografía web puede no haberse aplicado
await page.goto('/catalog');
await expect(page).toHaveScreenshot('catalog.png');
// ✅ Robusto: esperar a la API Font Loading y estabilidad de red
await page.goto('/catalog', { waitUntil: 'networkidle' });
await page.evaluate(async () => {
await document.fonts.ready;
});
await expect(page).toHaveScreenshot('catalog.png');Aumentar las tolerancias de discrepancia para suprimir tests inestables destruye la utilidad de la prueba: regresiones reales (como la desaparición de un botón de compra o un icono desplazado) pasarán inadvertidas.
// ❌ Peligroso: tolera un 10% de distorsión en la interfaz sin emitir error
await expect(page).toHaveScreenshot({
maxDiffPixelRatio: 0.1,
threshold: 0.5,
});
// ✅ Correcto: umbral estricto combinado con enmascaramiento selectivo
await expect(page).toHaveScreenshot({
maxDiffPixelRatio: 0.001, // Máximo 0.1% de píxeles modificados
threshold: 0.2,
mask: [page.getByTestId('dynamic-live-ticker')],
});Preparar un Dockerfile estandarizado basado en mcr.microsoft.com/playwright con fuentes del sistema preinstaladas.
Congelar el reloj del sistema mediante page.clock, mockear endpoints de API y bloquear trackers externos.
Aguardar a document.fonts.ready, desactivar animaciones CSS por código y ocultar cursores de entrada.
Proporcionar los localizadores de relojes, avatares y métricas dinámicas en la propiedad mask para rellenarlos de color plano.
Comparar el fotograma capturado con el Golden Master vía pixelmatch. Si se supera maxDiffPixelRatio, generar diff.png como artefacto.