Introdução à Testing Library — Testando componentes React

Vamos falar sobre testes no Front-end com React e criar um projeto para experimentar a lib react-testing-library.

🐙 Introdução

A organização Testing Library está ganhando muita relevância na área de testes de aplicação com React por terem criado a biblioteca react-testing-library que está presente na ferramenta mais famosa para criar apps com React, o create-react-app

Quando você cria uma aplicação usando o essa ferramenta, ela já tem os suportes necessários para que possa colocar em prática os testes da sua aplicação.

Se você não usa o create-react-app, basta instalar a biblioteca e configurar dentro do seu projeto. Testing Library pode ser utilizada em outros projetos que não utilizam o React.

🧪 Criando a nossa aplicação de testes

Vamos direto ao ponto: mostrar o poder dessa biblioteca na prática.

Nessa simplíssima aplicação vamos listar, cadastrar e deletar tecnologias.

  • Criando o projeto:
npx create-react-app react-testes --template typescript

Agora só acessar a pasta react-testes na sua IDE.

O código fonte está aqui e as instruções para inicializar o projeto estão no README.

📐 Organização do projeto

Apaguei os arquivos: logo192.png, logo512.png, manifest.json, App.css, App.test.jsx, index.css, logo.svg, serviceWorker.ts e todas as referências nos arquivos: index.js, App.jsx e index.html.

Há várias maneiras de estruturar arquivos e pastas com testes, pode criar o arquivo de teste com o arquivo de implementação na mesma pasta, ou criar uma pasta __testes__ dentro do src e deixar todos os arquivos de testes nesta pasta, seguindo a mesma estrutura de pastas da implementação.

Por exemplo:

src/components/Button.tsx
src/components/Button.spec.tsx
// ou 
src/components/Button.test.tsx

Ou com a pasta __tests__ :

src/components/Button.tsx

src/__tests__/components/Button.spec.ts

Por padrão o nome do arquivo de teste precisa ter um .spec. ou .test. entre o nome e a extensão do arquivo.

Siga a melhor convenção para você e seu time entre .spec e .test, isso não interfere em nada.

Nesse post vamos seguir a convenção de criar a pasta __tests__ e deixar todos os testes nesta pasta usando o .spec..

Criando o Componente App.tsx

src/App.tsx:

import React from "react";
import Technologies from "./pages/Technologies";

function App() {
  return (
    <>
      <h1>Bem vindo ao Teste</h1>
      <Technologies />
    </>
  );
}

export default App;

Criamos o componente App.tsx que tem uma mensagem de boas vindas com o elemento <h1>.

E importamos o componente <Technologies /> que será renderizado na tela.

Criando o Componente Technologies.tsx

src/pages/Technologies.tsx:

import React, { useState, FormEvent } from "react";

const Technologies: React.FC = () => {
  const [technologies, setTechnologies] = useState<string[]>(["React"]);
  const [newTech, setNewTech] = useState("");

  function handleSubmit(e: FormEvent) {
    e.preventDefault();

    if (!newTech || technologies.includes(newTech)) return;

    setTechnologies([...technologies, newTech]);
    setNewTech("");
  }

  function handleDelete(tech: string) {
    setTechnologies(technologies.filter((techItem) => techItem !== tech));
  }

  return (
    <>
      <ul data-testid="ul-techs">
        {technologies.map((tech) => (
          <li data-testid={tech} key={tech}>
            {tech}
            {"  "}
            <button
              disabled={tech === "React"}
              data-testid={`${tech}-btn-delete`}
              type="button"
              onClick={() => handleDelete(tech)}
            >
              ❌
            </button>
          </li>
        ))}
      </ul>

      <form data-testid="form-add-tech" onSubmit={handleSubmit}>
        <input
          data-testid="input-add-tech"
          type="text"
          value={newTech}
          onChange={(e) => setNewTech(e.target.value)}
        />
        <button type="submit">Salvar</button>
      </form>
    </>
  );
};

export default Technologies;

Temos o componente funcional Technologies que possui dois estados:

  • technologies — que é um array de string que armazena os nomes das tecnologias e que já vem com a tecnologia "React" preenchida por padrão.
  • newTech —  estado que armazena uma string com o nome da tecnologia que o usuário vai digitar.

