Um guia completo para testar Hooks React
Publicados: 2022-03-11Hooks foram introduzidos no React 16.8 no final de 2018. São funções que se conectam a um componente funcional e nos permitem usar recursos de estado e componente como componentDidUpdate
, componentDidMount
e muito mais. Isso não era possível antes.
Além disso, os ganchos nos permitem reutilizar a lógica de componentes e estados em diferentes componentes. Isso era complicado de fazer antes. Portanto, os ganchos mudaram o jogo.
Neste artigo, exploraremos como testar Hooks do React. Escolheremos um gancho suficientemente complexo e trabalharemos para testá-lo.
Esperamos que você seja um ávido desenvolvedor do React e já esteja familiarizado com o React Hooks. Caso você queira aprimorar seus conhecimentos, confira nosso tutorial, e aqui está o link para a documentação oficial.
O gancho que usaremos para testar
Para este artigo, usaremos um gancho que escrevi em meu artigo anterior, Stale-while-revalidate Data Fetching with React Hooks. O gancho é chamado useStaleRefresh
. Se você não leu o artigo, não se preocupe, pois vou recapitular essa parte aqui.
Este é o gancho que testaremos:
import { useState, useEffect } from "react"; const CACHE = {}; export default function useStaleRefresh(url, defaultValue = []) { const [data, setData] = useState(defaultValue); const [isLoading, setLoading] = useState(true); useEffect(() => { // cacheID is how a cache is identified against a unique request const cacheID = url; // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); setLoading(false); } else { // else make sure loading set to true setLoading(true); setData(defaultValue); } // fetch new data fetch(url) .then((res) => res.json()) .then((newData) => { CACHE[cacheID] = newData; setData(newData); setLoading(false); }); }, [url, defaultValue]); return [data, isLoading]; }
Como você pode ver, useStaleRefresh
é um gancho que ajuda a buscar dados de uma URL enquanto retorna uma versão em cache dos dados, se existir. Ele usa um armazenamento simples na memória para armazenar o cache.
Ele também retorna um valor isLoading
que é true se nenhum dado ou cache estiver disponível ainda. O cliente pode usá-lo para mostrar um indicador de carregamento. O valor isLoading
é configurado como false quando o cache ou a resposta nova está disponível.
Neste ponto, sugiro que você passe algum tempo lendo o gancho acima para obter uma compreensão completa do que ele faz.
Neste artigo, veremos como podemos testar esse gancho, primeiro usando nenhuma biblioteca de teste (somente React Test Utilities e Jest) e depois usando react-hooks-testing-library.
A motivação por trás de não usar bibliotecas de teste, ou seja, apenas um executor de testes Jest
, é demonstrar como o teste de um gancho funciona. Com esse conhecimento, você poderá depurar quaisquer problemas que possam surgir ao usar uma biblioteca que fornece abstração de teste.
Definindo os casos de teste
Antes de começarmos a testar este gancho, vamos criar um plano do que queremos testar. Como sabemos o que o gancho deve fazer, aqui está meu plano de oito etapas para testá-lo:
- Quando o gancho é montado com URL
url1
,isLoading
étrue
e data édefaultValue
. - Após uma solicitação de busca assíncrona, o gancho é atualizado com data
data1
eisLoading
éfalse
. - Quando a URL é alterada para
url2
,isLoading
se torna true novamente e os dados sãodefaultValue
. - Após uma solicitação de busca assíncrona, o gancho é atualizado com novos dados de
data2
. - Em seguida, alteramos o URL de volta para
url1
. Os dadosdata1
são recebidos instantaneamente, pois são armazenados em cache.isLoading
é falso. - Após uma solicitação de busca assíncrona, quando uma nova resposta é recebida, os dados são atualizados para
data3
. - Em seguida, alteramos o URL de volta para
url2
. Os dados dedata2
são recebidos instantaneamente, pois são armazenados em cache.isLoading
é falso. - Após uma solicitação de busca assíncrona, quando uma nova resposta é recebida, os dados são atualizados para
data4
.
O fluxo de teste mencionado acima define claramente a trajetória de como o gancho funcionará. Portanto, se pudermos garantir que esse teste funcione, estamos bem.
Testando ganchos sem uma biblioteca
Nesta seção, veremos como testar ganchos sem usar nenhuma biblioteca. Isso nos fornecerá um entendimento profundo de como testar Hooks React.
Para começar este teste, primeiro, gostaríamos de simular fetch
. Isso é para que possamos ter controle sobre o que a API retorna. Aqui está a fetch
simulada.
function fetchMock(url, suffix = "") { return new Promise((resolve) => setTimeout(() => { resolve({ json: () => Promise.resolve({ data: url + suffix, }), }); }, 200 + Math.random() * 300) ); }
Essa fetch
modificada assume que o tipo de resposta é sempre JSON e, por padrão, retorna o parâmetro url
como o valor dos data
. Também adiciona um atraso aleatório entre 200ms e 500ms à resposta.
Se quisermos alterar a resposta, simplesmente definimos o suffix
do segundo argumento para um valor de string não vazio.
Neste ponto, você pode perguntar, por que o atraso? Por que não retornamos a resposta instantaneamente? Isso ocorre porque queremos replicar o mundo real o máximo possível. Não podemos testar o gancho corretamente se o devolvermos instantaneamente. Claro, podemos reduzir o atraso para 50-100ms para testes mais rápidos, mas não vamos nos preocupar com isso neste artigo.
Com nosso mock de busca pronto, podemos configurá-lo para a função de fetch
. Usamos beforeAll
e afterAll
para fazer isso porque essa função não tem estado, portanto, não precisamos redefini-la após um teste individual.
// runs before any tests start running beforeAll(() => { jest.spyOn(global, "fetch").mockImplementation(fetchMock); }); // runs after all tests have finished afterAll(() => { global.fetch.mockClear(); });
Então, precisamos montar o gancho em um componente. Por quê? Porque os ganchos são apenas funções por conta própria. Somente quando usados em componentes eles podem responder a useState
, useEffect
, etc.
Então, precisamos criar um TestComponent
que nos ajude a montar nosso gancho.
// defaultValue is a global variable to avoid changing the object pointer on re-render // we can also deep compare `defaultValue` inside the hook's useEffect const defaultValue = { data: "" }; function TestComponent({ url }) { const [data, isLoading] = useStaleRefresh(url, defaultValue); if (isLoading) { return <div>loading</div>; } return <div>{data.data}</div>; }
Este é um componente simples que renderiza os dados ou renderiza um prompt de texto “Carregando” se os dados estiverem sendo carregados (sendo buscados).
Uma vez que temos o componente de teste, precisamos montá-lo no DOM. Usamos beforeEach
e afterEach
para montar e desmontar nosso componente para cada teste porque queremos começar com um novo DOM antes de cada teste.
let container = null; beforeEach(() => { // set up a DOM element as a render target container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { // cleanup on exiting unmountComponentAtNode(container); container.remove(); container = null; });
Observe que container
deve ser uma variável global, pois queremos ter acesso a ela para asserções de teste.
Com esse conjunto, vamos fazer nosso primeiro teste onde renderizamos uma URL url1
, e como a busca da URL levará algum tempo (consulte fetchMock
), ela deve renderizar o texto “carregando” inicialmente.
it("useStaleRefresh hook runs correctly", () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); })
Execute o teste usando yarn test
e funciona conforme o esperado. Aqui está o código completo no GitHub.
Agora, vamos testar quando esse texto de loading
muda para os dados de resposta buscados, url1
.
Como fazemos isso? Se você olhar para fetchMock
, verá que esperamos por 200-500 milissegundos. E se colocarmos um sleep
no teste que espera 500 milissegundos? Ele cobrirá todos os tempos de espera possíveis. Vamos tentar isso.
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } it("useStaleRefresh hook runs correctly", async () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); await sleep(500); expect(container.textContent).toBe("url1"); });
O teste passa, mas também vemos um erro (código).
PASS src/useStaleRefresh.test.js ✓ useStaleRefresh hook runs correctly (519ms) console.error node_modules/react-dom/cjs/react-dom.development.js:88 Warning: An update to TestComponent inside a test was not wrapped in act(...).
Isso ocorre porque a atualização de estado no gancho useStaleRefresh
acontece fora de act(). Para garantir que as atualizações do DOM sejam processadas em tempo hábil, o React recomenda que você use act()
sempre que uma nova renderização ou atualização da interface do usuário ocorrer. Portanto, precisamos encerrar nosso sono com act
, pois esse é o momento em que a atualização do estado acontece. Depois de fazer isso, o erro desaparece.
import { act } from "react-dom/test-utils"; // ... await act(() => sleep(500));
Agora, execute-o novamente (código no GitHub). Como esperado, ele passa sem erros.
Vamos testar a próxima situação em que primeiro alteramos o URL para url2
, verificamos a tela de loading
, aguardamos a resposta de busca e, finalmente, verificamos o texto do url2
. Como agora sabemos como aguardar corretamente as alterações assíncronas, isso deve ser fácil.

