Cómo probar una tabla en React con React Testing Library

Gabriel Jiménez | Hace 5 días

En este artículo, vamos a aprender que elementos de una tabla debemos de enfocarnos en probar y lo pondremos en práctica usando React Testing Library. Al final del artículo serás capaz de detectar los escenarios críticos en una tabla.


Qué deberías probar en una tabla en React

Las tablas son elementos muy utilizados en la web para listar registros. Además, cada registro puede acompañarse de acciones dependiendo su estado o alguna lógica propia del negocio.


Datos mostrados en cada columna

Los datos mostrados en cada una de las columnas son importantes, porque es la primera impresión que se llevan sobre el registro listado. Debemos asegurarnos que los datos de las columnas más importantes se muestren correctamente al usuario, de esta manera, le damos la seguridad y confianza al usuario de que todo anda bien.


Acciones por estado

La gran mayoría de registros de cierta forma mantiene un estado. Estos estados permiten que el usuario pueda realizar acciones sobre los registros. Por ejemplo, una compra guardada en un carrito, tendría el estado de “en espera del pago” y el usuario podría tener una acción de reanudar compra.


Retroalimentación al usuario

Cuando realizamos una acción sobre un registro, es importante mantener informado al usuario sobre si la acción se realizo correctamente o no.


Refrescar la tabla en cada acción

Al realizar una acción no basta con mostrarle un mensaje de confirmación al usuario, hay que refrescar la tabla para que los registros muestran su información actualizada y el usuario pueda continuar operando.


Cómo interactuar con una tabla usando React Testing Library

React Testing Library es una librería que facilita el desarrollo de las pruebas en React. Con esta librería podemos validar la mayoría de los casos posibles de una tabla.


Si quieres saber más sobre React Testing Library, te dejo el siguiente artículo: Qué es React Testing Library y cómo funciona con Jest


Selecionar filas


// SimpleTable.jsx
export function SimpleTable() {
  return (
    <table>
      <tbody>
        <tr data-testid="row-1">
          <td>Gabriel</td>
          <td>[email protected]</td>
        </tr>

        <tr data-testid="row-2">
          <td>Ana</td>
          <td>[email protected]</td>
        </tr>
      </tbody>
    </table>
  );
}

// SimpleTable.test.jsx
import { render, screen, fireEvent, within } from "@testing-library/react";
import { SimpleTable } from "./SimpleTable";

describe("SimpleTable", () => {
  it("selecciona una fila y valida sus campos", () => {
    // Renderizamos el componente
    render(<SimpleTable />);

    // Seleccionamos solo la fila 1
    const row = screen.getByTestId("row-1");

    // Limitamos la búsqueda dentro de la fila 1
    const utils = within(row);

    expect(utils.getByText("Gabriel")).toBeInTheDocument();
    expect(utils.getByText("[email protected]")).toBeInTheDocument();
  });
});

Click en acciones por fila


import { useState } from "react";

export function SimpleTable() {
  const [actionMessage, setActionMessage] = useState("");

  const handleAction = (id, type) => {
    setActionMessage(`Acción ${type} ejecutada en ID ${id}`);
  };

  return (
    <div>
      <table>
        <tbody>
          <tr data-testid="row-1">
            <td>Gabriel</td>
            <td>[email protected]</td>

            <td>
              <button onClick={() => handleAction(1, "A")}>Acción A</button>
              <button onClick={() => handleAction(1, "B")}>Acción B</button>
            </td>
          </tr>

          <tr data-testid="row-2">
            <td>Ana</td>
            <td>[email protected]</td>

            <td>
              <button onClick={() => handleAction(2, "A")}>Acción A</button>
              <button onClick={() => handleAction(2, "B")}>Acción B</button>
            </td>
          </tr>
        </tbody>
      </table>

      {actionMessage && <p data-testid="action-msg">{actionMessage}</p>}
    </div>
  );
}

import { render, screen, within, fireEvent } from "@testing-library/react";
import { SimpleTable } from "./SimpleTable";

describe("SimpleTable", () => {
  it("ejecuta una acción en la fila y muestra el mensaje correspondiente", () => {
    render(<SimpleTable />);

    // Buscamos la fila 2
    const row = screen.getByTestId("row-2");
    const rowUtils = within(row);

    // Ejecutamos la acción B
    const actionBtn = rowUtils.getByText("Acción B");
    fireEvent.click(actionBtn);

    // Validamos el mensaje
    expect(screen.getByTestId("action-msg"))
      .toHaveTextContent("Acción B ejecutada en ID 2");
  });
});

Validar navegación hacia otra página


import { useState } from "react";

