Probar componentes
En resumen: Los tests de componentes con Testing Library y Vitest comprueban lo que ve una persona: encuentran elementos por rol, etiqueta y texto y hacen clic como un usuario.
Problema
El test está en verde, el botón está roto
Alguien escribió un test para la tarjeta de producto. Ejecútalo.
Consola
El informe dice «Tests: 1 pasaron, 0 fallaron». Ahora abre ProductCard.jsx: el botón no tiene onClick. El cliente pulsa «Añadir al carrito» y no pasa nada, y el test está en verde.
El test encuentra el botón por su clase (.add) y comprueba su texto. Nadie lo pulsa. También pasa lo contrario: cambia el nombre de la clase a btn-add y el test falla, mientras que la persona delante de la pantalla no ve ninguna diferencia. El test sigue cómo está escrito el código, no lo que hace.
La idea
Prueba lo que ve una persona
Si has escrito tests en el curso de JavaScript, la idea es la misma: código que llama a tu código y compara el resultado. La diferencia es que el resultado de un componente es una página. Por eso el test hace lo que haría una persona delante de ella:
- Dibuja el componente:
render. - Encuentra el elemento como lo encuentra una persona: por rol y nombre, por etiqueta, por texto.
- Actúa como una persona:
user.click,user.type. - Comprueba lo que se ve:
expect(…).toBeInTheDocument().
Hay dos herramientas. Vitest ejecuta los tests (test, expect), y Testing Library dibuja el componente y busca en la página. Estos son los tests de componentes.
import { render, screen } from '@testing-library/react';
render(<ProductCard product={bread} />);screen.getByRole('button', { name: 'Añadir al carrito' });El orden en que Testing Library te aconseja buscar:
getByRolecon un nombre: un botón, un enlace, un encabezado, un campo. Así busca un lector de pantalla, por eso el test comprueba también la accesibilidad. Un botón sin nombre no se encuentra.getByLabelText: un campo por su etiqueta.getByText: texto que no está en un botón ni en un encabezado.getByTestIdsolo al final, cuando nada más funciona. El atributodata-testidno es visible para la persona.
Míralo
Mira cómo se ejecuta el test
Los cuatro pasos en una tarjeta. Después, el mismo test contra una versión rota.
test('el botón añade al carrito', async () => { const user = userEvent.setup(); render(<ProductCard product={bread} />); const button = screen.getByRole('button', { name: 'Añadir al carrito', }); await user.click(button); expect(screen.getByText('En el carrito: 1')) .toBeInTheDocument();});Paso a paso, en palabras
- test() le da nombre a una comprobación. Vitest ejecuta todos los tests del archivo e informa de cuáles pasaron.
- render dibuja ProductCard en una página vacía, como lo haría el navegador. Sin App, sin el resto de la tienda: solo este componente.
- El test busca el botón como lo haría una persona: el rol «button» y el nombre «Añadir al carrito». La clase y el lugar en el código no importan.
- user.click pulsa el botón como una persona: eventos del puntero y después click. El componente se actualiza y muestra «En el carrito: 1».
- expect comprueba lo que ve la persona: el texto está en la página. El test pasa.
- Ahora el mismo test contra una versión rota: el botón no está conectado a nada. El clic llega al botón y no cambia nada.
- expect no encuentra «En el carrito: 1», y el test falla. Verde con el código correcto y rojo con el roto: en un test así puedes confiar.
El test no sabe si el código tiene un className, cuántos <div> rodean el botón ni cómo se llama la función. Sabe lo que hay en la pantalla. Por eso sobrevive a una refactorización y falla ante un fallo.
Pruébalo
El primer test de verdad
Dos tests: la tarjeta muestra el nombre y el precio, y el botón añade al carrito. Ejecútalos. Después quita onClick={add} en ProductCard.jsx y ejecuta de nuevo: el segundo test falla.
Consola
userEvent.setup() crea un «usuario», y await user.click(…) pulsa como una persona: el puntero se mueve sobre el botón, pulsa, suelta, y solo entonces llega click. Cada acción de user devuelve una promesa, por eso el test es async y espera con await.
toBeInTheDocument() viene de jest-dom: comprobaciones para la página como toBeDisabled(), toHaveTextContent(), toHaveValue(). En el sandbox están integradas, y en un proyecto las activas con una línea (mira «Más precisamente, para curiosos»).
Pruébalo
Encuentra el error
El test falla con «Unable to find an element with the text: En el carrito: 1», y el componente es el correcto. ¿Qué línea tiene la culpa?
Elige la línea con el error.
Línea 11: user.click(…) sin await. El clic está prometido pero todavía no ha ocurrido cuando expect busca el texto. Con await user.click(…) el test espera al clic y a que el componente se actualice.
La idea
Qué le dice la tarjeta al padre: vi.fn()
La tarjeta no es dueña del carrito. Le dice al padre «añade este producto» (la lección sobre el estado compartido). Un test de la tarjeta no debería dibujar toda la tienda para ver eso. En lugar de un padre pasas vi.fn(): una función vacía que recuerda cada llamada. Una función sustituta así se llama mock.
const onAdd = vi.fn();render(<ProductCard product={bread} onAdd={onAdd} />);await user.click(screen.getByRole('button', { name: 'Añadir al carrito' }));expect(onAdd).toHaveBeenCalledTimes(1);expect(onAdd).toHaveBeenCalledWith(bread);not da la vuelta a la comprobación: expect(onAdd).not.toHaveBeenCalled() significa «nunca se llamó».
El test comprueba que la tarjeta envía exactamente este producto. ¿Qué va en el hueco?
test('el botón añade al carrito', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<ProductCard product={bread} onAdd={onAdd} />);
await user.click(screen.getByRole('button', { name: 'Añadir al carrito' }));
expect(onAdd).(bread);
});toHaveBeenCalledWith(bread): la función se llamó al menos una vez con estos argumentos. Compara los valores, así que pasa también un objeto con los mismos campos.
Pruébalo
El producto agotado
La banitsa está agotada: el botón debe estar desactivado, la tarjeta debe decir «Agotado» y un clic no debe llamar a onAdd. Termina el test: pulsa el botón y comprueba las tres cosas. «Comprobar» ejecuta tu test contra la tarjeta correcta y después contra dos versiones rotas. El test debe pasar con la correcta y fallar con cada una de las rotas.
Consola
Solución
ProductCard.test.jsximport { test, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ProductCard from './ProductCard.jsx';
const banitsa = { id: 4, name: 'Banitsa de queso', price: 2.5, stock: 0 };
test('un producto agotado no se añade', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<ProductCard product={banitsa} onAdd={onAdd} />);
await user.click(screen.getByRole('button', { name: 'Añadir al carrito' }));
expect(onAdd).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Añadir al carrito' })).toBeDisabled();
expect(screen.getByText('Agotado')).toBeInTheDocument();
});
ProductCard.jsximport { useState } from 'react';
import { money } from './money.js';
export default function ProductCard({ product, onAdd }) {
const [added, setAdded] = useState(0);
const soldOut = product.stock === 0;
function add() {
setAdded(added + 1);
onAdd?.(product);
}
return (
<article className="card">
<h3>{product.name}</h3>
<p>{money(product.price)}</p>
{soldOut && <p>Agotado</p>}
<button type="button" className="add" disabled={soldOut} onClick={add}>Añadir al carrito</button>
{added > 0 && <p>En el carrito: {added}</p>}
</article>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
await user.click(screen.getByRole('button', { name: 'Añadir al carrito' }));expect(onAdd).not.toHaveBeenCalled();expect(screen.getByRole('button', { name: 'Añadir al carrito' })).toBeDisabled();expect(screen.getByText('Agotado')).toBeInTheDocument();Un clic en un botón desactivado no llega al componente, así que onAdd no se llama. El test detecta los dos fallos: la versión en la que el botón no está desactivado (el clic llama a onAdd) y la versión sin el mensaje. Un test que solo comprueba el mensaje pasa con la primera. Por eso la comprobación lo rechaza.
La idea
Datos que llegan más tarde: findBy
La lista carga los productos desde la API. Primero dice «Cargando…», y los productos llegan un momento después. El test tiene que esperarlos. Ejecuta los dos tests: el primero falla a propósito.
Consola
Hay tres tipos de consultas:
getBy…busca de inmediato. Si el elemento no está, el test falla. El primer test falla exactamente así: la respuesta aún no ha llegado.findBy…devuelve una promesa y lo intenta de nuevo hasta que el elemento aparece. Si no aparece a tiempo (en Testing Library, 1 segundo por defecto), falla. Por eso va conawait.queryBy…devuelvenullsi no hay elemento, sin fallar. Con ella compruebas que algo ya no está:expect(screen.queryByText('Cargando…')).toBeNull().
Cuando esperas algo que no es un elemento (una función llamada después de la petición), usas waitFor: await waitFor(() => expect(onLoad).toHaveBeenCalled()). Ejecuta la comprobación hasta que pasa o se acaba el tiempo.
Más precisamente, para curiosos
En un proyecto real. Los paquetes: npm install -D vitest jsdom @testing-library/user-event @testing-library/jest-dom y @testing-library/react @testing-library/dom. En vite.config.js añades test: { environment: 'jsdom' }: los tests se ejecutan en Node, y jsdom les da una página. Al principio de los tests (o en un archivo de setupFiles) escribes import '@testing-library/jest-dom/vitest' para tener toBeInTheDocument() y los demás. Los tests se ejecutan con npx vitest, y sus archivos son *.test.jsx o *.test.js junto al componente.
Limpiar entre tests. Testing Library quita por sí misma lo que dibujó después de cada test si afterEach es global. En Vitest solo es global con test: { globals: true }. Si no, llamas a cleanup() en afterEach. En el sandbox cada test recibe una página vacía.
fireEvent y user. fireEvent.click(button) envía un solo evento click. user.click(button) recorre todo lo que hace una persona: el puntero, el foco, la pulsación. Un elemento con pointer-events: none, que una persona no puede pulsar, user se niega a pulsarlo con un error, mientras que fireEvent envía el evento. Por eso user está más cerca de la verdad.
Tests de extremo a extremo. Un test de componente dibuja una pieza. Un test de extremo a extremo (por ejemplo con Playwright) ejecuta toda la aplicación en un navegador real y la recorre como un cliente: catálogo, carrito, pedido. Detecta fallos entre las piezas, pero es más lento. Por eso hay muchos tests de componentes y pocos de extremo a extremo, para los recorridos más importantes.
Qué no pruebas. El estado interno (added, soldOut), los nombres de funciones y clases. Cambian en una refactorización. Pruebas la entrada (props, clics, las respuestas de la API) y la salida (la página y las llamadas hacia arriba).
El sandbox. Aquí vitest y Testing Library son versiones pequeñas con los mismos nombres: solo la parte de la API que usa esta lección. findBy y waitFor esperan hasta 1 segundo, y un test se detiene a los 4 segundos. El código de los tests está escrito como en un proyecto real.
Ejercicio
Tests para el carrito
CartSummary muestra las líneas («Pan blanco × 2» con un botón «Quitar»), el total («Total: 4,00 €») y «El carrito está vacío» cuando está vacío. Con «Quitar» le dice al padre qué línea quitar. Escribe tests que detecten tres versiones rotas: el total ignora la cantidad, «Quitar» envía la primera línea en lugar de la suya y el carrito vacío no dice nada. La comprobación exige que todos tus tests pasen con el código correcto y que se detecten los tres fallos.
Consola
Solución
CartSummary.test.jsximport { test, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CartSummary from './CartSummary.jsx';
const bread = { id: 1, name: 'Pan blanco', price: 1.2, qty: 2 };
const milk = { id: 5, name: 'Leche fresca', price: 1.6, qty: 1 };
test('muestra las líneas', () => {
render(<CartSummary items={[bread, milk]} onRemove={vi.fn()} />);
expect(screen.getByText('Pan blanco × 2')).toBeInTheDocument();
expect(screen.getByText('Leche fresca × 1')).toBeInTheDocument();
});
test('el total cuenta la cantidad', () => {
render(<CartSummary items={[bread, milk]} onRemove={vi.fn()} />);
expect(screen.getByText('Total: 4,00 €')).toBeInTheDocument();
});
test('«Quitar» envía su propia línea', async () => {
const user = userEvent.setup();
const onRemove = vi.fn();
render(<CartSummary items={[bread, milk]} onRemove={onRemove} />);
await user.click(screen.getByRole('button', { name: 'Quitar Leche fresca' }));
expect(onRemove).toHaveBeenCalledWith(milk);
});
test('el carrito vacío lo dice', () => {
render(<CartSummary items={[]} onRemove={vi.fn()} />);
expect(screen.getByText('El carrito está vacío')).toBeInTheDocument();
});
CartSummary.jsximport { money } from './money.js';
export default function CartSummary({ items, onRemove }) {
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
if (items.length === 0) return <p>El carrito está vacío</p>;
return (
<section aria-label="El carrito">
<ul>
{items.map((item) => (
<li key={item.id}>
<span>{item.name} × {item.qty}</span>
<button type="button" aria-label={`Quitar ${item.name}`} onClick={() => onRemove(item)}>
Quitar
</button>
</li>
))}
</ul>
<p>Total: {money(total)}</p>
</section>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
Tres tests, uno para cada comportamiento:
- El total: una línea con cantidad 2 (
bread) y una con 1 (milk) dan «Total: 4,00 €». Si todas las líneas tienen cantidad 1, precio y precio × cantidad son iguales y el fallo no se nota. - «Quitar»: pulsa el botón de la segunda línea («Quitar Leche fresca») y comprueba
toHaveBeenCalledWith(milk). Si pulsas la primera línea, la versión rota también envía la primera línea y el test pasa. - El carrito vacío:
items: []y «El carrito está vacío» en la página.
Un buen test elige datos con los que el fallo se nota. Es la misma habilidad que con los tests de funciones: los casos del borde.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Estado · Estado compartido: hacia arriba · Efectos · Datos de una API en un componente
- Continúa en: Del sandbox al proyecto · El escaparate de la tienda
- Ejercicios sobre el tema: Una aplicación completa