Testes automatizados com React

Testes automatizados com React
Imagem desenvolvida por Gregório Cavallari

Felizmente, a importância de testes automatizados já é bem conhecida e se tornou uma prática bem difundida entre os desenvolvedores e empresas. No entanto, é comum que os testes no frontend sejam, muitas vezes, negligenciados.

Alguns consideram o frontend uma parte menos crítica em comparação com o backend, já que ele seria apenas a interface que o usuário usa para interagir com o núcleo da aplicação, um bug nele não teria um potencial tão desastroso, e portanto, em prol de entregas mais rápidas, muitos times acabariam ignorando os testes nesse caso. Porém, exatamente pelo frontend ser a interface do usuário, ele é o rosto de seu negócio, uma má experiência do usuário irá afastar clientes e prejudicar a visão que o público tem de seu produto.

Neste artigo, introduzirei o leitor a esse tópico, e, ao final, você estará apto a testar suas aplicações. Usaremos React e Jest e, para gerar o projeto inicial, usaremos o Create React App. Também utilizaremos o Material UI para auxiliar na montagem do layout.

Primeiros passos

Gerado o projeto, em App.test.tsx estarão os testes referente ao App.tsx. Agora iremos criar uma tela que terá um input e um botão. Ao clicar no botão, o valor do input será adicionado como um item de uma lista abaixo dele. Assim, o arquivo App.tsx ficaria assim:

import React, { useState } from "react";
import { Grid } from "@mui/material";

function App() {
  const [inputValue, setInputValue] = useState("");
  const [displayValues, setDisplayValues] = useState<string[]>([])

  const handleAdd = () => {
    const values = [...displayValues, inputValue];
    setDisplayValues(values)
    setInputValue('');
  }
  
  return (
    <Grid
      container
      direction="column"
      justifyContent="center"
      alignItems="center"
      height="100vh"
      spacing={3}
     
    >
      <Grid item>
        <input placeholder="Digite uma palavra" value={inputValue} onChange={(e) => setInputValue(e.target.value)} onKeyDown={(e) => {
          if(e.key === 'Enter') {
            handleAdd();
          }
        }}/>
      </Grid>
      <Grid item>
        <button onClick={handleAdd}>Adicionar</button>
      </Grid>
      <Grid item>
        <Grid container>
          <ul>
          {
            displayValues.map(text => (<li>{text}</li>))
          }
          </ul>
 
        </Grid>
      </Grid>
    </Grid>
  );
}

export default App;

Não é díficil pensar em quais testes deveriamos criar para nos certificar que os requisitos dessa tela sejam atendidos. Primeiramente, podemos criar um teste que simula o usuário digitando valores no input e que, em seguida, clica no botão para adicionar o valor como um item na lista. Escrevemos o teste do jeito abaixo.

import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import App from "./App";

describe("Home Page", () => {
  test("Add item in display list", async () => {
    render(<App />);

    const input = screen.getByPlaceholderText("Digite uma palavra");
    const button = screen.getByRole("button", { name: "Adicionar" });

    fireEvent.change(input, { target: { value: "Item 1" } });
    fireEvent.click(button);

    const listItem = screen.getByRole("listitem");

    expect(listItem).toHaveTextContent("Item 1");
  });
});

Quando criamos um teste, montamos a tela usando a função render e passando o componente React como argumento. Em seguida utilizamos o recurso screen que, como o nome sugere, representa a tela renderizada, e utilizamos seus metodos para buscar os elementos com os quais iremos interagir, no caso, o input e o botão.

É uma boa prática buscar os elementos da mesma forma que o usuário os encontra na tela, preferencialmente pelo papel do elemento ou pelo seu texto. Também é possível buscar pelo id ou testid de um elemento, porém damos preferencia ao texto visível e, em seguinda, à aria-label. Neste teste buscamos o input pelo placeholder que é como o usuário o identificaria. Já o botão, buscamos pelo papel que ele desempenha (role) e seu nome, que é o texto do botão. Caso queira saber a role de cada elemento HTML, é possível consultá-lo na seguinte documentação.

Nosso próximo passo é interagir com os elementos. Para isso usamos fireEvent do própria testing-libraryDisparamos o evento change do input para atualizarmos o estado de inputValue que será usado em handleAdd que é disparado quando clicamos no botão.

Por fim, disparamos o evento onClick do botão. Para checarmos se o item foi adicionado com sucesso, nós buscamos um item de uma lista através de sua role e fazemos um assert que checa se o texto do item é o mesmo inserido no input.

Execute o comando para rodar os testes: yarn test

Outros casos de teste

Caso você tenha analisado bem o nosso componente, se o usuário digitar no input e apertar enter, o item é adicionado na lista. Para assegurar que esse comportamento esteja presente futuramente, podemos escrever um código testando isso.

test("Add item pressing enter", async () => {
  render(<App />);

  const input = screen.getByPlaceholderText("Digite uma palavra");

  fireEvent.change(input, { target: { value: "Another item" } });
  fireEvent.keyDown(input, { key: "Enter" });

  const listItem = screen.getByRole("listitem");

  expect(listItem).toHaveTextContent("Another item");
});

Basicamente, o teste é bem parecido, com a diferença de que ao invés disparar o evento de clique do botão, disparamos o evento keyDown com a chave Enter que é acessado pelo atributo key do evento.

Assim que adicionamos um item, gostariamos de certificar que o input seja limpado. Como podemos testar isso? Segue o teste.

test("Clear input when item is added", async () => {
  render(<App />);

  const input = screen.getByPlaceholderText("Digite uma palavra");

  fireEvent.change(input, { target: { value: "Clear this input" } });

  expect(input).toHaveDisplayValue("Clear this input");

  fireEvent.keyDown(input, { key: "Enter" });

  expect(input).toHaveDisplayValue("");
});

