Inicio / Artículos

Patrones anti-estándar en React Testing que generan una confianza falsa en 2026

Aprenda diez errores comunes al probar con React: desde el uso incorrecto de fireEvent hasta los problemas al simular conexiones a la red, que permiten que funcionalidades defectuosas pasen desapercibidas en un conjunto de pruebas que, en teoría, da resultados positivos.

Editor: REACTAPP.TOP14 min de lectura3125 palabras

Fuentes: 10 React Testing Mistakes That Are Secretly Making Your Tests Useless in 2026

Tu suite de pruebas muestra un resultado completo de aprobación, con todas las verificaciones en verde, y envías la aplicación a producción sintiéndote satisfecho. Pero luego la cola de soporte se llena: el botón de pago falla silenciosamente en dispositivos móviles, la pantalla de inicio de sesión muestra un error cuando la API responde lentamente, y las personas que usan teclado no pueden acceder en absoluto al menú de navegación. Nada de tus pruebas detectó ninguno de estos problemas. ¿Qué pasó entonces?

Hasta 2026, al utilizar React 19 junto con el React Compiler y la Testing Library v16, la diferencia entre una suite que simplemente da resultados positivos y otra que realmente protege tu aplicación se ha ampliado más que nunca. A continuación se presentan diez hábitos que hacen que tus pruebas parezcan exhaustivas, mientras tu aplicación en producción sigue fallando silenciosamente.

1. Probar detalles de implementación en lugar del comportamiento del usuario

Este es el error clásico en las pruebas de React, y aún se comete constantemente. Se renderiza un componente, se manipulan sus componentes internos y las afirmaciones verifican cosas como si useState se hubiera ejecutado o si se pasó una propiedad específica. Eso no verifica lo que realmente experimenta el usuario, sino cómo el desarrollador lo construyó.

// ❌ Useless: Testing internals
import { render } from '@testing-library/react';
import { LoginForm } from './LoginForm';
test('sets isSubmitting to true on submit', () => {
  const wrapper = render(<LoginForm />);
  const instance = wrapper.container.firstChild; // Reaching into internals

  // This tests React, not your app
  expect(instance).toHaveProperty('isSubmitting');
});

Una prueba de este tipo es frágil e inútil: cambiar el nombre de una variable, sustituir useState por useReducer o extraer la lógica a un hook personalizado hace que falle, aunque nada haya cambiado en el flujo real de inicio de sesión. No proporciona ninguna indicación sobre si una persona puede iniciar sesión.

// ✅ Useful: Testing behavior
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('submits the form with user credentials', async () => {
  const handleSubmit = vi.fn();
  render(<LoginForm onSubmit={handleSubmit} />);

  const user = userEvent.setup();

  await user.type(screen.getByLabelText(/email/i), 'test@example.com');
  await user.type(screen.getByLabelText(/password/i), 'secret123');
  await user.click(screen.getByRole('button', { name: /sign in/i }));

  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'secret123',
  });
});

El principio fundamental detrás de Testing Library sigue vigente en 2026: las pruebas que reflejan el uso real brindan una confianza más genuina. Si una refactorización inofensiva puede romper tu prueba, estás haciendo afirmaciones en la capa incorrecta.

2. Uso de fireEvent en lugar de userEvent

fireEvent simplemente dispara eventos DOM en bruto. userEvent, por su parte, reproduce toda la secuencia de acciones que realizaría una persona real. Casi no queda justificación para utilizar fireEvent en pruebas de componentes, pero persiste porque así lo enseñaban las guías antiguas.

// ❌ Useless: fireEvent does not simulate real interaction
test('toggles the menu', () => {
  render(<Navigation />);
  const button = screen.getByText('Menu');

  fireEvent.click(button); // Just fires a click event

  expect(screen.getByText('Dashboard')).toBeInTheDocument();
});

El problema es que fireEvent.click nunca confirma si el elemento realmente puede ser hecho clic por un humano. Puede estar deshabilitado, cubierto por otro elemento o ser completamente inaccesible mediante el teclado. La afirmación tiene éxito mientras que la función real resulta inutilizable.

