Testare React Hooks con Enzyme e Jest

Come testare il funzionamento degli hooks senza andare fuori di testa

Ogni volta che mi capita di leggere qualcosa sul testing di componenti React, non trovo mai nessun accenno riguardo agli hooks. Per questo motivo, ho deciso di scrivere questa breve guida.

Vediamo l'occorrente per questo meraviglioso art attack:

npx create-react-app testing-react-hooks

npm i --save-dev enzyme enzyme-adapter-react-16

Enzyme è una libreria JavaScript di testing creata da Airbnb che facilita il testing dei componenti. Va ricordato, però, che enzyme va associato ad un adapter relativo alla versione di React che si sta utilizzando (nel nostro caso, la versione 16).

Il componente che andremo a testare sarà un semplice click counter che utilizza useState per tenere traccia di un valore e useEffect per chiamare una callback ricevuta dall'esterno ogni volta che value cambia.

function MyComponent({ onChange }) {
  const [value, setValue] = useState(0);

  useEffect(() => {
    onChange(value);
  }, [value, onChange]);

  return (
    <div>
	  <p>Current value: {value}</p>
	  <button onClick={() => setValue(value+1)}>Increment</button>
	  <button onClick={() => setValue(value-1)}>Decrement</button>
    </div>
  )
}
export default MyComponent;

Iniziamo a scrivere un test, mycomponent.test.js, in cui ci assicuriamo che il test venga eseguito correttamente verificando una semplice asserzione:

import React from "react";
import Enzyme, { mount } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import MyComponent from "./MyComponent";

Enzyme.configure({ adapter: new Adapter() });

describe('MyComponent', () => {
  it('works', () => {
    expect(1+1).toEqual(2);
  })
});

Creiamo un wrapper per il componente e per la funzione di ingresso onChange e infine, solo per assicurarci di essere sulla strada giusta, testiamo i due pulsanti verificando che il testo renderizzato sia quello che ci aspettiamo e che il metodo onChange venga chiamato correttamente:

describe('MyComponent', () => {
  const onChange = jest.fn();
  let wrapper;

  beforeEach(() => {
    wrapper = mount(<MyComponent onChange={onChange}></MyComponent>)
  });

  it('correctly increments and decrements until 1', () => {
    expect(wrapper).not.toBeNull();
    expect(onChange).toBeCalledTimes(1);
    wrapper.find('button').at(0).simulate('click');
    wrapper.find('button').at(1).simulate('click');
    wrapper.find('button').at(1).simulate('click');
    wrapper.find('button').at(0).simulate('click');
    wrapper.find('button').at(0).simulate('click');
    expect(wrapper.find('p').text()).toEqual('Total: 1');
    expect(onChange).toBeCalledTimes(6);
  })
});

Torniamo al topic principale dell'articolo: testare gli hooks. Il modo più semplice che ho trovato per testarli è quello di trattare gli hook semplicemente come dei componenti. Estrapoliamo le funzionalità in un custom hook:

import { useState } from "react";

export default function useCounterHook() {
  const [count, setCount] = useState(0);
  const increment = () => setCount(count => count + 1 );
  const decrement = () => setCount(count => count - 1 );
  return { count, increment, decrement };
}

Se proviamo a testarlo in questo modo

it('works', () => {
  const hook = useCounterHook();
})

ci viene dato un errore:

Invalid hook call. Hooks can only be called inside of the body of a function component.

Non ha tutti i torti. Come sappiamo gli hooks possono essere utilizzati, citando la documentazione ufficiale, solo all'interno di function component o di custom hooks.

Inseriamo, quindi, l'utilizzo dell'hook in un componente Wrapper.

it('works', () => {
  let hook;
  function HookWrapper() {
    hook = useCounterHook();
    return null;
  }

  mount(<HookWrapper></HookWrapper>);
  expect(hook.count).toEqual(0);
})

Ora tutto sembra funzionare. Dico sembra perchè già provando a chiamare le funzioni increment e decrement, torniamo in errore.

hook.increment();

Warning: An update to HookWrapper inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): act(() => { /* fire events that update state / }); / assert on the output */

Come mai questo errore? Vediamo cosa dice la documentazione di act:

When writing UI tests, tasks like rendering, user events, or data fetching can be considered as “units” of interaction with a user interface. React provides a helper called act() that makes sure all updates related to these “units” have been processed and applied to the DOM before you make any assertions

Il problema, in sintesi, è che quando vogliamo effettuare un'asserzione, dobbiamo assicurarci che l'applicazione (o il componente, nello specifico) sia nello stato corretto. Deleghiamo tutto questo mal di testa ad act:

import { act } from "react-dom/test-utils";

// ...

act(() => {
  hook.increment();
});

Ora abbiamo tutti gli strumenti per testare il nostro hook nello stesso modo in cui testiamo gli altri componenti!