Como testar um aplicativo React com Jest e React Testing Library

O autor selecionou Vets Who Code para receber uma doação como parte do programa Write for DOnations.

Introdução

Obter uma cobertura de teste sólida é imperativo para construir confiança em sua aplicação web. Jest é um executor de testes JavaScript que fornece recursos para escrever e executar testes. O React Testing Library oferece um conjunto de ajudantes de teste que estruturam seus testes com base nas interações do usuário, em vez dos detalhes de implementação dos componentes. Tanto o Jest quanto o React Testing Library vêm pré-embalados com o Create React App e aderem ao princípio orientador de que testar aplicativos deve se assemelhar a como o software será usado.

Neste tutorial, você testará código assíncrono e interações em um projeto de amostra contendo vários elementos de interface do usuário. Você usará o Jest para escrever e executar testes unitários, e implementará o React Testing Library como uma biblioteca de DOM auxiliar (Document Object Model) para lidar com a interação com os componentes.

Pré-requisitos

Para completar este tutorial, você precisará de:

  • Node.js na versão 14 ou superior instalado em sua máquina local. Para instalar o Node.js no macOS ou Ubuntu 18.04, siga as etapas em Como Instalar o Node.js e Criar um Ambiente de Desenvolvimento Local no macOS ou a seção Instalando Usando um PPA de Como Instalar o Node.js no Ubuntu 18.04.

  • npm versão 5.2 ou superior em sua máquina local, que você precisará usar Create React App e npx no projeto de exemplo. Se você não instalou o npm junto com o Node.js, faça isso agora. Para Linux, use o comando sudo apt install npm.

    • Para que os pacotes do npm funcionem neste tutorial, instale o pacote build-essential. Para Linux, use o comando sudo apt install build-essential.
  • Git instalado em sua máquina local. Você pode verificar se o Git está instalado em seu computador ou passar pelo processo de instalação para o seu sistema operacional com Como Instalar o Git no Ubuntu 20.04.

  • Familiaridade com React, que você pode desenvolver com a série Como Codificar em React.js. Como o projeto de exemplo é inicializado com Create React App, você não precisa instalá-lo separadamente.

  • Alguma familiaridade com Jest como um executor ou framework de testes é útil, mas não é obrigatória. Como o Jest é pré-empacotado com o Create React App, você não precisa instalá-lo separadamente.

Passo 1 — Configurando o Projeto

Nesta etapa, você irá clonar um projeto de exemplo e iniciar a suíte de testes. O projeto de exemplo utiliza três ferramentas principais: Create React App, Jest e React Testing Library. Create React App é usado para inicializar uma aplicação React de página única. Jest é usado como o executor de testes, e React Testing Library fornece auxiliares de teste para estruturar testes em torno de interações do usuário.

Para começar, você irá clonar um React App pré-construído do GitHub. Você irá trabalhar com o aplicativo Doggy Directory, que é um projeto de exemplo que utiliza a Dog API para construir um sistema de busca e exibição para uma coleção de imagens de cachorro com base em uma raça específica.

Para clonar o projeto do Github, abra seu terminal e execute o seguinte comando:

  1. git clone https://github.com/do-community/doggy-directory

Você verá uma saída semelhante a esta:

Output
Cloning into 'doggy-directory'... remote: Enumerating objects: 64, done. remote: Counting objects: 100% (64/64), done. remote: Compressing objects: 100% (48/48), done. remote: Total 64 (delta 21), reused 55 (delta 15), pack-reused 0 Unpacking objects: 100% (64/64), 228.16 KiB | 3.51 MiB/s, done.

Mude para a pasta doggy-directory:

  1. cd doggy-directory

Instale as dependências do projeto:

  1. npm install

O comando npm install irá instalar todas as dependências do projeto definidas no arquivo package.json.

Após instalar as dependências, você pode visualizar a versão implantada do aplicativo ou pode executar o aplicativo localmente com o seguinte comando:

  1. npm start

Se escolher executar o aplicativo localmente, ele será aberto em http://localhost:3000/. Você verá a seguinte saída no terminal:

