Mock de funciones

Mock de funciones

¿Qué son las mock functions? ¿Cómo vamos a poder utilizarlas para probar nuestro código?

Antes de comenzar a ver código necesitamos una pequeña introducción teórica y es que cuando hablamos de un mock de una función es una técnica utilizada en pruebas de software, especialmente en pruebas unitarias, donde se simula el comportamiento de una función o método. En lugar de ejecutar el código real de la función, se proporciona una implementación falsa que devuelve valores predefinidos o simula ciertos comportamientos. Esto permite aislar la función que se está probando y controlar su entorno, lo que facilita la verificación de su comportamiento bajo diferentes condiciones. Los mocks son útiles cuando se necesita probar una parte del código que depende de otras partes que aún no están implementadas o que son difíciles de replicar en el entorno de prueba.

Mock de funciones en Jest

Ahora que ya sabemos lo que es un mock vamos a centrarnos en ver cómo los podemos usar en Jest y para ello vamos a partir de un componente de ejemplo sobre el que queremos trabajar:

type CounterProps = {
  count: number
  handleDecrement?: () => void
  handleIncrement?: () => void
}

export const Counter = (props: CounterProps) => {
  return (
    <div>
      <h1>Counter</h1>
      <p>{props.count}</p>
      {props.handleIncrement && (
        <button onClick={handleIncrement}>Increment</button>
      )}
      {props.handleDecrement && (
        <button onClick={handleDecrement}>Decrement</button>
      )}
    </div>
  )
}

Si nos fijamos en el código de este componente vemos que estamos ante un contador muy sencillo el cual recibe mediante props tanto el valor que contendrá el contador (el valor que se renderizará en la página) como los dos callbacks que se ejecutarán cuando se pulse sobre el botón increment y decrement respectivamente, botones que serán únicamente renderizados si es que el componente recibe alguna de estas props (así si se recibe la prop handleIncrement se renderizará el botón increment de tal manera que cuando este sea pulsado se ejecutará la función que se reciba en dicha prop y lo mismo podemos decir para el caso de la prop handleDecrement pero en este caso con el funcionamiento del botón decrement).

Nota: en lo que respecta a lo que vamos a explicar en este artículo no nos va a importar para nada el componente padre que renderizará a Counter y que por lo tanto será el encargado de pasarle las props que necesite.

Además vamos a definir una suite de test para probar nuestro componente que incialmente estará formada por un único test en el que simplemente vamos a asegurarnos de que nuestro componente se está renderizando correctamente:

import { render, screen } from '@testing-library/react'
import { Counter } from './Counter'

describe('Counter', () => {
  it('renders correctly', () => {
    render(<Counter count={0} />)
    const textElement = screen.getByText('Counter Two')
    expect(textElement).toBeInTheDocument()
  })
})

De hecho si nos dirigimos a la terminal de nuestro sistema podremos ver cómo este test estará pasando correctamente tal y como podemos esperar:

¿Qué es lo que trataremos de hacer ahora? Pues nos gustaría probar que los callbacks que están vinculados a cada uno de los botones que están siendo renderizados en el componente es llamado siemrpe que pulse en cada uno de ellos sin importar lo que realmente se haga dentro de dichos callbacks.

Nuevos test

Teniendo en cuenta el objetivo que estamos persiguiendo vamos a añadir un nuevo test a nuestra suite en el que vamos a probar que los callbacks son llamados:

it('handlers are called', () => {})

Dentro del test lo que vamos a hacer es renderizar el componente Counter al que le indicaremos que el valor que tiene que renderziar es 0 a través de la prop count:

it('handlers are called', () => {
  render(<Counter count={0} />)
})

Ahora necesitaremos pasarle los dos callbacks que queremos que el componente Counter asigne a cada uno de los botones, es decir, que tenemos que pasarle las funciones que queremos asignarle a las props handleIncrement y handleDecrement:

it('handlers are called', () => {
  render(<Counter count={0} handleDecrement={} handleIncrement={} />)
})

Pero... ¿qué es lo que vamos a pasar a estas props puesto que realmente nosotros no sabemos qué es lo que les va a pasar el componente padre de Counter? Pensemos que estas funciones pueden encargarse de incrementar o decrementar el valor del contador en, por ejemplo, una unidad pero bien podrían ser dos funciones que se encargasen de incrementar o decrementar el valor del contador en 100.

