test(ui): assert DataTable behavior instead of DOM structure

The shared DataTable test reached for elements by CSS selector and by
walking parentElement chains, then asserted on Tailwind class strings. It
had no role queries at all, so a wrapper div anywhere in the render tree
broke it while changing nothing a user sees.

Columns, rows and headers are now found the way a user finds them: by
role and by the text on screen. The compact skeleton row is compared
against the loaded row's height rather than a hard-coded h-8, so renaming
the class no longer breaks the test but shrinking the row still does.

The fillHeight and maxBodyHeight cases stay class assertions. jsdom has
no layout engine, so there is nothing behavioural to assert there. What
they no longer do is derive their elements from incidental nesting: the
three layout wrappers and the header now publish a stable test id, which
is also why the resizer's write-only data-resizer attribute became one.

Budgets drop with the counts: no-container 150 to 133, no-node-access 760
to 723.
This commit is contained in:
Yuneng Jiang 2026-09-01 00:19:27 -07:00
parent 847d737b8e
commit 2fbea77afa
No known key found for this signature in database
3 changed files with 66 additions and 65 deletions

View file

@ -5,7 +5,7 @@
"max-depth": { "max": 70, "target": 30 },
"local/no-large-inline-object-arg": { "max": 559, "target": 300 },
"local/no-long-condition-chain": { "max": 265, "target": 120 },
"testing-library/no-container": { "max": 150, "target": 50 },
"testing-library/no-node-access": { "max": 760, "target": 500 },
"testing-library/no-container": { "max": 133, "target": 50 },
"testing-library/no-node-access": { "max": 723, "target": 500 },
"testing-library/prefer-screen-queries": { "max": 221, "target": 0 }
}

View file