act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toContain("loading"); await act(() => sleep(500)); expect(container.textContent).toBe("url2");
Execute este teste e ele também passa. Agora, também podemos testar o caso em que os dados de resposta são alterados e o cache entra em ação.
Você notará que temos um suffix
de argumento adicional em nossa função fetchMock . Isso é para alterar os dados de resposta. Então, atualizamos nossa simulação de busca para usar o suffix
.
global.fetch.mockImplementation((url) => fetchMock(url, "__"));
Agora, podemos testar o caso em que a URL está definida como url1
novamente. Ele primeiro carrega url1
e depois url1__
. Podemos fazer o mesmo para url2
e não deve haver surpresas.
it("useStaleRefresh hook runs correctly", async () => { // ... // new response global.fetch.mockImplementation((url) => fetchMock(url, "__")); // set url to url1 again act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("url1"); await act(() => sleep(500)); expect(container.textContent).toBe("url1__"); // set url to url2 again act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toBe("url2"); await act(() => sleep(500)); expect(container.textContent).toBe("url2__"); });
Todo esse teste nos dá a confiança de que o gancho realmente funciona conforme o esperado (código). Viva! Agora, vamos dar uma olhada rápida na otimização usando métodos auxiliares.
Otimizando o teste usando métodos auxiliares
Até agora, vimos como testar completamente nosso gancho. A abordagem não é perfeita, mas funciona. E, no entanto, podemos fazer melhor?
sim. Observe que estamos aguardando um fixo de 500ms para que cada busca seja concluída, mas cada solicitação leva de 200 a 500ms. Então, estamos claramente perdendo tempo aqui. Podemos lidar melhor com isso apenas aguardando o tempo que cada solicitação leva.
Como fazemos isso? Uma técnica simples é executar a asserção até que ela passe ou um tempo limite seja atingido. Vamos criar uma função waitFor
que faça isso.
async function waitFor(cb, timeout = 500) { const step = 10; let timeSpent = 0; let timedOut = false; while (true) { try { await sleep(step); timeSpent += step; cb(); break; } catch {} if (timeSpent >= timeout) { timedOut = true; break; } } if (timedOut) { throw new Error("timeout"); } }
Esta função simplesmente executa um retorno de chamada (cb) dentro de um bloco try...catch
a cada 10ms e, se o timeout
for atingido, ele gera um erro. Isso nos permite executar uma asserção até que ela passe de maneira segura (ou seja, sem loops infinitos).
Podemos usá-lo em nosso teste da seguinte forma: Em vez de dormir por 500ms e depois declarar, usamos nossa função waitFor
.
// INSTEAD OF await act(() => sleep(500)); expect(container.textContent).toBe("url1"); // WE DO await act(() => waitFor(() => { expect(container.textContent).toBe("url1"); }) );
Faça isso em todas essas asserções e podemos ver uma diferença considerável na rapidez com que nosso teste é executado (código).
Agora, tudo isso é ótimo, mas talvez não queiramos testar o gancho via interface do usuário. Talvez queiramos testar um gancho usando seus valores de retorno. Como fazemos isso?
Não será difícil porque já temos acesso aos valores de retorno do nosso gancho. Eles estão apenas dentro do componente. Se pudermos levar essas variáveis para o escopo global, funcionará. Então vamos fazer isso.
Como testaremos nosso gancho por meio de seu valor de retorno e não do DOM renderizado, podemos remover o renderizador HTML de nosso componente e torná-lo renderizado como null
. Devemos também remover a desestruturação no retorno do gancho para torná-lo mais genérico. Assim, temos este componente de teste atualizado.
// global variable let result; function TestComponent({ url }) { result = useStaleRefresh(url, defaultValue); return null; }
Agora o valor de retorno do gancho é armazenado em result
, uma variável global. Podemos consultá-lo para nossas afirmações.
// INSTEAD OF expect(container.textContent).toContain("loading"); // WE DO expect(result[1]).toBe(true); // INSTEAD OF expect(container.textContent).toBe("url1"); // WE DO expect(result[0].data).toBe("url1");
Depois de alterá-lo em todos os lugares, podemos ver que nossos testes estão passando (código).
Neste ponto, temos a essência do teste React Hooks. Existem algumas melhorias que ainda podemos fazer, como:
- Movendo a variável de
result
para um escopo local - Removendo a necessidade de criar um componente para cada gancho que queremos testar
Podemos fazer isso criando uma função de fábrica que tenha um componente de teste dentro dela. Ele também deve renderizar o gancho no componente de teste e nos dar acesso à variável de result
. Vamos ver como podemos fazer isso.
Primeiro, movemos TestComponent
e result
dentro da função. Também precisaremos passar os argumentos Hook e Hook como argumentos da função para que possam ser usados em nosso componente de teste. Usando isso, aqui está o que temos. Estamos chamando essa função renderHook
.
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } act(() => { render(<TestComponent hookArgs={args} />, container); }); return result; }
A razão pela qual temos result
como um objeto que armazena dados em result.current
é porque queremos que os valores de retorno sejam atualizados à medida que o teste é executado. O valor de retorno do nosso gancho é um array, então ele teria sido copiado por valor se o devolvêssemos diretamente. Ao armazená-lo em um objeto, retornamos uma referência a esse objeto para que os valores de retorno possam ser atualizados atualizando result.current
.
Agora, como vamos atualizar o gancho? Como já estamos usando um closure, vamos incluir outra função rerender
que pode fazer isso.
A função renderHook
final se parece com isso:
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } function rerender(args) { act(() => { render(<TestComponent hookArgs={args} />, container); }); } rerender(args); return { result, rerender }; }
Agora, podemos usá-lo em nosso teste. Em vez de usar act
e render
, fazemos o seguinte:
const { rerender, result } = renderHook(useStaleRefresh, [ "url1", defaultValue, ]);
Então, podemos afirmar usando result.current
e atualizar o gancho usando rerender
. Aqui está um exemplo simples:
rerender(["url2", defaultValue]); expect(result.current[1]).toBe(true); // check isLoading is true
Depois de alterá-lo em todos os lugares, você verá que funciona sem problemas (código).
Brilhante! Agora temos uma abstração muito mais limpa para testar ganchos. Ainda podemos fazer melhor - por exemplo, defaultValue
precisa ser passado toda vez para rerender
, mesmo que não mude. Nós podemos resolver isso.
Mas não vamos enrolar muito, pois já temos uma biblioteca que melhora significativamente essa experiência.
Digite react-hooks-testing-library.
Testando usando React-hooks-testing-library
React-hooks-testing-library faz tudo o que falamos antes e mais um pouco. Por exemplo, ele lida com a montagem e desmontagem do contêiner para que você não precise fazer isso em seu arquivo de teste. Isso nos permite focar em testar nossos ganchos sem nos distrairmos.
Ele vem com uma função renderHook
que retorna rerender
e result
. Ele também retorna wait
, que é semelhante a waitFor
, para que você não precise implementá-lo por conta própria.
Aqui está como renderizamos um gancho em React-hooks-testing-library. Observe que o gancho é passado na forma de um retorno de chamada. Esse retorno de chamada é executado toda vez que o componente de teste é renderizado novamente.
const { result, wait, rerender } = renderHook( ({ url }) => useStaleRefresh(url, defaultValue), { initialProps: { url: "url1", }, } );
Então, podemos testar se a primeira renderização resultou em isLoading
como true e retornar o valor como defaultValue
fazendo isso. Exatamente semelhante ao que implementamos acima.
expect(result.current[0]).toEqual(defaultValue); expect(result.current[1]).toBe(true);
Para testar atualizações assíncronas, podemos usar o método wait
que renderHook
retornou. Ele vem envolto com act()
então não precisamos envolvê-lo com act()
.
await wait(() => { expect(result.current[0].data).toEqual("url1"); }); expect(result.current[1]).toBe(false);
Então, podemos usar o rerender
para atualizá-lo com novos adereços. Observe que não precisamos passar defaultValue
aqui.
rerender({ url: "url2" });
Finalmente, o resto do teste irá proceder de forma semelhante (código).
Empacotando
Meu objetivo era mostrar como testar Hooks React tomando um exemplo de um hook assíncrono. Espero que isso ajude você a enfrentar com confiança o teste de qualquer tipo de gancho, pois a mesma abordagem deve ser aplicada à maioria deles.
Eu recomendaria que você usasse React-hooks-testing-library, pois está completo e não tive problemas significativos com ele até agora. Caso encontre um problema, agora você sabe como abordá-lo usando os meandros dos ganchos de teste descritos neste artigo.