Has montado la red de seguridad del Nivel 8 casi entera: la base de tipos y linter, los unitarios del motor, el testing de componentes y hooks, lo asíncrono con sus dobles, la red simulada con MSW, la integración de features, el smoke test e2e, la seguridad, la accesibilidad como test y la cobertura en CI. Todo eso protege el código. Este capítulo añade un peldaño: de proteger el código a escalar con el equipo. Cuando muchas manos tocan el mismo producto durante años, aparece un problema que ningún test de los anteriores resuelve: que la interfaz siga siendo coherente y que los componentes estén documentados para que nadie reinvente lo que ya existe. Esa es la doble herramienta de este capítulo: un sistema de diseño y Storybook.
Lo ves de concepto: Storybook arranca su propio servidor para mostrar los componentes aislados, así que no cabe en el playground de esta página. El código que verás es real y lo escribes en tu proyecto (está en el ejercicio); aquí lo importante es entender el porqué.
Por qué un sistema de diseño#
Imagina cinco personas trabajando en el Team Builder durante dos años. Cada una necesita un botón, una tarjeta, un color de acento. Sin nada que lo gobierne, acabas con cinco botones parecidos pero distintos, tres rosas “casi iguales” y cuatro espaciados a ojo. La interfaz empieza a parecer de varios productos a la vez. Ese es el problema que resuelve un sistema de diseño: una fuente única de verdad para cómo se ve y se comporta el producto.
Su primera capa son los design tokens: los valores de diseño guardados como variables con nombre, en un solo sitio. Ya los conoces del Nivel 1 (las custom properties de CSS); el cambio de mirada es tratarlos como el vocabulario compartido del equipo:
/* Los design tokens del Team Builder: los valores de diseño con nombre, en un solo sitio. */
:root {
/* El acento de la marca: el rosa de botones y enlaces. */
--color-acento: #b8336a;
/* La tinta del texto principal. */
--color-tinta: #1c1b22;
/* El espaciado base; los demás salen de multiplicarlo. */
--espaciado: 0.75rem;
/* El radio de las esquinas de tarjetas y botones. */
--radio: 0.75rem;
}Y cualquier componente los consume en vez de inventar sus propios valores:
/* El botón no decide su color: usa el token de la marca. */
.boton {
/* Si el acento de la marca cambia, cambia AQUÍ y en todo el producto a la vez. */
background: var(--color-acento);
/* Mismo espaciado y mismo radio que el resto de la interfaz. */
padding: var(--espaciado);
border-radius: var(--radio);
}¿Y qué? Que cambiar el rosa de la marca pasa de ser una cacería de #b8336a por treinta
ficheros (con los que se escapan) a editar un token. Y que lo coherente se vuelve lo
fácil: coges el token y el componente ya hechos en lugar de improvisar otro botón. Encima de
los tokens va la librería de componentes (el botón, la tarjeta, el campo de formulario, ya
construidos y consensuados), y encima la documentación de cómo se usan. La pregunta es: ¿dónde
viven y se documentan esos componentes? Ahí entra Storybook.
Storybook: el taller de componentes#
Storybook es un taller donde construyes, ves y documentas tus componentes aislados, fuera
de la app. En vez de arrancar el Team Builder entero y navegar hasta la pantalla donde aparece una
tarjeta de héroe, abres Storybook y ves la TarjetaHeroe sola, en todos los estados que
quieras, sin montar nada alrededor.
Se instala con un asistente que detecta tu stack (React + Vite, del Nivel 4):
# Detecta React + Vite, instala Storybook y crea la carpeta .storybook/ con la
# configuración y unas stories de ejemplo. Lo arrancas con: pnpm storybook
pnpm dlx storybook@latest initCada componente trae un fichero .stories.tsx al lado. Dentro escribes stories: cada story
es el componente en un estado concreto. Esta es la story más simple de la TarjetaHeroe:
// El fichero de stories va junto al componente: TarjetaHeroe.stories.tsx
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TarjetaHeroe } from "./TarjetaHeroe";
// El meta describe a QUÉ componente pertenecen las stories de este fichero.
// satisfies valida la forma sin perder el tipo exacto (Nivel 5): así cada story
// sabe qué args admite.
const meta = {
component: TarjetaHeroe,
// autodocs genera una página de documentación a partir de las stories y los tipos.
tags: ["autodocs"],
} satisfies Meta<typeof TarjetaHeroe>;
export default meta;
// El tipo de cada story, derivado del meta.
type Story = StoryObj<typeof meta>;
// Una story: el componente con unas props concretas. args son esas props.
export const Default: Story = {
args: {
heroe: { nombre: "Tracer", rol: "Daño", partidas: 200, victorias: 130 },
},
};Esto es CSF (Component Story Format): un export default con el meta (a qué componente
pertenece todo el fichero) y un export con nombre por cada story. Al abrir Storybook, ves
TarjetaHeroe → Default en la lista y la tarjeta renderizada sola a la derecha.
Args y controls: estados documentados#
Los args de una story son las props con las que se monta. Y como Storybook conoce esos args, te da los controls: un panel para tocarlos en vivo sin editar código —cambiar el nombre del héroe, sus victorias— y ver la tarjeta reaccionar. Pero el valor de verdad llega al escribir varias stories, una por estado que merezca documentarse:
// Un héroe de Daño con buen winrate: el caso más habitual.
export const Dano: Story = {
args: {
heroe: { nombre: "Tracer", rol: "Daño", partidas: 200, victorias: 130 },
},
};
// Un Apoyo con winrate bajo: el caso opuesto. Documentar solo el feliz miente.
export const ApoyoBajoWinrate: Story = {
args: {
heroe: { nombre: "Mercy", rol: "Apoyo", partidas: 150, victorias: 54 },
},
};Ahora el fichero de stories es documentación viva: quien entre al proyecto ve de un vistazo
cómo se comporta la tarjeta con cada rol y con un winrate malo, sin arrancar la app ni buscar el
caso a mano. Y como añadiste tags: ["autodocs"], Storybook genera además una página de docs
del componente a partir de las stories y de los tipos de sus props. La documentación deja de ser
un wiki que nadie actualiza: sale del código que de verdad se usa.
Las stories también se testean: play functions#
Hasta aquí, Storybook documenta. Pero puede ir más allá y probar comportamiento, con lo
que ya sabes del nivel. Una story puede llevar una play function: una función que, tras
renderizar la story, conduce el componente y comprueba el resultado. Es un interaction
test, escrito con el mismo getByRole / userEvent / expect del capítulo de Testing
Library:
// expect viene de storybook/test (no de vitest): el mismo repertorio de aserciones,
// incluido toBeInTheDocument de jest-dom, cableado para correr en Storybook.
import { expect } from "storybook/test";
export const MarcarFavorito: Story = {
args: {
heroe: { nombre: "Tracer", rol: "Daño", partidas: 200, victorias: 130 },
},
// play recibe canvas (el DOM de la story) y userEvent por contexto.
play: async ({ canvas, userEvent }) => {
// Arranca sin marcar: el botón ofrece "Marcar favorito".
const boton = canvas.getByRole("button", { name: "Marcar favorito" });
// El usuario lo pulsa (con await, porque user-event simula la interacción real).
await userEvent.click(boton);
// Tras el clic, el mismo botón pasa a "Quitar de favoritos": el toggle funcionó.
await expect(
canvas.getByRole("button", { name: "Quitar de favoritos" }),
).toBeInTheDocument();
},
};Es el mismo doble que ya viste —si la story necesitara espiar un callback, usarías fn() de
storybook/test, la misma idea de vi.fn() del capítulo de dobles—. Lo nuevo y valioso es
dónde vive el test: junto a la story, junto al componente. Documentar un estado y probar su
comportamiento pasan a ser lo mismo, y la play function corre tanto al abrir la story en el
navegador como desde el test runner de Storybook en CI.
Cuidado con confundir esto con un e2e. La play function corre el componente aislado (rápido, determinista): cae en la banda de componente/integración del trofeo de testing. El e2e con Playwright corre la app entera, montada y servida en un navegador real: es la punta fina. Que los dos “pulsen botones” no los hace lo mismo; lo que cambia es el alcance.
Regresión visual: otra capa de la red#
Queda un hueco que ni los interaction tests ni los snapshots del capítulo de integración cubren.
Recuerda: un snapshot (toMatchSnapshot) compara el DOM serializado —las etiquetas y
atributos que produce el componente, como texto—. Si cambias un token y la tarjeta se descuadra
pero el HTML sigue igual, el snapshot pasa: no ve píxeles, ve marcado.
La regresión visual ataca justo eso: renderiza cada story, le hace una captura, y en cada PR compara los píxeles nuevos con los aprobados. Un color roto, un layout descuadrado, una sombra que desaparece: lo caza aunque el DOM no haya cambiado ni una etiqueta. La herramienta estándar sobre Storybook es Chromatic (de los creadores de Storybook), que sube tus stories y marca los diffs visuales para que alguien los apruebe o los rechace.
¿Y qué? Que es otra capa de la misma red de seguridad, con la misma medicina —y la misma
enfermedad— que el snapshot. La medicina: caza regresiones visuales que una aserción de DOM jamás
vería. La enfermedad: si tienes cientos de capturas frágiles que cambian en cada PR, el equipo
aprende a darle a “aprobar todo” sin mirar, exactamente como quien actualiza un snapshot gigante
con -u a ciegas. Un check que nadie revisa no protege nada: solo añade ruido que esconde la
regresión real el día que llega. La regla, otra vez: pocas, sobre lo que importa, y revisadas de
verdad.
Comprueba lo que sabes#
Pregunta 1 de 8
¿Qué problema resuelve un sistema de diseño con design tokens en un equipo grande?
Tu turno#
Este ejercicio se hace en local, sobre tu Team Builder de React: Storybook arranca su propio
servidor para ver los componentes aislados. Instálalo, escribe las stories de la TarjetaHeroe y
sube de tier: de una story con args, a varias que documentan estados, a una story con una play
function que prueba el toggle. Cuando lo tengas (o si te atascas), despliega las soluciones y
fíjate en el salto de un tier al siguiente.
Ejercicio · hazlo en local
Escribe las stories de la TarjetaHeroe
En tu Team Builder de React —en local: Storybook arranca su propio servidor para ver los componentes aislados, no cabe en el editor de esta página—, instala Storybook con pnpm dlx storybook@latest init y escribe TarjetaHeroe.stories.tsx. Sube de tier: de una story con args, a varias que documentan estados reales, a una story con play (un interaction test) que comprueba el toggle de favorito, con la nota honesta de por qué la regresión visual es otra capa y no un sustituto.
Paso 1: El componente, aislado y documentado
- Instalas Storybook (pnpm dlx storybook@latest init) y lo arrancas con pnpm storybook.
- Escribes un meta con satisfies Meta<typeof TarjetaHeroe> y tags: ["autodocs"], y exportas una story Default con sus args (un héroe).
- La tarjeta se ve aislada en Storybook y tiene su página de documentación automática.
Paso 2: Documentación viva de los estados
- Exportas varias stories que documentan estados reales: distintos roles (Daño, Tanque, Apoyo) y winrate alto frente a bajo.
- Quien llega nuevo entiende cómo se comporta la tarjeta solo mirando las stories, sin arrancar la app.
- Los controls del panel dejan tocar los args en vivo para explorar otros casos.
Paso 3: La story también se testea, y honestidad sobre las capas
- Añades una story con play (un interaction test) que pulsa el toggle de favorito y comprueba, con getByRole y el expect de storybook/test, que el nombre accesible del botón cambia.
- Localizas por rol y nombre accesible (lo que ve el usuario), nunca por clases CSS, igual que en el testing de componentes.
- Dejas la nota honesta: las play functions son interaction tests junto al componente, y la regresión visual (Chromatic) es otra capa —pocas y revisadas de verdad—, no un sustituto de los tests de comportamiento.
Cómo hacerlo en local
Clona el repositorio del curso, entra en la carpeta del ejercicio y abre el
index.html en tu navegador. Toda tu solución va en
solucion.js.
git clone <repo>
cd exercises/nivel-8/design-systems-y-storybook
# abre index.html en el navegador y edita solucion.js Ver soluciones
// Las stories de la TarjetaHeroe: el componente visto AISLADO en Storybook, fuera de
// la app, en los estados que quieras documentar. Va junto al componente, como un test.
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TarjetaHeroe } from "./TarjetaHeroe";
// El "meta" describe a QUÉ componente pertenecen las stories de este fichero.
// satisfies valida la forma sin perder el tipo exacto (lo viste en el Nivel 5): así
// TypeScript sabe qué args admite cada story.
const meta = {
component: TarjetaHeroe,
// autodocs genera una página de documentación a partir de las stories y los tipos.
tags: ["autodocs"],
} satisfies Meta<typeof TarjetaHeroe>;
export default meta;
// El tipo de cada story, derivado del meta.
type Story = StoryObj<typeof meta>;
// Una story es el componente en un estado concreto. args son sus props: aquí, el héroe
// con el que se monta la tarjeta.
export const Default: Story = {
args: {
heroe: { nombre: "Tracer", rol: "Daño", partidas: 200, victorias: 130 },
},
}; Por qué este nivel
- Lo mínimo que ya aporta valor: el meta enlaza las stories con el componente, satisfies le da los tipos exactos (sabe qué args admite) y tags: ["autodocs"] genera la página de documentación sola. La story Default monta la tarjeta aislada, fuera de la app.
- Su límite: documenta UN estado. Cómo se ve un Tanque, o un héroe con mal winrate, o si el toggle de favorito funciona, se queda fuera. Eso son los siguientes tiers.
// Varias stories: cada una documenta un ESTADO real del componente. Juntas, el fichero
// de stories es documentación VIVA: quien llega nuevo ve de un vistazo cómo se comporta
// la tarjeta sin tener que arrancar la app entera y buscar el caso a mano.
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TarjetaHeroe } from "./TarjetaHeroe";
// El meta enlaza estas stories con el componente y activa su página de autodocs.
const meta = {
component: TarjetaHeroe,
tags: ["autodocs"],
} satisfies Meta<typeof TarjetaHeroe>;
export default meta;
type Story = StoryObj<typeof meta>;
// Un héroe de Daño con buen winrate: el caso más habitual.
export const Dano: Story = {
args: {
heroe: { nombre: "Tracer", rol: "Daño", partidas: 200, victorias: 130 },
},
};
// Un Tanque: el mismo componente con otro rol, para ver que la tarjeta lo refleja.
export const Tanque: Story = {
args: {
heroe: { nombre: "Reinhardt", rol: "Tanque", partidas: 180, victorias: 99 },
},
};
// Un Apoyo con winrate bajo: el caso opuesto. Documentar solo el feliz miente; este
// deja claro cómo se ve un héroe que no va fino.
export const ApoyoBajoWinrate: Story = {
args: {
heroe: { nombre: "Mercy", rol: "Apoyo", partidas: 150, victorias: 54 },
},
}; Por qué es mejor que el anterior
- Varias stories convierten el fichero en documentación viva: distintos roles y un winrate bajo a propósito, no solo el caso feliz. Quien entre al proyecto entiende la tarjeta de un vistazo, y los controls dejan probar más combinaciones sin tocar código.
- Le falta el salto de calidad del nivel: esto DOCUMENTA estados, pero no comprueba COMPORTAMIENTO. Que el botón de favorito haga lo que dice sigue sin probarse. Para eso está la play function.
// Las stories documentan estados Y testean comportamiento, en el mismo sitio. La play
// function es un interaction test: el mismo getByRole/userEvent/expect del capítulo de
// Testing Library, ahora dentro de Storybook, junto al componente.
import type { Meta, StoryObj } from "@storybook/react-vite";
// expect viene de storybook/test (no de vitest): el mismo repertorio de aserciones,
// incluido toBeInTheDocument de jest-dom, cableado para correr en Storybook.
import { expect } from "storybook/test";
import { TarjetaHeroe } from "./TarjetaHeroe";
const meta = {
component: TarjetaHeroe,
tags: ["autodocs"],
} satisfies Meta<typeof TarjetaHeroe>;
export default meta;
type Story = StoryObj<typeof meta>;
// Estados documentados, como en el tier anterior.
export const Dano: Story = {
args: {
heroe: { nombre: "Tracer", rol: "Daño", partidas: 200, victorias: 130 },
},
};
export const Apoyo: Story = {
args: {
heroe: { nombre: "Mercy", rol: "Apoyo", partidas: 150, victorias: 54 },
},
};
// Una story con play: la story TAMBIÉN se testea. play recibe canvas (el DOM de la
// story) y userEvent por contexto; conduce el componente y comprueba el resultado.
export const MarcarFavorito: Story = {
args: {
heroe: { nombre: "Tracer", rol: "Daño", partidas: 200, victorias: 130 },
},
play: async ({ canvas, userEvent }) => {
// Arranca sin marcar: el botón ofrece "Marcar favorito".
const boton = canvas.getByRole("button", { name: "Marcar favorito" });
// El usuario lo pulsa (con await, porque user-event simula la interacción real).
await userEvent.click(boton);
// Tras el clic, el mismo botón pasa a "Quitar de favoritos": el toggle funcionó.
// Localizamos por rol y nombre accesible, lo que ve el usuario, no por clases.
await expect(
canvas.getByRole("button", { name: "Quitar de favoritos" }),
).toBeInTheDocument();
},
}; Por qué es mejor que el anterior
- La story también se testea: la play function conduce el componente con el MISMO getByRole/userEvent/expect del capítulo de Testing Library —pulsa el toggle y comprueba que el nombre accesible cambia—, ahora junto a la story. Documentar y probar el comportamiento pasan a ser lo mismo, y corre también en CI desde el test runner.
- Y la honestidad que es media lección: esto es un interaction test, la banda de componente del trofeo; la regresión visual (capturar cada story y comparar píxeles) es OTRA capa, que caza lo que el DOM no ve, pero con la misma disciplina que el snapshot —pocas y revisadas de verdad, o el equipo aprueba diffs a ciegas y dejan de proteger—.