React-testing-library: fireEvent vs userEvent

18 de noviembre de 2021

La oveja negra

Cuando desarrollamos nuestras aplicaciones React existe una parte tan importante como el propio desarrollo que a veces se pasa por alto, y esa es la parte del testing.

Con un testing adecuado podemos realizar ajustes a nuestro código con la seguridad de que la aplicación (o el componente) seguirán funcionando como es debido ya que, de cambiar algo que no debería haber cambiado, o de hallar un comportamiento inesperado, nuestros tests fallarán y nos lo indicarán.

Podemos entrar a debatir si hay que seguir una filosofía de TDD, BDD, o desarrollar primero y luego hacer los tests, o tal vez hacer una mezcla de todo porque somos unos inconformistas y nos gusta hacerlo a nuestra manera rompiendo con los patrones establecidos; pero lo que nadie pone nunca en duda ni saca a debate es que si tenemos herramientas que nos faciliten la tarea, mejor que mejor.

React-testing-library: testing desde la experiencia del usuario

Aquí es donde entra react-testing-library (o @testing-library/react, si nos referimos al nombre del paquete).

Esta librería nos ayudará a enfocar nuestro testing de cara a una mejor experiencia de usuario, dado que nos da herramientas para probar no tanto la lógica del componente, sino su resultado en pantalla. Es decir, nos ayuda a hacer los tests enfocados a la experiencia del propio usuario y lo que él terminará viendo con sus ojos y experimentando en sus carnes como resultado de cualquier interacción.

Si queremos interaccionar con nuestros componentes y elementos en nuestros tests debemos ser capaces de simular tales interacciones, y es por eso que esta librería incluye dentro una funcionalidad que nos permite disparar eventos en los elementos del DOM y así poder simular las acciones que haría el usuario: fireEvent.

import { fireEvent } from "@testing-library/react";

Dentro de este objeto fireEvent tenemos diversos métodos con los que disparar eventos programáticamente, como pueden ser click(), mouseOver(), focus(), change()...

Pero si lo que queremos es sacarle todo el partido a este tipo de tests y lo que queremos es realmente intentar simular lo que el usuario haría y vería para asegurarnos que nuestra aplicación no solo responde bien a la lógica, sino también a las interacciones de nuestros usuarios, necesitamos algo más.

fireEvent vs userEvent

Aquí es donde llega al rescate la librería de user-event (o @testing-library/user-event, si nos referimos al nombre del paquete).

import userEvent from "@testing-library/user-event";

Esta librería nos va a permitir, al igual que fireEvent, disparar algunos eventos en nuestros elementos del DOM, de manera que podremos simular esas interacciones y comprobar cómo reacciona nuestra aplicación.

¿Cuál es la diferencia, pues, entre una y otra? La diferencia es que userEvent simula interacciones completas y no solo eventos en concreto. Podemos encontrar dentro del objecto userEvent, entre otros, los métodos click(), type(), hover(), clear()...

Pero ¿qué quiere decir que simula interacciones completas? Podemos entenderlo un poco mejor si miramos su código fuente:

function click(...) {
  if (!skipPointerEventsCheck && !hasPointerEvents(element)) {
    throw new Error(...)
  }
  // We just checked for `pointerEvents`. We can always skip this one in `hover`.
  if (!skipHover) hover(element, init, {skipPointerEventsCheck: true})

  if (isElementType(element, 'label')) {
    clickLabel(element, init, {clickCount})
  } else if (isElementType(element, 'input')) {
    if (element.type === 'checkbox' || element.type === 'radio') {
      clickBooleanElement(element, init, {clickCount})
    } else {
      clickElement(element, init, {clickCount})
    }
  } else {
    clickElement(element, init, {clickCount})
  }
}

En este fragmento de código, podemos apreciar cómo la librería de userEvent utiliza la librería fireEvent para disparar algunos eventos, pero lo hace en secuencia y simulando el orden en el que una interacción real de un usuario los dispararía.

En el caso de la función click(), no solo realiza el clic como tal, sino que simula el hover previo que un usuario real haría en caso de mover el cursor hacia un elemento para clicarlo.

Si seguimos el caminito de migas, veremos cómo la función clickElement() a la que acaba llamando es de nuevo un conjunto de eventos de fireEvent disparados uno detrás de otro, simulando un pointerDown y posteriormente un pointerUp, entre otros.