Mais abaixo temos duas funções:

  • handleSubmit(e) —  quando disparada adiciona a tecnologia no array de tecnologias e limpa o campo. Primeiro valide se newTech não está vazio e se no array de technologies já existe a tecnologia que está sendo inserida, se sim dá um return para não continuar a operação.
  • handleDelete(tech) —  recebe uma string com a tecnologia, e usando o conceito de imutabilidade recria um array de tecnologias exceto com a tech que foi passada como parâmetro.

Por fim temos o return do componente que renderiza uma lista de tecnologias e que cada item tem sua respectiva tecnologia e um botão para deletar o item assim que clicado no mesmo. Porém não é possível remover a tech "React" pois o botão fica desabilitado para esse valor.

Tem  também um formulário com apenas um input para salvar a tecnologia digitada pelo usuário e o botão de Salvar para submeter o formulário invocando a função handleSubmit.

Para executar o projeto: yarn start na raiz do projeto.

👩‍🏫 Entendendo o it, describe e expect nos arquivos de testes

Primeiro conceito na implementação de teste é que ele possui as funções globais describe, it e expect, as duas primeiras recebem uma string como parâmetro e uma função:

  • Test Suite:

describe("Testando o arquivo App.tsx", () ⇒ {})  - passa uma descrição do teste e a função de callback que contém a implementação de cada teste. Describe é útil para separar um grupo de testes no arquivo.

  • Test Case:

it("Deve listar três elementos na <li>", () => {}) -  recebe a descrição do o que será testado e a implementação do teste como segundo parâmetro.

Geralmente por convenção, escrevemos as descrições em inglês, a palavra it do inglês significa "isto".

it("should be able to show the h1 element")...

Tradução literal: "Isto deve ser capaz de exibir o elemento h1 na tela".

O it dá uma semântica legal e quem sabe inglês curte isso ;)

E por fim, o expect().  é o método para fazer asserção onde comparamos o que esperamos com o resultado. Dentro da função do expect passamos o resultado esperado e depois expect().toBe(null) usamos algum método de assertividade.

describe('true is truthy and false is falsy', () => {
  test('true is truthy', () => {
    expect(true).toBe(true);
  });
 
  test('false is falsy', () => {
    expect(false).toBe(false);
  });
});

Testes no Front-end é descrever as ações do usuário em formato de código e ver se condiz com o resultado esperado, simulando a ação do usuário e o comportamento da página.

🍏 Testando a aplicação

Explicado o contexto da aplicação vamos entrar nos testes.

Observando o código dentro do return (Technologies.tsx) podemos observar que tem uma propriedade data-testid em alguns elementos.

data-testid é o identificador para a biblioteca de testes fazer o bind e acessar o elemento via arquivo de testes usando as queries (consultas). Vamos ver isso na prática.

Nos dois arquivos de testes estamos usando componentes e por isso o React foi importado e a extensão do arquivo de testes é .tsx.

Criando o arquivo de teste App.spec.tsx:

__tests__/App.spec.tsx:

import { render, screen } from "@testing-library/react";

import React from "react";
import App from "../App";

describe("Testing App.jsx", () => {
  // Deve ser possível exibir o elemento h1 na página
  it("should be able to show the h1 element", () => {
    render(<App />);
    const h1Element = screen.getByText(/bem vindo ao teste/i);

    expect(h1Element).toBeInTheDocument();
  });
});

O método render da lib @testing-library/react é responsável por renderizar o componente. Ele recebe como parâmetro o componente e retorna um RenderResult que tem vários métodos e propriedades utilitários como o por exemplo container.

Objeto screen é utilizado para fazer consultas e debugging no DOM. É mais recomendado, e a vantagem que não precisa ficar desestruturando o retorno do render para pegar as funções que fazem as queries.

// ❌
const {getByRole} = render(<App />)

// ✅
render(<App />)
const errorMessageNode = screen.getByRole('alert')

