Pruebas de formularios
Prueba inputs controlados, validación, mensajes de error, subidas de archivos y formularios con Server Action.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
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();
});Cuándo usarlo: Siempre que pruebes un componente con inputs de formulario, validación o lógica de envío.
Ejemplo funcional
// 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...");
});
});
});Lo que demuestra esto:
- Probar react-hook-form con validación Zod
- Función helper
fillFormpara reducir la duplicación en los tests - Probar reglas de validación específicas (formato de correo, longitud mínima, coincidencia de contraseñas)
- Probar el estado deshabilitado durante el envío
- Usar
findByText/findAllByRolepara el renderizado asíncrono de validación
Profundización
Cómo funciona
- React Hook Form valida al enviar por defecto — los errores aparecen después de hacer clic en enviar
- El adaptador
zodResolverejecuta la validación del esquema Zod y mapea los errores a los nombres de los campos userEvent.typesimula pulsaciones de tecla reales, incluyendo eventos focus, keydown, keypress, keyup e inputrole="alert"en los mensajes de error garantiza que los lectores de pantalla los anuncien y que sean consultables congetByRole("alert")isSubmittinglo gestiona react-hook-form y estruemientras la Promise del manejadoronSubmitestá pendiente
Variaciones
Probar subidas de archivos:
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");
});Probar formularios con Server Action y 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!");
});
});Notas de TypeScript
// 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",
};
// ...
}Errores comunes
-
Usar
fireEvent.changeen lugar deuserEvent.type—fireEvent.changeestablece el valor directamente sin disparar eventos de pulsación de tecla, lo que puede omitir la lógica de validación. Solución: Usa siempreuserEvent.type(). -
Probar validación al renderizar — react-hook-form valida al enviar por defecto, no al cambiar. Solución: Envía el formulario primero y luego aserciona los errores, a menos que configures
mode: "onChange". -
Anclas regex en consultas por etiqueta —
screen.getByLabelText(/contraseña/i)coincide tanto con "Contraseña" como con "Confirmar contraseña". Solución: Usa una regex anclada:/^contraseña$/i. -
Los formularios con Server Action no se pueden probar por completo en jsdom —
useActionStatecon una Server Action real requiere el servidor de Next.js. Solución: SimulauseActionStatepara controlar el estado del formulario, o prueba las Server Actions como funciones unitarias por separado. -
Confusión con el tipo del input de archivo —
userEvent.uploadrequiere el elemento<input type="file">real. Solución: Consulta el input directamente por etiqueta, no el botón contenedor.
Alternativas
| Alternativa | Usar cuando | No usar cuando |
|---|---|---|
| Playwright E2E | Necesitas probar el flujo completo del formulario, incluida la validación del lado del servidor | Los tests unitarios con retroalimentación rápida son suficientes |
| Storybook interaction testing | Tienes stories de formularios y quieres probar dentro de Storybook | Necesitas capacidades completas de mocking y aserciones |
| Cypress Component Testing | Quieres comportamiento real del navegador en formularios con aislamiento de componentes | Quieres tests rápidos basados en Node |
FAQs
¿Por qué debería usar userEvent.type() en lugar de fireEvent.change()?
fireEvent.changeestablece el valor directamente sin disparar eventos de pulsación de tecla.- Esto puede omitir la lógica de validación que depende de eventos input/keydown.
userEvent.type()simula pulsaciones de tecla reales, incluyendo focus, keydown, keypress, keyup e input.
¿Cuándo aparecen los errores de validación con react-hook-form?
Por defecto, react-hook-form valida al enviar, no al cambiar. Envía el formulario primero y luego aserciona los errores. Configura mode: "onChange" si necesitas validación en cada pulsación de tecla.
¿Cómo pruebo subidas de archivos con Testing Library?
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);Trampa: ¿Por qué getByLabelText(/contraseña/i) también coincide con "Confirmar contraseña"?
La regex /contraseña/i coincide con cualquier etiqueta que contenga "contraseña". Usa una regex anclada:
screen.getByLabelText(/^password$/i);¿Cómo reduzco la duplicación en los tests al rellenar formularios?
Crea una función helper:
async function fillForm(overrides: Partial<FormValues> = {}) {
const values = { ...defaults, ...overrides };
await user.type(screen.getByLabelText(/email/i), values.email);
// ...
}¿Cómo pruebo un formulario con Server Action que usa useActionState?
Simula useActionState para controlar el estado del formulario directamente:
vi.mocked(useActionState).mockReturnValue([
{ message: "", errors: { email: "Required" } },
vi.fn(),
false,
]);¿Cómo pruebo que el botón de envío está deshabilitado mientras se envía?
Pasa un onSubmit que nunca se resuelva y luego aserciona:
const slowSubmit = vi.fn(() => new Promise(() => {}));
// ... fill and submit form
await waitFor(() => {
expect(screen.getByRole("button")).toBeDisabled();
});Trampa: ¿Se pueden probar por completo los formularios con Server Action en jsdom?
No. useActionState con una Server Action real requiere el servidor de Next.js. Simula useActionState en tests unitarios, o prueba las Server Actions como funciones async independientes por separado.
¿Cómo tipar la función helper del formulario en TypeScript?
type FormValues = {
name: string;
email: string;
password: string;
};
async function fillForm(overrides: Partial<FormValues> = {}) {
const values: FormValues = { ...defaults, ...overrides };
// ...
}¿Cómo selecciono una opción de un desplegable en un test?
await user.selectOptions(screen.getByLabelText(/role/i), "admin");¿Cómo funciona zodResolver con react-hook-form en los tests?
El adaptador zodResolver ejecuta tu esquema Zod al enviar y mapea los errores de validación a los nombres de los campos. En los tests, disparas la validación enviando el formulario y luego asercionas que aparecen los mensajes de error.
¿Cómo pruebo interacciones con casillas de verificación?
await user.click(screen.getByLabelText(/agree to terms/i));
expect(screen.getByLabelText(/agree to terms/i)).toBeChecked();Relacionado
- Fundamentos de React Testing Library — conceptos básicos de userEvent y consultas
- Probar comportamiento asíncrono — envío asíncrono de formularios
- Simulación en tests — simular manejadores de formularios y Server Actions
- Probar Server Components y Actions — probar Server Actions directamente