Testando Formulários
Teste inputs controlados, validação, mensagens de erro, uploads de arquivos e formulários Server Action.
Busque em todas as páginas da documentação
Teste inputs controlados, validação, mensagens de erro, uploads de arquivos e formulários Server Action.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Cartão de receita de referência rápida -- pronto para copiar e colar.
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const user = userEvent.setup();
// Type into an input
await user.type(screen.getByLabelText(/email/i), "alice@example.com");
// Clear and retype
await user.clear(screen.getByLabelText(/email/i));
await user.type(screen.getByLabelText(/email/i), "bob@example.com");
// Select from a dropdown
await user.selectOptions(screen.getByLabelText(/role/i), "admin");
// Check a checkbox
await user.click(screen.getByLabelText(/agree to terms/i));
// Submit a form
await user.click(screen.getByRole("button", { name: /submit/i }));
// Assert validation errors
await waitFor(() => {
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});Quando usar isso: Sempre que você testar qualquer componente com inputs de formulário, validação ou lógica de submissão.
// src/components/registration-form.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const registrationSchema = z
.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
type RegistrationData = z.infer<typeof registrationSchema>;
interface RegistrationFormProps {
onSubmit: (data: RegistrationData) => Promise<void>;
}
export function RegistrationForm({ onSubmit }: RegistrationFormProps) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegistrationData>({
resolver: zodResolver(registrationSchema),
});
return (
<form onSubmit={handleSubmit(onSubmit)} aria-label="Registration">
<div>
<label htmlFor="name">Name</label>
<input id="name" {...register("name")} />
{errors.name && <p role="alert">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register("password")} />
{errors.password && <p role="alert">{errors.password.message}</p>}
</div>
<div>
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<p role="alert">{errors.confirmPassword.message}</p>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Registering..." : "Register"}
</button>
</form>
);
}// src/components/registration-form.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect } from "vitest";
import { RegistrationForm } from "./registration-form";
describe("RegistrationForm", () => {
const user = userEvent.setup();
const mockSubmit = vi.fn().mockResolvedValue(undefined);
async function fillForm(overrides: Record<string, string> = {}) {
const defaults = {
name: "Alice Johnson",
email: "alice@example.com",
password: "password123",
confirmPassword: "password123",
};
const values = { ...defaults, ...overrides };
await user.type(screen.getByLabelText(/^name$/i), values.name);
await user.type(screen.getByLabelText(/email/i), values.email);
await user.type(screen.getByLabelText(/^password$/i), values.password);
await user.type(
screen.getByLabelText(/confirm password/i),
values.confirmPassword
);
}
it("submits valid form data", async () => {
render(<RegistrationForm onSubmit={mockSubmit} />);
await fillForm();
await user.click(screen.getByRole("button", { name: /register/i }));
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith(
{
name: "Alice Johnson",
email: "alice@example.com",
password: "password123",
confirmPassword: "password123",
},
expect.anything() // react-hook-form passes event as second arg
);
});
});
it("shows validation errors for empty fields", async () => {
render(<RegistrationForm onSubmit={mockSubmit} />);
await user.click(screen.getByRole("button", { name: /register/i }));
const alerts = await screen.findAllByRole("alert");
expect(alerts.length).toBeGreaterThanOrEqual(3);
expect(mockSubmit).not.toHaveBeenCalled();
});
it("validates email format", async () => {
render(<RegistrationForm onSubmit={mockSubmit} />);
await fillForm({ email: "not-an-email" });
await user.click(screen.getByRole("button", { name: /register/i }));
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
});
it("validates minimum password length", async () => {
render(<RegistrationForm onSubmit={mockSubmit} />);
await fillForm({ password: "short", confirmPassword: "short" });
await user.click(screen.getByRole("button", { name: /register/i }));
expect(
await screen.findByText(/password must be at least 8/i)
).toBeInTheDocument();
});
it("validates password confirmation match", async () => {
render(<RegistrationForm onSubmit={mockSubmit} />);
await fillForm({ confirmPassword: "different123" });
await user.click(screen.getByRole("button", { name: /register/i }));
expect(
await screen.findByText(/passwords do not match/i)
).toBeInTheDocument();
});
it("disables button while submitting", async () => {
const slowSubmit = vi.fn(() => new Promise(() => {})); // never resolves
render(<RegistrationForm onSubmit={slowSubmit} />);
await fillForm();
await user.click(screen.getByRole("button", { name: /register/i }));
await waitFor(() => {
expect(screen.getByRole("button")).toBeDisabled();
expect(screen.getByRole("button")).toHaveTextContent("Registering...");
});
});
});O que isso demonstra:
fillForm para reduzir duplicação de testesfindByText/findAllByRole para renderização assíncrona de validaçãozodResolver executa a validação do schema Zod e mapeia erros para nomes de camposuserEvent.type simula digitações reais, incluindo eventos de foco, keydown, keypress, keyup e inputrole="alert" em mensagens de erro garante que elas sejam anunciadas por leitores de tela e consultáveis por getByRole("alert")isSubmitting é gerenciado pelo react-hook-form e é true enquanto a Promise do handler onSubmit está pendenteTestando uploads de arquivos:
it("uploads a file", async () => {
const user = userEvent.setup();
render(<AvatarUpload onUpload={vi.fn()} />);
const file = new File(["avatar"], "avatar.png", { type: "image/png" });
const input = screen.getByLabelText(/upload avatar/i);
await user.upload(input, file);
expect(input.files).toHaveLength(1);
expect(input.files![0].name).toBe("avatar.png");
});Testando formulários Server Action com useActionState:
// src/components/contact-form.tsx
"use client";
import { useActionState } from "react";
import { submitContact } from "@/app/actions";
export function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, {
message: "",
errors: {},
});
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" />
{state.errors.email && <p role="alert">{state.errors.email}</p>}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" />
{state.errors.message && <p role="alert">{state.errors.message}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Sending..." : "Send"}
</button>
{state.message && <p role="status">{state.message}</p>}
</form>
);
}// src/components/contact-form.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect } from "vitest";
// Mock the server action
vi.mock("@/app/actions", () => ({
submitContact: vi.fn(),
}));
// Mock useActionState to control the form state
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
useActionState: vi.fn(),
};
});
import { useActionState } from "react";
import { ContactForm } from "./contact-form";
describe("ContactForm", () => {
it("renders errors from server action", () => {
vi.mocked(useActionState).mockReturnValue([
{ message: "", errors: { email: "Email is required" } },
vi.fn(),
false,
]);
render(<ContactForm />);
expect(screen.getByText("Email is required")).toBeInTheDocument();
});
it("shows success message", () => {
vi.mocked(useActionState).mockReturnValue([
{ message: "Message sent!", errors: {} },
vi.fn(),
false,
]);
render(<ContactForm />);
expect(screen.getByRole("status")).toHaveTextContent("Message sent!");
});
});// Type the form data helper
type FormValues = {
name: string;
email: string;
password: string;
confirmPassword: string;
};
async function fillForm(overrides: Partial<FormValues> = {}) {
const defaults: FormValues = {
name: "Alice",
email: "alice@example.com",
password: "password123",
confirmPassword: "password123",
};
// ...
}Usar fireEvent.change em vez de userEvent.type -- fireEvent.change define o valor diretamente sem disparar eventos de digitação, o que pode pular a lógica de validação. Correção: Sempre use userEvent.type().
Testando validação no render -- react-hook-form valida na submissão por padrão, não na mudança. Correção: Submeta o formulário primeiro, depois afirme os erros, a menos que você configure mode: "onChange".
Âncoras de Regex em consultas de label -- screen.getByLabelText(/password/i) corresponde tanto a "Password" quanto a "Confirm Password". Correção: Use regex ancorada: /^password$/i.
Formulários Server Action não podem ser totalmente testados em jsdom -- useActionState com um Server Action real requer o servidor Next.js. Correção: Mock useActionState para controlar o estado do formulário, ou teste Server Actions como funções unitárias separadamente.
Confusão no tipo de input de arquivo -- userEvent.upload requer o elemento <input type="file"> real. Correção: Consulte o input diretamente pela label, não pelo botão que o envolve.
| Alternativa | Usar Quando | Não Usar Quando |
|---|---|---|
| Playwright E2E | Você precisa testar o fluxo completo do formulário, incluindo validação do lado do servidor | Testes unitários com feedback rápido são suficientes |
| Teste de Interação do Storybook | Você tem stories de formulário e quer testar dentro do Storybook | Você precisa de capacidades completas de mock e afirmação |
| Teste de Componentes Cypress | Você quer comportamento real do formulário no navegador com isolamento de componentes | Você quer testes rápidos baseados em Node |
fireEvent.change define o valor diretamente sem disparar eventos de digitação.userEvent.type() simula digitações reais, incluindo eventos de foco, keydown, keypress, keyup e input.Por padrão, react-hook-form valida na submissão, não na mudança. Submeta o formulário primeiro, depois afirme os erros. Configure mode: "onChange" se precisar de validação a cada digitação.
const file = new File(["content"], "avatar.png", { type: "image/png" });
const input = screen.getByLabelText(/upload avatar/i);
await user.upload(input, file);
expect(input.files).toHaveLength(1);A regex /password/i corresponde a qualquer label que contenha "password". Use regex ancorada:
screen.getByLabelText(/^password$/i);Crie uma função auxiliar:
async function fillForm(overrides: Partial<FormValues> = {}) {
const values = { ...defaults, ...overrides };
await user.type(screen.getByLabelText(/email/i), values.email);
// ...
}Mock useActionState para controlar o estado do formulário diretamente:
vi.mocked(useActionState).mockReturnValue([
{ message: "", errors: { email: "Required" } },
vi.fn(),
false,
]);Passe um onSubmit que nunca resolve, depois afirme:
const slowSubmit = vi.fn(() => new Promise(() => {}));
// ... fill and submit form
await waitFor(() => {
expect(screen.getByRole("button")).toBeDisabled();
});Não. useActionState com um Server Action real requer o servidor Next.js. Mock useActionState em testes unitários, ou teste Server Actions como funções assíncronas independentes separadamente.
type FormValues = {
name: string;
email: string;
password: string;
};
async function fillForm(overrides: Partial<FormValues> = {}) {
const values: FormValues = { ...defaults, ...overrides };
// ...
}await user.selectOptions(screen.getByLabelText(/role/i), "admin");O adaptador zodResolver executa seu schema Zod na submissão e mapeia erros de validação para nomes de campos. Em testes, você dispara a validação submetendo o formulário, depois afirma que as mensagens de erro aparecem.
await user.click(screen.getByLabelText(/agree to terms/i));
expect(screen.getByLabelText(/agree to terms/i)).toBeChecked();Revisado por Chris St. John·Última atualização: 19 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