En los flujos tradicionales de desarrollo frontend, Storybook se percibió históricamente como un catálogo aislado de componentes (un Living Styleguide) y un entorno visual interactivo para maquetación. Las pruebas de comportamiento de la interfaz se duplicaban en archivos unitarios independientes con React Testing Library y Jest o Vitest, lo que implicaba costes continuos de sincronización: los desarrolladores debían redeclarar una y otra vez datos simulados, proveedores de temas y estructuras auxiliares de prueba.
A partir de la versión 8+, Storybook evolucionó hacia un entorno integral de pruebas de componentes. Su arquitectura moderna articula tres capas esenciales:
args) y los escenarios interactivos en una única especificación centralizada.@storybook/test: Una API unificada que combina el motor de aserciones de Vitest con @testing-library/user-event para la simulación de entradas de usuario.@storybook/test-runner: Una herramienta de CLI impulsada por Playwright que transforma las historias publicadas en una suite automatizada ejecutable en navegadores reales sin interfaz.Esta arquitectura unificada permite automatizar aserciones funcionales, flujos de interacción e incorporar auditorías continuas de accesibilidad (a11y) conformes con los estándares WCAG mediante el motor integrado axe-core.
Las pruebas de interacción en Storybook 8+ se estructuran aislando el contenedor raíz de la historia (canvasElement) y despachando secuencias de eventos de usuario dentro del marco embebido del navegador.
+-------------------------------------------------------------------------+
| Declaración de historia CSF3 en Storybook 8+ |
| meta = { component: TransferModal, decorators: [WithThemeProvider] } |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| Historia: ConFunciónPlay |
| |
| play: async ({ canvasElement, step }) => { |
| const canvas = within(canvasElement); |
| await step('Introducir datos de transferencia', async () => { ... });|
| await step('Confirmar envío de fondos', async () => { ... }); |
| } |
+-------------------------------------------------------------------------+
|
+------------------+------------------+
| |
v (UI Interactiva) v (Automatización CI)
+------------------------------------+ +----------------------------------+
| Storybook Browser UI | | @storybook/test-runner |
| - Pestaña de interacciones | | - Orquestación con Playwright |
| - Depurador por línea de tiempo | | - Headless Chromium / Firefox |
| - Indicadores visuales de roles | | - Hooks preVisit / postVisit |
+------------------------------------+ +----------------------------------+
|
v
+----------------------------------+
| Motor a11y de axe-core |
| - Validación WCAG 2.1 AA/AAA |
| - Contraste de color y ARIA |
+----------------------------------+
|
v
+----------------------------------+
| Aprobado / Fallo en consola CI |
+----------------------------------+500 Internal Server Error o listas vacías sin necesidad de levantar servicios de backend.aria-label o botones con semántica incorrecta antes de fusionar el código.TransferForm.tsx#Componente de React que gestiona el estado del formulario, la validación de datos y el envío asíncrono:
import React, { useState } from 'react';
interface TransferFormProps {
onSuccess: (amount: number, recipient: string) => void;
maxDailyLimit?: number;
}
export function TransferForm({ onSuccess, maxDailyLimit = 50_000 }: TransferFormProps) {
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
const parsedAmount = parseFloat(amount);
if (!recipient.trim()) {
setError('Se requiere el número de cuenta de destino');
return;
}
if (isNaN(parsedAmount) || parsedAmount <= 0) {
setError('El importe de la transferencia debe ser superior a cero');
return;
}
if (parsedAmount > maxDailyLimit) {
setError(`Límite diario superado: ${maxDailyLimit.toLocaleString()} €`);
return;
}
setIsSubmitting(true);
// Simular latencia de red en la transacción
await new Promise((resolve) => setTimeout(resolve, 300));
setIsSubmitting(false);
onSuccess(parsedAmount, recipient);
};
return (
<form
onSubmit={handleSubmit}
aria-label="Formulario de transferencia monetaria"
className="p-6 bg-slate-900 border border-slate-800 rounded-xl max-w-md w-full space-y-4"
>
<h2 className="text-lg font-bold text-white">Transferencia de fondos</h2>
<div>
<label htmlFor="recipient-input" className="block text-sm font-medium text-slate-300 mb-1">
Número de cuenta de destino
</label>
<input
id="recipient-input"
type="text"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder="ACC-489201"
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-violet-500"
/>
</div>
<div>
<label htmlFor="amount-input" className="block text-sm font-medium text-slate-300 mb-1">
Importe a transferir (€)
</label>
<input
id="amount-input"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="5000"
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-violet-500"
/>
</div>
{error && (
<div role="alert" className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2.5 px-4 bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 text-white font-medium rounded-lg transition-colors cursor-pointer"
>
{isSubmitting ? 'Procesando...' : 'Confirmar transferencia'}
</button>
</form>
);
}play() y step()#Mediante @storybook/test, las historias simulan interacciones de usuario estructuradas. El método step() segmenta la lógica en fases visibles en la línea de tiempo del panel de interacciones:
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect, fn } from '@storybook/test';
import { TransferForm } from './TransferForm';
const meta: Meta<typeof TransferForm> = {
title: 'Features/Payments/TransferForm',
component: TransferForm,
parameters: {
layout: 'centered',
},
args: {
onSuccess: fn(),
maxDailyLimit: 20_000,
},
};
export default meta;
type Story = StoryObj<typeof TransferForm>;
// Estado base sin interactuar
export const DefaultState: Story = {};
// Escenario interactivo de transferencia completada con éxito
export const SuccessfulTransfer: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const recipientInput = canvas.getByLabelText('Número de cuenta de destino');
const amountInput = canvas.getByLabelText('Importe a transferir (€)');
const submitButton = canvas.getByRole('button', { name: 'Confirmar transferencia' });
await step('Paso 1: Introducir identificador de cuenta de destino', async () => {
await userEvent.type(recipientInput, 'ACC-998877', { delay: 40 });
await expect(recipientInput).toHaveValue('ACC-998877');
});
await step('Paso 2: Escribir importe permitido', async () => {
await userEvent.type(amountInput, '12500', { delay: 40 });
await expect(amountInput).toHaveValue(12500);
});
await step('Paso 3: Enviar formulario y verificar callback', async () => {
await userEvent.click(submitButton);
// Esperar resolución y verificar payload en onSuccess
await expect(meta.args?.onSuccess).toHaveBeenCalledWith(12500, 'ACC-998877');
});
},
};
// Escenario de validación por límite diario superado
export const ExceedsLimitError: Story = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const recipientInput = canvas.getByLabelText('Número de cuenta de destino');
const amountInput = canvas.getByLabelText('Importe a transferir (€)');
const submitButton = canvas.getByRole('button', { name: 'Confirmar transferencia' });
await step('Introducir importe que supera el umbral diario de 20 000 €', async () => {
await userEvent.type(recipientInput, 'ACC-112233');
await userEvent.type(amountInput, '75000');
await userEvent.click(submitButton);
});
await step('Comprobar visibilidad de la alerta de validación', async () => {
const alert = await canvas.findByRole('alert');
await expect(alert).toHaveTextContent('Límite diario superado: 20.000 €');
await expect(meta.args?.onSuccess).not.toHaveBeenCalled();
});
},
};@storybook/test-runner y axe-core#@storybook/test-runner se ejecuta como un proceso independiente de CLI que analiza las historias precompiladas a través de Playwright.
El archivo .storybook/test-runner.ts utiliza los hooks preVisit y postVisit para inyectar auditorías automáticas de accesibilidad con axe-core:
import type { TestRunnerConfig } from '@storybook/test-runner';
import { injectAxe, checkA11y, configureAxe } from 'axe-playwright';
const config: TestRunnerConfig = {
// Ejecutado antes de que la historia cargue en el navegador headless
async preVisit(page) {
// Inyectar el motor axe-core en el contexto de la página
await injectAxe(page);
},
// Ejecutado tras finalizar la función play()
async postVisit(page, context) {
// Configurar reglas estrictas WCAG 2.1 AA
await configureAxe(page, {
rules: [
// Prohibir textos con contraste deficiente
{ id: 'color-contrast', enabled: true },
// Exigir nombres accesibles en botones interactivos
{ id: 'button-name', enabled: true },
{ id: 'aria-valid-attr', enabled: true },
],
});
// Ejecutar auditoría sobre el nodo raíz de la historia
await checkA11y(page, '#storybook-root', {
detailedReport: true,
detailedReportOptions: {
html: true,
},
axeOptions: {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
},
},
});
},
};
export default config;{
"scripts": {
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build -o storybook-static",
"test:storybook": "test-runner --url http://localhost:6006",
"test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"npx http-server storybook-static -p 6006 -s\" \"wait-on tcp:127.0.0.1:6006 && test-runner\""
}
}Storybook 8 incorpora el concepto de Portable Stories mediante composeStories(), lo que permite importar historias directamente en suites unitarias y de integración sin redeclarar datos simulados ni envolturas decoradoras:
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { composeStories } from '@storybook/react';
import * as stories from './TransferForm.stories';
// Convertir definiciones CSF3 en componentes React ejecutables
const { SuccessfulTransfer, ExceedsLimitError } = composeStories(stories);
describe('Pruebas de integración de TransferForm con Portable Stories', () => {
it('ejecuta la función play dentro del entorno virtual de Vitest', async () => {
const onSuccessMock = vi.fn();
const { container } = render(<SuccessfulTransfer onSuccess={onSuccessMock} />);
// Ejecutar la función play compuesta dentro del DOM virtual
await SuccessfulTransfer.play({ canvasElement: container });
expect(onSuccessMock).toHaveBeenCalledTimes(1);
expect(onSuccessMock).toHaveBeenCalledWith(12500, 'ACC-998877');
});
it('muestra el mensaje de error cuando se supera el límite', async () => {
const { container } = render(<ExceedsLimitError />);
await ExceedsLimitError.play({ canvasElement: container });
const alertElement = await screen.findByRole('alert');
expect(alertElement).toBeInTheDocument();
});
});El uso de métodos globales como screen.getByRole() dentro de play() rompe el aislamiento: las consultas pueden coincidir involuntariamente con la barra de herramientas de Storybook, la barra lateral o elementos de decoradores externos.
// ❌ INCORRECTO: Consulta el documento global del navegador
play: async () => {
const btn = screen.getByRole('button', { name: 'Guardar' });
await userEvent.click(btn);
}
// ✅ CORRECTO: Delimita la búsqueda estrictamente al canvasElement de la historia
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const btn = canvas.getByRole('button', { name: 'Guardar' });
await userEvent.click(btn);
}Todos los métodos de simulación en @storybook/test devuelven promesas. Omitir await provoca que los eventos se despachen fuera de secuencia después de la fase de desmontaje, generando condiciones de carrera e inconsistencias.
// ❌ INCORRECTO: Despacho síncrono sin esperar la promesa
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
userEvent.click(canvas.getByRole('button')); // ¡Falta await!
expect(canvas.getByText('Éxito')).toBeInTheDocument(); // Fallará: el DOM aún no se ha actualizado
}
// ✅ CORRECTO: Esperar secuencialmente todas las interacciones asíncronas
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
await expect(await canvas.findByText('Éxito')).toBeInTheDocument();
}Definir manualmente fixtures paralelas en archivos de test introduce divergencias de mantenimiento. Al cambiar la interfaz del componente, los tests fallan mientras las historias quedan desactualizadas.
Solución: Utilizar composeStories() para importar las definiciones de las historias directamente en los tests de Vitest.
Definir estados base, límite y de error del componente en un archivo CSF 3 mediante propiedades args.
Describir las acciones del usuario con within(canvasElement), userEvent y bloques estructurados step().
Inspeccionar la línea de tiempo de interacciones y revisar la accesibilidad en la pestaña a11y.
Configurar .storybook/test-runner.ts con checkA11y en el hook postVisit para auditorías WCAG automáticas.
Generar la compilación estática de Storybook y ejecutar test-runner de forma paralela en CI.