Skip to main content

Test Automation

How to Test HTML Tables in Cypress Without the Flake

Marko KolasinacJul 28, 20269 min read
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 the sm breakpoint.
  • Salary renders through toLocaleString(), so the cell text is $120,000, not 120000.
  • 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.