learning-front

Nivel 8 · Calidad: que no se rompa en producción

Design systems y Storybook

Componentes documentados y consistentes que escalan con el equipo, con las interaction tests (play functions) de Storybook y la regresión visual como otra capa de la red de seguridad.

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:

css
/* 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:

css
/* 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):

shell
# 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 init

Cada 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:

tsx
// 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:

tsx
// 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:

tsx
// 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.

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.