Testes unitários com Next JS, React Hook Form, Jest e Testing Library

Lucas Peixoto
7 min readDec 27, 2023

--

Introdução

Antes de iniciar gostaria de dizer que este artigo é para quem já conhece os fundamentos de testes unitários com Jest e React testing library para projetos React ou NextJs.

Aqui vamos apresentar um tutorial de como testar componentes de formulários com React Hook Form, tanto testes de renderização quanto de comportamento envolvendo as nossas validações conforme as múltiplas ações possíveis de usuário.

Configuração

Vamos começar a partir deste projeto: https://github.com/lucasspeixoto/react-forms-unittests-example na branch start.

Vamos começar instalando as dependências necessárias para configuração do nosso ambiente de testes.

npm install jest jest-environment-jsdom ts-jest @testing-library/jest-dom @testing-library/react @testing-library/user-event @types/jest @types/mocha --
save-dev

Agora vamos incluir na raiz do projeto um arquivo de setup do jest, somente para importar o @testing-library/jest-dom, jest.setup.js:

// Optional: configure or set up a testing framework before each test.
// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`

// Used for __tests__/testing-library.js
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

Agora o arquivo de configuração jest.config.js:

const nextJest = require('next/jest');

const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
});

// Add any custom config to be passed to Jest
/** @type {import('jest').Config} */
const config = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
preset: 'ts-jest',
verbose: true,
};

// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(config);

Criação Schema

Como mencionei, nossos testes vão ser realizados em cima de um formulário com react-hook-form, como estamos utilizando typescript, vamos utilizar tambem zod e o @hookform/resolvers.

npm i zod @hookform/resolvers react-hook-form

Agora vamos criar um arquivo login-schema.ts dentro da pasta /src/schemas onde vamos configurar com o zod as nossas validações para um formulário de login.

import { z } from 'zod';

export const loginSchema = z.object({
email: z
.string()
.min(1, 'E-mail é obrigatório!')
.email('E-mail em formato inválido!')
.toLowerCase(),
password: z.string().min(6, 'A senha precisa conter no mínimo 6 caracteres!'),
});

export type LoginFormData = z.infer<typeof loginSchema>;

Criação components

Para isolar melhor os componentes, vamos criar um componente LoginWrapper que vai conter toda a nossa lógica de validação do nosso formulário.
O LoginWrapper vai chamar o LoginForm aonde vamos de fato incluir os campos e configurar o formulário com o react-hook-form.
Para ganhar tempo, seguem os componentes page.tsx (src/app/page.tsx), LoginWrapper (src/app/components/LoginWrapper) e LoginForm (src/app/components/LoginForm).

page.tsx:

import React from "react";
import LoginWrapper from "./components/login/LoginWrapper";

export default function Home() {
return (
<React.Fragment>
<div className="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
<div className="flex flex-wrap items-center justify-center p-0">
<div className="h-screen bg-[url('/images/wallpaper.png')] bg-cover bg-repeat md:block w-full">
<div className="flex h-screen w-full flex-col items-center justify-center px-0">
<LoginWrapper />
</div>
</div>
</div>
</div>
</React.Fragment>
);
}

LoginWrapper.tsx:

"use client";

import React from "react";

import LoginForm from "./LoginForm";
import { LoginFormData } from "@/app/schemas/login-schema";

const LoginWrapper: React.FC = () => {
const singInWithEmailAndPasswordHandler = async (data: LoginFormData) => {
console.log(data);
};

return (
<LoginForm
singInWithEmailAndPasswordHandler={singInWithEmailAndPasswordHandler}
/>
);
};

export default LoginWrapper;

LoginForm.tsx:

"use client";

import { LoginFormData, loginSchema } from "@/app/schemas/login-schema";
import { zodResolver } from "@hookform/resolvers/zod";
import React from "react";
import { useForm } from "react-hook-form";

type LoginFormProps = {
singInWithEmailAndPasswordHandler: (data: LoginFormData) => void;
};

const LoginForm: React.FC<LoginFormProps> = ({
singInWithEmailAndPasswordHandler,
}) => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});

