El concepto de pruebas unitarias por definición consiste en probar pequeños bloques de código que permitan con mayor precisión validar su funcionamiento. En React probar estos bloques de código que podrian ser componentes pueden derivar en la ejecución de otros componentes y a su vez extender el alcance de las pruebas a un escenario que no nos interesa o que no podemos controlar. Es ahí donde entra en escena los Mocks.
Los Mocks
son versiones falsas de una funcionalidad interna o externa de nuestro código. Nos permite aislar las pruebas y separar los bloques de sus posibles dependencias. Vitest nos proporciona unas utilidades para tal fin. Veremos de manera práctica cómo funciona vi.mock()
usando componentes React y sus diferencias con vi.spyOn()
.
Requerimientos
Asumo que tenemos nuestro entorno de desarrollado configurado y listo para usar, en caso contrario les dejo los siguientes links donde podrán encontrar la información necesaria:
Diferencias
La principal diferencia entre vi.mock()
y vi.spyOn()
es que una reemplaza la dependencia que está siendo llamada en tiempo de ejecución y la segunda espía su comportamiento sin necesariamente alterar su funcionamiento.
Otra gran diferencia es que vi.mock()
siempre es movido al inicio del archivo de pruebas, y, por lo tanto, es ejecutado de primero, a diferencia de vi.spyOn()
que es ejecutado justo donde se hace el llamado.
Veamos un ejemplo, supongamos que tenemos la siguiente función:
// sum.ts
export const sum = (a: number, b: number) => {
return a + b;
};
Es llamada desde otro bloque de código:
// main.ts
import { sum } from './sum';
export default () => {
return sum(2, 2);
};
Su correspondiente prueba unitaria sería:
// main.test.ts
import main from './main';
test('call main()', () => {
expect(main()).toBe(4); // Ok
});
Perfecto, ahora me gustaría probar que la función sum()
fue ejecutada solo una vez, para eso haremos uso de vi.spyOn()
para no alterar su funcionamiento y que no siga retornando 4 en este caso.
// main.test.ts
import main from './main';
import * as sum from './sum';
test('call main()', () => {
const spy = vi.spyOn(sum, 'sum');
expect(main()).toBe(4); // Ok
expect(spy).toHaveBeenCalledOnce(); // Ok
});
vi.spyOn()
retorna algo similar a vi.fn()
permitiendo la utilización de .toHaveBeenCalledOnce()
.
Hasta ahí todo bien, pero imaginemos que sum()
representa un cálculo mucho más costoso o el llamado a un servicio cuyas validaciones ya están definidas en su correspondiente archivo de pruebas sum.test.ts
, y queremos simplemente validar que sum()
se ejecute.
// main.test.ts
// ...
test('call main()', () => {
const spy = vi.spyOn(sum, 'sum').mockReturnValue(6);
expect(main()).toBe(6); // Ok
expect(spy).toHaveBeenCalledOnce(); // Ok
});
Vemos que ahora alteramos el funcionamiento de sum()
implementando un mock que retorna 6
y omitimos todo lo que pueda encontrarse dentro del bloque de la función.
Ahora veamos un ejemplo con vi.mock()
:
// main.test.ts
import main from './main';
vi.mock('./sum', () => ({ sum: () => 6 }));
test('call main()', () => {
expect(main()).toBe(6);
});
Aquí hay varios puntos a tomar en cuenta:
- La implementación del mock se realiza afuera del
test
. - No estamos invocando la dependencia
./sum
. - No solo se está mockeando la función
sum()
, también todo lo que se encuentre dentro del archivo, lo cual, hay que tener en cuenta al utilizar este método. Hay formas de traer la funcionalidad original en su totalidad o parcial, ya dependerá de cada escenario que estemos probando. - Para probar cuántas veces se está ejecutando la función
sum()
deberemos traer su función como dependencia y usarvi.mocked()
, ej:
// main.test.ts
import main from './main';
import { sum } from './sum';
vi.mock('./sum');
test('call main()', () => {
const mock = vi.mocked(sum).mockReturnValue(6);
expect(main()).toBe(6);
expect(mock).toHaveBeenCalledOnce();
});
Componentes React
Vimos cómo funciona vi.mock()
cuya tarea es reemplazar la dependencia y vi.spyOn()
como su nombre lo indica, espía la funcionalidad. En este orden de ideas si estamos probando un componente con hijos que son llamados a través de dependencias, lo común sería mockear sus hijos siempre y cuando contengan funcionalidades adicionales a la de renderizar, como por ejemplo: el uso de hooks, consumo de servicios, dependencias externas o incluso si su comportamiento es condicionado a sus propiedades.
En una prueba de funcionamiento no tendría sentido mockear un componente cuya única tarea es renderizar información con algún estilo particular. Ej:
// label.tsx
const Label = () => <span className="label-text">Label</span>;
export default Label;
Ahora supognamos que nuestro componente Label
consume un servicio con un hook que llamaremos useService()
:
// ...
const Label = () => {
const { data } = useService('./sayHello'); // "Hello"
return <span className="label-text">{data}</span>;
};
// ...
En las pruebas, esto implica controlar el hook useService()
, sus dependencias y lo que pueda retornar. Pasaríamos de una prueba unitaria a una prueba escalada al uso de otros bloques funcionales que en el actual escenario no nos interesa validar, ya que hace parte exclusivamente del archivo ./label
.
Pongamos el siguiente ejemplo de un componente padre que hace uso del componente hijo Label
:
// container.tsx
import Label from './label';
const Container = () => (
<div>
<h1>Container</h1>
<Label />
</div>
);
Probar el componente Container
se deberá limitar a mostrar correctamente el título “Container” y que el componente <Label />
sea llamado. Veamos cómo sería la prueba sin limitar el alcance del bloque a probar y seguramente con un posible error de ejecución o timeout:
// container.test.tsx
import { render, screen } from '@testing-library/react';
import Container from './container';
test('renders "Container"', () => {
render(<Container />); // Error
screen.getByText('Container');
});
Aquí lo correcto sería mockear el componente Label:
// container.test.tsx
import { render, screen } from '@testing-library/react';
import Container from './container';
vi.mock('./label', () => ({ default: () => <div>Mocked Label</div> }));
test('renders "Container" and "Mocked Label"', () => {
render(<Container />);
screen.getByText('Container'); // Ok
screen.getByText('Mocked Label'); // Ok
});
De esta manera, no solo aplicamos de manera precisa el concepto de prueba unitaria, reducimos la posibilidad de errores en dependencias y optimizamos la velocidad de las pruebas al omitir ejecución de código adicional.
¿Pero qué pasa si nuestro componente padre depende de acciones que realice el usuario dentro del componente hijo?, por ejemplo un modal, nuestro modal consume un servicio y a su vez tiene un botón que nos permite cerrar el modal; que es el que nos interesa para cubrir la funcionalidad de nuestro modal cuando se abre y se cierra.
Veamos el modal:
// modal.tsx
export interface ModalProps {
handleClose: React.MouseEventHandler<HTMLButtonElement>;
}
const Modal: React.FC<ModalProps> = ({ handleClose }) => {
const { data } = useService('./sayHello'); // "Hello"
return (
<span className="modal">
<button onClick={handleClose}>Close</button>
{data}
</span>
);
};
export default Modal;
Y su implementación:
// container.tsx
import { useState } from 'react';
import Modal from './modal';
const Container = () => {
const [showModal, setShowModal] = useState(false);
return (
<div>
<h1>Container</h1>
<button
onClick={() => {
setShowModal(true);
}}
>
Show Modal
</button>
{showModal && (
<Modal
handleClose={() => {
setShowModal(false);
}}
/>
)}
</div>
);
};
export default Container;
Ahora utilizando la misma idea de la prueba anterior:
// container.test.tsx
import { fireEvent, render, screen } from '@testing-library/react';
import Container from './container';
vi.mock('./modal', () => ({ default: () => <>Mocked Modal</> }));
describe('<Container />', () => {
test('renders "Container"', () => {
render(<Container />);
screen.getByText('Container'); // Ok
});
test('click "Show Modal" and renders "Modal"', () => {
render(<Container />);
fireEvent.click(screen.getByText('Show Modal'));
screen.getByText('Mocked Modal'); // Ok
});
});
Todo perfecto, sin embargo nos falta cubrir:
// ...
handleClose={() => {
setShowModal(false);
}}
// ...
La estrategia que suelo utilizar para estos casos es incluir botones de acciones para ejecutarlas al momento de las pruebas:
// container.test.tsx
import { fireEvent, render, screen } from '@testing-library/react';
import Container from './container';
import type { ModalProps } from './modal';
vi.mock('./modal', () => ({
default: ({ handleClose }: ModalProps) => (
<>
<button onClick={handleClose}>Close</button>Mocked Modal
</>
),
}));
describe('<Container />', () => {
test('renders "Container"', () => {
render(<Container />);
screen.getByText('Container'); // Ok
});
test('click "Show Modal", renders "Modal" and close "Modal"', () => {
render(<Container />);
fireEvent.click(screen.getByText('Show Modal'));
screen.getByText('Mocked Modal'); // Ok
fireEvent.click(screen.getByText('Close'));
expect(screen.queryByText('Mocked Modal')).toBeNull(); // Ok
});
});
Perfecto, cobertura completa y validamos que nuestro modal no esté renderizado después de hacer click en “Close”.
Una alternativa en caso de querer un comportamiento distinto del modal según la necesidad de cada test sería usando vi.mocked()
. Veamos su implementación con el mismo ejemplo del modal:
// container.test.tsx
import { fireEvent, render, screen } from '@testing-library/react';
import Container from './container';
import Modal from './modal';
vi.mock('./modal');
describe('<Container />', () => {
test('renders "Container"', () => {
render(<Container />);
screen.getByText('Container'); // Ok
});
c;
test('click "Show Modal", renders "Modal" and close "Modal"', () => {
vi.mocked(Modal).mockImplementation(({ handleClose }) => (
<>
<button onClick={handleClose}>Close</button>Mocked Modal
</>
));
render(<Container />);
fireEvent.click(screen.getByText('Show Modal'));
screen.getByText('Mocked Modal'); // Ok
fireEvent.click(screen.getByText('Close'));
expect(screen.queryByText('Mocked Modal')).toBeNull(); // Ok
});
});
Hay que tener en cuenta que llamar la dependencia ./modal
sin una implementación factory
en la función vi.mock()
implica que todas sus operaciones se van a ejecutar antes de las pruebas, parecido a vi.spyOn()
.
Conclusiones
En este breve artículo pudimos poner en práctica el uso de vi.mock()
y entendimos su principal diferencia con respecto a vi.spyOn()
, su uso en componentes React y por qué es necesario en una buena realización de pruebas unitarias.