Olhando o código, podemos ver que inserimos um valor no input e checamos para ver se o valor do input é igual ao texto que inserimos. Em seguida, pressionamos enter e verificamos se o campo foi limpo.

Caso adicionemos vários itens, surge a questão: será que a ordem dos itens da lista está correta? Suponhamos que o requisito especificasse que os itens devem estar em ordem alfabética. Nesse caso, realizemos o seguinte teste:

test("Added items must be sorted in alphabetical order", async () => {
  render(<App />);

  const itemsToAdd = ["mix", "courtship", "bird", "prey", "disco"];

  const input = screen.getByPlaceholderText("Digite uma palavra");

  itemsToAdd.forEach((item) => {
    fireEvent.change(input, { target: { value: item } });
    fireEvent.keyDown(input, { key: "Enter" });
  });

  const itemElements = screen
    .getAllByRole("listitem")
    .map((el) => el.textContent);

  expect(itemElements).toEqual(itemsToAdd.sort());
});

Temos uma lista com os itens na ordem em que o usuário irá digitar. Iteramos por essa lista, repetindo as ações que o usuário toma para adicionar cada item, e por fim, usamos getAllByRole para achar todos os elementos, pegamos apenas o texto de cada um e comparamos com essa lista ordenada. Agora é só rodar o teste e…

expect(received).toEqual(expected) // deep equality

- Expected  - 3
+ Received  + 3

  Array [
-   "bird",
-   "courtship",
-   "disco",
    "mix",
+   "courtship",
+   "bird",
    "prey",
+   "disco",
  ]

  61 |       .map((el) => el.textContent);
  62 |
> 63 |     expect(itemElements).toEqual(itemsToAdd.sort());
     |                          ^
  64 |   });
  65 | });
  66 |

  at Object.<anonymous> (src/App.test.tsx:63:26)

Erro! Claro, no nosso componente, nós não ordenamos os itens adicionados, nós apenas colocamos o item no final do array, estando em ordem de criação. Muito bom, o teste realmente está funcionando! Caso o comportamento desejado não esteja lá, o teste irá quebrar. Ajustando o componente, temos que:

import React, { useState } from "react";
import { Grid } from "@mui/material";

function App() {
  const [inputValue, setInputValue] = useState("");
  const [displayValues, setDisplayValues] = useState<string[]>([]);

  const handleAdd = () => {
    const values = [...displayValues, inputValue];
    values.sort();
    setDisplayValues(values);
    setInputValue("");
  };

  return (
    <Grid
      container
      direction="column"
      justifyContent="center"
      alignItems="center"
      height="100vh"
      spacing={3}
    >
      <Grid item>
        <input
          placeholder="Digite uma palavra"
          value={inputValue}
          onChange={(e) => {
            setInputValue(e.target.value);
          }}
          onKeyDown={(e) => {
            if (e.key === "Enter") {
              handleAdd();
            }
          }}
        />
      </Grid>
      <Grid item>
        <button onClick={handleAdd}>Adicionar</button>
      </Grid>
      <Grid item>
        <Grid container>
          <ul>
            {displayValues.map((text, index) => (
              <li key={index}>{text}</li>
            ))}
          </ul>
        </Grid>
      </Grid>
    </Grid>
  );
}

export default App;

Rode o teste novamente e os testes passarão sem problemas.

Trabalhando com select

Vamos adicionar um select na nossa página que representará uma categoria para o item que nós iremos adicionar. Vamos mostrar essa categoria ao lado do nome na lista. Para isso, devemos fazer algumas alterações que está abaixo.

import React, { useState } from "react";
import { Grid } from "@mui/material";

type Item = {
  name: string;
  category: string;
};

function App() {
  const [name, setName] = useState("");
  const [category, setCategory] = useState("");
  const [items, setItems] = useState<Item[]>([]);

  const categoryOptions = [
    { label: "Móveis", value: "furniture" },
    { label: "Eletrônicos", value: "eletronics" },
    { label: "Roupas", value: "clothes" },
    { label: "Outros", value: "others" },
  ];

  const getCategoryLabel = (categoryValue: string) => {
    const foundCategory = categoryOptions.find(
      (c) => c.value === categoryValue
    );

    return foundCategory?.label || categoryValue;
  };

  const clearForm = () => {
    setName("");
    setCategory("");
  }

  const handleAdd = () => {
    const newItems = [...items, { name, category }];
    newItems.sort((a, b) => a.name.localeCompare(b.name));
    setItems(newItems);
    clearForm();
  };

  return (
    <Grid
      container
      direction="column"
      justifyContent="center"
      alignItems="center"
      height="100vh"
      spacing={3}
    >
      <Grid item container justifyContent="center" spacing={3}>
        <Grid item>
          <input
            placeholder="Digite uma palavra"
            value={name}
            onChange={(e) => {
              setName(e.target.value);
            }}
            onKeyDown={(e) => {
              if (e.key === "Enter") {
                handleAdd();
              }
            }}
          />
        </Grid>
        <Grid item>
          <select
            name="select"
            value={category}
            onChange={(e) => setCategory(e.target.value)}
          >
            <option value="" disabled>
              Selecione uma categoria
            </option>
            {categoryOptions.map((category) => (
              <option value={category.value} key={category.value}>
                {category.label}
              </option>
            ))}
          </select>
        </Grid>
        <Grid item>
          <button onClick={handleAdd}>Adicionar</button>
        </Grid>
      </Grid>
      <Grid item>
        <Grid container>
          <ul>
            {items.map((item, index) => (
              <li key={index}>
                {item.name} - {getCategoryLabel(item.category)}
              </li>
            ))}
          </ul>
        </Grid>
      </Grid>
    </Grid>
  );
}