// ✅ Useful: userEvent validates the interaction
test('toggles the menu with keyboard and mouse', async () => {
  render(<Navigation />);
  const user = userEvent.setup();
  const button = screen.getByRole('button', { name: /menu/i });

  // Verifies element is focusable and clickable
  await user.click(button);
  expect(screen.getByRole('link', { name: /dashboard/i })).toBeVisible();

  // Tests keyboard accessibility
  await user.keyboard('{Escape}');
  expect(screen.queryByRole('link', { name: /dashboard/i })).not.toBeInTheDocument();
});

userEvent primero verifica que el destino no esté deshabilitado ni sea de solo lectura y que realmente sea visible antes de activar cualquier acción, luego simula la secuencia realista de eventos de puntero, teclado y enfoque. La accesibilidad ya no es algo opcional, y sus pruebas deberían garantizarlo automáticamente.

3. Simular llamadas a la red en el nivel incorrecto

Imaginemos que está probando un componente que obtiene datos de una API. La solución rápida es crear un stub para fetch o axios.get directamente dentro del archivo de pruebas. Estas pruebas pasan sin problemas. Luego, el equipo de backend cambia el nombre de un campo de userName a username, la aplicación en producción deja de funcionar, y su conjunto de pruebas no se da cuenta de nada.

// ❌ Useless: Mocking fetch directly
test('displays user profile', async () => {
  global.fetch = vi.fn(() =>
    Promise.resolve({
      json: () => Promise.resolve({ userName: 'Alice' }), // Drifts from reality
    })
  );

  render(<UserProfile userId="123" />);

  expect(await screen.findByText('Alice')).toBeInTheDocument();
});

El problema aquí es que estás validando contra una API ficticia, no la real. El stub no verifica los encabezados, no comprueba la estructura de la URL y no simula tiempos de respuesta lentos ni modos de fallo. Te da una falsa sensación de seguridad mientras la integración real se desincroniza silenciosamente.

// ✅ Useful: Mocking at the network layer with MSW
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({ username: 'Alice', id: params.id });
  })
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('displays user profile from API', async () => {
  render(<UserProfile userId="123" />);

  // Tests the actual component + network layer together
  expect(await screen.findByText(/alice/i)).toBeInTheDocument();
  expect(screen.getByText(/id: 123/i)).toBeInTheDocument();
});

MSW interviene en el límite de la red, por lo que tu componente sigue exactamente el mismo camino de llamada a fetch que utilizaría en producción. Cuando el contrato de la API cambia, solo necesitas corregir el manejador de MSW en un único lugar, y todas las pruebas que acceden a ese endpoint reflejarán automáticamente la nueva realidad. Para 2026, MSW 2.x se ha convertido en la opción por defecto, y usar stubs directamente para fetch es esencialmente deuda técnica acumulada.

4. Depender de capturas como sustituto para las pruebas de regresión

Las pruebas de instantánea son tentadoras precisamente porque exigen muy poco de uno. Se renderiza algo, se captura el HTML resultante y se envía para revisión. Meses después, un diseñador modifica un módulo CSS, la instantánea deja de funcionar, y alguien aprueba la nueva instantánea con un simple clic, sin leer realmente qué cambió.

// ❌ Useless: Snapshot as a crutch
test('renders correctly', () => {
  const { container } = render(<Card title="Hello" />);
  expect(container.firstChild).toMatchSnapshot();
});

El problema es que nadie revisa realmente una diferencia de 400 líneas de marcado serializado. La instantánea indica que *algo* cambió, pero no puede decirte si ese algo fue un ajuste de diseño inofensivo o un problema en el layout. Es ruido disfrazado de red de seguridad.

// ✅ Useful: Targeted assertions
test('renders card with accessible title and action', () => {
  render(<Card title="Hello" onAction={() => {}} />);

  expect(screen.getByRole('heading', { name: /hello/i })).toBeInTheDocument();
  expect(screen.getByRole('button', { name: /action/i })).toBeEnabled();
  expect(screen.getByRole('article')).toHaveAttribute('aria-labelledby');
});