return (
<div className="w-[80%] sm:w-[50%] md:w-[35%] lg:w-[25%]">
<form onSubmit={handleSubmit(singInWithEmailAndPasswordHandler)}>
<div className="mb-3 gap-2">
<div className="relative">
<label
htmlFor="email"
data-testid="email"
className="mb-1 block font-medium text-white"
>
E-mail
</label>
<input
id="email"
type="email"
aria-label="email"
{...register("email")}
className="w-full rounded-lg border border-stroke bg-transparent py-2 pl-6 pr-10 outline-none focus:border-primary focus-visible:shadow-none dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary"
/>
</div>

<>
{errors.email && (
<span
role="alert"
data-testid="email-error"
className="text-sm text-meta-1 dark:text-meta-7"
>
{errors.email.message}
</span>
)}
</>
</div>

<div className="mb-3 gap-2">
<div className="relative">
<label
htmlFor="password"
data-testid="password"
className="mb-1 block font-medium text-white"
>
Senha
</label>
<input
id="password"
type="password"
aria-label="password"
{...register("password")}
className="w-full rounded-lg border border-stroke bg-transparent py-2 pl-6 pr-10 outline-none focus:border-primary focus-visible:shadow-none dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary"
/>
</div>

<>
{errors.password && (
<span
role="alert"
data-testid="password-error"
className="text-sm text-meta-1 dark:text-meta-7"
>
{errors.password.message}
</span>
)}
</>
</div>

<div className="mb-4 mt-5 items-center flex justify-center w-full">
<button
data-testid="login-button"
type="submit"
className="flex w-auto px-4 cursor-pointer items-center justify-center gap-3.5 rounded-lg border border-primary bg-primary p-2 text-white transition hover:bg-opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
>
Entrar
</button>
</div>
</form>
</div>
);
};

export default LoginForm;

Aqui ja temos nosso formulário funcionando com dois campos, um de e-mail e outro de senha, ambos com validações customizadas conforme configuramos em src/app/schemas/login-schema.ts.

Criação dos testes

Vamos de fato agora escrever nossos testes unitários para garantir o correto comportamento do nosso formulário.

Antes disso vamos incluir dois scripts em nosso package.json para rodar os testes, “test” e “test”:watch”:

{
"name": "react-forms-unittests-example",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "jest",
"test:watch": "jest --watchAll"
},
...

Uma forma do jest reconhecer testes em nosso projeto Next é criar os arquivos dentro de pastas nomeadas como __tests__, sendo assim, na pasta src/app.components/login, vamos criar a pasta __tests__ e incluir o arquivo para testes do nosso formulário, LoginForm.tests.jsx:

import "@testing-library/jest-dom";

describe("LoginForm", () => {
describe("Render", () => {
//! Testes de renderização
});

describe("Behaviour", () => {
//! Testes de Comportamento
});
});

Gosto de dividir testes unitários em React em dois tipos, um describe de “Render” onde verifico se as renderizações necessárias do componente ocorrem e outro describe de “Behaviour”, para validar lógicas e iterações do usuário como cliques e preenchimento de campos.

Como todos os testes vão precisar renderizar nosso componente de login, que recebe a função singInWithEmailAndPasswordHandler como parâmetro, vamos incluir essa renderização dentro de um beforeEach com a função mocada.

import '@testing-library/jest-dom';

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

import LoginForm from '../LoginForm';

describe("LoginForm", () => {
const singInWithEmailAndPasswordHandler = jest.fn();

beforeEach(() => {
render(
<LoginForm
singInWithEmailAndPasswordHandler={singInWithEmailAndPasswordHandler}
/>
);
});

describe("Render", () => {
//! Testes de renderização
});

describe("Behaviour", () => {
//! Testes de Comportamento
});
});

Começando pelos testes de renderização, vamos verificar primeiramente se nossos inputs de E-mail e Senha estão renderizado com os labels correto.

it('should render E-mail input and label', () => {
const myEmailLabelElement = screen.getByTestId('email');
const emailInput = screen.getByLabelText('email'); // ? screen.getByRole('textbox', { name: /email/i });

expect(emailInput).toBeVisible();
expect(myEmailLabelElement).toBeInTheDocument();
expect(myEmailLabelElement).toHaveTextContent('E-mail');
});

it('should render Password input and label', () => {
const myPasswordLabelElement = screen.getByTestId('password');
const passwordInput = screen.getByLabelText('password');

expect(passwordInput).toBeVisible();
expect(myPasswordLabelElement).toBeInTheDocument();
expect(myPasswordLabelElement).toHaveTextContent('Senha');
});