Podemos ver un ejemplo muy claro y completo explicado en la propia documentación oficial de la librería.

Probando la teoría

Por si todavía nos quedan dudas, podemos comprobarlo con un pequeño proyecto en React y unos tests muy simples para ver algunas de esas diferencias entre ambas librerías en la práctica.

Para crear el proyecto React, podemos seguir estos sencillos pasos, que incluyen también Prettier y Eslint para seguir usando buenas prácticas en nuestro código (incluso aunque sea un proyecto de prueba):

Creamos nuestro proyecto con CRA

> npx create-react-app testing-library-project

Instalamos eslint y prettier

> yarn add -D eslint-config-airbnb eslint-config-prettier eslint-plugin-jsx-a11y eslint-plugin-prettier prettier

Creamos un archivo .eslintrc en nuestro root

Después de crear nuestro archivo .eslintrc con este contenido, también eliminaremos la configuración "eslintConfig" para evitar conflictos que aparece en nuestro package.json:

{
  "extends": [
    "react-app",
    "react-app/jest",
    "airbnb",
    "plugin:jsx-a11y/recommended",
    "prettier"
  ],
  "plugins": ["jsx-a11y", "prettier"],
  "rules": {
    "jsx-a11y/mouse-events-have-key-events": 0
  }
}

Opcional: creamos un .eslintignore para evitar errores

Podemos recibir algunos errores de eslint debido a algunos paquetes instalados en nuestra carpeta node_modules. Para evitarlos, simplemente tenemos que especificar esa misma carpeta en nuestro .eslintignore:

node_modules

Añadimos las librerías de user-event y dom

> yarn add -D @testing-library/user-event @testing-library/dom

Preparando el terreno

Debido a las reglas de eslint, deberemos realizar algunos cambios en nuestros ficheros para evitar los errores y los warnings. Una vez lo hayamos hecho, modificaremos nuestro fichero App.jsx para que luzca tal que así:

import React, { useState } from "react";
import "./App.css";

function App() {
  const [state, setState] = useState({
    onHoverActivated: false,
    onClickActivated: false,
    onTypeActivated: false,
    onFocusActivated: false,
  });
  const {
    onHoverActivated,
    onClickActivated,
    onTypeActivated,
    onFocusActivated,
  } = state;

  const handleOnHover = () => {
    setState((current) => ({ ...current, onHoverActivated: true }));
  };

  const handleOnClick = () => {
    setState((current) => ({ ...current, onClickActivated: true }));
  };

  const handleOnType = () => {
    setState((current) => ({ ...current, onTypeActivated: true }));
  };

  const handleOnFocus = () => {
    setState((current) => ({ ...current, onFocusActivated: true }));
  };

  return (
    <div className="App">
      <section>
        <div>
          <button
            type="button"
            data-testid="button-target"
            onClick={handleOnClick}
            onMouseOver={handleOnHover}
          >
            This will have hover and click events assigned!
          </button>
          <span data-testid="on-hover-span">{onHoverActivated.toString()}</span>
          <span data-testid="on-click-span">{onClickActivated.toString()}</span>
        </div>
      </section>
      <section>
        <div>
          <input
            type="text"
            onFocus={handleOnFocus}
            onKeyDown={handleOnType}
            data-testid="input-target"
          />
          <span data-testid="on-type-span">{onTypeActivated.toString()}</span>
          <span data-testid="on-focus-span">{onFocusActivated.toString()}</span>
        </div>
      </section>
    </div>
  );
}

export default App;

Como vemos, lo único que estamos haciendo es crear un botón y un campo de texto con unos event handlers asociados, y hemos añadido unos elementos span adicionales que nos ayudarán a comprobar el valor de nuestras variables.

Vayamos ahora a lo importante.

Los tests: probando las diferencias

Modificaremos nuestro fichero App.test.jsx para realizar los import necesarios para nuestras pruebas:

import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";

Si queremos, podemos añadir un pequeño helper para ayudarnos a renderizar la aplicación en cada test, hacer un test inicial para ver que todo funcione como debería, y meterlo todo en un describe para una mejor organización:

describe("App container", () => {
  const renderApp = () => render(<App />);

  it("renders the app", () => {
    renderApp();
    expect(screen.getByTestId("on-hover-span")).toBeInTheDocument();
  });
});

Ahora deberíamos poder ejecutar yarn test en la consola y nos debería aparecer que todos los tests pasan correctamente.

Comprobando interacciones de nuestro botón