export function SimpleTable() {
  const [actionMessage, setActionMessage] = useState("");

  const handleAction = (id, type) => {
    setActionMessage(`Acción ${type} ejecutada en ID ${id}`);
  };

  const goToDetail = (id) => {
    document.location.href = `/detalles/${id}`;
  };

  return (
    <div>
      <table>
        <tbody>
          <tr data-testid="row-1">
            <td>Gabriel</td>
            <td>[email protected]</td>

            <td>
              <button onClick={() => handleAction(1, "A")}>Acción A</button>
              <button onClick={() => handleAction(1, "B")}>Acción B</button>
              <button onClick={() => goToDetail(1)}>Ir a detalle</button>
            </td>
          </tr>

          <tr data-testid="row-2">
            <td>Ana</td>
            <td>[email protected]</td>

            <td>
              <button onClick={() => handleAction(2, "A")}>Acción A</button>
              <button onClick={() => handleAction(2, "B")}>Acción B</button>
              <button onClick={() => goToDetail(2)}>Ir a detalle</button>
            </td>
          </tr>
        </tbody>
      </table>

      {actionMessage && <p data-testid="action-msg">{actionMessage}</p>}
    </div>
  );
}

import { render, screen, within, fireEvent } from "@testing-library/react";
import { SimpleTable } from "./SimpleTable";

describe("SimpleTable", () => {
  it("redirecciona usando document.location.href", () => {
    render(<SimpleTable />);

    const row = screen.getByTestId("row-1");
    const rowUtils = within(row);

    // Hacemos clic en el botón "Ir a detalle"
    fireEvent.click(rowUtils.getByText("Ir a detalle"));

    // Validamos la nueva URL
    expect(document.location.href).toContain("/detalles/1");
  });
});

Peticiones asíncronas


import { useState } from "react";

export function SimpleTable() {
  const [actionMessage, setActionMessage] = useState("");

  const handleAction = (id, type) => {
    setActionMessage(`Acción ${type} ejecutada en ID ${id}`);
  };

  const goToDetail = (id) => {
    document.location.href = `/detalles/${id}`;
  };

  const deleteRecord = async (id) => {
    await fetch(`/api/records/${id}`, { method: "DELETE" });
    setActionMessage(`Registro ${id} eliminado`);
  };

  return (
    <div>
      <table>
        <tbody>
          <tr data-testid="row-1">
            <td>Gabriel</td>
            <td>[email protected]</td>

            <td>
              <button onClick={() => handleAction(1, "A")}>Acción A</button>
              <button onClick={() => handleAction(1, "B")}>Acción B</button>
              <button onClick={() => goToDetail(1)}>Ir a detalle</button>
              <button onClick={() => deleteRecord(1)}>Eliminar</button>
            </td>
          </tr>

          <tr data-testid="row-2">
            <td>Ana</td>
            <td>[email protected]</td>

            <td>
              <button onClick={() => handleAction(2, "A")}>Acción A</button>
              <button onClick={() => handleAction(2, "B")}>Acción B</button>
              <button onClick={() => goToDetail(2)}>Ir a detalle</button>
              <button onClick={() => deleteRecord(2)}>Eliminar</button>
            </td>
          </tr>
        </tbody>
      </table>

      {actionMessage && <p data-testid="action-msg">{actionMessage}</p>}
    </div>
  );
}

import { render, screen, within, fireEvent } from "@testing-library/react";
import { SimpleTable } from "./SimpleTable";

describe("SimpleTable", () => {
  it("ejecuta la acción de eliminar y muestra el mensaje", async () => {
    // Mock global fetch
    global.fetch = jest.fn(() =>
      Promise.resolve({ ok: true })
    );

    render(<SimpleTable />);

    const row = screen.getByTestId("row-2");
    const rowUtils = within(row);

    // Hacemos clic en "Eliminar"
    fireEvent.click(rowUtils.getByText("Eliminar"));

    // Validamos que fetch fue llamado correctamente
    expect(global.fetch).toHaveBeenCalledWith("/api/records/2", {
      method: "DELETE",
    });

    // Validamos el mensaje final
    expect(await screen.findByTestId("action-msg"))
      .toHaveTextContent("Registro 2 eliminado");
  });
});

Probar tablas con paginación y filtros

Otras funcionalidades muy utilizadas en las tablas es la paginación y los filtros. La paginación se encarga de reducir los resultados que se muestra al usuario, minimizando la velocidad con la que se muestran los registros. Por otro lado, los filtros permiten buscar en registro dependiendo ciertas características. Por ejemplo, id, nombre, estado.

Cambiar de página


// PaginatedTable.jsx
import { useState } from "react";

