C

CultivAI Platform

React Component Testing Suite — Panel de Administración

✓ 18 passed Vitest 1.6 RTL + MSW 2
94%
Lines
91%
Functions
87%
Branches
93%
Statements
Resultados de los tests
📋 src/components/leads/LeadCaptureForm.test.tsx
312ms 7 / 7 ✓
renders form fields with correct labels
getByLabelText("Nombre"), getByLabelText("Email"), getByLabelText("Empresa")
8ms
shows validation error when email is invalid
userEvent.type → getByRole("alert") con /email inválido/i
42ms
submits form and shows success message
MSW POST /api/leads → 201 → findByText(/lead guardado/i)
87ms
shows error banner on API 500
server.use(http.post → 500) → findByText(/algo salió mal/i)
63ms
disables submit button while request is pending
MSW delay → getByRole("button", {name:/guardar/i}) toBeDisabled()
54ms
form has no accessibility violations
axe(container) → toHaveNoViolations()
38ms
resets form after successful submission
inputs vacíos tras POST 201 + toHaveValue("")
20ms
🎣 src/hooks/useLeads.test.tsx
198ms 6 / 6 ✓
starts in loading state
isLoading toBe(true) en el primer render
6ms
resolves with leads list from API
waitFor → isSuccess + data.length toBe(3)
72ms
exposes error state on network failure
MSW 500 → isError toBe(true), error.message toMatch(/500/)
48ms
approveLead mutates cache optimistically
act → approveLead("id-1") → data[0].status toBe("approved")
35ms
rejectLead removes lead from list
act → rejectLead("id-2") → data.length toBe(2)
28ms
does not refetch when window regains focus (staleTime)
window.dispatchEvent(focus) → fetch spy toHaveBeenCalledTimes(1)
9ms
🃏 src/components/leads/LeadCard.test.tsx
145ms 5 / 5 ✓
renders lead name, email and company
getByText("Alice Martínez"), getByText("alice@acme.com")
7ms
calls onApprove with lead id when approve is clicked
userEvent.click(getByRole("button", {name:/aprobar/i})) → spy("lead-1")
25ms
calls onReject with lead id when reject is clicked
userEvent.click → onReject spy called once with "lead-1"
18ms
shows "Aprobado" badge when status is approved
render({"{...lead, status:'approved'}"}) → getByText("Aprobado")
10ms
card has no accessibility violations
axe(container) → toHaveNoViolations()
85ms
Código generado
TSX LeadCaptureForm.test.tsx — happy path + error state
// 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()
})
TSX useLeads.test.tsx — hook con TanStack Query + MSW
// 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")
})
TS test/server.ts — MSW setup con onUnhandledRequest: "error"
// 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())
Patrones aplicados

🎯 Query Priority

Se usan queries accesibles en orden de prioridad: role > labelText > text. Se evita querySelector y getByTestId.

  • getByRole("button", {name:/guardar/i})
  • getByLabelText("Email")
  • findByText(/éxito/i) para async

🌐 MSW Network Mock

Mock a nivel de red con MSW 2. El componente, el hook y el cliente HTTP se comportan exactamente como en producción.

  • Handlers base en server.ts
  • Override por test para error 500
  • onUnhandledRequest: "error"

Axe a11y

Cada componente interactivo tiene un test de accesibilidad con jest-axe. Detecta labels faltantes, ARIA inválido y alt text ausente.

  • axe(container) en form
  • axe(container) en card
  • Sin falsos positivos de color

userEvent v14

userEvent.setup() una vez por test. Simula la secuencia real del navegador (focus, keydown, input, keyup). Se evita fireEvent.

  • user.type() para campos
  • user.click() para botones
  • Siempre await

🔧 Providers Wrapper

renderWithProviders() en test-utils.tsx envuelve con QueryClient, ThemeProvider y MemoryRouter. Un QueryClient nuevo por test para evitar contaminación de caché.

  • retry: false en tests
  • Import desde test-utils
  • QueryClient fuera del wrapper

🎣 Hook Testing

renderHook() con wrapper de QueryClient. act() para mutaciones. Se testa la API pública del hook, no los internos.

  • waitFor → isSuccess
  • act → mutaciones
  • wrapper preserva caché
Anti-patrones corregidos
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