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

Las pruebas de integración son un ajuste natural para los sitios web interactivos, como los que podría 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 a 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 un mayor valor a partir de menos líneas de código. El contenido asume una familiaridad con React y pruebas en JavaScript. La experiencia con la biblioteca de pruebas Jest y React es útil, pero no obligatoria.

Hay tres tipos de pruebas:

  • Pruebas unitarias verificar un fragmento de código de forma aislada. Son fáciles de escribir, pero pueden perder el panorama general.
  • Pruebas de un extremo a otro (E2E) use un marco de automatización, como Cypress o Selenium, para interactuar con su sitio como un usuario: cargando páginas, llenando formularios, haciendo clic en botones, etc. Generalmente son más lentos de escribir y ejecutar, pero se asemejan mucho al usuario real experiencia.
  • Pruebas de integración caen en algún lugar intermedio. Validan cómo funcionan juntas varias unidades de su aplicación, pero son más ligeras que las pruebas E2E. Jest, por ejemplo, viene con algunas utilidades integradas para facilitar las pruebas de integración; Jest usa jsdom bajo el capó para emular las API de navegador comunes con menos gastos generales que la automatización, y sus robustas herramientas de simulación pueden eliminar las llamadas de API externas.

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

Empezando con las pruebas de React

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

Puede iniciar sesión correctamente:

… 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, es posible que tengas la tentación de eliminar una prueba unitaria para cada archivo.

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

Eso se vería más o menos así (donde agregué unit para probar los nombres de los archivos 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:unit script para generar un informe de cobertura (una función incorporada de Jest). Podemos llegar al 100% de cobertura con los cuatro archivos de prueba unitarios:

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

Profundicemos en una de las pruebas unitarias creadas para el onLogin Reaccionar gancho. No se preocupe si no está bien versado en React hooks 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: '[email protected]',
      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: '[email protected]',
    },
    error: null,
  });
});

Esta prueba fue divertida de escribir (porque React Hooks Testing Library hace que probar hooks sea muy fácil), pero tiene algunos problemas.

Primero, la prueba valida que una parte del estado interno cambia de 'pending' a 'resolved'; este detalle de implementación no está expuesto al usuario y, por lo tanto, probablemente no sea algo bueno para probar. 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 solo una parte de la imagen. 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

Consideremos 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:integration script para generar un informe de cobertura. Al igual que las pruebas unitarias, podemos llegar al 100% de cobertura, 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 exitoso:

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: '[email protected]' } });
  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('[email protected]');
    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 varias piezas de código diferentes trabajen juntas.

Esta prueba no tiene un conocimiento específico de los componentes o el gancho que hacen que funcione el comportamiento esperado, y eso es bueno. Deberíamos poder reescribir y reestructurar dichos detalles de implementación sin romper 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 animo a que las revises en GitHub.

Entonces, ¿qué necesita una prueba unitaria?

En lugar de pensar en pruebas unitarias frente a pruebas de integración, retrocedamos y pensemos en cómo decidimos qué debe probarse en primer lugar. LoginModule necesita ser probado porque es una entidad que queremos que los consumidores (otros archivos en 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 onLogin tiene casos de uso en otros lugares, entonces querríamos agregar nuestras propias pruebas (unitarias) para validar su funcionalidad como una utilidad reutilizable. (También querríamos mover el archivo porque no sería específico de LoginModule nunca más.)

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 desarrollo impulsado por pruebas con una prueba unitaria, incluso si luego mueve esa lógica más arriba a una prueba de integración.

Además, las pruebas unitarias hacen un gran trabajo al probar exhaustivamente contra 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 golosinas

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 de 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 DOM Testing Library’s waitFor ayudante:

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 algunos otros elementos? No hay muchos buenos ejemplos de cómo manejar esto en línea, y en proyectos anteriores, eliminé elementos adicionales fuera del waitFor:

// wait for the button
await waitFor(() => {
  expect(button).not.toBeInTheDocument();
});


// then test the confirmation message
const confirmationText = getByText('Logged in as [email protected]');
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 message
await waitFor(() => {
  const confirmationText = getByText('Logged in as [email protected]');
  expect(confirmationText).toBeInTheDocument();
});


// then test the button
expect(button).not.toBeInTheDocument();

En mi opinión, es mucho mejor agrupar todo lo relacionado con la misma actualización dentro del waitFor llamar de vuelta:

await waitFor(() => {
  expect(button).not.toBeInTheDocument();
  
  const confirmationText = screen.getByText('Logged in as [email protected]');
  expect(confirmationText).toBeInTheDocument();
});

Realmente me gusta esta técnica para afirmaciones simples como estas, pero puede ralentizar sus pruebas en ciertos casos esperando fallas que ocurrirían de inmediato fuera del waitFor. Consulte “Tener varias afirmaciones en una sola waitFor callback ”en Errores comunes con React Testing Library para ver un ejemplo de esto.

Para pruebas con unos pocos pasos, podemos tener múltiples waitFor bloques en fila:

const button = screen.getByRole('button');
const emailField = screen.getByRole('textbox', { name: 'Email' });


// fill out form
fireEvent.change(emailField, { target: { value: '[email protected]' } });


await waitFor(() => {
  // check button is enabled
  expect(button).not.toBeDisabled();
  expect(button).toHaveTextContent('Submit');
});


// submit form
fireEvent.click(button);


await waitFor(() => {
  // check button is no longer present
  expect(button).not.toBeInTheDocument();
});

Si está esperando que aparezca un solo elemento, puede usar el findBy consulta en su lugar. Usa waitFor bajo el capó.

Comentarios en línea

Otra de las mejores prácticas de prueba es escribir menos pruebas más largas; esto le permite correlacionar sus casos de prueba con flujos de usuarios significativos mientras mantiene las pruebas aisladas para evitar comportamientos inesperados. Me suscribo a 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 realizar 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 [email protected]');
    expect(confirmationText).toBeInTheDocument();
  });
});

Un desarrollador que investiga 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 el menos conocido test sintaxis para cada prueba, y agregando en línea itcomentarios de estilo que describen cada comportamiento clave que se está probando:

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 [email protected]');
    expect(confirmationText).toBeInTheDocument();
  });
});

Estos comentarios no se integran mágicamente con Jest, por lo que si obtiene un error, el nombre de la prueba fallida corresponderá al argumento que pasó a su test etiqueta, en este caso 'successful login'. Sin embargo, los mensajes de error de Jest contienen código circundante, por lo que estos it los comentarios aún ayudan a identificar el comportamiento defectuoso. Aquí está el mensaje de error que recibí cuando quité el not de una de mis expectativas:

Para errores aún más explícitos, existe un paquete llamado jest-wait-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 un poco granular en la mayoría de situaciones, ya que un solo it a menudo implica múltiples expectativas.

Próximos pasos para equipos

A veces desearía que pudiéramos hacer reglas para los humanos. Si es así, podríamos configurar una regla de pruebas de integración preferida para nuestros equipos y dejarlo todo.

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 LoginModule ejemplo que cubrimos anteriormente. Como la mayoría de las cosas, esto se reduce a discutir su estrategia de prueba en equipo, acordar algo que tenga sentido para el proyecto y, con suerte, documentarlo en un ADR.

Al idear un plan de prueba, debemos evitar una cultura que presione a los desarrolladores para que escriban 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 cordura 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 este ejercicio me ayudó a analizar un caso de uso en el que las pruebas de integración ofrecían un valor mayor que las pruebas unitarias. Espero que compartir esto con su equipo, o realizar un ejercicio similar en su base de código, lo guíe en la incorporación de pruebas de integración en su flujo de trabajo.