Pruebas de integración de React: mayor cobertura, menos pruebas

Índice
  1. Comenzando con las pruebas de React
  2. Opción 1: pruebas unitarias
  3. Opción 2: Pruebas de integración
  4. Entonces, ¿para qué se necesita una prueba unitaria?
  5. Otras delicias
  6. Próximos pasos para los equipos

Las pruebas de integración son una opción natural para los sitios web interactivos, como los que podrían crear con React. Validan cómo un usuario interactúa con su aplicación sin la sobrecarga de las pruebas de un extremo a otro.

Este artículo sigue un ejercicio que comienza con un sitio web simple, valida el comportamiento con pruebas unitarias y de integración y demuestra cómo las pruebas de integración ofrecen mayor valor con menos líneas de código. El contenido asume familiaridad con React y las pruebas en JavaScript. La experiencia con Jest y React Testing Library es útil, pero no obligatoria.

Hay tres tipos de pruebas:

  • Las pruebas unitarias verifican una pieza de código de forma aislada. Son fáciles de escribir, pero pueden perder el panorama general.
  • Las pruebas de un extremo a otro (E2E) utilizan un marco de automatización, como Cypress o Selenium, para interactuar con su sitio como un usuario: cargando páginas, completando formularios, haciendo clic en botones, etc. Por lo general, son más lentas de escribir y ejecutar. pero se acerca mucho a la experiencia real del usuario.
  • Las pruebas de integración se encuentran en algún punto intermedio. Validan cómo funcionan juntas varias unidades de su aplicación, pero son más livianas que las pruebas E2E. Jest, por ejemplo, viene con algunas utilidades integradas para facilitar las pruebas de integración; Jest utiliza jsdom internamente para emular las API de navegador comunes con menos gastos generales que la automatización, y sus sólidas herramientas de simulación pueden bloquear llamadas API externas.

Otro inconveniente: en las aplicaciones React, la unidad y la integración se escriben de la misma manera, con las mismas herramientas.

Comenzando con las pruebas de React

Creé una aplicación React simple (disponible en GitHub) con un formulario de inicio de sesión. Conéctate esto a reqres.in, una API útil que encontré para probar proyectos front-end.

Puedes iniciar sesión exitosamente:

…o encuentra un mensaje de error de la API:

El código está estructurado así:

LoginModule/├── components/⎪   ├── Login.js // renders LoginForm, error messages, and login confirmation⎪   └── LoginForm.js // renders login form fields and button├── hooks/⎪    └── useLogin.js // connects to API and manages state└── index.js // stitches everything together

Opción 1: pruebas unitarias

Si eres como yo y te gusta escribir pruebas, tal vez con los auriculares puestos y algo bueno en Spotify, entonces podrías sentirte tentado a realizar una prueba unitaria para cada archivo.

Incluso si no eres un aficionado a las pruebas, es posible que estés trabajando en un proyecto que “trata de ser bueno con las pruebas” sin una estrategia clara y un enfoque de prueba del tipo “¿Supongo que cada archivo debería tener su propia prueba?” ?”.

Se vería así (donde agregué unitnombres de archivos de prueba para mayor claridad):

LoginModule/├── components/⎪  ├── Login.js⎪  ├── Login.unit.test.js⎪  ├── LoginForm.js⎪  └── LoginForm.unit.test.js├── hooks/⎪  ├── useLogin.js⎪  └──useLogin.unit.test.js├── index.js└── index.unit.test.js

Realicé el ejercicio de agregar cada una de estas pruebas unitarias en GitHub y creé un test:coverage:unitscript para generar un informe de cobertura (una característica incorporada de Jest). Podemos llegar al 100% de cobertura con los cuatro archivos de prueba unitaria:

Una cobertura del 100% suele ser excesiva, pero se puede lograr con una base de código tan simple.

Profundizamos en una de las pruebas unitarias creadas para el onLogingancho React. No se preocupe si no conoce bien los ganchos de React o cómo probarlos.