export default App;

Renomeamos displayValues para items, já que estamos chamando cada elemento de item da lista, e cada item agora é um objeto que tem um nome e uma categoria, e iremos ordenar a lista pelo nome. Criamos o estado category que é o valor do select de categoria, assim como o categoryOptions que são as opções do select contendo o value, que é o valor usado de fato, e o label, que é o valor mostrado para o usuário. Também criamos a função clearForm para limpar os valores dos inputs.

O que você acha que aconteceria se rodássemos os testes? Deveriam quebrar, certo? Bom, vamos ver…

Home Page
    ✓ Add item in display list (163 ms)
    ✓ Add item pressing enter (34 ms)
    ✓ Clear input when item is added (17 ms)
    ✕ Added items must be sorted in alphabetical order (77 ms)

Ué, apenas o último quebrou! Isso porque nosso componente não tem nenhuma validação e adiciona itens sem categoria. O toHaveTextContent confere se o texto bate, mesmo que parcialmente. Se o elemento tiver mais que o texto, o assert não reclama. Tudo conforme previsto. Apenas o último teste quebra, pois compara os arrays como um todo, e o conteúdo dos arrays diferem entre si. Primeiramente queremos certificar que o conteúdo da categoria esteja sendo passado para o item coretamente, então vamos refatorar o primeiro teste.

test("Add item in display list", async () => {
  render(<App />);
  
  const nameInput = screen.getByPlaceholderText("Digite uma palavra");
  const categorySelect = screen.getByDisplayValue("Selecione uma categoria");
  const button = screen.getByRole("button", { name: "Adicionar" });

  const itemName = "Item 1";

  fireEvent.change(nameInput, { target: { value: itemName } });

  const options: HTMLInputElement[] = screen.getAllByRole("option");

  fireEvent.change(categorySelect, { target: { value: options[1].value }})
  fireEvent.click(button);

  const listItem = screen.getByRole("listitem");

  expect(listItem).toHaveTextContent(`Item 1 - ${options[1].textContent}`);
});

E vamos consertar o último teste também!

test("Added items must be sorted in alphabetical order", async () => {
  render(<App />);

  const itemsToAdd = [
    { name: "mix", categoryIndex: 1 },
    { name: "courtship", categoryIndex: 1 },
    { name: "bird", categoryIndex: 2 },
    { name: "prey", categoryIndex: 2 },
    { name: "disco", categoryIndex: 1 },
  ];

  const input = screen.getByPlaceholderText("Digite uma palavra");
  const categorySelect = screen.getByDisplayValue("Selecione uma categoria");
  const options: HTMLInputElement[] = screen.getAllByRole("option");

  itemsToAdd.forEach((item) => {
    fireEvent.change(input, { target: { value: item.name } });
    fireEvent.change(categorySelect, {
      target: { value: options[item.categoryIndex].value },
    });
    fireEvent.keyDown(input, { key: "Enter" });
  });

  const itemElements = screen
    .getAllByRole("listitem")
    .map((el) => el.textContent);

  expect(itemElements).toEqual(
    itemsToAdd
      .sort((a, b) => a.name.localeCompare(b.name))
      .map(
        (item) => `${item.name} - ${options[item.categoryIndex].textContent}`
      )
  );
});

Cobrindo outros casos

Queremos garantir que seja preenchido corretamente os dados de cada item da lista. Portanto, validaremos os inputs antes de submeter os dados. Ao tentar submeter, caso um dos campos não esteja preenchido, deve mostrar uma mensagem abaixo dizendo que o campo é obrigatório.

import React, { useState } from "react";
import { Grid } from "@mui/material";

type Item = {
  name: string;
  category: string;
};

function App() {
  const [name, setName] = useState("");
  const [category, setCategory] = useState("");
  const [items, setItems] = useState<Item[]>([]);
  const [validateForm, setValidateForm] = useState(false);

  const categoryOptions = [
    { label: "Móveis", value: "furniture" },
    { label: "Eletrônicos", value: "eletronics" },
    { label: "Roupas", value: "clothes" },
    { label: "Outros", value: "others" },
  ];

  const isFormValid = name && category;

  const getCategoryLabel = (categoryValue: string) => {
    const foundCategory = categoryOptions.find(
      (c) => c.value === categoryValue
    );

    return foundCategory?.label || categoryValue;
  };

  const clearForm = () => {
    setName("");
    setCategory("");
  };

  const handleAdd = () => {
    setValidateForm(true);
    if (isFormValid) {
      const newItems = [...items, { name, category }];
      newItems.sort((a, b) => a.name.localeCompare(b.name));
      setItems(newItems);
      clearForm();
      setValidateForm(false);
    }
  };

  return (
    <Grid
      container
      direction="column"
      justifyContent="center"
      alignItems="center"
      height="100vh"
      spacing={3}
    >
      <Grid item container justifyContent="center" spacing={3}>
        <Grid item alignItems="center">
          <Grid container direction="column">
            <Grid item>
              <input
                placeholder="Digite uma palavra"
                value={name}
                onChange={(e) => {
                  setName(e.target.value);
                }}
                onKeyDown={(e) => {
                  if (e.key === "Enter") {
                    handleAdd();
                  }
                }}
              />
            </Grid>
            {validateForm && !name && (
              <Grid item data-testid="name-error-message">
                Campo obrigatório
              </Grid>
            )}
          </Grid>
        </Grid>
        <Grid item>
          <Grid container direction="column" alignItems="center">
            <Grid item>
              <select
                name="select"
                value={category}
                onChange={(e) => setCategory(e.target.value)}
              >
                <option value="" disabled>
                  Selecione uma categoria
                </option>
                {categoryOptions.map((category) => (
                  <option value={category.value} key={category.value}>
                    {category.label}
                  </option>
                ))}
              </select>
            </Grid>
            {validateForm && !category && (
              <Grid item data-testid="category-error-message">
                Campo obrigatório
              </Grid>
            )}
          </Grid>
        </Grid>
        <Grid item>
          <button onClick={handleAdd}>Adicionar</button>
        </Grid>
      </Grid>
      <Grid item>
        <Grid container>
          <ul>
            {items.map((item, index) => (
              <li key={index}>
                {item.name} - {getCategoryLabel(item.category)}
              </li>
            ))}
          </ul>
        </Grid>
      </Grid>
    </Grid>
  );
}

