React Component Testing Suite — Panel de Administración
// LeadCaptureForm.test.tsx import { renderWithProviders, screen } from "@/test-utils" import userEvent from "@testing-library/user-event" import { http, HttpResponse } from "msw" import { server } from "@/test/server" import { LeadCaptureForm } from "./LeadCaptureForm" import { axe, toHaveNoViolations } from "jest-axe" expect.extend(toHaveNoViolations) test("submits form and shows success message", async () => { server.use( http.post("/api/leads", () => HttpResponse.json({ id: "lead-99", nombre: "Alice" }, { status: 201 }) ) ) const user = userEvent.setup() renderWithProviders(<LeadCaptureForm />) await user.type(screen.getByLabelText("Nombre"), "Alice Martínez") await user.type(screen.getByLabelText("Email"), "alice@acme.com") await user.type(screen.getByLabelText("Empresa"), "Acme Corp") await user.click(screen.getByRole("button", { name: /guardar lead/i })) expect(await screen.findByText(/lead guardado correctamente/i)) .toBeInTheDocument() }) test("shows error banner on API 500", async () => { server.use( http.post("/api/leads", () => new HttpResponse(null, { status: 500 }) ) ) const user = userEvent.setup() renderWithProviders(<LeadCaptureForm />) await user.type(screen.getByLabelText("Email"), "alice@acme.com") await user.click(screen.getByRole("button", { name: /guardar/i })) expect(await screen.findByText(/algo salió mal/i)).toBeInTheDocument() }) test("form has no accessibility violations", async () => { const { container } = renderWithProviders(<LeadCaptureForm />) expect(await axe(container)).toHaveNoViolations() })
// useLeads.test.tsx import { renderHook, waitFor } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { useLeads } from "./useLeads" import { server } from "@/test/server" import { mockLeads } from "@/test/fixtures/leads" import { http, HttpResponse } from "msw" // Instanciar QueryClient FUERA del wrapper para preservar caché entre re-renders function makeWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }) return ({ children }: { children: React.ReactNode }) => ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> ) } test("resolves with leads list from API", async () => { server.use( http.get("/api/leads", () => HttpResponse.json(mockLeads) ) ) const { result } = renderHook(() => useLeads(), { wrapper: makeWrapper(), }) await waitFor(() => expect(result.current.isSuccess).toBe(true) ) expect(result.current.data).toHaveLength(3) expect(result.current.data[0].nombre).toBe("Alice Martínez") })
// test/server.ts import { setupServer } from "msw/node" import { http, HttpResponse } from "msw" import { mockLeads } from "./fixtures/leads" export const handlers = [ http.get("/api/leads", () => HttpResponse.json(mockLeads) ), http.post("/api/leads", async ({ request }) => { const body = await request.json() return HttpResponse.json({ id: "new-id", ...body }, { status: 201 }) }), http.patch("/api/leads/:id", ({ params }) => HttpResponse.json({ id: params.id, status: "approved" }) ), ] // onUnhandledRequest: "error" → cualquier fetch no mockeado falla el test en voz alta export const server = setupServer(...handlers) beforeAll(() => server.listen({ onUnhandledRequest: "error" })) afterEach(() => server.resetHandlers()) afterAll(() => server.close())
Se usan queries accesibles en orden de prioridad: role > labelText > text. Se evita querySelector y getByTestId.
Mock a nivel de red con MSW 2. El componente, el hook y el cliente HTTP se comportan exactamente como en producción.
Cada componente interactivo tiene un test de accesibilidad con jest-axe. Detecta labels faltantes, ARIA inválido y alt text ausente.
userEvent.setup() una vez por test. Simula la secuencia real del navegador (focus, keydown, input, keyup). Se evita fireEvent.
renderWithProviders() en test-utils.tsx envuelve con QueryClient, ThemeProvider y MemoryRouter. Un QueryClient nuevo por test para evitar contaminación de caché.
renderHook() con wrapper de QueryClient. act() para mutaciones. Se testa la API pública del hook, no los internos.
| Anti-patrón | Corrección aplicada | Por qué |
|---|---|---|
| container.querySelector(".lead-btn") | getByRole("button", {name:/aprobar/i}) | Bypasses a11y; si cambia la clase el test pasa aunque el usuario no pueda operar el botón |
| expect(fetch).toHaveBeenCalledTimes(2) | findByText(/guardado/i) | Detalle de implementación; el test debe verificar lo que el usuario ve, no las llamadas internas |
| fireEvent.click(button) | await user.click(button) | fireEvent despacha un evento sintético único; userEvent simula la secuencia real del browser |
| jest.mock("../LeadCard") | Render sin mock | Mockear hijos por defecto convierte el test en un test de implementación, no de integración |
| await new Promise(r => setTimeout(r, 500)) | await screen.findByText(...) | Timeouts hardcodeados producen tests lentos y flaky; findBy* espera el estado real |