learning-front

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

Testing de componentes con Testing Library

Renderizar un componente y probarlo como lo usa una persona: queries por rol (getByRole primero), user-event frente a fireEvent y los matchers de jest-dom. El mismo principio del Nivel 6 —comportamiento sobre implementación— ahora aplicado a la interfaz.

En el Nivel 6 probaste funciones puras: les dabas una entrada y comprobabas su salida. Un componente de React no tiene una “salida” tan limpia: lo que produce es una interfaz —algo que se ve y con lo que se interactúa—. Así que la pregunta cambia de “¿qué devuelve?” a “¿qué ve y qué puede hacer una persona con este componente?”. Esa es toda la idea de Testing Library.

El principio es el mismo que ya conoces: comportamiento sobre implementación. No vamos a mirar el estado interno de React ni qué hook usa el componente por dentro. Vamos a montarlo, mirar la pantalla y actuar sobre ella como lo haría un usuario. Cuanto más se parezca el test a ese uso real, más confianza te da.

render y screen: montar el componente y mirarlo#

Dos piezas hacen casi todo. render(...) monta el componente en un DOM de prueba, igual que el navegador lo pintaría. screen es tu ventana a ese DOM: sus queries buscan elementos como los buscaría una persona mirando la pantalla.

screen.getByText("...") busca un elemento por su texto. Si lo encuentra, lo devuelve; si no, no devuelve null: lanza un error y el test falla con un mensaje que te dice qué buscaba. Esa es una diferencia clave con querySelector, que devolvía null y te dejaba el fallo para más tarde.

El toBeInTheDocument() del final es un matcher de jest-dom (ahora lo vemos): afirma que el elemento está de verdad en la página. Lee como una frase.

getByRole primero: buscar como una persona#

Testing Library tiene varias queries, pero hay un orden recomendado, y la primera es getByRole: buscar por el rol accesible del elemento. El rol es lo que un lector de pantalla anuncia: un <button> es button, un <h1><h6> es heading, un <input type="text"> es textbox.

El segundo argumento, { name: "..." }, filtra por el nombre accesible del elemento: el texto de un botón, el contenido de un encabezado, la etiqueta de un campo.

¿Y por qué el rol primero, y no un data-testid? Porque un getByRole("button") que pasa te dice algo que importa: que ese elemento es alcanzable por teclado y por un lector de pantalla. Si alguien convirtiera el botón en un <div onClick> —que parece un botón pero no tiene rol, ni foco, ni respuesta al teclado—, getByRole("button") dejaría de encontrarlo y el test fallaría, avisándote de que has roto la accesibilidad. Un getByTestId("boton-ver"), en cambio, pasaría igual: solo mira un atributo que el usuario no ve. Por eso el testid es el último recurso, para cuando de verdad no hay forma accesible de apuntar a un elemento.

Las otras queries que más usarás, cada una con su caso:

tsx
// getByRole: por rol accesible. El name es el texto del botón. (La recomendada.)
screen.getByRole("button", { name: "Registrar victoria" });

// getByText: por el texto visible, cuando el rol no aplica (un párrafo, un span suelto).
screen.getByText("Victorias: 0");

// getByLabelText: un campo de formulario por el texto de su <label>. Imita cómo lo
// encuentra una persona: lee la etiqueta, no el id interno del input.
// <label htmlFor="buscar">Buscar héroe</label><input id="buscar" />
screen.getByLabelText("Buscar héroe");

Cada una existe para un caso distinto: getByRole para elementos con rol (botones, encabezados, campos), getByText para texto sin rol propio, getByLabelText para entradas de formulario por su etiqueta. No son intercambiables: elegir la query correcta es parte de escribir un buen test.

Los matchers de jest-dom#

expect(elemento) te trae los matchers de siempre (toBe, toEqual…), pero para el DOM se quedan cortos. jest-dom añade matchers pensados para elementos, que leen como una frase y dan mensajes de error claros:

tsx
// ¿Está el elemento en la página?
expect(boton).toBeInTheDocument();

// ¿Su texto es el esperado?
expect(parrafo).toHaveTextContent("Victorias: 2");

// ¿El botón está deshabilitado?
expect(boton).toBeDisabled();

// ¿Tiene este atributo con este valor? (útil para aria-*)
expect(boton).toHaveAttribute("aria-pressed", "true");

Compáralo con la alternativa sin jest-dom: expect(boton).not.toBe(null) para saber si existe, o expect(boton.getAttribute("aria-pressed")).toBe("true") a mano. ¿Y qué ganas? Legibilidad y, sobre todo, mejores mensajes: cuando toHaveTextContent falla, te dice qué texto había de verdad en el elemento; un toBe te diría solo “esperaba X, recibí Y” sin contexto. En este curso, jest-dom va pre-cableado (como un fichero de setup que se carga una vez): no lo importas, ya está disponible en expect.