export default App;

E os testes neste caso? Algum deveria falhar? Consegue prever? Bom, vamos rodar novamente os testes.

Home Page
  ✓ Add item in display list (211 ms)
  ✕ Add item pressing enter (214 ms)
  ✕ Clear input when item is added (18 ms)
  ✓ Added items must be sorted in alphabetical order (115 ms)

Como esperado os testes que não alteramos, inserindo os dados da categoria, falharam, agora que ela é obrigatória. Mas antes de corrgir esses testes, percebemos que o processo de preencher os dados e submetê-los está se tornando repetitivo, poluíndo nosso código e prejudicando na compreensão dos testes. Então vamos criar uma função que simula essas ações.

const addItems = (data: Array<{ name: string; optionIndex: number }>) => {
  const nameInput = screen.getByPlaceholderText("Digite uma palavra");
  const categorySelect = screen.getByDisplayValue("Selecione uma categoria");
  const button = screen.getByRole("button", { name: "Adicionar" });

  const categoryOptions: HTMLInputElement[] = screen.getAllByRole("option");

  data.forEach(({ name, optionIndex }) => {
    fireEvent.change(nameInput, { target: { value: name } });
    fireEvent.change(categorySelect, {
      target: { value: categoryOptions[optionIndex].value },
    });
    fireEvent.click(button);
  });

  return data.map(({ name, optionIndex }) => ({
    name,
    option: categoryOptions[optionIndex].textContent,
  }));
};

Repare que ele retorna os items adicionados com os valores que são renderizados para o usuário. Agora podemos refatorar e consertar os testes.

describe("Home Page", () => {
  test("Add item in display list", async () => {
    render(<App />);

    const itemsToAdd = [{ name: "Item 1", optionIndex: 1 }];

    const [addedItem] = addItems(itemsToAdd);

    const listItem = screen.getByRole("listitem");

    expect(listItem).toHaveTextContent(`Item 1 - ${addedItem.option}`);
  });

  test("Add item pressing enter", async () => {
    render(<App />);

    const input = screen.getByPlaceholderText("Digite uma palavra");
    const categorySelect = screen.getByDisplayValue("Selecione uma categoria");
    const categoryOptions: HTMLInputElement[] = screen.getAllByRole("option");

    fireEvent.change(categorySelect, {
      target: { value: categoryOptions[1].value },
    });
    fireEvent.change(input, { target: { value: "Another item" } });
    fireEvent.keyDown(input, { key: "Enter" });

    const listItem = screen.getByRole("listitem");

    expect(listItem).toHaveTextContent(
      `Another item - ${categoryOptions[1].textContent}`
    );
  });

  test("Clear input when item is added", async () => {
    render(<App />);

    const input = screen.getByPlaceholderText("Digite uma palavra");

    const itemsToAdd = [{ name: "Item 1", optionIndex: 1 }];

    addItems(itemsToAdd);

    expect(input).toHaveDisplayValue("");
  });

  test("Added items must be sorted in alphabetical order", async () => {
    render(<App />);

    const addedItems = addItems([
      { name: "mix", optionIndex: 1 },
      { name: "courtship", optionIndex: 1 },
      { name: "bird", optionIndex: 2 },
      { name: "prey", optionIndex: 2 },
      { name: "disco", optionIndex: 1 },
    ]);

    const itemElements = screen
      .getAllByRole("listitem")
      .map((el) => el.textContent);

    expect(itemElements).toEqual(
      addedItems
        .sort((a, b) => a.name.localeCompare(b.name))
        .map((item) => `${item.name} - ${item.option}`)
    );
  });
});

Desejamos garantir que, ao clicar no botão para criar um item, os campos sejam validados e, nos campos que não estiverem preenchidos, seja mostrada uma mensagem de erro. As mensagens somente devem ser vistas após clicar o botão. Então, primeiro testaremos a presença das mensagens de erro e se, ao clicar o botão, nosso componente impede a criação. Muito trivial, não? Seguindo como estávamos fazendo anteriormente, teriamos um teste assim.

test("Validate fields before create item", async () => {
  render(<App />);

  const button = screen.getByRole("button", { name: "Adicionar" });

  expect(screen.queryByText("Campo obrigatório")).not.toBeInTheDocument();

  fireEvent.click(button);

  expect(screen.getByText("Campo obrigatório")).toBeInTheDocument()

  const listItem = screen.queryAllByRole("listitem");

  expect(listItem).toHaveLength(0);
});

Agora é só rodar e…

● Home Page › Validate fields before create item

TestingLibraryElementError: Found multiple elements with the text: Campo obrigatório

