Ir al contenido
wannacode

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.

Vue

El informe dice «Tests: 1 pasaron, 0 fallaron». Ahora abre ProductCard.vue: el botón no tiene @click. 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:

  1. Dibuja el componente: render.
  2. Encuentra el elemento como lo encuentra una persona: por rol y nombre, por etiqueta, por texto.
  3. Actúa como una persona: user.click, user.type.
  4. 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/vue';
render(ProductCard, { props: { product: bread } });
screen.getByRole('button', { name: 'Añadir al carrito' });

El orden en que Testing Library te aconseja buscar:

  • getByRole con 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.
  • getByTestId solo al final, cuando nada más funciona. El atributo data-testid no 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, { props: { 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
  1. test() le da nombre a una comprobación. Vitest ejecuta todos los tests del archivo e informa de cuáles pasaron.
  2. 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.
  3. 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.
  4. 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».
  5. expect comprueba lo que ve la persona: el texto está en la página. El test pasa.
  6. 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.
  7. 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 @click="add" en ProductCard.vue y ejecuta de nuevo: el segundo test falla.

Vue

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.

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, { props: { product: bread, onAdd } });
await user.click(screen.getByRole('button', { name: 'Añadir al carrito' }));
expect(onAdd).toHaveBeenCalledTimes(1);
expect(onAdd).toHaveBeenCalledWith(bread);

La tarjeta hace emit('add', product). En Vue un oyente de un evento es una prop con on delante: @add="…" en una plantilla es lo mismo que la prop onAdd. Así, onAdd en las props de render escucha el evento add.

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, { props: { product: bread, onAdd } });
  await user.click(screen.getByRole('button', { name: 'Añadir al carrito' }));
  expect(onAdd).(bread);
});

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.

Vue

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.

Vue

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 con await.
  • queryBy… devuelve null si 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/vue. 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.

emitted(). render de @testing-library/vue devuelve también emitted(): cada evento emitido con sus argumentos, por ejemplo emitted().add. El oyente onAdd con vi.fn() da lo mismo, y las comprobaciones son las mismas que en React.

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.

Vue

Test

Ponte a prueba

1. El test tiene que encontrar el botón «Añadir al carrito». ¿Qué consulta usas primero?
2. El componente carga los productos desde la API. El test llama a getByRole('heading', { name: 'Pan blanco' }) justo después de renderizar y falla. ¿Por qué?
3. Tu test pasa con el código correcto y también con una versión en la que el botón no está conectado. ¿Qué significa?
4. ¿Por qué en el test de ProductCard pasas onAdd = vi.fn()?
5. Quieres comprobar todo el recorrido: catálogo, carrito, formulario de pedido, un servidor real. ¿Qué tipo de test es?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·