Al escribir afirmaciones específicas, se detalla con exactitud el comportamiento que promete el componente. Si el marcado cambia pero el contrato de accesibilidad subyacente sigue vigente, la prueba sigue pasando. Si un rol de encabezado se elimina por error, se obtiene un mensaje de fallo preciso que señala el problema real. Al acercarnos a 2026, vale la pena eliminar por completo los archivos de snapshot y reemplazarlos por afirmaciones que expresen las expectativas reales.

5. Seleccionar elementos exclusivamente mediante data-testid

Poner data-testid en cada elemento parece ser la opción segura e infalible: la consulta siempre encuentra su objetivo. Pero un selector data-testid no confirma nada sobre accesibilidad, semántica o cómo experimenta la interfaz un usuario real. Lo único que confirma es que el desarrollador recordó escribir una cadena específica.

// ❌ Useless: Brittle, tests nothing about accessibility
function Form() {
  return (
    <div data-testid="form-container">
      <input data-testid="email-input" />
      <button data-testid="submit-btn">Submit</button>
    </div>
  );
}
test('submits form', async () => {
  render(<Form />);
  await userEvent.type(screen.getByTestId('email-input'), 'test@example.com');
  await userEvent.click(screen.getByTestId('submit-btn'));
});

Este tipo de prueba pasa sin problemas incluso cuando la entrada carece de etiqueta, el “botón” es en realidad un div con estilo, y una persona que use un lector de pantalla quedaría completamente perdida. En efecto, certifica como correcto un marcado defectuoso.

// ✅ Useful: Semantic queries enforce accessibility
function Form() {
  return (
    <form aria-label="Login form">
      <label htmlFor="email">Email address</label>
      <input id="email" type="email" />
      <button type="submit">Submit</button>
    </form>
  );
}
test('submits login form', async () => {
  render(<Form />);
  const user = userEvent.setup();

  // These queries fail if the elements are not accessible
  await user.type(screen.getByLabelText(/email address/i), 'test@example.com');
  await user.click(screen.getByRole('button', { name: /submit/i }));

  expect(screen.getByRole('form', { name: /login form/i })).toBeInTheDocument();
});

El orden de consultas recomendado por Testing Library no es arbitrario: getByRole, getByLabelText y getByPlaceholderText te guían hacia un marcado que sea realmente accesible. Si la única forma de obtener un elemento es a través de data-testid, eso suele ser señal de que el componente en sí mismo no es accesible. Con el aumento de los litigios por accesibilidad impulsados por la IA hacia 2026, esto deja de ser un mero atajo en las pruebas y se convierte en una verdadera exposición legal.

6. Omitir la espera por tareas asíncronas

Casi todo en React ocurre con retraso. Los cambios de estado se agrupan, los efectos se ejecutan después de que el navegador dibuje la interfaz y la obtención de datos solo se resuelve en un momento posterior. Si una prueba no espera adecuadamente a que estas operaciones finalicen, sus afirmaciones se ejecutan demasiado pronto y la prueba pasa sin verificar realmente nada significativo.

// ❌ Useless: Missing await on async operations
test('shows search results', () => {
  render(<Search />);

  userEvent.type(screen.getByRole('searchbox'), 'react');
  userEvent.keyboard('{Enter}');

  // This runs before the fetch resolves
  expect(screen.getByText('React Testing')).toBeInTheDocument();
});

Dependiendo del caché o del tiempo de ejecución, una prueba como esta podría pasar por suerte o fallar de manera impredecible. Eso es, sin duda, peor que no tener ninguna prueba, ya que crea una falsa sensación de seguridad.

// ✅ Useful: Explicitly await async states
test('shows search results after typing', async () => {
  render(<Search />);
  const user = userEvent.setup();

  await user.type(screen.getByRole('searchbox'), 'react');
  await user.keyboard('{Enter}');

  // findBy queries wait for the element to appear
  expect(await screen.findByText('React Testing')).toBeInTheDocument();

  // Ensure loading state was shown and removed
  expect(screen.queryByRole('status', { name: /loading/i })).not.toBeInTheDocument();
});