Erro!? Sim! Se você buscar algo que tenha mais de um elemento em que bata com os parametros de busca, você deve usar um *AllBy*, isso porque você deve ter ciência da existência de todos os elementos da tela em que você está trabalhando. Por isso, o testing-library te obriga a falar explicitamente que você está ciente de todos os elementos ao buscar todos eles. Assim você evita de esquecer de testar tudo corretamente. Veja que nesse teste você está checando se existe um elemento que tem o texto “Campo obrigatório”, caso ele simplesmente retornasse o primeiro elemento que ele acha, sem reclamar, teriamos um teste passando normalmente. E se apenas um campo deveria ser obrigatório, mas os dois estão mostrando a mensagem de erro? Um comportamento inesperado que estaria passando despercebido pelo desenvolvedor. Então, para resolvermos isso, basta buscar por todos os campos e ver se tem 2 elementos na tela? Veja bem, e se futuramente realmente tiver dois elementos que batem com o texto, mas que, por algum motivo, não é o elemento da mensagem de erro e sim um outro elemento em qualquer outro lugar da tela? Estariamos testando algo errado. Para resolver isso, usamos o testid para buscar pelo elemento. Veja que, dessa forma, garantimos que caso preenchessemos o campo do nome, deixando o campo de categoria em branco, a mensagem de erro mostrada na tela é a da categoria e não do nome. Assim teremos testes mais seguros e assertivos.

test("Validate fields before create item", async () => {
  render(<App />);

  const button = screen.getByRole("button", { name: "Adicionar" });

  expect(screen.queryByTestId("name-error-message")).not.toBeInTheDocument();
  expect(
    screen.queryByTestId("category-error-message")
  ).not.toBeInTheDocument();

  fireEvent.click(button);

  const nameErrorMessage = screen.getByTestId("name-error-message");
  const categoryErrorMessage = screen.getByTestId("category-error-message");

  expect(nameErrorMessage).toHaveTextContent("Campo obrigatório");
  expect(categoryErrorMessage).toHaveTextContent("Campo obrigatório");

  const listItem = screen.queryAllByRole("listitem");

  expect(listItem).toHaveLength(0);
});

Note que usamos queryByTestId ao inves de getByTestId, isso porque usando metódos getBy*, será jogado um erro caso o elemento não seja encontrado, quebrando nosso teste, sendo que o que queremos seja justamente que o elemento não esteja na tela. Isso é possível com queryBy* que retorna o elemento ou null. Checamos se as mensagens são mostradas de acordo e verificamos se de fato não há nenhum item de lista na tela.

Próximo passo: testar o caso do usuário preencher apenas um campo, se o item não será criado, além de verificar as mensagens de erro.

test("Validate each field individually", async () => {
  render(<App />);

  const nameInput = screen.getByPlaceholderText("Digite uma palavra");
  const categorySelect = screen.getByDisplayValue("Selecione uma categoria");
  const categoryOptions: HTMLInputElement[] = screen.getAllByRole("option");
  const button = screen.getByRole("button", { name: "Adicionar" });

  const itemToAdd = { name: "Item 1", optionIndex: 1 };
  
  // Fill name
  fireEvent.change(nameInput, { target: { value: itemToAdd.name } });
  fireEvent.click(button);

  expect(screen.queryByTestId("name-error-message")).not.toBeInTheDocument();
  expect(screen.getByTestId("category-error-message")).toHaveTextContent("Campo obrigatório");

  // Clear name and fill category
  fireEvent.change(nameInput, { target: { value: "" } });
  fireEvent.change(categorySelect, {
    target: { value: categoryOptions[itemToAdd.optionIndex].value },
  });
  fireEvent.click(button);

  expect(screen.getByTestId("name-error-message")).toHaveTextContent("Campo obrigatório");
  expect(screen.queryByTestId("category-error-message")).not.toBeInTheDocument();

  // Check if no items were added
  const listItem = screen.queryAllByRole("listitem");

  expect(listItem).toHaveLength(0);
});

Primeiro preenchemos o nome e clicamos no botão. Depois limpamos o nome, preenchemos a categoria e tentamos criar novamente. Por fim, verificamos se nenhum item foi criado de fato.

Trabalhando com requisições externas

Até agora vimos a base que alguém necessita para começar a testar suas aplicações com React. Porém seus projetos não vão trabalhar isoladamente, geralmente o frontend se comunica com algum backend. Então, por exemplo, teríamos um service com métodos que fazem requisições de alguns dados. No caso da nossa aplicação, podemos enviar os dados para criar um item, e ao iniciar a tela, faríamos uma requisição para carregar os dados criados. Você já deve ter notado que teriamos um problema: como fariámos com os testes? Um teste não poderia depender de uma requisição. O objetivo dos nosso testes é checar comportamento do frontend somente. A solução é simples, basta criar mocks desses services. Iremos simplesmente simular o comportamento e retornar alguma resposta que nós mesmos definimos.

Antes de criarmos o service, vamos criar um arquivo com os tipos que iremos usar na aplicação, moveremos a tipo Item de App.tsx para types.ts. Em seguida criaremos o arquivo services.ts. Neste caso, iremos usar a biblioteca axios para fazer as requisições http, vamos precisar instalar antes de prosseguirmos.

import axios from "axios";
import { Item } from "./types";

const URL = "https://my-fake-api.com";

export const itemService = {
  create: async (data: Item) => {
    await axios.post(URL, data);
  },
  all: async (): Promise<Item[]> => {
    const response = await axios.get(URL);

    return response.data;
  },
};

No nosso componente, vamos definir os seguintes requisitos:
1 - Ao inciar a aplicação, ela deve requisitar os items da lista.
2 - Deve aparecer um ícone para mostrar que está sendo carregado a página.
3 - Ao finalizar o carregamento incial, deve aparecer os items da lista existentes e caso haja algum erro na requisição, deve mostar um snackbar informando que não foi possível carregar.
4 - Ao criar um item, deve aparecer o ícone de carregamento enquanto aguarda a respota de criação. Em caso de sucesso, deve ser adicionado o item na lista, caso de falha, deve aparecer uma mensagem informando que não foi possível criar.