Output
Compiled successfully! You can now view doggy-directory in the browser. Local: http://localhost:3000 On Your Network: http://network_address:3000

Depois de lançado, a página inicial do aplicativo terá esta aparência:

O projeto de dependências foi instalado e o aplicativo está agora em execução. Em seguida, abra um novo terminal e inicie os testes com o seguinte comando:

  1. npm test

O comando npm test inicia os testes em um modo interativo de observação com o Jest como seu executor de testes. Quando no modo de observação, os testes são executados automaticamente após uma alteração de arquivo. Os testes serão executados sempre que você alterar um arquivo e informá-lo se essa alteração passou nos testes.

Após executar o npm test pela primeira vez, você verá esta saída no terminal:

Output
No tests found related to files changed since last commit. Press `a` to run all tests, or run Jest with `--watchAll`. Watch Usage › Press a to run all tests. › Press f to run only failed tests. › Press q to quit watch mode. › Press p to filter by a filename regex pattern. › Press t to filter by a test name regex pattern. › Press Enter to trigger a test run.

Agora que você tem o exemplo de aplicativo e suíte de testes em execução, você pode começar a testar com a página inicial.

Passo 2 — Testando a Página Inicial

Por padrão, o Jest procurará por arquivos com o sufixo .test.js e arquivos com o sufixo .js nas pastas __tests__. Quando você faz alterações nos arquivos de teste relevantes, eles serão detectados automaticamente. Conforme os casos de teste são modificados, a saída será atualizada automaticamente. O arquivo de teste preparado para o projeto de exemplo doggy-directory é configurado com código mínimo antes de você adicionar paradigmas de teste. Neste passo, você escreverá testes para verificar se a página inicial do aplicativo será carregada antes de realizar uma pesquisa.

Abra src/App.test.js em seu editor para ver o seguinte código:

src/App.test.js
import { render, screen } from '@testing-library/react';
import App from './App';

test('renders the landing page', () => {
  render(<App />);
});

A minimum of one test block is required in each test file. Each test block accepts two required parameters: the first argument is a string representing the name of the test case; the second argument is a function that holds the expectations of the test.

Dentro da função, há um método render que o React Testing Library fornece para renderizar seu componente no DOM. Com o componente que você deseja testar renderizado no DOM do ambiente de teste, agora você pode começar a escrever código para assert contra a funcionalidade esperada.

Você adicionará um bloco de teste ao método render que testará se a página de destino é renderizada com precisão antes que quaisquer chamadas de API ou seleções sejam feitas. Adicione o código destacado abaixo do método render:

src/App.test.js
...
test('renders the landing page', () => {
  render(<App />);
  
  expect(screen.getByRole("heading")).toHaveTextContent(/Doggy Directory/);
  expect(screen.getByRole("combobox")).toHaveDisplayValue("Select a breed");
  expect(screen.getByRole("button", { name: "Search" })).toBeDisabled();
  expect(screen.getByRole("img")).toBeInTheDocument();
});

A função expect é usada sempre que você deseja verificar um determinado resultado e aceita um argumento único representando o valor que seu código produz. A maioria das funções expect é combinada com uma função matcher para afirmar algo sobre um valor específico. Para a maioria dessas asserções, você usará matchers adicionais fornecidos por jest-dom para facilitar a verificação de aspectos comuns encontrados no DOM. Por exemplo, .toHaveTextContent é o matcher para a função expect na primeira linha, enquanto getByRole("heading") é o seletor para pegar o elemento DOM.

O React Testing Library fornece o objeto screen como uma maneira conveniente de acessar as consultas pertinentes necessárias para afirmar contra o ambiente DOM de teste. Por padrão, o React Testing Library fornece consultas que permitem localizar elementos dentro do DOM. Existem três categorias principais de consultas:

  • getBy* (mais comumente usado)
  • queryBy* (usado ao testar a ausência de um elemento sem lançar um erro)
  • findBy* (usado ao testar código assíncrono)

Cada tipo de consulta serve a um propósito específico que será definido posteriormente no tutorial. Nesta etapa, você vai focar na consulta getBy*, que é o tipo de consulta mais comum. Para ver uma lista exaustiva das diferentes variações de consulta, você pode revisar a folha de dicas de consulta do React.