Crearemos nuestros tests para el botón, así que volveremos a utilizar el bloque describe:

describe("button control", () => {
  let onClickValue;
  let onHoverValue;

  it("clicks the button using fireEvent", () => {
    renderApp();
    fireEvent.click(screen.getByTestId("button-target"));

    expect(screen.getByTestId("on-click-span")).toHaveTextContent(onClickValue);
    expect(screen.getByTestId("on-hover-span")).toHaveTextContent(onHoverValue);
  });

  it("clicks the button using userEvent", () => {
    renderApp();
    userEvent.click(screen.getByTestId("button-target"));

    expect(screen.getByTestId("on-click-span")).toHaveTextContent(onClickValue);
    expect(screen.getByTestId("on-hover-span")).toHaveTextContent(onHoverValue);
  });
});

Este test va a fallar, evidentemente, porque en el documento aparecerá o true o false como contenido de esos span. Teniendo en cuenta lo que hemos visto hasta ahora, ¿qué valor creéis que tendrían que tener onClickValue y onHoverValue en cada caso?

Como hemos dicho, fireEvent solo dispara el evento en cuestión, por lo que en el primer test los valores serán true y false, respectivamente. Solo se ha disparado el evento de click, y nada más.

En el segundo, sin embargo, usando userEvent simulamos una interacción completa del usuario, por lo que ambos valores serán true: la librería simulará que el usuario mueve el ratón hasta ponerlo encima del botón, aprieta el botón del ratón, y lo suelta, con lo que el valor del hover será verdadero en este caso.

En este caso, si hubiéramos puesto event handlers para los eventos de mouseUp y de mouseMove, también tendrían un valor de true.

Comprobando interacciones de nuestro input

Crearemos también unos tests para el input:

describe("input control", () => {
  let onTypeValue;
  let onFocusValue;

  it("changes the input value using fireEvent", () => {
    renderApp();
    const input = screen.getByTestId("input-target");
    fireEvent.change(input, { target: { value: "hello" } });

    expect(input).toHaveDisplayValue("hello");
    expect(screen.getByTestId("on-type-span")).toHaveTextContent(onTypeValue);
    expect(screen.getByTestId("on-focus-span")).toHaveTextContent(onFocusValue);
  });

  it("changes the input value using userEvent", () => {
    renderApp();
    const input = screen.getByTestId("input-target");
    userEvent.type(input, "hello");

    expect(input).toHaveDisplayValue("hello");
    expect(screen.getByTestId("on-type-span")).toHaveTextContent(onTypeValue);
    expect(screen.getByTestId("on-focus-span")).toHaveTextContent(onFocusValue);
  });
});

En este caso, ¿qué valor diríais que tendrían que tener las variables onTypeValue y onFocusValue? Pues de la misma manera que hemos visto anteriormente, fireEvent solo disparará el evento onChange y, por tanto, ambos valores serán false en este caso. Tened en cuenta que en nuestra aplicación estamos escuchando los eventos de onFocus y onKeyDown, y ninguno de los dos se ha disparado.

Sin embargo, en el segundo caso, con userEvent, ambos valores serán true, dado que directamente estamos simulando una escritura y la librería ejecutará los eventos onFocus y onKeyDown, lo que a su vez ejecutará el evento onChange en el elemento input.

¿Cuál usar y en qué casos?

La respuesta a esta pregunta es fácil: siempre que sea posible, nos interesará usar userEvent antes que fireEvent, salvo en casos muy específicos.

Dichos casos pueden ser escenarios en los que al disparar alguno de esos eventos en cadena imposibilitemos la correcta ejecución del código que queremos probar.

Por ejemplo, si quisiéramos probar que un input no ejecuta la función onChange si no tiene un estado de focused, al usar userEvent no seríamos capaces pues la librería ya haría el focus automáticamente. En este caso, sí sería mejor usar fireEvent para disparar únicamente ese evento.

Pero este enfoque responde a un testing orientado más a la lógica pura del componente, y eso a nuestros usuarios no les importa, ya que no es algo que puedan ver ni experimentar. Si queremos probar algo que el usuario final sí pueda comprobar, esta no sería la metodología correcta.

Por ello, userEvent debería ser la librería a elegir en la gran mayoría de nuestros tests que requieran simular interacciones con nuestros componentes y elementos del DOM.

Sobre el autor: Albert Nevado
Comments
Únete a nosotros