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
). Setrue
o teste passa sefalse
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 odata-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
eform
usado osdata-testid
. - Utilizo
userEvent.type
para simular o usuário preenchendo o input com React Native e faço o submit comfireEvent.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.
🔗 Links:
- https://testing-library.com/
- https://testing-playground.com/
- https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
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! 💡