Abaixo está uma imagem anotada da página inicial do Diretório de Cachorros indicando cada seção que o primeiro teste (ao renderizar a página inicial) cobre:

Cada função expect está afirmando o seguinte (mostrado na imagem anotada acima):

  1. Você espera que o elemento com o papel de cabeçalho tenha uma correspondência de substring de Diretório de Cachorros.
  2. Você espera que a entrada de seleção tenha um valor de exibição exato de Selecione uma raça.
  3. Você espera que o botão Buscar esteja desativado, já que uma seleção não foi feita.
  4. Você espera que a imagem de espaço reservado esteja presente no documento, já que uma busca não foi realizada.

Quando terminar, salve o arquivo src/App.test.js. Como os testes estão sendo executados no modo de observação, as alterações serão registradas automaticamente. Se as alterações não forem registradas automaticamente, pode ser necessário parar e reiniciar a suíte de testes.

Agora, ao visualizar seus testes no terminal, você verá a seguinte saída:

Output
PASS src/App.test.js ✓ renders the landing page (172 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 2.595 s, estimated 5 s Ran all test suites related to changed files. Watch Usage: Press w to show more.

Neste passo, você escreveu um teste inicial para verificar a visualização inicial da página de entrada do Diretório de Cachorros. No próximo passo, você aprenderá como simular uma chamada de API para testar código assíncrono.

Passo 3 — Simulando o Método fetch

Neste passo, você revisará uma abordagem para simular o método fetch do JavaScript. Embora existam inúmeras maneiras de fazer isso, esta implementação usará os métodos spyOn e mockImplementation do Jest.

Quando você depende de APIs externas, há uma chance de que sua API fique indisponível ou demore para retornar uma resposta. Simular o método fetch proporciona um ambiente consistente e previsível, dando mais confiança aos seus testes. Um mecanismo de simulação de API é necessário para executar corretamente testes que usam uma API externa.

Nota: Em um esforço para manter este projeto simplificado, você irá simular o método fetch. No entanto, é aconselhável utilizar uma solução mais robusta como Mock Service Worker (MSW) ao simular código assíncrono para bases de código maiores e prontas para produção.

Abra o arquivo src/mocks/mockFetch.js em seu editor para revisar como o método mockFetch funciona:

src/mocks/mockFetch.js
const breedsListResponse = {
    message: {
        boxer: [],
        cattledog: [],
        dalmatian: [],
        husky: [],
    },
};

const dogImagesResponse = {
    message: [
        "https://images.dog.ceo/breeds/cattledog-australian/IMG_1042.jpg ",
        "https://images.dog.ceo/breeds/cattledog-australian/IMG_5177.jpg",
    ],
};

export default async function mockFetch(url) {
    switch (url) {
        case "https://dog.ceo/api/breeds/list/all": {
            return {
                ok: true,
                status: 200,
                json: async () => breedsListResponse,
            };
        }
        case "https://dog.ceo/api/breed/husky/images" :
        case "https://dog.ceo/api/breed/cattledog/images": {
            return {
                ok: true,
                status: 200,
                json: async () => dogImagesResponse,
            };
        }
        default: {
            throw new Error(`Unhandled request: ${url}`);
        }
    }
}

O método mockFetch retorna um objeto que se assemelha de perto à estrutura do que um chamada fetch retornaria em resposta às chamadas de API dentro da aplicação. O método mockFetch é necessário para testar a funcionalidade assíncrona em duas áreas do aplicativo Doggy Directory: o dropdown de seleção que popula a lista de raças e a chamada de API para recuperar imagens de cachorro quando uma pesquisa é realizada.

Feche o arquivo src/mocks/mockFetch.js. Agora que você entende como o método mockFetch será usado em seus testes, você pode importá-lo para o arquivo de teste. A função mockFetch será passada como argumento para o método mockImplementation e será usada como uma implementação falsa da API fetch.

No arquivo src/App.test.js, adicione as linhas destacadas de código para importar o método mockFetch:

src/App.test.js
import { render, screen } from '@testing-library/react';
import mockFetch from "./mocks/mockFetch";
import App from './App';

beforeEach(() => {
   jest.spyOn(window, "fetch").mockImplementation(mockFetch);
})

afterEach(() => {
   jest.restoreAllMocks()
});
...

Este código configurará e desativará a implementação simulada para que cada teste comece em uma situação de igualdade.

jest.spyOn(window, "fetch"); cria uma função simulada que rastreará as chamadas ao método fetch anexado à variável global window no DOM.

.mockImplementation(mockFetch); aceita uma função que será usada para implementar o método de simulação. Como este comando substitui a implementação original de fetch, ele será executado sempre que fetch for chamado dentro do código do aplicativo.

Ao terminar, salve o arquivo src/App.test.js.

Agora, ao visualizar seus testes no terminal, você receberá a seguinte saída:

Output
console.error Warning: An update to App 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 */ This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act at App (/home/sammy/doggy-directory/src/App.js:5:31) 18 | }) 19 | .then((json) => { > 20 | setBreeds(Object.keys(json.message)); | ^ 21 | }); 22 | }, []); 23 | ... PASS src/App.test.js ✓ renders the landing page (429 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 1.178 s, estimated 2 s Ran all test suites related to changed files.

O aviso indica que ocorreu uma atualização de estado quando não era esperado. No entanto, a saída também indica que os testes simularam com sucesso o método fetch.

Neste passo, você simulou o método fetch e o incorporou a um conjunto de testes. Embora o teste esteja passando, ainda é necessário lidar com o aviso.

Passo 4 — Corrigindo o Aviso act

Neste passo, você aprenderá como corrigir o aviso act que surgiu após as alterações no Passo 3.

O aviso act ocorre porque você simulou o método fetch, e quando o componente é montado, ele faz uma chamada de API para obter a lista de raças. A lista de raças é armazenada em uma variável de estado que popula o elemento option dentro do input select.

A imagem abaixo mostra como o input select fica após uma chamada de API bem-sucedida para popular a lista de raças:

O aviso é lançado porque o estado é definido após o bloco de teste terminar de renderizar o componente.

Para corrigir este problema, adicione as modificações destacadas ao caso de teste em src/App.test.js:

src/App.test.js
...
test('renders the landing page', async () => {
   render(<App />);
   
   expect(screen.getByRole("heading")).toHaveTextContent(/Doggy Directory/);
   expect(screen.getByRole("combobox")).toHaveDisplayValue("Select a breed");
   expect(await screen.findByRole("option", { name: "husky"})).toBeInTheDocument();
   expect(screen.getByRole("button", { name: "Search" })).toBeDisabled();
   expect(screen.getByRole("img")).toBeInTheDocument();
});

A palavra-chave async informa ao Jest que o código assíncrono é executado como resultado da chamada de API que ocorre quando o componente é montado.

A new assertion with the findBy query verifies that the document contains an option with the value of husky. findBy queries are used when you need to test asynchronous code that is dependent on something being in the DOM after a period of time. Because the findBy query returns a promise that gets resolved when the requested element is found in the DOM, the await keyword is used within the expect method.

Ao terminar, salve as alterações feitas em src/App.test.js.

Com as novas adições, você verá que o aviso act não está mais presente nos seus testes:

Output
PASS src/App.test.js ✓ renders the landing page (123 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 0.942 s, estimated 2 s Ran all test suites related to changed files. Watch Usage: Press w to show more.

Neste passo, você aprendeu como corrigir o aviso act que pode ocorrer ao trabalhar com código assíncrono. Em seguida, você adicionará um segundo caso de teste para verificar as funcionalidades interativas da aplicação Doggy Directory.

Passo 5 — Testando a Funcionalidade de Pesquisa

No passo final, você escreverá um novo caso de teste para verificar a funcionalidade de pesquisa e exibição de imagens. Você utilizará uma variedade de consultas e métodos de API para obter a cobertura de teste adequada.

Retorne ao arquivo src/App.test.js em seu editor. No topo do arquivo, importe a biblioteca complementar user-event e o método assíncrono waitForElementToBeRemoved para o arquivo de teste com os comandos destacados:

src/App.test.js
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';import userEvent from '@testing-library/user-event'; 
...

Você usará essas importações mais tarde nesta seção.

Após o método test() inicial, adicione um novo bloco de teste assíncrono e renderize o componente App com o seguinte bloco de código:

src/App.test.js
...
test("should be able to search and display dog image results", async () => {
   render(<App />);
})

Com o componente renderizado, agora você pode adicionar funções que verificam os recursos interativos do aplicativo Doggy Directory.

Ainda em src/App.test.js, adicione os blocos de código destacados dentro do segundo método test():

src/App.test.js
...
test("should be able to search and display dog image results", async () => {
   render(<App />);
   
   //Simular a seleção de uma opção e verificar seu valor
   const select = screen.getByRole("combobox");
   expect(await screen.findByRole("option", { name: "cattledog"})).toBeInTheDocument();
   userEvent.selectOptions(select, "cattledog");
   expect(select).toHaveValue("cattledog");
})

A seção destacada acima irá simular a seleção de uma raça de cachorro e verificar se o valor correto é exibido.

A consulta getByRole captura o elemento selecionado e o atribui à variável select.

Similar à forma como você corrigiu o aviso act no Passo 4, use a consulta findByRole para aguardar a opção cattledog aparecer no documento antes de prosseguir com mais afirmações.

O objeto userEvent importado anteriormente irá simular interações comuns do usuário. Neste exemplo, o método selectOptions seleciona a opção cattledog pela qual você esperou na linha anterior.

A última linha afirma que a variável select contém o valor cattledog selecionado acima.

A próxima seção que você irá adicionar ao bloco de código Javascript test() iniciará a solicitação de pesquisa para encontrar imagens de cachorro com base na raça selecionada e confirmará a presença de um estado de carregamento.

Adicione as linhas destacadas:

src/App.test.js
...
test("should be able to search and display dog image results", async () => {
   render(<App />);
    
   //...Simular a seleção de uma opção e verificar seu valor

  //Simular o início da solicitação de pesquisa
   const searchBtn = screen.getByRole("button", { name: "Search" });
   expect(searchBtn).not.toBeDisabled();
   userEvent.click(searchBtn);

   //O estado de carregamento é exibido e removido assim que os resultados são mostrados
   await waitForElementToBeRemoved(() => screen.queryByText(/Loading/i));
})

A consulta getByRole localiza o botão de pesquisa e o atribui à variável searchBtn.

O matcher toBeDisabled do jest-dom verificará se o botão de pesquisa não está desativado quando uma seleção de raça é feita.

O método click no objeto userEvent simula clicar no botão de pesquisa.

A função waitForElementToBeRemoved assíncrona importada anteriormente aguardará o aparecimento e desaparecimento da mensagem Loading enquanto a chamada da API de pesquisa estiver em andamento. queryByText dentro do retorno de chamada de waitForElementToBeRemoved verifica a ausência de um elemento sem lançar um erro.

A imagem abaixo mostra o estado de carregamento que será exibido quando uma pesquisa estiver em andamento:

Em seguida, adicione o seguinte código Javascript para validar a imagem e a exibição do contador de resultados:

src/App.test.js
...
test("should be able to search and display dog image results", async () => {
   render(<App />)
   
   //...Simular a seleção de uma opção e verificar seu valor
   //...Simular a inicialização da solicitação de pesquisa
   //...O estado de carregamento é exibido e removido assim que os resultados são exibidos
          
   //Verificar exibição da imagem e contagem de resultados
   const dogImages = screen.getAllByRole("img");
   expect(dogImages).toHaveLength(2);
   expect(screen.getByText(/2 Results/i)).toBeInTheDocument();
   expect(dogImages[0]).toHaveAccessibleName("cattledog 1 of 2");
   expect(dogImages[1]).toHaveAccessibleName("cattledog 2 of 2");
})

A consulta getAllByRole selecionará todas as imagens de cachorro e as atribuirá à variável dogImages. A variante *AllBy* da consulta retorna uma matriz contendo vários elementos que correspondem ao papel especificado. A variante *AllBy* difere da variante ByRole, que só pode retornar um único elemento.

A implementação simulada de fetch continha dois URLs de imagem dentro da resposta. Com o comparador toHaveLength do Jest, você pode verificar que há duas imagens exibidas.

A consulta getByText verificará se a contagem de resultados adequada aparece no canto direito.

Dois testes usando os verificadores toHaveAccessibleName garantem que o texto alternativo apropriado esteja associado às imagens individuais.

A completed search displaying images of the dog based on the breed selected along with the number of results found will look like this:

Quando você combina todas as partes do novo código JavaScript, o arquivo App.test.js ficará assim:

src/App.test.js
import {render, screen, waitForElementToBeRemoved} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import mockFetch from "./mocks/mockFetch";
import App from './App';

beforeEach(() => {
   jest.spyOn(window, "fetch").mockImplementation(mockFetch);
})

afterEach(() => {
   jest.restoreAllMocks();
});

test('renders the landing page', async () => {
   render(<App />);

   expect(screen.getByRole("heading")).toHaveTextContent(/Doggy Directory/);
   expect(screen.getByRole("combobox")).toHaveDisplayValue("Select a breed");
   expect(await screen.findByRole("option", { name: "husky"})).toBeInTheDocument()
   expect(screen.getByRole("button", { name: "Search" })).toBeDisabled();
   expect(screen.getByRole("img")).toBeInTheDocument();
});

test("should be able to search and display dog image results", async () => {
   render(<App />);

   //Simular a seleção de uma opção e verificar seu valor
   const select = screen.getByRole("combobox");
   expect(await screen.findByRole("option", { name: "cattledog"})).toBeInTheDocument();
   userEvent.selectOptions(select, "cattledog");
   expect(select).toHaveValue("cattledog");

   //Iniciar a solicitação de pesquisa
   const searchBtn = screen.getByRole("button", { name: "Search" });
   expect(searchBtn).not.toBeDisabled();
   userEvent.click(searchBtn);

   //O estado de carregamento é exibido e removido assim que os resultados são exibidos
   await waitForElementToBeRemoved(() => screen.queryByText(/Loading/i));

   //Verificar exibição de imagem e contagem de resultados
   const dogImages = screen.getAllByRole("img");
   expect(dogImages).toHaveLength(2);
   expect(screen.getByText(/2 Results/i)).toBeInTheDocument();
   expect(dogImages[0]).toHaveAccessibleName("cattledog 1 of 2");
   expect(dogImages[1]).toHaveAccessibleName("cattledog 2 of 2");
})

Salve as alterações feitas em src/App.test.js.

Ao revisar seus testes, a saída final no terminal agora terá a seguinte saída:

Output
PASS src/App.test.js ✓ renders the landing page (273 ms) ✓ should be able to search and display dog image results (123 ms) Test Suites: 1 passed, 1 total Tests: 2 passed, 2 total Snapshots: 0 total Time: 4.916 s Ran all test suites related to changed files. Watch Usage: Press w to show more.

Neste último passo, você adicionou um teste que verifica as funcionalidades de busca, carregamento e exibição do aplicativo Doggy Directory. Com a afirmação final escrita, agora você sabe que seu aplicativo funciona.

Conclusão

Ao longo deste tutorial, você escreveu casos de teste usando Jest, React Testing Library e matchers jest-dom. Construindo incrementalmente, você escreveu testes baseados em como um usuário interage com a UI. Você também aprendeu as diferenças entre as consultas getBy*, findBy* e queryBy*, e como testar código assíncrono.

Para saber mais sobre os tópicos mencionados acima, dê uma olhada na documentação oficial do Jest, React Testing Library e jest-dom. Você também pode ler o Common Mistakes with React Testing Library de Kent C. Dodd para aprender sobre as melhores práticas ao trabalhar com React Testing Library. Para mais informações sobre o uso de testes de snapshot em um aplicativo React, confira How To Write Snapshot Tests.

Source:
https://www.digitalocean.com/community/tutorials/how-to-test-a-react-app-with-jest-and-react-testing-library