@ -1,5 +1,5 @@
import type { ColumnDef, ExpandedState } from "@tanstack/react-table";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
@ -21,6 +21,12 @@ function person(id: string, name: string, flagged = false): Person {
const names = (): (string | null)[] => screen.getAllByTestId("name-cell").map((el) => el.textContent);
const heightClassesOf = (el: HTMLElement | undefined): string[] =>
(el?.className ?? "")
.split(/\s+/)
.filter((cls) => cls.startsWith("h-"))
.sort();
const nameCellColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
@ -229,12 +235,10 @@ describe("DataTable sorting", () => {
describe("DataTable layout", () => {
it("stretches the table to fill the container when resizing is on, so hidden columns leave no right-side gap", () => {
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} enableColumnResizing />);
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} enableColumnResizing />);
const table = container.querySelector("table");
expect(table).not.toBeNull();
// width pins the natural column total (horizontal scroll on overflow); minWidth:100% fills the gap on underflow.
expect(table?.style.minWidth).toBe("100%");
expect(screen.getByRole("table")).toHaveStyle({ minWidth: "100%" });
});
});
@ -354,17 +358,21 @@ describe("DataTable loading", () => {
const { rerender } = render(
<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} size="compact" isLoading />,
);
const skeletonRow = screen.getAllByTestId("skeleton-row").at(0);
const loadedRowHeight = "h-8";
expect(skeletonRow?.className).toContain(loadedRowHeight);
const skeletonHeight = heightClassesOf(screen.getAllByRole("row").at(-1));
rerender(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} size="compact" />);
expect(document.querySelector("[data-row-id]")?.className).toContain(loadedRowHeight);
const loadedHeight = heightClassesOf(screen.getByRole("row", { name: /Charlie/ }));
expect(loadedHeight).not.toEqual([]);
expect(skeletonHeight).toEqual(loadedHeight);
});
it("does not force the compact height on default-size skeleton rows", () => {
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} isLoading />);
expect(screen.getAllByTestId("skeleton-row").at(0)?.className).not.toContain("h-8");
const { rerender } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} isLoading />);
const skeletonHeight = heightClassesOf(screen.getAllByRole("row").at(-1));
rerender(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} size="compact" isLoading />);
expect(heightClassesOf(screen.getAllByRole("row").at(-1))).not.toEqual(skeletonHeight);
});
it("varies skeleton shape and width per column instead of one fixed bar", () => {
@ -420,7 +428,7 @@ describe("DataTable loading", () => {
describe("DataTable column visibility", () => {
it("hides a column when toggled off in the view-options menu", async () => {
const user = userEvent.setup();
const { container } = render(
render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={nameEmailColumns}
@ -428,13 +436,13 @@ describe("DataTable column visibility", () => {
/>,
);
expect(container.querySelector('th[data-header-id="email"]')).not.toBeNull();
expect(screen.getByRole("columnheader", { name: "Email" })).toBeInTheDocument();
await user.click(screen.getByTestId("view-options-trigger"));
await user.click(await screen.findByTestId("view-option-email"));
await waitFor(() => expect(container.querySelector('th[data-header-id="email"]')).toBeNull());
await waitFor(() => expect(screen.queryByRole("columnheader", { name: "Email" })).not.toBeInTheDocument());
await user.click(screen.getByTestId("view-option-email"));
await waitFor(() => expect(container.querySelector('th[data-header-id="email"]')).not.toBeNull());
expect(await screen.findByRole("columnheader", { name: "Email" })).toBeInTheDocument();
});
it("omits columns that opt out of hiding from the menu", async () => {
@ -468,14 +476,10 @@ describe("DataTable column visibility", () => {
describe("DataTable pinned columns", () => {
it("applies sticky positioning to a pinned column only", () => {
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={pinnedColumns} />);
render(<DataTable data={CHARLIE_ALICE_BOB} columns={pinnedColumns} />);
const pinnedHead = container.querySelector<HTMLElement>('th[data-header-id="name"]');
const normalHead = container.querySelector<HTMLElement>('th[data-header-id="email"]');
expect(pinnedHead?.style.position).toBe("sticky");
expect(pinnedHead?.style.left).toBe("0px");
expect(normalHead?.style.position).toBe("");
expect(screen.getByRole("columnheader", { name: "Name" })).toHaveStyle({ position: "sticky", left: "0px" });
expect(screen.getByRole("columnheader", { name: "Email" })).not.toHaveStyle({ position: "sticky" });
});
});
@ -570,7 +574,7 @@ describe("DataTable expansion", () => {
describe("DataTable row styling and footer", () => {
it("applies rowClassName to the matching row only", () => {
const data = [person("a", "Alice", true), person("b", "Bob", false)];
const { container } = render(
render(
<DataTable
data={data}
columns={nameCellColumns}
@ -579,8 +583,8 @@ describe("DataTable row styling and footer", () => {
/>,
);
expect(container.querySelector('tr[data-row-id="a"]')?.className).toContain("flagged-row");
expect(container.querySelector('tr[data-row-id="b"]')?.className).not.toContain("flagged-row");
expect(screen.getByRole("row", { name: /Alice/ })).toHaveClass("flagged-row");
expect(screen.getByRole("row", { name: /Bob/ })).not.toHaveClass("flagged-row");
});
it("renders the footer slot inside a tfoot element", () => {
@ -596,63 +600,57 @@ describe("DataTable row styling and footer", () => {
/>,
);
expect(screen.getByTestId("footer-row").closest("tfoot")).not.toBeNull();
const rowGroups = screen.getAllByRole("rowgroup");
expect(within(rowGroups.at(-1) as HTMLElement).getByText("Total: 3")).toBeInTheDocument();
});
});
describe("DataTable layout", () => {
it("exposes resize handles with stable selectors only when resizing is enabled", () => {
const { container, rerender } = render(
<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} enableColumnResizing />,
);
expect(container.querySelectorAll("[data-resizer][data-header-id]").length).toBe(2);
const { rerender } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} enableColumnResizing />);
expect(screen.getByTestId("column-resizer-name")).toBeInTheDocument();
expect(screen.getByTestId("column-resizer-email")).toBeInTheDocument();
rerender(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} />);
expect(container.querySelectorAll("[data-resizer]").length).toBe(0);
expect(screen.queryByTestId("column-resizer-name")).not.toBeInTheDocument();
});
it("makes the header sticky and constrains body height when maxBodyHeight is set", () => {
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} maxBodyHeight={240} />);
expect(container.querySelector("thead")?.className).toContain("sticky");
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
expect(scroller).toHaveStyle({ maxHeight: "240px" });
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} maxBodyHeight={240} />);
expect(screen.getByTestId("data-table-head")).toHaveClass("sticky");
expect(screen.getByTestId("data-table-scroller")).toHaveStyle({ maxHeight: "240px" });
});
it("caps fillHeight at the parent's height instead of stretching to it, so a short table stays short", () => {
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} fillHeight />);
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
const frame = scroller.parentElement as HTMLElement;
const outer = frame.parentElement as HTMLElement;
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} fillHeight />);
const outer = screen.getByTestId("data-table-root");
const frame = screen.getByTestId("data-table-frame");
const scroller = screen.getByTestId("data-table-scroller");
// A ceiling, not a stretch: flex-1 here would hold the footer at the bottom on a two-row table.
expect(outer.className).toContain("max-h-full");
expect(outer.className).not.toContain("flex-1");
expect(frame.className).not.toContain("flex-1");
expect(scroller.className).not.toContain("flex-1");
expect(outer).toHaveClass("max-h-full", "flex-col");
expect(outer).not.toHaveClass("flex-1");
expect(frame).toHaveClass("flex-col");
expect(frame).not.toHaveClass("flex-1");
expect(scroller).not.toHaveClass("flex-1");
expect(outer.className).toContain("flex-col");
expect(frame.className).toContain("flex-col");
expect(scroller.className).toContain("min-h-0");
expect(scroller.className).toContain("overflow-auto");
expect(scroller).toHaveClass("min-h-0", "overflow-auto");
expect(scroller).toHaveStyle({ maxHeight: "" });
// Without this the Table primitive's own overflow container captures the sticky header.
expect(scroller.className).toContain("[&_[data-slot=table-container]]:overflow-visible");
expect(scroller).toHaveClass("[&_[data-slot=table-container]]:overflow-visible");
const thead = container.querySelector("thead") as HTMLElement;
expect(thead.className).toContain("sticky");
// Rows pass under the header, so the semi-transparent row tint alone would let them show through.
expect(thead.className).toContain("bg-background");
expect(screen.getByTestId("data-table-head")).toHaveClass("sticky", "bg-background");
});
it("leaves the default layout untouched when neither height mode is set", () => {
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} />);
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} />);
const scroller = screen.getByTestId("data-table-scroller");
expect(scroller.className).toContain("overflow-x-auto");
expect(scroller.className).not.toContain("min-h-0");
expect(scroller).toHaveClass("overflow-x-auto");
expect(scroller).not.toHaveClass("min-h-0");
expect(scroller).toHaveStyle({ maxHeight: "" });
expect((scroller.parentElement as HTMLElement).className).not.toContain("flex-col");
expect(container.querySelector("thead")?.className).not.toContain("sticky");
expect(container.querySelector("thead")?.className).not.toContain("bg-background");
expect(screen.getByTestId("data-table-frame")).not.toHaveClass("flex-col");
expect(screen.getByTestId("data-table-head")).not.toHaveClass("sticky", "bg-background");
});
});

View file

@ -195,8 +195,7 @@ function DataTableHeadCell<TData>({ header, size, stickyHeader, enableColumnResi
)}
{canResize && (
<div
data-resizer
data-header-id={header.id}
data-testid={`column-resizer-${header.id}`}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onDoubleClick={() => column.resetSize()}
@ -589,15 +588,19 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
const paginationNode = renderPagination();
return (
<div className={cn("w-full", fill.outer)}>
<div className={cn("overflow-hidden rounded-lg border border-border", fill.frame)}>
<div data-testid="data-table-root" className={cn("w-full", fill.outer)}>
<div data-testid="data-table-frame" className={cn("overflow-hidden rounded-lg border border-border", fill.frame)}>
{toolbar !== undefined && <div className="shrink-0 border-b border-border px-4 py-3">{toolbar(table)}</div>}
<div
data-testid="data-table-scroller"
className={cn(stickyHeader ? "overflow-auto" : "overflow-x-auto", fill.body)}
style={maxBodyHeight !== undefined ? { maxHeight: maxBodyHeight } : undefined}
>
<TableRoot className={enableColumnResizing ? "table-fixed" : ""} style={tableStyle}>
<TableHeader className={cn(stickyHeader ? "sticky top-0 z-sticky" : "", fill.header)}>
<TableHeader
data-testid="data-table-head"
className={cn(stickyHeader ? "sticky top-0 z-sticky" : "", fill.header)}
>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50">
{headerGroup.headers.map((header) => (