export function PaginatedTable() {
  const [currentPage, setCurrentPage] = useState(1);

  const pages = {
    1: [
      { id: 1, name: "Producto A" },
      { id: 2, name: "Producto B" }
    ],
    2: [
      { id: 3, name: "Producto C" },
      { id: 4, name: "Producto D" }
    ]
  };

  const rows = pages[currentPage];

  return (
    <div>
      <table>
        <tbody>
          {rows.map((row) => (
            <tr data-testid={`row-${row.id}`} key={row.id}>
              <td>{row.name}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <section>
        <button
          onClick={() => setCurrentPage(1)}
          disabled={currentPage === 1}
        >
          Página anterior
        </button>

        <button
          onClick={() => setCurrentPage(2)}
          disabled={currentPage === 2}
        >
          Página siguiente
        </button>
      </section>
    </div>
  );
}

// PaginatedTable.test.jsx
import { render, screen, fireEvent } from "@testing-library/react";
import { PaginatedTable } from "./PaginatedTable";

describe("PaginatedTable", () => {
it("cambia a la segunda página al hacer clic en 'Página siguiente'", () => {
    render(<PaginatedTable />);

    const nextButton = screen.getByText("Página siguiente");

    fireEvent.click(nextButton);

    // Debe aparecer contenido de página 2
    expect(screen.getByText("Producto C")).toBeInTheDocument();
    expect(screen.getByText("Producto D")).toBeInTheDocument();

    // Y dejar de aparecer el contenido de página 1
    expect(screen.queryByText("Producto A")).not.toBeInTheDocument();
    expect(screen.queryByText("Producto B")).not.toBeInTheDocument();
  });
});

Aplicar filtros y validar resultados


// FilterableTable.jsx
import { useState } from "react";

export function FilterableTable() {
  const [search, setSearch] = useState("");

  const data = [
    { id: 1, name: "Gabriel" },
    { id: 2, name: "Ana" },
    { id: 3, name: "Mario" }
  ];

  const filtered = data.filter((item) =>
    item.name.toLowerCase().includes(search.toLowerCase())
  );

  return (
    <div>
      <input
        placeholder="Buscar por nombre"
        value={search}
        onChange={(e) => setSearch(e.target.value)}
      />

      <table>
        <tbody>
          {filtered.map((row) => (
            <tr key={row.id} data-testid={`row-${row.id}`}>
              <td>{row.name}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

// FilterableTable.test.jsx
import { render, screen, fireEvent } from "@testing-library/react";
import { FilterableTable } from "./FilterableTable";

describe("FilterableTable", () => {
  it("filtra por nombre correctamente", () => {
    render(<FilterableTable />);

    // Al inicio deben aparecer todos
    expect(screen.getByText("Gabriel")).toBeInTheDocument();
    expect(screen.getByText("Ana")).toBeInTheDocument();
    expect(screen.getByText("Mario")).toBeInTheDocument();

    // Escribimos en el input
    const input = screen.getByPlaceholderText("Buscar por nombre");
    fireEvent.change(input, { target: { value: "ga" } });

    // Debe aparecer solo Gabriel
    expect(screen.getByText("Gabriel")).toBeInTheDocument();

    // Ya NO deben aparecer los demás
    expect(screen.queryByText("Ana")).not.toBeInTheDocument();
    expect(screen.queryByText("Mario")).not.toBeInTheDocument();
  });
});

Restablecer filtros


// FilterResetTable.jsx
import { useState } from "react";

export function FilterResetTable() {
  const [search, setSearch] = useState("");

  const data = [
    { id: 1, name: "Gabriel" },
    { id: 2, name: "Ana" },
    { id: 3, name: "Mario" }
  ];

  const filtered = data.filter((item) =>
    item.name.toLowerCase().includes(search.toLowerCase())
  );

  return (
    <div>
      <input
        placeholder="Buscar"
        value={search}
        onChange={(e) => setSearch(e.target.value)}
      />

      <button onClick={() => setSearch("")}>
        Limpiar filtros
      </button>

      <table>
        <tbody>
          {filtered.map((row) => (
            <tr key={row.id} data-testid={`row-${row.id}`}>
              <td>{row.name}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

// FilterResetTable.test.jsx
import { render, screen, fireEvent } from "@testing-library/react";
import { FilterResetTable } from "./FilterResetTable";

describe("FilterResetTable", () => {
  it("resetea los filtros correctamente", () => {
    render(<FilterResetTable />);

    const input = screen.getByPlaceholderText("Buscar");
    const resetBtn = screen.getByText("Limpiar filtros");

    // Al inicio: todos visibles
    expect(screen.getByText("Gabriel")).toBeInTheDocument();
    expect(screen.getByText("Ana")).toBeInTheDocument();
    expect(screen.getByText("Mario")).toBeInTheDocument();

    // Aplicamos un filtro
    fireEvent.change(input, { target: { value: "ga" } });

    // Solo debe quedar Gabriel
    expect(screen.getByText("Gabriel")).toBeInTheDocument();
    expect(screen.queryByText("Ana")).not.toBeInTheDocument();
    expect(screen.queryByText("Mario")).not.toBeInTheDocument();

    // Reseteamos
    fireEvent.click(resetBtn);

    // Todos vuelven a aparecer
    expect(screen.getByText("Gabriel")).toBeInTheDocument();
    expect(screen.getByText("Ana")).toBeInTheDocument();
    expect(screen.getByText("Mario")).toBeInTheDocument();

    // Input vacío
    expect(input).toHaveValue("");
  });
});

Conclusión

Probar las tablas asegura que el usuario pueda continuar operando con normalidad. Si los datos de cada registro no se muestran acorde a lo que deben de expresar, el usuario puede sentir que la aplicación no está haciendo lo que debe.


Si quieres aprender a probar tus tablas como un profesional, tengo un libro enfocado únicamente en testing en React o un HUB donde hablo sobre testing en React.


HUB: Testing en React
Libro: Testing en React: Guía práctica con Jest y React Testing Library