test('successful login flow', async () = {  // mock a successful API response  jest    .spyOn(window, 'fetch')    .mockResolvedValue({ json: () = ({ token: '123' }) });
  const { result, waitForNextUpdate } = renderHook(() = useLogin());
  act(() = {    result.current.onSubmit({      email: 'test@email.com',      password: 'password',    });  });
  // sets state to pending  expect(result.current.state).toEqual({    status: 'pending',    user: null,    error: null,  });
  await waitForNextUpdate();
  // sets state to resolved, stores email address  expect(result.current.state).toEqual({    status: 'resolved',    user: {      email: 'test@email.com',    },    error: null,  });});

Fue divertido escribir esta prueba (porque la biblioteca de pruebas de React Hooks hace que probar los ganchos sea muy sencillo), pero tiene algunos problemas.

Primero, la prueba valida que una parte del estado interno cambie de 'pending'a 'resolved'; Este detalle de implementación no está expuesto al usuario y, por lo tanto, probablemente no sea bueno probarlo. Si refactorizamos la aplicación, tendremos que actualizar esta prueba, incluso si nada cambia desde la perspectiva del usuario.

Además, como prueba unitaria, esto es sólo una parte del panorama. Si queremos validar otras características del flujo de inicio de sesión, como que el texto del botón de envío cambie a “Cargando”, tendremos que hacerlo en un archivo de prueba diferente.

Opción 2: Pruebas de integración

Consideramos el enfoque alternativo de agregar una prueba de integración para validar este flujo:

LoginModule/├── components/⎪  ├─ Login.js⎪  └── LoginForm.js├── hooks/⎪  └── useLogin.js├── index.js└── index.integration.test.js

Implementé esta prueba y un test:coverage:integrationscript para generar un informe de cobertura. Al igual que con las pruebas unitarias, podemos llegar a una cobertura del 100%, pero esta vez todo está en un solo archivo y requiere menos líneas de código.

Aquí está la prueba de integración que cubre un flujo de inicio de sesión exitosa:

test('successful login', async () = {  jest    .spyOn(window, 'fetch')    .mockResolvedValue({ json: () = ({ token: '123' }) });  render(LoginModule /);  const emailField = screen.getByRole('textbox', { name: 'Email' });  const passwordField = screen.getByLabelText('Password');  const button = screen.getByRole('button');  // fill out and submit form  fireEvent.change(emailField, { target: { value: 'test@email.com' } });  fireEvent.change(passwordField, { target: { value: 'password' } });  fireEvent.click(button);  // it sets loading state  expect(button).toBeDisabled();  expect(button).toHaveTextContent('Loading...');  await waitFor(() = {    // it hides form elements    expect(button).not.toBeInTheDocument();    expect(emailField).not.toBeInTheDocument();    expect(passwordField).not.toBeInTheDocument();    // it displays success text and email address    const loggedInText = screen.getByText('Logged in as');    expect(loggedInText).toBeInTheDocument();    const emailAddressText = screen.getByText('test@email.com');    expect(emailAddressText).toBeInTheDocument();  });});

Realmente me gusta esta prueba porque valida todo el flujo de inicio de sesión desde la perspectiva del usuario: el formulario, el estado de carga y el mensaje de confirmación de éxito. Las pruebas de integración funcionan muy bien para las aplicaciones React precisamente para este caso de uso; la experiencia del usuario es lo que queremos probar, y eso casi siempre implica que varios fragmentos de código diferentes trabajen juntos.

Esta prueba no tiene ningún conocimiento específico de los componentes o ganchos que hacen que el comportamiento esperado funcione, y eso es bueno. Deberíamos poder reescribir y reestructurar dichos detalles de implementación sin alterar las pruebas, siempre que la experiencia del usuario siga siendo la misma.

No voy a profundizar en las otras pruebas de integración para el estado inicial del flujo de inicio de sesión y el manejo de errores, pero te recomiendo que las consultas en GitHub.

Entonces, ¿para qué se necesita una prueba unitaria?

En lugar de pensar en pruebas unitarias versus pruebas de integración, retrocedamos y pensemos en cómo decidimos qué se debe probar en primer lugar. LoginModuledebe probarse porque es una entidad que queremos que los consumidores (otros archivos de la aplicación) puedan usar con confianza.

El gancho onLogin, por otro lado, no necesita ser probado porque es solo un detalle de implementación de LoginModule. Sin embargo, si nuestras necesidades cambian y onLoginhay casos de uso en otros lugares, entonces querríamos agregar nuestras propias pruebas (unitarias) para validar su funcionalidad como una utilidad reutilizable. (También nos gustaría mover el archivo porque ya no sería específico LoginModule).

Todavía hay muchos casos de uso para las pruebas unitarias, como la necesidad de validar selectores, ganchos y funciones simples reutilizables. Al desarrollar su código, también puede resultarle útil practicar el desarrollo basado en pruebas con una prueba unitaria, incluso si luego traslada esa lógica a una prueba de integración.

Además, las pruebas unitarias hacen un gran trabajo al probar exhaustivamente múltiples entradas y casos de uso. Por ejemplo, si mi formulario necesitara mostrar validaciones en línea para varios escenarios (por ejemplo, correo electrónico no válido, contraseña faltante, contraseña corta), cubriría un caso representativo en una prueba de integración y luego profundizaría en los casos específicos en una prueba unitaria.

Otras delicias

Mientras estamos aquí, quiero mencionar algunos trucos sintácticos que ayudaron a que mis pruebas de integración se mantuvieran claras y organizadas.

Espera inequívoca para bloques

Nuestra prueba debe tener en cuenta el retraso entre los estados de carga y éxito de LoginModule:

const button = screen.getByRole('button');fireEvent.click(button);
expect(button).not.toBeInTheDocument(); // too soon, the button is still there!

Podemos hacer esto con waitForel ayudante de la biblioteca de pruebas DOM:

const button = screen.getByRole('button');fireEvent.click(button);
await waitFor(() = {  expect(button).not.toBeInTheDocument(); // ahh, that's better});

Pero, ¿qué pasa si también queremos probar otros elementos? No hay muchos buenos ejemplos de cómo manejar esto en línea y, en proyectos anteriores, eliminé elementos adicionales fuera de waitFor:

// wait for the buttonawait waitFor(() = {  expect(button).not.toBeInTheDocument();});
// then test the confirmation messageconst confirmationText = getByText('Logged in as test@email.com');expect(confirmationText).toBeInTheDocument();

Esto funciona, pero no me gusta porque hace que la condición del botón parezca especial, aunque podríamos cambiar fácilmente el orden de estas declaraciones:

// wait for the confirmation messageawait waitFor(() = {  const confirmationText = getByText('Logged in as test@email.com');  expect(confirmationText).toBeInTheDocument();});
// then test the buttonexpect(button).not.toBeInTheDocument();

Es mucho mejor, en mi opinión, agrupar todo lo relacionado con la misma actualización dentro de la waitFordevolución de llamada:

await waitFor(() = {  expect(button).not.toBeInTheDocument();    const confirmationText = screen.getByText('Logged in as test@email.com');  expect(confirmationText).toBeInTheDocument();});

Realmente me gusta esta técnica para afirmaciones simples como estas, pero puede ralentizar las pruebas en ciertos casos, esperando fallas que ocurrirían inmediatamente fuera del archivo waitFor. Consulte "Tener varias afirmaciones en una sola waitFordevolución de llamada" en Errores comunes con la biblioteca de pruebas de React para ver un ejemplo de esto.

Para pruebas con unos pocos pasos, podemos tener varios waitForbloques seguidos:

const button = screen.getByRole('button');const emailField = screen.getByRole('textbox', { name: 'Email' });
// fill out formfireEvent.change(emailField, { target: { value: 'test@email.com' } });
await waitFor(() = {  // check button is enabled  expect(button).not.toBeDisabled();  expect(button).toHaveTextContent('Submit');});
// submit formfireEvent.click(button);
await waitFor(() = {  // check button is no longer present  expect(button).not.toBeInTheDocument();});

Si está esperando que aparezca solo un elemento, puede utilizar la findByconsulta en su lugar. Se utiliza waitFordebajo del capó.

comentarios en línea

Otra mejor práctica de prueba es escribir menos pruebas y más largas; esto le permite correlacionar sus casos de prueba con flujos de usuarios importantes mientras mantiene las pruebas aisladas para evitar comportamientos inesperados. Suscribo este enfoque, pero puede presentar desafíos para mantener el código organizado y documentar el comportamiento deseado. Necesitamos que los futuros desarrolladores puedan volver a una prueba y comprender qué está haciendo, por qué está fallando, etc.

Por ejemplo, digamos que una de estas expectativas comienza a fallar:

it('handles a successful login flow', async () = {  // beginning of test hidden for clarity
  expect(button).toBeDisabled();  expect(button).toHaveTextContent('Loading...');
  await waitFor(() = {    expect(button).not.toBeInTheDocument();    expect(emailField).not.toBeInTheDocument();    expect(passwordField).not.toBeInTheDocument();
    const confirmationText = screen.getByText('Logged in as test@email.com');    expect(confirmationText).toBeInTheDocument();  });});

Un desarrollador que investigue esto no puede determinar fácilmente qué se está probando y podría tener problemas para decidir si la falla es un error (lo que significa que debemos corregir el código) o un cambio en el comportamiento (lo que significa que debemos corregir la prueba).

Mi solución favorita a este problema es usar la sintaxis menos conocida testpara cada prueba y agregar itcomentarios de estilo en línea que describan cada comportamiento clave que se prueba:

test('successful login', async () = {  // beginning of test hidden for clarity
  // it sets loading state  expect(button).toBeDisabled();  expect(button).toHaveTextContent('Loading...');
  await waitFor(() = {    // it hides form elements    expect(button).not.toBeInTheDocument();    expect(emailField).not.toBeInTheDocument();    expect(passwordField).not.toBeInTheDocument();
    // it displays success text and email address    const confirmationText = screen.getByText('Logged in as test@email.com');    expect(confirmationText).toBeInTheDocument();  });});

Estos comentarios no se integran mágicamente con Jest, por lo que si falla, el nombre de la prueba fallida corresponderá al argumento que pasó a su testetiqueta, en este caso 'successful login'. Sin embargo, los mensajes de error de Jest contienen código circundante, por lo que estos itcomentarios aún ayudan a identificar el comportamiento fallido. Este es el mensaje de error que recibí cuando eliminé notuna de mis expectativas:

Para errores aún más explícitos, existe un paquete llamado jest-expect-message que le permite definir mensajes de error para cada expectativa:

expect(button, 'button is still in document').not.toBeInTheDocument();

Algunos desarrolladores prefieren este enfoque, pero lo encuentro demasiado granular en la mayoría de las situaciones, ya que una sola ita menudo implica múltiples expectativas.

Próximos pasos para los equipos

A veces desearía que pudiéramos establecer reglas de linter para humanos. Si es así, podríamos establecer una regla de preferencia de pruebas de integración para nuestros equipos y dar por terminado el proceso.

Pero, por desgracia, necesitamos encontrar una solución más analógica para alentar a los desarrolladores a optar por pruebas de integración en una situación como la del LoginModuleejemplo que cubrimos anteriormente. Como la mayoría de las cosas, esto se reduce a discutir su estrategia de prueba como equipo, acordar algo que tenga sentido para el proyecto y, con suerte, documentarlo en un ADR.

Al elaborar un plan de prueba, debemos evitar una cultura que presione a los desarrolladores a escribir una prueba para cada archivo. Los desarrolladores deben sentirse capacitados para tomar decisiones de prueba inteligentes, sin preocuparse de que "no estén probando lo suficiente". Los informes de cobertura de Jest pueden ayudar con esto al proporcionar una verificación de que está logrando una buena cobertura, incluso si las pruebas están consolidadas en el nivel de integración.

Todavía no me considero un experto en pruebas de integración, pero realizar este ejercicio me ayudó a analizar un caso de uso en el que las pruebas de integración ofrecieron mayor valor que las pruebas unitarias. Espero que compartir esto con su equipo o realizar un ejercicio similar en su código base le ayude a incorporar pruebas de integración en su flujo de trabajo.

SUSCRÍBETE A NUESTRO BOLETÍN 
No te pierdas de nuestro contenido ni de ninguna de nuestras guías para que puedas avanzar en los juegos que más te gustan.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Subir

Este sitio web utiliza cookies para mejorar tu experiencia mientras navegas por él. Este sitio web utiliza cookies para mejorar tu experiencia de usuario. Al continuar navegando, aceptas su uso. Mas informacion