Vamos agora iniciar os testes de comportamento. Conforme as validações de nosso formulário, se o usuário clicar no botão ‘Entrar’, devem aparecer duas mensagens de erro, uma para email e outra para senha, conforme imagem abaixo.

Mensagens de erro para tentativa de login com os campos vazios.

Sendo assim, nosso primeiro teste de comportamento vai simular um clique com os campos vazios e vamos testar se as mensagens aparecem conforme esperado.

it('should display two alerts error when email and password are invalid', async () => {
const loginButton = screen.getByTestId('login-button');

fireEvent.submit(loginButton);

const spanErrorElements = await screen.findAllByRole('alert');

expect(spanErrorElements).toHaveLength(2);
});

Aqui podemos utilizar o fireEvent do @testing-library/react para executar o submit, na sequência utilizamos o findAllByRole para encontrar todos os elementos span com a propriedade role igual a alert, como esperamos duas mensagens de erro, o retorno deve ser um array de duas posições.

Agora vamos escrever alguns testes para o caso de e-mail inválido e senha correta, na sequência e-mail correto e senha inválida. Para isso vamos precisar inserir valores nos inputs. Aqui podemos validar a mensagem de erro igual o que fizemos no teste anterior e podemos também verificar a chamada da função singInWithEmailAndPasswordHandler, que não deve ocorrer já que um dos campos não estará válido.

it('should display matching error when email is invalid', async () => {
const emailInput = screen.getByLabelText('email');
fireEvent.input(emailInput, { target: { value: 'test' } });

const passwordInput = screen.getByLabelText('password');
fireEvent.input(passwordInput, { target: { value: 'password' } });

const loginButton = screen.getByTestId('login-button');
fireEvent.submit(loginButton);

//! Mensagem de erro
const emaiLErrorMessage = 'E-mail em formato inválido!';
const spanErrorElements = await screen.findAllByRole('alert');
expect(spanErrorElements).toHaveLength(1);
expect(spanErrorElements[0]).toHaveTextContent(emaiLErrorMessage);

//! Textos inseridos nos elementos de input
expect(emailInput).toHaveValue('test');
expect(passwordInput).toHaveValue('password');

await waitFor(() =>
expect(singInWithEmailAndPasswordHandler).not.toHaveBeenCalledTimes(1)
);
});

it('should display matching error when password is invalid', async () => {
const emailInput = screen.getByLabelText('email');
fireEvent.input(emailInput, { target: { value: 'lucas@gmail.com' } });

const passwordInput = screen.getByLabelText('password');
fireEvent.input(passwordInput, { target: { value: '123' } });

const loginButton = screen.getByTestId('login-button');
fireEvent.submit(loginButton);

//! Mensagem de erro
const passwordErrorMessage =
'A senha precisa conter no mínimo 6 caracteres!';
const spanErrorElements = await screen.findAllByRole('alert');
expect(spanErrorElements).toHaveLength(1);
expect(spanErrorElements[0]).toHaveTextContent(passwordErrorMessage);

//! Textos inseridos nos elementos de input
expect(emailInput).toHaveValue('lucas@gmail.com');
expect(passwordInput).toHaveValue('123');

await waitFor(() =>
expect(singInWithEmailAndPasswordHandler).not.toHaveBeenCalledTimes(1)
);
});

Para finalizar, outra situação importante, é o caso onde todos os campos estão válidos e a função singInWithEmailAndPasswordHandler deve ser chamada e o total de erros é zero.

Conclusão

Testes são fundamentais mesmo no frontend, devemos obter a maior cobertura possível dentro dos projetos que estamos desenvolvendo, testes evitam bug, melhoram a qualidade do nosso código, já que muitas melhorias ocorrem durante a escrita dos testes.
Neste artigo vimos quais situações devemos abordar em testes unitários de frontend para formulários, em especial react-hook-form, mas os mesmos conceitos poderiam ser aplicados com qualquer outra biblioteca.

“Por que a maioria dos desenvolvedores tem medo de alterações contínuas em seu código? Eles têm medo de quebrá-lo! Por que eles têm medo de quebrá-lo? Porque eles não têm testes.”

Robert C Martin (Autor de Clean Code)

--

--

Lucas Peixoto

Sou o Lucas, desenvolvedor Web apaixonado em frontend atuando profissionalmente a 4 anos com Angular, React e diversas ferramentas do escossistema frontend.