A implementação do teste do App é bem simples:

  • Primeiro renderizo o <App/>
  • Atribuo na contante h1Element o retorno da consulta por texto.
  • Espero que h1Element esteja no documento (toBeInTheDocument). Se true o teste passa se false o teste falha.

Para executar o teste: yarn test na raiz do projeto.

Vamos avançar um pouco mais.

Criando o arquivo de teste Technologies.spec.tsx:

src/__tests__/pages/Technologies.spec.tsx

import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Technologies from "../../pages/Technologies";

// Testando a página de tecnologias
describe("Testing Technologies Page", () => {
  // deve ser possível adicionar novas tecnologias
  it("should be able to add new technology", () => {
    render(<Technologies />);

    const input = screen.getByTestId("input-add-tech");
    const form = screen.getByTestId("form-add-tech");

    userEvent.type(input, "React Native");
    fireEvent.submit(form);

    // toBeTruthy significa que o elemento existe na árvore
    expect(screen.getByTestId("React Native")).toBeTruthy();

    // A mesma coisa que o expect acima porém com má legibilidade e semântica.
    // expect(!!screen.getByTestId("React Native")).toBe(true);
  });

  // deve ser possível listar três
  it("should be able to list three techs", () => {
    // a primeira tecnologia é adicionada por padrão

    const { getByTestId } = render(<Technologies />);

    const input = getByTestId("input-add-tech");
    const form = getByTestId("form-add-tech");
    fireEvent.change(input, { target: { value: "React Native" } });
    fireEvent.submit(form);
    fireEvent.change(input, { target: { value: "Flutter" } });
    fireEvent.submit(form);

    const techList = getByTestId("ul-techs");
    expect(techList.children.length).toBe(3);
  });
});

// Deve ser possível deletar uma tecnologia
it("should be able to delete one tech", () => {
  render(<Technologies />);

  const input = screen.getByTestId("input-add-tech");
  const form = screen.getByTestId("form-add-tech");

  userEvent.type(input, "React Native");
  fireEvent.submit(form);

  // toBeTruthy significa que o elemento existe na árvore
  expect(screen.getByTestId("React Native")).toBeTruthy();

  const itemButton = screen.getByTestId("React Native-btn-delete");
  userEvent.click(itemButton);

  expect(screen.queryByTestId("React Native")).toBeNull();
});

// Botão delete deve estar desabilidade apenas para a tecnologia React
it("button delete should be disabled only for React technology", () => {
  render(<Technologies />);

  const button = screen.getByTestId("React-btn-delete");
  expect(button).toBeDisabled();
});

Vamos comentar cada trecho do arquivo Technologies.spec.tsx.

🔬 1º Teste:

FireEvent e useEvent

  • Importação:

import { fireEvent } from "@testing-library/react";

import userEvent from "@testing-library/user-event";

  • fireEvent — dispara eventos de clique (.click), mudança de texto (.change) e submit do formulário.

Para maioria dos casos você vai precisar usar userEvent que foi criado sob o pacote do fireEvent. Ele fornece vários métodos que estão mais próximos da realidade da interação do usuário com a tela.  Ele possui o método type para disparar os eventos de click, keyDown, keyPress e keyUp. Sempre que possível utilize @testing-library/user-event ao invés de fireEvent.

it("should be able to add new technology", () => {
    render(<Technologies />);

    const input = screen.getByTestId("input-add-tech");
    const form = screen.getByTestId("form-add-tech");

    userEvent.type(input, "React Native");
    fireEvent.submit(form);

    expect(screen.getByTestId("React Native")).toBeTruthy();
  });
  • O teste acima irá renderizar o componente Technologies que será testado.
  • Buscamos o input de texto usando a query screen.getByTestId e o formulário que possui o data-testid="form-add-tech". É nesse momento que usamos os ids que definimos lá no componente.

Com input e form em mãos podemos realizar ações nesses elementos via programação.

  • userEvent.type(input, "React Native");

Utilizo o userEvent  para preencher o input com 'React Native' como valor. E utilizo o fireEvent para disparar o evento de submeter o formulário.

Espero que "React Native" exista, ou seja o resultado da query screen.getByTestId("React Native") seja true (verdadeiro).

  • screen.getByTestId("React Native")

