Por qué tu componente en React es difícil de probar (y cómo solucionarlo)

Gabriel Jiménez | Hace 9 días

Los componentes mal diseñados suelen ser difíciles de probar, ya que no permiten simular su comportamiento fácilmente.


Cuando un componente mezcla demasiadas responsabilidades y dependencias externas, las pruebas se vuelven complejas y frágiles. Por consecuencia, el componente también se vuelve difícil de mantener y escalar.


En este artículo, aprenderemos a detectar qué cosas hacen que un componente sea difícil de probar.


Tu componente tiene demasiadas responsabilidades

Uno de los problemas más comunes es crear componentes que intentan hacer demasiadas cosas al mismo tiempo. Por ejemplo, obtener datos del backend, procesar información, manejar formularios y renderizar la interfaz dentro del mismo componente.


El problema de este enfoque es que las pruebas comienzan a depender de demasiados escenarios. Ahora necesitamos simular peticiones HTTP, estados de carga, validaciones, cálculos y renderizado visual solo para probar una pequeña parte del comportamiento.


Mientras más responsabilidades tenga un componente, más difícil será aislar y probar cada caso de uso.


Ejemplo


import { useEffect, useState } from "react";

export default function ProductList() {
  const [products, setProducts] = useState([]);
  const [search, setSearch] = useState("");
  const [filteredProducts, setFilteredProducts] = useState([]);
  const [message, setMessage] = useState("");

  useEffect(() => {
    fetch("/api/products")
      .then((response) => response.json())
      .then((data) => {
        setProducts(data);
        setFilteredProducts(data);
      });
  }, []);

  const handleSearch = (event) => {
    const value = event.target.value;

    setSearch(value);

    const results = products.filter((product) => {
      return product.name.toLowerCase().includes(value.toLowerCase());
    });

    setFilteredProducts(results);

    if (results.length === 0) {
      setMessage("No se encontraron productos");
    } else {
      setMessage("");
    }
  };

  return (
    <div>
      <input
        type="text"
        placeholder="Buscar producto"
        value={search}
        onChange={handleSearch}
      />

      {message && <p>{message}</p>}

      <ul>
        {filteredProducts.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  );
}

Este componente tiene demasiadas responsabilidades:


  • Obtener productos del backend
  • Manejar el estado del buscador
  • Filtrar información
  • Manejar mensajes de error
  • Renderizar la interfaz

Solución

Una mejor estrategia es dividir responsabilidades.


Por ejemplo:

  • Un hook para obtener datos
  • Una función para manejar cálculos o reglas de negocio
  • Un componente enfocado únicamente en renderizar UI

import { useEffect, useState } from "react";

function useProducts() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetch("/api/products")
      .then((response) => response.json())
      .then((data) => {
        setProducts(data);
      });
  }, []);

  return { products };
}

function filterProducts(products, search) {
  return products.filter((product) => {
    return product.name.toLowerCase().includes(search.toLowerCase());
  });
}

function ProductListView({
  search,
  products,
  message,
  onSearchChange,
}) {
  return (
    <div>
      <input
        type="text"
        placeholder="Buscar producto"
        value={search}
        onChange={onSearchChange}
      />

      {message && <p>{message}</p>}

      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  );
}

export default function ProductList() {
  const { products } = useProducts();
  const [search, setSearch] = useState("");

  const filteredProducts = filterProducts(products, search);

  const message =
    search && filteredProducts.length === 0
      ? "No se encontraron productos"
      : "";

  const handleSearch = (event) => {
    setSearch(event.target.value);
  };

  return (
    <ProductListView
      search={search}
      products={filteredProducts}
      message={message}
      onSearchChange={handleSearch}
    />
  );
}

De esta forma, cada parte puede probarse de forma aislada y las pruebas se vuelven más simples y mantenibles. 


Aunque esto siempre depende del contexto y del tamaño de la aplicación. En aplicaciones pequeñas, mover toda la lógica a hooks o abstraer demasiado código puede terminar agregando complejidad innecesaria.


El objetivo no es dividir por dividir, sino mantener componentes fáciles de entender, mantener y probar.


Cuando necesitas demasiados mocks para probar algo simple

Los mocks son útiles para aislar dependencias externas durante una prueba. El problema aparece cuando necesitamos mockear demasiadas cosas para validar un comportamiento pequeño.


Si para probar un botón necesitamos mockear APIs, contexto global, hooks, rutas, localStorage y múltiples servicios, probablemente el componente está demasiado acoplado a su entorno.


Mientras más dependencias tenga un componente, más difícil será reutilizarlo, entenderlo y probarlo de forma aislada.


Ejemplo


import { useAuth } from "./hooks/useAuth";
import { useCart } from "./hooks/useCart";
import { useNavigate } from "react-router-dom";
import { saveOrder } from "./services/orderService";