user-event frente a fireEvent: simular al usuario#

Probar lo que se ve está bien, pero un componente también reacciona. Para simular la interacción hay dos herramientas, y la diferencia entre ellas es importante.

fireEvent.click(boton) dispara un evento sintético: el click, y nada más. user-event, en cambio, reproduce la secuencia completa de lo que hace una persona al pulsar: mover el puntero encima, enfocar el botón, presionar, soltar y, entonces sí, el clic.

¿Y qué más da, si al final el botón recibe su clic? Da mucho, y este es el corazón del asunto: un componente que abre un menú al mousedown, o un botón que solo actúa cuando tiene el foco, pasaría un test con fireEvent.click estando roto para una persona real —porque fireEvent nunca disparó el mousedown ni el focus—. user-event sí los dispara, así que tu test falla cuando el componente está roto de verdad. Por eso user-event es el estándar de 2026 para probar la interacción, y fireEvent queda para casos de bajo nivel muy concretos.

Hay un detalle de sintaxis: user-event es asíncrono. Cada acción devuelve una promesa, así que se usa con await y el test es async. Al esperar, le das tiempo a React a procesar el evento y repintar antes de que afirmes sobre el resultado. Sin el await, tu expect correría sobre la pantalla vieja y el test fallaría en falso (o pasaría por casualidad).

Pruébalo#

Toca el Marcador de arriba: cambia el número de clics esperado a propósito (pon "Victorias: 3") y mira cómo el test pasa a rojo con un mensaje que te dice qué texto había de verdad. Luego arréglalo. Esa es la sensación que buscas: el test te dice, sin ambigüedad, qué esperaba y qué encontró.

Comprueba lo que sabes#

Pregunta 1 de 5

Quieres comprobar que existe el botón "Registrar victoria". ¿Por qué se prefiere getByRole("button", { name: "Registrar victoria" }) antes que getByTestId("boton-victoria")?

Tu turno#

Escribe los tests de la TarjetaHeroe. No toques el componente: pruébalo como lo usa una persona. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en el salto de un tier al siguiente: del caso feliz a la interacción, y de la interacción al comportamiento accesible completo.

Ejercicio · en esta página

Prueba la TarjetaHeroe como la usa una persona

Tienes la TarjetaHeroe del Team Builder: muestra nombre, rol y winrate, y tiene un botón para marcarla como favorita. No tocas el componente: escribe sus tests con Testing Library. Busca por rol accesible (getByRole), afirma con los matchers de jest-dom y simula la interacción con user-event.

Paso 1: Lo que muestra la ficha

  • Renderizas la ficha y compruebas que aparece el nombre del héroe como encabezado (getByRole "heading").
  • Compruebas también el rol y el winrate calculado.
  • Afirmas con un matcher de jest-dom (toBeInTheDocument), no con comprobaciones manuales.
Ver soluciones
// Tier OK: el caso feliz. La ficha, recién montada, muestra los datos del héroe.
import { describe, it, expect } from "vitest";
// render monta el componente en un DOM de prueba; screen consulta ese DOM.
import { render, screen } from "@testing-library/react";
// El componente bajo prueba y su tipo.
import { TarjetaHeroe, type Heroe } from "./TarjetaHeroe";

// Un héroe de ejemplo, tipado: 13 de 20 partidas → 65% de winrate.
const genji: Heroe = { nombre: "Genji", rol: "Daño", partidas: 20, victorias: 13 };

describe("TarjetaHeroe", () => {
  it("muestra los datos del héroe", () => {
    // Monta la ficha en el DOM de prueba.
    render(<TarjetaHeroe heroe={genji} />);
    // El nombre lo buscamos por su ROL de encabezado, no por texto suelto: así el test
    // además verifica que "Genji" es un encabezado de verdad (un h1-h6 accesible).
    expect(screen.getByRole("heading", { name: "Genji" })).toBeInTheDocument();
    // El rol aparece en el texto de la ficha.
    expect(screen.getByText("Rol: Daño")).toBeInTheDocument();
    // 13/20 = 65%: comprobamos que la ficha calculó y pintó el winrate.
    expect(screen.getByText("Winrate: 65%")).toBeInTheDocument();
  });
});

Por qué este nivel

  • Prueba el caso feliz: la ficha, recién montada, muestra nombre, rol y winrate. Busca el nombre por su ROL de encabezado, no por texto suelto: así el test también verifica que "Genji" es un encabezado de verdad.
  • Su límite: solo mira el estado inicial. No toca el botón de favorito —la parte interactiva—, que es justo donde vive la lógica que puede romperse.