Testando Formulários com React Hook Form e TDD
Formulários são, sem dúvida, um dos componentes mais temidos pelos desenvolvedores front-end. Eles geralmente envolvem validações…
Testando Formulários com React Hook Form e TDD

Formulários são, sem dúvida, um dos componentes mais temidos pelos desenvolvedores front-end. Eles geralmente envolvem validações complexas, lógicas de enable/disable e campos personalizados. Por isso, testar formulários é crucial para garantir uma experiência de usuário consistente e livre de erros.
Neste artigo, vamos construir um formulário simples usando React Hook Form e TDD (Test-Driven Development). Vamos começar com as dependências necessárias e, em seguida, criar e testar o formulário passo a passo.
// dependencies
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0"
"react-hook-form": "^7.54.2"
"zod": "^3.24.2"
"@hookform/resolvers": "^4.1.3"
// dev dependencies
"@testing-library/jest-dom": "^6.6.3"
"@types/jest": "^29.5.14"
"jest": "^29.7.0"
"jest-environment-jsdom": "^29.7.0",
"react-testing-library": "^8.0.1",
"ts-jest": "^29.2.6"
Lembre-se de ajustar a versão de cada pacote segundo a sua necessidade. Em seguida vamos criar um forms simples com um email e uma senha:
export const Form = () => {
return (
<form>
<input aria-label="email" type="email" placeholder="email" />
<input aria-label="password" type="text" placeholder="password" />
<button>send</button>
</form>
);
};
E um arquivo de teste para ele que garante a mais básica funcionalidade: aparecer na tela para o usuário:
import "@testing-library/jest-dom";
import { logRoles, render, screen } from "@testing-library/react";
import { Form } from "../components/Form";
describe("Form with react hook form", () => {
it("should render forms", () => {
const view = render(<Form />);
logRoles(view.container);
const inputEmail = view.getByRole("textbox", { name: "email" });
const inputPassword = view.getByRole("textbox", { name: "password" });
const buttonSubmit = view.getByRole("button", { name: "send" });
// toBeVisible garante que o elemento está vísivel para o usuário, uma vez que
// o elemento pode estar na DOM (na qual checamos com toBeInTheDocument) mas
// pode não estar sendo exibido
expect(inputEmail).toBeVisible();
expect(inputPassword).toBeVisible();
expect(buttonSubmit).toBeVisible();
});
});
O método render renderiza o nosso form, ou seja, insere ele na DOM, já o getByRole vai procurar pelo nó (elemento HTML) com a role especificada (a role de cada componente pode ser visto no console usando logRoles(view.container)). Por fim, estamos apenas verificando se os elementos estão visíveis.
Você pode “assistir” os seus testes com o comando
yarn test file-name.spec.ts --watch
Dessa forma, sempre que você salvar seus arquivos de teste, os testes rodarão automaticamente.
Agora podemos começar a integrar o React Hook Form e o Zod no nosso formulário:
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
const formSchema = z.object({
email: z.string().email("Informe um email válido"),
password: z.string().min(8, "A senha deve possuir pelo menos 8 caracteres"),
});
type FormType = z.infer<typeof formSchema>;
export const Form = () => {
const {
handleSubmit,
register,
formState: { errors },
} = useForm<FormType>({
mode: "all",
resolver: zodResolver(formSchema),
criteriaMode: "all",
defaultValues: {
email: "",
password: "",
},
});
const submitForm = (data: FormType) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(submitForm)}>
<input
{...register("email")}
aria-label="email"
type="email"
placeholder="email"
/>
<input
{...register("password")}
aria-label="password"
type="text"
placeholder="password"
/>
<button>send</button>
</form>
);
};
Você pode saber mais sobre o zod e o React hook Forms nos links:
Você pode reparar que os testes continuam passando normalmente, uma vez que não mudamos nada na lógica de exibição dos elementos em tela. Podemos então começar testando a parte de validação do nosso formulário:
it("sould show error message when submit form empty", async () => {
const view = render(<Form />);
const buttonSubmit = view.getByRole("button", { name: "send" });
act(() => {
fireEvent.click(buttonSubmit);
});
await waitFor(() => {
expect(view.getByText("Informe um email válido")).toBeVisible();
expect(
view.getByText("A senha deve possuir pelo menos 8 caracteres")
).toBeVisible();
});
});
O fireEvent permite que a gente simule uma interação do usuário com os componentes (nesse caso, a ação de click no botão) e o waitFor aguarda a ação ser concluída antes de realizar alguma checagem. O uso do act é uma boa prática para garantir a consistência dos eventos.
Você vai reparar que agora nossos testes estão falhando, isso porque não estamos exibindo nenhuma mensagem de erro ainda. Vamos arrumar isso, mude o HTML do seu formulário para:
<form onSubmit={handleSubmit(submitForm)}>
<input
{...register("email")}
aria-label="email"
type="email"
placeholder="email"
/>
<span style={{ color: "red" }}>{errors.email?.message ?? ""}</span>
<input
{...register("password")}
aria-label="password"
type="text"
placeholder="password"
/>
<span style={{ color: "red" }}>{errors.password?.message ?? ""}</span>
<button>send</button>
</form>
Agora nossos testes estão passando novamente, porém, lembre-se de testar todos os casos possíveis de validação, como por exemplo:
it("sould show error message when email is invalid", async () => {
const view = render(<Form />);
const inputEmail = view.getByRole("textbox", { name: "email" });
const buttonSubmit = view.getByRole("button", { name: "send" });
act(() => {
fireEvent.change(inputEmail, { target: { value: "email-invalid.com" } });
buttonSubmit.click();
});
await waitFor(() => {
expect(view.getByText("Informe um email válido")).toBeInTheDocument();
});
});
it("sould show error message when password is smaller than eight", async () => {
const view = render(<Form />);
const inputPassword = view.getByRole("textbox", { name: "password" });
const buttonSubmit = view.getByRole("button", { name: "send" });
act(() => {
fireEvent.change(inputPassword, { target: { value: "123456" } });
buttonSubmit.click();
});
await waitFor(() => {
expect(
view.getByText("A senha deve possuir pelo menos 8 caracteres")
).toBeInTheDocument();
});
});
O fireEvent.change permite simular a mudança de valor em um elemento de entrada (como um <input>, <textarea>, ou <select>) no DOM. Ele é usado para disparar o evento change em um elemento, o que é útil para testar como um componente reage quando o usuário insere ou altera um valor em um campo de formulário.
Agora podemos testar o caso de sucesso do nosso formulário, ou seja, a mensagem de erro não deve aparecer, a função de submit deve ser chamado e os campos devem ser limpos. Vamos começar com as mensagens de erro e os campos limpos:
it("sould submit when values is valid", async () => {
const view = render(<Form />);
const inputEmail = view.getByRole("textbox", {
name: "email",
}) as HTMLInputElement;
const inputPassword = view.getByRole("textbox", {
name: "password",
}) as HTMLInputElement;
const buttonSubmit = view.getByRole("button", { name: "send" });
act(() => {
fireEvent.change(inputPassword, { target: { value: "12345678" } });
fireEvent.change(inputEmail, { target: { value: "email@teste.com" } });
buttonSubmit.click();
});
await waitFor(() => {
// mensagens de erro não devem aparecer
expect(
view.queryByText("Informe um email válido")
).not.toBeInTheDocument();
expect(
view.queryByText("A senha deve possuir pelo menos 8 caracteres")
).not.toBeInTheDocument();
// os campos devem ser limpos após o submit
expect(inputEmail.value).toBe("");
expect(inputPassword.value).toBe("");
});
});
Aqui os testes irão falhar, uma vez que o forms não está sendo limpo após o submit, para resolver isso podemos chamar a função reset do react hook form no handleSubmit:
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
...
export const Form = () => {
const {
handleSubmit,
register,
reset,
formState: { errors },
} = useForm<FormType>({
mode: "all",
resolver: zodResolver(formSchema),
criteriaMode: "all",
defaultValues: {
email: "",
password: "",
},
});
const submitForm = (data: FormType) => {
console.log(data);
};
return (
<form
onSubmit={handleSubmit((data) => {
reset();
submitForm(data);
})}
>
<input
{...register("email")}
aria-label="email"
type="email"
placeholder="email"
/>
<span style={{ color: "red" }}>{errors.email?.message ?? ""}</span>
<input
{...register("password")}
aria-label="password"
type="text"
placeholder="password"
/>
{errors.password && (
<span style={{ color: "red" }}>{errors.password?.message ?? ""}</span>
)}
<button>send</button>
</form>
);
};
Agora os testes passam novamente! Vamos então testar se nossa função submitForm está sendo chamada. Para isso, vamos precisar alterar um pouco o nosso componente para que a função de submit seja injetada nele (a injeção de dependência ajuda a manter os componentes desacoplados e testáveis). Altere o componente para:
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
const formSchema = z.object({
email: z.string().email("Informe um email válido"),
password: z.string().min(8, "A senha deve possuir pelo menos 8 caracteres"),
});
type FormType = z.infer<typeof formSchema>;
type Props = {
submitForm: (data: FormType) => void;
};
export const Form = ({ submitForm }: Props) => {
const {
handleSubmit,
register,
reset,
formState: { errors },
} = useForm<FormType>({
mode: "all",
resolver: zodResolver(formSchema),
criteriaMode: "all",
defaultValues: {
email: "",
password: "",
},
});
return (
<form
onSubmit={handleSubmit((data) => {
reset();
submitForm(data);
})}
>
<input
{...register("email")}
aria-label="email"
type="email"
placeholder="email"
/>
<span style={{ color: "red" }}>{errors.email?.message ?? ""}</span>
<input
{...register("password")}
aria-label="password"
type="text"
placeholder="password"
/>
{errors.password && (
<span style={{ color: "red" }}>{errors.password?.message ?? ""}</span>
)}
<button>send</button>
</form>
);
};
Agora nosso arquivo de teste irá quebrar, uma vez que precisamos passar a função de submit para o componente. Vamos arrumar isso:
import "@testing-library/jest-dom";
import {
act,
fireEvent,
logRoles,
render,
waitFor,
} from "@testing-library/react";
import { Form } from "../components/Form";
const handleSubmitForm = jest.fn();
describe("Form with react hook form", () => {
it("should render forms", () => {
const view = render(<Form submitForm={handleSubmitForm} />);
logRoles(view.container);
const inputEmail = view.getByRole("textbox", { name: "email" });
const inputPassword = view.getByRole("textbox", { name: "password" });
const buttonSubmit = view.getByRole("button", { name: "send" });
// toBeVisible garante que o elemento está vísivel para o usuário, uma vez que
// o elemento pode estar na DOM (na qual checamos com toBeInTheDocument) mas
// pode não estar sendo exibido
expect(inputEmail).toBeVisible();
expect(inputPassword).toBeVisible();
expect(buttonSubmit).toBeVisible();
});
it("sould show error message when submit form empty", async () => {
const view = render(<Form submitForm={handleSubmitForm} />);
const buttonSubmit = view.getByRole("button", { name: "send" });
act(() => {
fireEvent.click(buttonSubmit);
});
await waitFor(() => {
expect(view.getByText("Informe um email válido")).toBeVisible();
expect(
view.getByText("A senha deve possuir pelo menos 8 caracteres")
).toBeVisible();
});
});
it("sould show error message when email is invalid", async () => {
const view = render(<Form submitForm={handleSubmitForm} />);
const inputEmail = view.getByRole("textbox", { name: "email" });
const buttonSubmit = view.getByRole("button", { name: "send" });
act(() => {
fireEvent.change(inputEmail, { target: { value: "email-invalid.com" } });
buttonSubmit.click();
});
await waitFor(() => {
expect(view.getByText("Informe um email válido")).toBeInTheDocument();
});
});
it("sould show error message when password is smaller than eight", async () => {
const view = render(<Form submitForm={handleSubmitForm} />);
const inputPassword = view.getByRole("textbox", { name: "password" });
const buttonSubmit = view.getByRole("button", { name: "send" });
act(() => {
fireEvent.change(inputPassword, { target: { value: "123456" } });
buttonSubmit.click();
});
await waitFor(() => {
expect(
view.getByText("A senha deve possuir pelo menos 8 caracteres")
).toBeInTheDocument();
});
});
it("sould submit when values is valid", async () => {
const view = render(<Form submitForm={handleSubmitForm} />);
const inputEmail = view.getByRole("textbox", {
name: "email",
}) as HTMLInputElement;
const inputPassword = view.getByRole("textbox", {
name: "password",
}) as HTMLInputElement;
const buttonSubmit = view.getByRole("button", { name: "send" });
act(() => {
fireEvent.change(inputPassword, { target: { value: "12345678" } });
fireEvent.change(inputEmail, { target: { value: "email@teste.com" } });
buttonSubmit.click();
});
await waitFor(() => {
// mensagens de erro não devem aparecer
expect(
view.queryByText("Informe um email válido")
).not.toBeInTheDocument();
expect(
view.queryByText("A senha deve possuir pelo menos 8 caracteres")
).not.toBeInTheDocument();
// os campos devem ser limpos após o submit
expect(inputEmail.value).toBe("");
expect(inputPassword.value).toBe("");
});
});
});
Agora os testes passam novamente. Note que a nossa função de submit foi criada usando jest.fn(). O jest.fn() é uma função fornecida pelo Jest que permite criar uma função simulada (ou “mock function”). Essa função simulada é usada para substituir funções reais durante os testes, permitindo que você controle seu comportamento, observe como ela é chamada e verifique se ela foi chamada corretamente.
Podemos então verificar se a função foi corretamente chamada:
it("sould submit when values is valid", async () => {
const view = render(<Form submitForm={handleSubmitForm} />);
const mockEmail = "email@teste.com";
const mockPass = "12345678";
const inputEmail = view.getByRole("textbox", {
name: "email",
}) as HTMLInputElement;
const inputPassword = view.getByRole("textbox", {
name: "password",
}) as HTMLInputElement;
const buttonSubmit = view.getByRole("button", { name: "send" });
act(() => {
fireEvent.change(inputPassword, { target: { value: mockPass } });
fireEvent.change(inputEmail, { target: { value: mockEmail } });
buttonSubmit.click();
});
await waitFor(() => {
// mensagens de erro não devem aparecer
expect(
view.queryByText("Informe um email válido")
).not.toBeInTheDocument();
expect(
view.queryByText("A senha deve possuir pelo menos 8 caracteres")
).not.toBeInTheDocument();
// os campos devem ser limpos após o submit
expect(inputEmail.value).toBe("");
expect(inputPassword.value).toBe("");
// a função de submit deve ser chamada com os argumentos corretos
expect(handleSubmitForm).toHaveBeenCalledTimes(1);
expect(handleSubmitForm).toHaveBeenCalledWith({
email: mockEmail,
password: mockPass,
});
});
});
O toHaveBeenCalledTimes verifica quantas vezes a função foi chamada e o toHaveBeenCalledWith verifica se a função foi chamada com os argumentos corretos.
✅ Lembre-se de testar os casos de borda do seu componente (submit com campos vazios, campos com limite de caracteres, etc.)
✅ Sempre teste os casos de sucesso e de erro também! Não se apegue somente aos caminhos felizes.
✅ Tente manter os seus componentes o mais desacoplados possíveis, isso garante que eles serão passíveis de ser testados.
Espero que esse conteúdo tenha sido útil 💜
Acesse o código em: examples/form-tdd
메타데이터
- post_id
- 2900a4d67277
- slug
- testando-formulários-com-react-hook-form-e-tdd-2900a4d67277
- url
- https://medium.com/@karoldm/testando-formul%C3%A1rios-com-react-hook-form-e-tdd-2900a4d67277
- canonical_url
- https://medium.com/@karoldm/testando-formul%C3%A1rios-com-react-hook-form-e-tdd-2900a4d67277
- author_url
- https://medium.com/@karoldm
- status
- ok
- fetched_at
- 2026-06-24 04:09:36