Vai retornar uma string ou não. Se retornar string o valor truthy de uma string é verdadeiro, null, undefined é falso ou seja falsy.

Saiba mais sobre valores Truthy e Falsy aqui.

Poderíamos implementar:

expect(!!screen.getByTestId("React Native")).toBe(true);

O resultado seria o mesmo, porém a asserção com toBeTruthy() é mais semântica e melhora a legibilidade do código ;)

Cabe uma dica: sempre utilize as asserções corretas para cada caso de uso.

🔬 2º Teste:

  it("should be able to list three techs", () => {

    const { getByTestId } = render(<Technologies />);

    const input = getByTestId("input-add-tech");
    const form = getByTestId("form-add-tech");

    fireEvent.change(input, { target: { value: "React Native" } });
    fireEvent.submit(form);

    fireEvent.change(input, { target: { value: "Flutter" } });
    fireEvent.submit(form);

    const techList = getByTestId("ul-techs");
    expect(techList.children.length).toBe(3);
  });

Nesse teste utilizei a desestruturação do render para pegar o método getByTestId, usei o fireEvent.change que simula o usuário digitando um texto, utilizei-o para demonstrar que é possível e existe na API, porém o userEvent.type é mais recomendado para simular eventos do usuário.

Nesse teste espero que após adicionar 2 elementos (o primeiro já foi adicionado quando declarei o array de estados de technologies com valor padrão: React) o tamanho da lista seja 3.

Resumindo, techList é uma ul e seus childrens são as li, se tiver 3 nós filhos o resultado é verdadeiro e o teste passa.

🔬 3º Teste:

  it("should be able to delete one tech", () => {
    render(<Technologies />);

    const input = screen.getByTestId("input-add-tech");
    const form = screen.getByTestId("form-add-tech");

    userEvent.type(input, "React Native");
    fireEvent.submit(form);

    expect(screen.getByTestId("React Native")).toBeTruthy();

    const itemButton = screen.getByTestId("React Native-btn-delete");
    userEvent.click(itemButton);

    expect(screen.queryByTestId("React Native")).toBeNull();
  });
  • Renderiza o componente Technologies, pego o input e form usado os data-testid.
  • Utilizo userEvent.type para simular o usuário preenchendo o input com React Native e faço o submit com fireEvent.submit.
  • Faço uma asserção para verificar se foi adicionado e por fim atribuo na constante itemButtun o botão de deleção do item da lista.
  • Disparo o evento com userEvent.click no botão.
  • Espero que o retorno de React Native seja nulo: <li data-testid={tech} key={tech}>

Para cada item que for renderizado o id será o nome da tecnologia.

O ideal é que fosse o id da tecnologia, mas para exemplo está ok assim.

🔬 4º Teste:

it("button delete should be disabled only for React technology", () => {
    render(<Technologies />);

    const button = screen.getByTestId("React-btn-delete");
    expect(button).toBeDisabled();
  });

Por último e não menos importante, temos o teste que verifica se o botão que tem o item React está desabilitado.

Única novidade aqui é o método de asserção toBeDisabled() – verifica espera que o botão esteja desabilitado para passar no teste.

<button
    disabled={tech === "React"}
    data-testid={`${tech}-btn-delete`}
    type="button"
    onClick={() => handleDelete(tech)}
>❌</button>

🧑‍🎓 Conclusão

É bom conhecer os métodos de asserção, bem como a API para poder implementar de maneira correta, Kent Dodds um mantenedor da biblioteca escreveu esse artigo que fala dos erros comuns na hora de implementar —  é bem interessante dar uma lida.

Cabe aqui uma informação importante não utilize o cleanup antes ou após os testes, eles já são feitos automaticamente. Mais detalhes.

// ❌
import {render, screen, fireEvent, cleanup} from '@testing-library/react'

afterEach(cleanup)

// ✅
import {render, screen, fireEvent} from '@testing-library/react'

Se chegou até aqui, é interessante baixar o projeto que implementamos e colocar a mão na massa.

E aí o que achou da lib? Do post? Manda seu feedback nos comentários ;)

O conhecimento é contínuo, e sempre vai ter um próximo nível! 💡