import React, { useEffect, useState } from "react";
import { Grid, Snackbar, Alert, CircularProgress } from "@mui/material";
import { Item } from "./types";
import { itemService } from "./services";

function App() {
  const [name, setName] = useState("");
  const [category, setCategory] = useState("");
  const [items, setItems] = useState<Item[]>([]);
  const [validateForm, setValidateForm] = useState(false);
  const [loading, setLoading] = useState(true);
  const [showFetchSnack, setShowFetchSnack] = useState(false);
  const [showCreateSnack, setShowCreateSnack] = useState(false);

  const categoryOptions = [
    { label: "Móveis", value: "furniture" },
    { label: "Eletrônicos", value: "eletronics" },
    { label: "Roupas", value: "clothes" },
    { label: "Outros", value: "others" },
  ];

  const isFormValid = name && category;

  const getCategoryLabel = (categoryValue: string) => {
    const foundCategory = categoryOptions.find(
      (c) => c.value === categoryValue
    );

    return foundCategory?.label || categoryValue;
  };

  const clearForm = () => {
    setName("");
    setCategory("");
  };

  const addItem = () => {
    const newItems = [...items, { name, category }];
    newItems.sort((a, b) => a.name.localeCompare(b.name));
    setItems(newItems);
  };

  const createItem = async () => {
    try {
      setLoading(true);
      await itemService.create({ name, category });

      addItem();
      clearForm();
      setValidateForm(false);
    } catch (error) {
      setShowCreateSnack(true);
    } finally {
      setLoading(false);
    }
  };

  const handleSubmit = async () => {
    setValidateForm(true);
    if (isFormValid) {
      await createItem();
    }
  };

  const getData = async () => {
    try {
      setLoading(true);
      const items = await itemService.all();
      items.sort((a, b) => a.name.localeCompare(b.name));

      setItems(items);
    } catch (error) {
      setShowFetchSnack(true);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    getData();
  }, []);

  return (
    <Grid
      container
      direction="column"
      justifyContent="center"
      alignItems="center"
      height="100vh"
      spacing={3}
    >
      {loading ? (
        <CircularProgress data-testid="loading-icon" />
      ) : (
        <Grid container data-testid="list-item-form">
          <Grid item container justifyContent="center" spacing={3}>
            <Grid item alignItems="center">
              <Grid container direction="column">
                <Grid item>
                  <input
                    placeholder="Digite uma palavra"
                    value={name}
                    onChange={(e) => {
                      setName(e.target.value);
                    }}
                    onKeyDown={(e) => {
                      if (e.key === "Enter") {
                        handleSubmit();
                      }
                    }}
                  />
                </Grid>
                {validateForm && !name && (
                  <Grid item data-testid="name-error-message">
                    Campo obrigatório
                  </Grid>
                )}
              </Grid>
            </Grid>
            <Grid item>
              <Grid container direction="column" alignItems="center">
                <Grid item>
                  <select
                    name="select"
                    value={category}
                    onChange={(e) => setCategory(e.target.value)}
                  >
                    <option value="" disabled>
                      Selecione uma categoria
                    </option>
                    {categoryOptions.map((category) => (
                      <option value={category.value} key={category.value}>
                        {category.label}
                      </option>
                    ))}
                  </select>
                </Grid>
                {validateForm && !category && (
                  <Grid item data-testid="category-error-message">
                    Campo obrigatório
                  </Grid>
                )}
              </Grid>
            </Grid>
            <Grid item>
              <button onClick={handleSubmit}>Adicionar</button>
            </Grid>
          </Grid>
          <Grid item>
            <Grid container>
              <ul>
                {items.map((item, index) => (
                  <li key={index}>
                    {item.name} - {getCategoryLabel(item.category)}
                  </li>
                ))}
              </ul>
            </Grid>
          </Grid>
        </Grid>
      )}
      <Snackbar
        open={showFetchSnack}
        autoHideDuration={6000}
        onClose={() => setShowFetchSnack(false)}
      >
        <Alert
          onClose={() => setShowFetchSnack(false)}
          severity="error"
          sx={{ width: "100%" }}
        >
          Não foi possível carregar items
        </Alert>
      </Snackbar>
      <Snackbar
        open={showCreateSnack}
        autoHideDuration={6000}
        onClose={() => setShowCreateSnack(false)}
      >
        <Alert
          onClose={() => setShowCreateSnack(false)}
          severity="error"
          sx={{ width: "100%" }}
        >
          Não foi possível criar item
        </Alert>
      </Snackbar>
    </Grid>
  );
}

export default App;

Se você está usando o axios da versão 1.0 pra frente, deve aparecer o seguinte erro

Jest encountered an unexpected token

Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.

Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.

By default "node_modules" folder is ignored by transformers.

Isso acontece quando você tenta usar algum módulo escrito com ES Module e o Jest é compatível com CommonJs. Como o comportamento padrão do Jest é não aplicar parsers de transformação dos pacotes instalados, usados para manter a compatibilidade de códigos com o Jest, nós só precisamos pedir para que ele aplique o transform no axios, isso é possível usando a configuração de chave transformIgnorePatterns, nele você informa através de uma expressão regular qual path você deseja que nenhum transform seja aplicado, nós iremos dizer que queremos que todo o node_modules seja ignorado, exceto o axios.

{
  # ...

  "jest": {
    "transformIgnorePatterns": ["/node_modules/(?!(axios)/)"]
  }
}