export default function CheckoutButton() {
  const { user } = useAuth();
  const { items, clearCart } = useCart();
  const navigate = useNavigate();

  const handleCheckout = async () => {
    await saveOrder({
      userId: user.id,
      items,
    });

    clearCart();

    navigate("/success");
  };

  return (
    <button onClick={handleCheckout}>
      Finalizar compra
    </button>
  );
}

Para probar este componente, probablemente necesitaremos mockear:

  • useAuth
  • useCart
  • useNavigate
  • saveOrder

Y todo eso solo para validar que un botón ejecuta una acción.


Solución

Una forma de reducir esta complejidad es mover parte de la lógica fuera del componente y dejar que reciba información mediante props o funciones externas.


Por ejemplo, el componente podría únicamente renderizar el botón y recibir una función onCheckout.


export default function CheckoutButton({ onCheckout }) {
  return (
    <button onClick={onCheckout}>
      Finalizar compra
    </button>
  );
}

Ahora el componente es mucho más fácil de probar, porque ya no depende directamente de múltiples hooks o servicios externos. Aunque, nuevamente, todo depende del contexto. Si es una aplicación pequeña no vale la pena agregarle complejidad.


Depender de muchos useEffect puede complicar las pruebas

Los useEffect son muy utilizados para invocar acciones al momento que se renderiza el componente. Normalmente, solemos realizar varias llamadas y lógica de negocio dentro de ellos, esto provoca que las pruebas sean más frágiles y difíciles de mantener.


Mientras más lógica tengamos, más complicado será entender el flujo del componente y simular todos los escenarios durante las pruebas.


Ejemplo


import { useEffect, useState } from "react";

export default function Dashboard() {
  const [user, setUser] = useState(null);
  const [notifications, setNotifications] = useState([]);
  const [isPremium, setIsPremium] = useState(false);

  useEffect(() => {
    fetch("/api/user")
      .then((response) => response.json())
      .then((data) => {
        setUser(data);
      });
  }, []);

  useEffect(() => {
    if (!user) {
      return;
    }

    fetch(`/api/users/${user.id}/notifications`)
      .then((response) => response.json())
      .then((data) => {
        setNotifications(data);
      });
  }, [user]);

  useEffect(() => {
    if (!user) {
      return;
    }

    setIsPremium(user.plan === "premium");
  }, [user]);

  if (!user) {
    return <p>Cargando...</p>;
  }

  return (
    <div>
      <h1>{user.name}</h1>

      {isPremium && <strong>Usuario premium</strong>}

      <p>
        Tienes {notifications.length} notificaciones
      </p>
    </div>
  );
}

Este componente depende de múltiples efectos para funcionar:


  • Obtener el usuario
  • Obtener notificaciones
  • Determinar si el usuario es premium

Por consecuencia, las pruebas necesitan esperar múltiples actualizaciones de estado y controlar varios flujos asíncronos.


Solución

Una mejor estrategia es encapsular la petición del usuario en un hook específico, por ejemplo useAuth.


import { useEffect, useState } from "react";

export function useAuth() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch("/api/user")
      .then((response) => response.json())
      .then((data) => {
        setUser(data);
      });
  }, []);

  return { user };
}

Después, el componente puede consumir ese hook:


import { useAuth } from "./useAuth";

export default function Dashboard() {
  const { user } = useAuth();

  if (!user) {
    return <p>Cargando...</p>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
    </div>
  );
}

Así el componente deja de encargarse directamente de la petición y se enfoca más en mostrar la interfaz.


El problema no son las pruebas, es la arquitectura

Tener un patrón definido para crear nuestros componentes nos ayuda a mantener una estructura clara y predecible.

Cuando cada componente se organiza de una forma diferente, las pruebas se vuelven más difíciles porque primero necesitamos entender cómo está construido antes de poder validar su comportamiento.


El problema no aparece porque escribir pruebas sea complicado. Muchas veces, las pruebas solo evidencian que el componente no tiene una buena separación de responsabilidades.


Ejemplo


export default function UserProfile({ user }) {
  const fullName = `${user.name} ${user.lastName}`;

  const isAdult = user.age >= 18;

  const roleLabel = user.role === "admin"
    ? "Administrador"
    : "Usuario";

  return (
    <div>
      <h1>{fullName}</h1>

      {isAdult && <p>Usuario mayor de edad</p>}

      <span>{roleLabel}</span>
    </div>
  );
}

En este ejemplo, el componente no solo renderiza información. También está construyendo nombres, evaluando reglas y transformando datos.


Si esta lógica crece, las pruebas empiezan a enfocarse en demasiados detalles internos del componente.


Solución

Podemos mover esa lógica a funciones externas y dejar que el componente se enfoque en renderizar.


export function getUserFullName(user) {
  return `${user.name} ${user.lastName}`;
}

export function isAdultUser(user) {
  return user.age >= 18;
}

export function getRoleLabel(role) {
  return role === "admin" ? "Administrador" : "Usuario";
}

import {
  getUserFullName,
  isAdultUser,
  getRoleLabel,
} from "./userProfileUtils";

