Test Automation
How to Test HTML Tables in Cypress Without the Flake
Ask a team which tests they mute first and the answer is usually the table tests. Sorting, filtering, and pagination all mutate the same DOM, they mutate it fast, and they mutate it without a network call to wait on. That combination produces tests that pass locally, fail in CI, and pass again on retry.
The table is rarely the problem. The test is asserting on something that was never stable to begin with.
Everything below runs against our web tables practice
page. That table deliberately ships without data-cy
attributes, which makes it a good stand-in for the legacy grids you actually get
handed. The controls around it (search, filters, pagination) do have test IDs,
so you can focus on the part that matters.
What the table actually looks like
Worth knowing before writing a selector:
- The table itself has
id="employees-table". Rows and cells have nothing. - Columns are ID, First Name, Last Name, Email, Age, Salary, Dept, Status.
- The ID column carries
hidden sm:table-cell, so it is in the DOM at every viewport but invisible below thesmbreakpoint. - Salary renders through
toLocaleString(), so the cell text is$120,000, not120000. - Default page size is 5 rows against 15 records.
- Sorting cycles through three states: ascending, descending, unsorted.
- Filtering is synchronous React state. There is no request to wait for.
1. Stale element references after a re-render
This is the single most common table flake. Sorting rebuilds tbody, so every
row and cell you grabbed before the click is gone.
// Flaky: $rows is a snapshot taken before the sort
cy.get("#employees-table tbody tr").then(($rows) => {
cy.contains("#employees-table thead th", "Age").click();
cy.wrap($rows.eq(0)).should("contain", "26");
});
cy.wrap() freezes a detached element. Cypress will happily assert against a
node that is no longer attached to the document, and whether it passes depends
on timing that has nothing to do with your application.
Read the DOM after the action, never before:
cy.contains("#employees-table thead th", "Age").click();
cy.get("#employees-table tbody tr").first().should("contain", "26");
The rule: any value you pull out with .then() or .invoke("text") is a
snapshot. If an action happens between the snapshot and the assertion, the
assertion is testing history.
2. Hard-coded column indexes
td:nth-child(6) works until someone inserts a column, and then twelve tests
fail with a message about salaries that mentions departments. Derive the index
from the header text once and reuse it.
// cypress/support/commands.js
Cypress.Commands.add("columnIndex", (label) =>
cy.get("#employees-table thead th").then(($headers) => {
const index = $headers
.toArray()
.findIndex((th) => th.innerText.trim().toLowerCase() === label.toLowerCase());
expect(index, `column "${label}" exists`).to.be.at.least(0);
return index + 1;
})
);
Cypress.Commands.add("cell", (rowIndex, label) =>
cy.columnIndex(label).then((position) =>
cy
.get("#employees-table tbody tr")
.eq(rowIndex)
.find(`td:nth-child(${position})`)
)
);
Now the test reads like the requirement instead of like the markup:
cy.cell(0, "Email").should("have.text", "john.smith@company.com");
When the column moves, one helper adapts and no test changes. When the column
disappears, you get column "Email" exists instead of a confusing diff.
For row lookup, anchor on a value that identifies the record rather than on position:
cy.contains("#employees-table tbody tr", "d.jones@company.com")
.find("td:nth-child(7)")
.should("have.text", "Executive");
Row 5 changes every time someone sorts. The row containing David's email does not.
3. Asserting on formatted text
The salary cell renders $120,000. Half of the flake here is not flake at all,
it is a test that asserts on presentation and breaks when the locale, currency,
or separator changes.
// Brittle: couples the test to number formatting
cy.cell(4, "Salary").should("have.text", "$120,000");
// Better: assert on the value, let the format vary
cy.cell(4, "Salary")
.invoke("text")
.then((text) => Number(text.replace(/[$,\s]/g, "")))
.should("eq", 120000);
If the format itself is the requirement, test it once in a dedicated case and keep it out of every other assertion.
4. The empty state is still a row
Filter the table down to nothing and this is what renders:
<tr>
<td colspan="8">No employees found matching your criteria.</td>
</tr>
So the obvious assertion is wrong:
cy.get('[data-cy="search-input"]').type("zzzz");
cy.get("#employees-table tbody tr").should("have.length", 0); // fails, length is 1
Assert on the message and on the absence of data rows separately:
cy.get('[data-cy="search-input"]').type("zzzz");
cy.contains("#employees-table tbody td", "No employees found").should("be.visible");
cy.get("#employees-table tbody tr").should("have.length", 1);
cy.get("#employees-table tbody td[colspan]").should("exist");
Most grids do some version of this. Check what the empty state renders before writing a length assertion, because "zero rows" and "one row that says zero" are different DOMs.
5. Trusting the UI to prove that sorting worked
Clicking a header and checking that the first row changed proves almost nothing. It passes if the sort is reversed, if it sorts alphabetically instead of numerically, or if it silently sorts only the current page.
Assert the ordering property across the whole visible column:
cy.contains("#employees-table thead th", "Age").click();
cy.get("#employees-table tbody tr td:nth-child(5)").then(($cells) => {
const ages = [...$cells].map((cell) => Number(cell.innerText));
expect(ages).to.deep.equal([...ages].sort((a, b) => a - b));
});
Then check the part that actually catches bugs. This table sorts the full data set and paginates afterwards, so ascending sort by age must put the youngest employee on page one, not the youngest of the five rows that happened to be visible.
cy.contains("#employees-table thead th", "Age").click();
cy.get("#employees-table tbody tr").first().should("contain", "26");
Sorting the current page only is a real defect, it ships more often than you would expect, and a first-row check will never find it.
Remember the third state too. A second click gives descending, a third clears the sort entirely. If your test clicks twice assuming a toggle, it is asserting descending order against unsorted data.
6. Filters and pagination that quietly reset each other
Typing in the search box on this table resets the current page to 1. That is correct behaviour, and it breaks any test that navigates to page 3, applies a filter, and expects to still be on page 3.
Assert the state you depend on instead of assuming it survived:
cy.get('[data-cy="pagination-next"]').click();
cy.get('[data-cy="pagination-info"]').should("contain", "Page 2");
cy.get('[data-cy="department-filter"]').select("Engineering");
cy.get('[data-cy="pagination-info"]').should("contain", "Page 1");
cy.get("#employees-table tbody tr").should("have.length", 5);
And when you need to wait for a filter to apply, wait on the result, not on the clock:
// Never do this
cy.get('[data-cy="search-input"]').type("Garcia");
cy.wait(500);
// Do this
cy.get('[data-cy="search-input"]').type("Garcia");
cy.contains("Showing 1 of 1 results").should("be.visible");
cy.get("#employees-table tbody tr").should("have.length", 1);
A fixed wait is a bet that the machine running CI is at least as fast as your laptop. It usually is not, and the test fails at the worst possible time.
7. Visibility assertions on responsive columns
The ID column is present in the DOM at every width and hidden by CSS below
sm. A test written on a desktop viewport and run in CI at a narrower default
will fail on this:
cy.cell(0, "ID").should("be.visible"); // fails below the sm breakpoint
Set the viewport explicitly for any test that asserts visibility, and keep responsive checks in their own spec:
describe("employee table on desktop", () => {
beforeEach(() => {
cy.viewport(1280, 800);
cy.visit("/practice/webtables");
});
it("shows the ID column", () => {
cy.cell(0, "ID").should("be.visible");
});
});
should("exist") and should("be.visible") answer different questions. Pick
the one that matches the requirement, because the DOM will not tell you which
one you meant.
The short version
Anchor rows on data, derive columns from headers, read the DOM after the action rather than before, and assert on values instead of on formatting. Table tests stop being flaky when they stop depending on position, timing, and presentation.
The table used throughout this post is live on our web tables practice page. Open your editor, point Cypress at it, and try the sorting assertion in section 5 first, since that is where most suites are quietly wrong.
If your suite is red for reasons nobody can explain any more, that is the kind of thing our test automation work exists to fix. Tell us what is failing and we will take a look.