Ao tentarmos rodar os testes novamente, todos devem falhar, isso porque a nossa tela começa carregando, existindo somente o ícone de carregamento. Podemos resolver isso pedindo que o teste tente achar os elementos até que eles sejam renderizados, mas antes disso, devemos incluir no teste a verificação da existencia do ícone de carregamento, para evitar que futuramente alguma mudança no código faça ele não aparecer enquanto a aplicação espera a resposta de algo.

test("Show loading icon while is fetching data", async () => {
  render(<App />);

  expect(screen.getByTestId("loading-icon")).toBeInTheDocument();
  expect(screen.queryByTestId("list-item-form")).not.toBeInTheDocument();

  expect(await screen.findByTestId("list-item-form")).toBeInTheDocument();
  expect(screen.queryByTestId("loading-icon")).not.toBeInTheDocument();
});

Dessa vez, usamos findByTestId que é uma método assíncrono que aguarda até achar o elemento, é o que precisamos quando precisamos aguardar um componente ser atualizado. Criamos um testid para o elemento que contém os inputs e a lista e usamos ele para verificar a existência dele ou não. Este teste verifica se quando o loading está na tela, o list-item-form não está, e quando o list-item-form é renderizado o loading já não está mais. Ótimo, garantimos isso! Mas você pode desconfiar de que alguém possa colocar os elementos que deveriam estar dentro do list-item-form em outro lugar. Bom, podemos garantir isso com um novo teste.

test("Render form and list correctly", async () => {
  render(<App />);

  const listItemForm = await screen.findByTestId("list-item-form");
  expect(listItemForm).toBeInTheDocument();

  expect(within(listItemForm).getByPlaceholderText("Digite uma palavra")).toBeInTheDocument();
  expect(within(listItemForm).getByDisplayValue("Selecione uma categoria")).toBeInTheDocument();
  expect(within(listItemForm).getByRole("list")).toBeInTheDocument();
});

Para fazer uma query dentro de um certo elemento, usamos a função within, com isso certificamos que o elemento buscado está dentro de listItemForm. O próximo passo será consertar os demais testes. Ao rodar os testes, nós temos a seguinte mensagem de erro:

● Home Page › Add item in display list

TestingLibraryElementError: Unable to find an element with the placeholder text of: Digite uma palavra

Isso acontece porque a função addItems que criamos busca os elementos enquanto o ícone de loading está na tela, então a solução seria aguardar o “list-item-form” ser renderizado antes de continuarmos o teste, mas não se esqueça que agora, ao adicionar o elemento, nós dependemos da requisição de criação ao backend, precisamos, então, fazer com que o service retorne uma resposta mock. Para isso nós usamos o spyOn do jest, que substituirá o método de um objeto por uma função que enviará as informações das interações do método para o spy, além de também retornar como resposta o que quisermos.

test("Add item in display list", async () => {
  render(<App />);

  const createSpy = jest
    .spyOn(itemService, "create")
    .mockImplementation(async () => {});

  const itemsToAdd = [{ name: "Item 1", optionIndex: 1 }];

  expect(await screen.findByTestId("list-item-form")).toBeInTheDocument();

  const [addedItem] = addItems(itemsToAdd);

  expect(screen.getByTestId("loading-icon")).toBeInTheDocument();
  
  const listItem = await screen.findByRole("listitem");

  expect(listItem).toHaveTextContent(`Item 1 - ${addedItem.option}`);
  expect(createSpy).toHaveBeenCalledWith({
    name: "Item 1",
    category: "furniture",
  });
});

Criamos um spy do método create do service, passamos a função, que deve ser executada quando o service for usado, como um argumento dentro do método mockImplementation do spy. Checamos se o ícone de loading aparece enquanto aguardamos a resposta do back e aguardamos o listitem ser renderizado. Por fim, testamos o argumento passado para o service com o toHaveBeenCalledWith, porém, note os argumento passados são fixos, o que pode ser um problema, pois se futuramente os opções de categoria for modificada, o teste poderá quebrar. Contudo, temos o problema de que o valor renderizado (label) é diferente do valor que é enviado ao backend, o que é uma situação bem comum. Como faremos para acessar esse valor? Vamos escolher uma solução bem simples que é passar um atributo chamado category-value para o elemento option. Assim precisamos fazer uma modificação no componente.

<Grid item>
  <select
    name="select"
    value={category}
    onChange={(e) => setCategory(e.target.value)}
  >
    <option value="" disabled>
      Selecione uma categoria
    </option>
    {categoryOptions.map((category) => (
      <option value={category.value} key={category.value} category-value={category.value}>
        {category.label}
      </option>
    ))}
  </select>
</Grid>

E no App.test.tsx.

# ...

const addItems = (data: Array<{ name: string; optionIndex: number }>) => {
  # ...

  return data.map(({ name, optionIndex }) => ({
    name,
    optionLabel: categoryOptions[optionIndex].textContent,
    optionValue: categoryOptions[optionIndex].getAttribute("category-value")
  }));
};

# ...


  test("Add item in display list", async () => {
    # ...

    expect(listItem).toHaveTextContent(`${addedItem.name} - ${addedItem.optionLabel}`);
    expect(createSpy).toHaveBeenCalledWith({
      name: addedItem.name,
      category: addedItem.optionValue,
    });
  });

Podemos passar para o próximo passo que e criar um teste para garantirmos que os itens da lista serão renderizados, se o carregamento inicial retornar itens.

test("Render initial list items", async () => {
  const addedItems: Item[] = [
    { name: "chair", category: "furniture" },
    { name: "prey", category: "others" },
    { name: "mouse", category: "eletronics" },
  ];

  jest.spyOn(itemService, "all").mockImplementation(async () => addedItems);

  render(<App />);

  expect(await screen.findByTestId("list-item-form")).toBeInTheDocument();

  const listItem = await screen.findAllByRole("listitem");

  expect(listItem.map((i) => i.textContent)).toEqual([
    "chair - Móveis",
    "mouse - Eletrônicos",
    "prey - Outros",
  ]);
});