Al acercarnos a 2026, con el gancho use de React 19 y un uso más extendido de los límites Suspense, los patrones asíncronos aparecen en casi todas partes. Cualquier interacción que inicie una actualización de estado, una transición o una llamada a la red debe esperarse adecuadamente. Utilice las consultas findBy cuando un elemento se muestra de forma asíncrona, emplee waitFor para condiciones que van más allá de simples verificaciones de presencia, y asegúrese de que cada llamada a userEvent sea esperada.

7. Pruebas de componentes de forma aislada de su entorno real

Es común renderizar un componente por completo de forma independiente: sin router, sin proveedor de tema, sin cliente de consultas y sin contexto de autenticación; observar cómo pasa la prueba y luego ver cómo el mismo componente falla en producción porque dependía silenciosamente de un contexto que la prueba nunca proporcionó.

// ❌ Useless: Missing app context
test('renders dashboard', () => {
  render(<Dashboard />);
  expect(screen.getByText('Welcome')).toBeInTheDocument();
});

Esta prueba no proporciona mucha información, ya que es probable que Dashboard dependa de hooks como useQuery, useAuth o useNavigate. Sin los proveedores correspondientes, en realidad no estás probando el componente, sino una versión imaginaria que no tiene equivalente en tu aplicación real.

// ✅ Useful: Custom render with all providers
import { render as rtlRender } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
function render(ui, { route = '/', ...options } = {}) {
  window.history.pushState({}, 'Test page', route);

  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  const Wrapper = ({ children }) => (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <AuthProvider>
          {children}
        </AuthProvider>
      </BrowserRouter>
    </QueryClientProvider>
  );

  return rtlRender(ui, { wrapper: Wrapper, ...options });
}
// Now your tests run in an environment that matches production
test('renders dashboard for authenticated user', async () => {
  render(<Dashboard />, { route: '/dashboard' });

  expect(await screen.findByText(/welcome back/i)).toBeInTheDocument();
});

Dada la gran dependencia del contexto en las arquitecturas modernas, junto con la prevalencia de capas de caché como React Query, para el año 2026 crear una ayuda de renderizado personalizada compartida deja de ser algo opcional y se convierte en la base fundamental de todo tu conjunto de herramientas. Cada prueba debe ejecutarse con el mismo conjunto de proveedores que utiliza realmente tu aplicación en producción.

8. Cubrir solo el camino exitoso

Verificar que un formulario se envíe con datos válidos, que aparezca una lista cuando la API devuelve datos o que un botón responda al estar habilitado no es un error en sí mismo, pero detenerse ahí deja tu cobertura muy incompleta, y las pruebas incompletas fallan precisamente cuando ocurren los errores.

// ❌ Useless: Only the sunny day scenario
test('displays products', async () => {
  server.use(
    http.get('/api/products', () => HttpResponse.json([{ id: 1, name: 'Widget' }]))
  );

  render(<ProductList />);
  expect(await screen.findByText('Widget')).toBeInTheDocument();
});

Este tipo de pruebas no dicen nada sobre una respuesta 500, un conjunto de resultados vacío, una solicitud que se ha agotado el tiempo o cuando un usuario envía datos inválidos. Una gran parte de los errores reales en producción —a menudo estimados en alrededor del ochenta por ciento— se ocultan dentro de estos casos límite exactos.

// ✅ Useful: Testing states that actually happen to users
test('displays empty state when no products exist', async () => {
  server.use(http.get('/api/products', () => HttpResponse.json([])));

  render(<ProductList />);
  expect(await screen.findByText(/no products available/i)).toBeInTheDocument();
});
test('displays error state when API fails', async () => {
  server.use(
    http.get('/api/products', () => new HttpResponse(null, { status: 500 }))
  );

  render(<ProductList />);
  expect(await screen.findByRole('alert')).toHaveTextContent(/failed to load/i);
});
test('prevents submission with invalid email', async () => {
  render(<NewsletterForm />);
  const user = userEvent.setup();

  await user.type(screen.getByLabelText(/email/i), 'not-an-email');
  await user.click(screen.getByRole('button', { name: /subscribe/i }));

  expect(screen.getByRole('alert')).toHaveTextContent(/valid email required/i);
  expect(screen.getByRole('button')).toBeDisabled(); // Submit blocked
});