Nota: siendo sinceros con nosotros mismos el cómo se comporten cada una de estas funciones que se asociarán a los botones no nos importan de cara a la realización del test en el que estamos trabajando.

Desde el punto de vista del funcionamiento de componente Counter lo que tenemos que entender es que a este lo único que le importa es que estos dos callbacks serán llamados en el momento en el que se interacciona (pulsa) sobre cualquiera de los botones que se estén renderizando y esto es precisamente lo que vamos a hacer gracias a la utilización de las mock functions que nos proporciona Jest.

Mock functions al rescate

Lo primero que vamos a tener que hacer es crear estas mock functions gracias al uso de Jest y para ello nos valdremos del objeto global jest que tenemos a nuestra disposición en los test y vamos a invocar al método fn() que nos proporciona puesto que es el que nos retornará una mock función. Así pues escribiremos:

it('handlers are called', () => {
  const incrementHandler = jest.fn()
  const decrementHandler = jest.fn()

Es más si estamos usando un editor como VS Code y situamos el cursor sobre el me´todo fn() se nos desplegará un tooltip donde se muestra algo parecido a lo que podemos ver en la siguiente imagen donde explícitamente se nos está diciendo que la invocación de esta función crea una mock function.

y ahora ya podemos pasar estas dos mock funcions como props de nuestro componente en el test:

it('handlers are called', () => {
  const incrementHandler = jest.fn()
  const decrementHandler = jest.fn()
  render(
    <Counter
      count={0}
      handleDecrement={decrementHandler}
      handleIncrement={incrementHandler}
    />
  )
})

Sabemos que cuando se le pasan la props handleIncrement y handleDecrement se renderizarán los botones para incrementar y decrementar el valor del contador por lo que nuestro objetivo ahora será probar que se está llamando corectamente a estos callback.

Nota: podríamos hacer los test que comprobasen que cuando se reciben estas props se están mostrando los botones pero con el fin de no alargar demasiado la explicación vamos a dejarlo a parte.

Vamos a comenzar comprobando que se llama al callback que está asociado al botón increment por lo que lo primero que tendremos que hacer será obtenerlo:

it('handlers are called', () => {
  const incrementHandler = jest.fn()
  const decrementHandler = jest.fn()
  render(
    <Counter
      count={0}
      handleDecrement={decrementHandler}
      handleIncrement={incrementHandler}
    />
  )

  const incrementButton = screen.getByRole('button', { name: /increment/i })
})

Repetimos este mismo proceso para el botón decrement:

it('handlers are called', () => {
  const incrementHandler = jest.fn()
  const decrementHandler = jest.fn()
  render(
    <Counter
      count={0}
      handleDecrement={decrementHandler}
      handleIncrement={incrementHandler}
    />
  )

  const incrementButton = screen.getByRole('button', { name: /increment/i })
  const decrementButton = screen.getByRole('button', { name: /decrement/i })
})

Ahora que tenemos ambos botones lo siguiente que vamos a tener que hacer es pulsar sobre ellos y para ello vamos a usar la user event library que nos proporciona React Testing Library. Esto quiere decir que, una vez que la tengamos instalada, vamos a importarla en el código de nuestra suite de test:

import user from '@testing-library/user-event'

Nota: se puede obtener más información sobre user event library leyendo la documentación oficial de la misma.

Y una vez que la tenemos importada lo que vamos a hacer al principio de nuestro test es llamar al método setup() que nos ofrece para que esté lista para ser ejecutada en el test como sigue:

it('handlers are called', () => {
  user.setup()
  // ... resto de nuestro código

Con esto la librería user event library está a nuestra disposición para que la utilicemos y, en nuestro caso, como lo que queremos hacer es pulsar sobre ambos botones que incrementarán y decrementan el valor del contador lo que vamos a hacer es llamar al método click() que tenemos a nuestra disposición pasándole, en cada una de las llamadas, el botón que queremos que sea pulsado:

it('handlers are called', () => {
  user.setup()
  const incrementHandler = jest.fn()
  const decrementHandler = jest.fn()
  render(
    <Counter
      count={0}
      handleDecrement={decrementHandler}
      handleIncrement={incrementHandler}
    />
  )

  const incrementButton = screen.getByRole('button', { name: /increment/i })
  const decrementButton = screen.getByRole('button', { name: /decrement/i })
  user.click(incrementButton)
  user.click(decrementButton)
})

Sin embargo este código no funcionará como esperábamos... la razón es que tenemos que saber que todos los métodos que están definidos dentro de la user evento library son asíncronos por lo que vamos a tener que declarar la función que ejecuta nuestro test como async y esperar await a que se pulse sobre cada uno de los botones:

it('handlers are called', async () => {
  user.setup()
  const incrementHandler = jest.fn()
  const decrementHandler = jest.fn()
  render(
    <Counter
      count={0}
      handleDecrement={decrementHandler}
      handleIncrement={incrementHandler}
    />
  )

  const incrementButton = screen.getByRole('button', { name: /increment/i })
  const decrementButton = screen.getByRole('button', { name: /decrement/i })
  await user.click(incrementButton)
  await user.click(decrementButton)

¿Qué nos quedará por hacer en el test? Hemos preparado nuestro componente, hemos hecho click en los botones pues lo que tenemos que ver es cómo podemos asegurar que las dos callbacks que se han recibido han sido llamadas. Recordemos además que en nuestro test ambos callbacks son mocks por lo que vamos a poder usar un nuevo matcher en nuestro test que se denomina toHaveBeenCalledTimes() como sigue:

it('handlers are called', async () => {
  user.setup()
  const incrementHandler = jest.fn()
  const decrementHandler = jest.fn()
  render(
    <Counter
      count={0}
      handleDecrement={decrementHandler}
      handleIncrement={incrementHandler}
    />
  )

  const incrementButton = screen.getByRole('button', { name: /increment/i })
  const decrementButton = screen.getByRole('button', { name: /decrement/i })
  await user.click(incrementButton)
  await user.click(decrementButton)

  expect(incrementHandler).toHaveBeenCalledTimes(1)
  expect(decrementHandler).toHaveBeenCalledTimes(1)
})

Es decir, que en las dos aserciones que estamos haciendo la función expect está recibiendo una mock function y como tal podemos hacer uso del matcher toHaveBeenCalledTimes() al que le vamos a tener que pasar como parámetro el número de veces que queremos que se compruebe que dicha mock function ha sido invocada.

Con estos cambios si ahora grabamos nuestro trabajo y vamos a ver el resultado de los test podemos comprobar como todos ellos pasan tal y como estábamos esperando:

¿Qué ocurriría en el caso de que la aserción no se cumpliese? Pues para verlo vamos a hacer un pequeño cambio en el código de nuestro test pero intentando ver que el número de veces que se llama a la función incrementHandler es de 2 en lugar de 1:

it('handlers are called', async () => {
  user.setup()
  const incrementHandler = jest.fn()
  const decrementHandler = jest.fn()
  render(
    <Counter
      count={0}
      handleDecrement={decrementHandler}
      handleIncrement={incrementHandler}
    />
  )

  const incrementButton = screen.getByRole('button', { name: /increment/i })
  const decrementButton = screen.getByRole('button', { name: /decrement/i })
  await user.click(incrementButton)
  await user.click(decrementButton)

  expect(incrementHandler).toHaveBeenCalledTimes(2)
  expect(decrementHandler).toHaveBeenCalledTimes(1)
})

Si ahora nos vamos a la terminal del sistema para poder ver el resultado de la ejecución del test nos vamos a encontrar con algo como lo que se puede ver en la siguiente imagen:

Resumen

Hemos visto como gracias al uso de las mock functions vamos a poder probar las funciones que están involucradas en nuestro código sin preocuparnos en ningún momento de cuál es la implementación de estas funciones

Código completo

El código completo que contiene todos los test que hemos ido desarrollando a lo largo de este artículo lo podemos ver a continuación:

import { render, screen } from '@testing-library/react'
import user from '@testing-library/user-event'
import { Counter } from './Counter'

describe('Counter', () => {
  it('renders correctly', () => {
    render(<Counter count={0} />)
    const textElement = screen.getByText('Counter Two')
    expect(textElement).toBeInTheDocument()
  })

  it('handlers are called', async () => {
    user.setup()
    const incrementHandler = jest.fn()
    const decrementHandler = jest.fn()
    render(
      <Counter
        count={0}
        handleDecrement={decrementHandler}
        handleIncrement={incrementHandler}
      />
    )

    const incrementButton = screen.getByRole('button', { name: /increment/i })
    const decrementButton = screen.getByRole('button', { name: /decrement/i })
    await user.click(incrementButton)
    await user.click(decrementButton)

    expect(incrementHandler).toHaveBeenCalledTimes(1)
    expect(decrementHandler).toHaveBeenCalledTimes(1)
  })
})