Todos os testes que precisamos estão criados, agora só precisamos consertar os demais testes.

test("Add item pressing enter", async () => {
  jest.spyOn(itemService, "create").mockImplementation(async () => {});

  render(<App />);

  const input = await screen.findByPlaceholderText("Digite uma palavra");
  const categorySelect = screen.getByDisplayValue("Selecione uma categoria");
  const categoryOptions: HTMLInputElement[] = screen.getAllByRole("option");

  fireEvent.change(categorySelect, {
    target: { value: categoryOptions[1].value },
  });
  fireEvent.change(input, { target: { value: "Another item" } });
  fireEvent.keyDown(input, { key: "Enter" });

  const listItem = await screen.findByRole("listitem");

  expect(listItem).toHaveTextContent(
    `Another item - ${categoryOptions[1].textContent}`
  );
});

test("Clear input when item is added", async () => {
  jest.spyOn(itemService, "create").mockImplementation(async () => {});

  render(<App />);

  await waitFor(() =>
    expect(screen.queryByTestId("loading-icon")).not.toBeInTheDocument()
  );

  const itemsToAdd = [{ name: "Item 1", optionIndex: 1 }];

  addItems(itemsToAdd);

  const input = await screen.findByPlaceholderText("Digite uma palavra");

  expect(input).toHaveDisplayValue("");
});

Note que usamos uma função do testing-library chamada waitFor, ele é interessante para os casos em que precisamos aguardar por uma atualização da tela que depende de algum recursos assíncrono, assim a expressão dentro dele será executada por diversas vezes até obtiver sucesso ou então até estourar o tempo do timeout configurado pelo jest. Perceba que, no nosso caso, o teste irá buscar pelo ícone de loading até ele não existir mais na tela, para então podermos seguir com os testes. Mas você pode se perguntar se nós não poderiamos fazer um findByTestId do list-item-form sem a necessidade de usar o waitFor, e sim, poderíamos, porém escolhi fazer desse jeito para introduzir mais esse recurso e exemplificar o uso desse recurso. E por fim, segue o restante dos testes arrumados.

test("Added items must be sorted in alphabetical order", async () => {
  jest.spyOn(itemService, "create").mockImplementation(async () => {});

  jest.spyOn(itemService, "all").mockImplementation(async () => [
    { name: "chair", category: "furniture" },
    { name: "prey", category: "others" },
    { name: "mouse", category: "eletronics" },
    { name: "table", category: "furniture" },
    { name: "ipad", category: "eletronics" },
  ]);

  render(<App />);

  await waitFor(() =>
    expect(screen.queryByTestId("loading-icon")).not.toBeInTheDocument()
  );

  const listItem = await screen.findAllByRole("listitem");

  const itemElements = listItem.map((el) => el.textContent);

  expect(itemElements).toEqual([
    "chair - Móveis",
    "ipad - Eletrônicos",
    "mouse - Eletrônicos",
    "prey - Outros",
    "table - Móveis",
  ]);
});

test("Validate fields before create item", async () => {
  render(<App />);

  await waitFor(async () =>
    expect(await screen.findByTestId("list-item-form")).toBeInTheDocument()
  );

  const button = screen.getByRole("button", { name: "Adicionar" });

  expect(screen.queryByTestId("name-error-message")).not.toBeInTheDocument();
  expect(
    screen.queryByTestId("category-error-message")
  ).not.toBeInTheDocument();

  fireEvent.click(button);

  const nameErrorMessage = screen.getByTestId("name-error-message");
  const categoryErrorMessage = screen.getByTestId("category-error-message");

  expect(nameErrorMessage).toHaveTextContent("Campo obrigatório");
  expect(categoryErrorMessage).toHaveTextContent("Campo obrigatório");

  const listItem = screen.queryAllByRole("listitem");

  expect(listItem).toHaveLength(0);
});

test("Validate each field individually", async () => {
  render(<App />);

  await waitFor(async () =>
    expect(await screen.findByTestId("list-item-form")).toBeInTheDocument()
  );

  const nameInput = screen.getByPlaceholderText("Digite uma palavra");
  const categorySelect = screen.getByDisplayValue("Selecione uma categoria");
  const categoryOptions: HTMLInputElement[] = screen.getAllByRole("option");
  const button = screen.getByRole("button", { name: "Adicionar" });

  const itemToAdd = { name: "Item 1", optionIndex: 1 };

  // Fill name
  fireEvent.change(nameInput, { target: { value: itemToAdd.name } });
  fireEvent.click(button);

  expect(screen.queryByTestId("name-error-message")).not.toBeInTheDocument();
  expect(screen.getByTestId("category-error-message")).toHaveTextContent(
    "Campo obrigatório"
  );

  // Clear name and fill category
  fireEvent.change(nameInput, { target: { value: "" } });
  fireEvent.change(categorySelect, {
    target: { value: categoryOptions[itemToAdd.optionIndex].value },
  });
  fireEvent.click(button);

  expect(screen.getByTestId("name-error-message")).toHaveTextContent(
    "Campo obrigatório"
  );
  expect(
    screen.queryByTestId("category-error-message")
  ).not.toBeInTheDocument();

  // Check if no items were added
  const listItem = screen.queryAllByRole("listitem");

  expect(listItem).toHaveLength(0);
});

Conclusão

Foram introduzidos os principais recursos utilizados para criação de testes enquanto simulamos o desenvolvimento de um projeto, mostrando como lidamos com questões em relação aos testes que vão surgindo no processo. Com isso, o leitor deve ser capaz de começar a testar suas aplicações.