Con las acciones de formulario y las actualizaciones optimistas de la UI de React 19 convirtiéndose en estándar, es más importante que nunca comprobar cómo se comportan tus componentes en estados de error y escenarios de deshacer. Los usuarios inevitablemente se encontrarán con casos de fallo, y tu conjunto de pruebas debe demostrar que esos casos se manejan realmente.

9. Permitir que el estado se transfiera entre casos de prueba

El estado global compartido es una fuente recurrente de problemas en los conjuntos de pruebas. Un almacén Zustand puede conservar valores de una prueba a otra. El caché de TanStack Query podría retener entradas obsoletas. Un window.location simulado configurado en un archivo puede afectar silenciosamente al siguiente. El resultado es un conjunto de pruebas que funcionan bien por separado pero fallan al ejecutarse juntas; o, de forma aún más confusa, pasan como parte de la ejecución completa pero fallan al ejecutarse de forma aislada.

// ❌ Useless: Polluted global state
import { useAuthStore } from './store';
test('logs in user', () => {
  useAuthStore.getState().setUser({ id: 1, name: 'Alice' });
  render(<Profile />);
  expect(screen.getByText('Alice')).toBeInTheDocument();
});
// This test might fail depending on run order
test('shows guest view', () => {
  render(<Profile />);
  // If the previous test ran first, Alice is still logged in
  expect(screen.getByText(/sign in/i)).toBeInTheDocument();
});

La consecuencia es un conjunto de pruebas inestable, lento e poco fiable. Una vez que ocurre esto, los ingenieros dejan de confiar en las fallas del CI, y las pruebas se convierten en ruido de fondo que nadie investiga.

// ✅ Useful: Isolated state for every test
import { act } from '@testing-library/react';
beforeEach(() => {
  // Reset Zustand store
  act(() => {
    useAuthStore.setState({ user: null, isAuthenticated: false });
  });

  // Clear React Query cache
  queryClient.clear();

  // Reset MSW handlers
  server.resetHandlers();

  // Reset mocks
  vi.clearAllMocks();
});
test('logs in user', async () => {
  // Test with explicit setup
  act(() => {
    useAuthStore.getState().setUser({ id: 1, name: 'Alice' });
  });

  render(<Profile />);
  expect(screen.getByText('Alice')).toBeInTheDocument();
});
test('shows guest view', () => {
  // Guaranteed clean state
  render(<Profile />);
  expect(screen.getByText(/sign in/i)).toBeInTheDocument();
});

Al acercarnos a 2026, con bibliotecas de estado atómico y cachés compartidos por todas partes, es esencial tratar el aislamiento de pruebas como una cuestión arquitectónica y no como algo secundario. Cada prueba debe crear su propio contexto limpio, verificar su comportamiento y desmontarlo todo posteriormente. Una buena prueba de fuego: si tu suite no puede ejecutarse en un orden aleatorio, algo en tu estrategia de aislamiento está defectuoso.

10. Afirmaciones que nunca pueden fallar

Tal vez el error más sutil sea escribir una prueba que parece válida pero que estructuralmente es incapaz de detectar un error. Esto suele ocurrir cuando la afirmación apunta a lo incorrecto, cuando se omite un await o cuando se utiliza un comparador demasiado permisivo.