export default function UserProfile({ user }) {
  return (
    <div>
      <h1>{getUserFullName(user)}</h1>

      {isAdultUser(user) && <p>Usuario mayor de edad</p>}

      <span>{getRoleLabel(user.role)}</span>
    </div>
  );
}

Ahora la lógica puede probarse de forma aislada y el componente mantiene una estructura más simple.


Componentes que dependen demasiado del contexto global

El contexto global es muy útil para compartir información entre múltiples componentes. Sin embargo, cuando un componente depende demasiado de contextos, stores o estados globales, las pruebas comienzan a complicarse.


Ahora, para probar algo simple, necesitamos configurar providers, estados iniciales y múltiples dependencias externas antes de renderizar el componente. Además, el propio contexto puede contener lógica de negocio que afecte el comportamiento del componente hijo, provocando que las pruebas sean más difíciles de entender y mantener.


Mientras más acoplado esté un componente al contexto global, más difícil será reutilizarlo y probarlo de forma aislada.


Ejemplo


import { useContext } from "react";
import { AuthContext } from "./contexts/AuthContext";
import { ThemeContext } from "./contexts/ThemeContext";
import { CartContext } from "./contexts/CartContext";

export default function Header() {
  const { user } = useContext(AuthContext);
  const { theme } = useContext(ThemeContext);
  const { items } = useContext(CartContext);

  return (
    <header className={theme}>
      <h1>Hola, {user.name}</h1>

      <p>
        Productos en carrito: {items.length}
      </p>
    </header>
  );
}

Solución

Existen dos formas comunes de abordar este problema.


La primera es reducir el acoplamiento haciendo que el componente reciba únicamente la información que necesita mediante props.


export default function Header({
  userName,
  totalItems,
  theme,
}) {
  return (
    <header className={theme}>
      <h1>Hola, {userName}</h1>

      <p>
        Productos en carrito: {totalItems}
      </p>
    </header>
  );
}

De esta forma, el componente se vuelve más simple de reutilizar y probar.


La segunda opción es simular los contextos durante las pruebas utilizando providers personalizados o mocks.


import { render, screen } from "@testing-library/react";
import { AuthContext } from "./contexts/AuthContext";
import { ThemeContext } from "./contexts/ThemeContext";
import { CartContext } from "./contexts/CartContext";
import Header from "./Header";

test("muestra el nombre del usuario y los productos del carrito", () => {
  const user = { name: "Gabriel" };

  render(
    <AuthContext.Provider value={{ user }}>
      <ThemeContext.Provider value={{ theme: "dark" }}>
        <CartContext.Provider value={{ items: [{ id: 1 }] }}>
          <Header />
        </CartContext.Provider>
      </ThemeContext.Provider>
    </AuthContext.Provider>
  );

  expect(screen.getByText("Hola, Gabriel")).toBeInTheDocument();
  expect(screen.getByText("Productos en carrito: 1")).toBeInTheDocument();
});

Aunque este enfoque funciona, también puede ocultar información importante. Por ejemplo, el contexto podría contener lógica de negocio que afecte directamente el comportamiento del componente hijo, y al simularlo manualmente podríamos ignorar reglas, validaciones o transformaciones que realmente ocurren en producción.


Por eso, el objetivo no siempre es eliminar Context API, sino evitar componentes demasiado acoplados al comportamiento interno del contexto.


Conclusión

Existen varias señales que hacen que un componente sea difícil de probar, sin embargo, siempre dependerá del criterio y del contexto del proyecto.

No siempre vale la pena agregar complejidad innecesaria solo por seguir una arquitectura “perfecta”. 


Conforme comenzamos a agregar nuevas funcionalidades, el mismo componente empieza a pedir auxilio. Normalmente es ahí cuando vale la pena comenzar a refactorizar y separar responsabilidades.


Sigue aprendiendo cómo escalar aplicaciones React mediante un enfoque de pruebas y construye componentes más fáciles de mantener y evolucionar.


Visita mi Hub Testing en React.

Más de esta serie

Cómo consumir APIs en React sin ensuciar tus componentes

Cómo consumir APIs en React sin ensuciar tus componentes

Aprende cómo transformar datos entre React y tu backend usando adapters y mappers para crear componentes más fáciles de mantener y escalar.

Cómo organizar tus carpetas y archivos en React para escalar tu aplicación

Cómo organizar tus carpetas y archivos en React para escalar tu aplicación

Aprende a organizar carpetas y archivos en React para crear proyectos más mantenibles, escalables y fáciles de desarrollar.

¿Cuándo usar hooks personalizados en React (y cuándo no)?

¿Cuándo usar hooks personalizados en React (y cuándo no)?

Aprende cuándo crear hooks personalizados en React para reutilizar lógica, simplificar componentes y mejorar el testing.

Señales de un componente difícil de mantener en React

Señales de un componente difícil de mantener en React

Aprende a detectar componentes difíciles de mantener en React antes de que se conviertan en deuda técnica y errores costosos.