// ❌ Useless: Assertion that cannot fail
test('modal opens', async () => {
  render(<Modal trigger={<button>Open</button>} />);

  // getBy throws if not found, but this is not an assertion
  screen.getByRole('dialog');

  // This passes even if the modal is empty or hidden
  expect(screen.getByRole('dialog')).toBeTruthy();
});
// ❌ Useless: Missing await on expect
test('shows notification', async () => {
  render(<Notifier />);
  await userEvent.click(screen.getByRole('button'));

  // Without await, this checks before the notification appears
  expect(screen.findByText(/success/i)).toBeInTheDocument();
});
// ❌ Useless: Testing presence of truthy object
test('form is valid', () => {
  const { container } = render(<Form />);
  // container is always truthy
  expect(container).toBeTruthy();
});

Pruebas como esta son, sin duda, peores que no tener ninguna prueba en absoluto: siguen consumiendo minutos en los procesos de integración continua y dan al equipo una falsa sensación de confianza, todo ello sin poder detectar una verdadera regresión cuando ocurre.

// ✅ Useful: Specific, awaited, negative assertions
test('modal opens and traps focus', async () => {
  render(<Modal trigger={<button>Open</button>} />);
  const user = userEvent.setup();

  await user.click(screen.getByRole('button', { name: /open/i }));

  // Assert visibility, not just presence
  const modal = screen.getByRole('dialog', { name: /confirm/i });
  expect(modal).toBeVisible();

  // Assert focus management
  expect(screen.getByRole('button', { name: /close/i })).toHaveFocus();

  // Assert escape key behavior
  await user.keyboard('{Escape}');
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('shows success notification after action', async () => {
  render(<Notifier />);
  const user = userEvent.setup();

  await user.click(screen.getByRole('button'));

  // await the findBy, then assert
  const notification = await screen.findByRole('alert');
  expect(notification).toHaveTextContent(/successfully saved/i);
  expect(notification).toHaveAttribute('aria-live', 'polite');
});

Dado el constante enfoque de React Testing Library hacia la verificación orientada al usuario, en 2026 debería considerarse una señal de alerta recurrir a comprobadores poco precisos como toBeTruthy o toBeDefined. Es preferible utilizar afirmaciones basadas en el texto visible, en los atributos ARIA, en el estado deshabilitado y en lo que realmente se muestra. Una forma útil de verificarlo: si la afirmación seguiría pasando con un div vacío, es necesario reescribirla.

La lista de verificación para pruebas en 2026

Antes de fusionar una nueva prueba, revise esta lista:

  • [ ] ¿Está validando el comportamiento orientado al usuario en lugar de la implementación interna?
  • [ ] ¿Está generando interacciones con userEvent en lugar de fireEvent?
  • [ ] ¿Se simula la capa de red mediante MSW en lugar de un stub raw de fetch?
  • [ ] ¿Hace afirmaciones deliberadas en lugar de depender de capturas instantáneas?
  • [ ] ¿Los elementos se localizan por rol y etiqueta en lugar de mediante data-testid?
  • [ ] ¿Se esperan adecuadamente todos los pasos asíncronos?
  • [ ] ¿Se renderiza dentro de la misma jerarquía de proveedores que utiliza la aplicación real?
  • [ ] ¿Prueba estados de error, vacío y carga, no solo el camino óptimo?
  • [ ] ¿Se reinicia el estado global para que la prueba pueda ejecutarse en cualquier orden?
  • [ ] ¿La afirmación fallaría realmente si el comportamiento subyacente se rompiera?
  • Obtener simplemente una marca de verificación verde en tu suite de pruebas no es el objetivo real; lo verdadero es ganar la confianza en esa marca de verificación. Al abordar estos diez problemas, tus pruebas dejarán de ser una mera formalidad para convertirse en la verdadera red de seguridad que deben ser.

    Lecturas relacionadas

  • Guía de configuración de React + Vite (2026): Tu primera aplicación funcional, explicada — Se explica paso a paso cómo instalar Node.js, npm, VS Code y Vite para crear una estructura básica de una aplicación React, además de qué función realiza realmente cada herramienta en el fondo.
  • De los scripts DOM a la interfaz de usuario como función del estado: el modelo central de React — Por qué React sustituye las actualizaciones manuales del DOM por componentes declarativos, y cómo JSX, props, estado, volver a renderizar y el flujo de datos unidireccional encajan dentro de un mismo modelo mental.