Test HTML Tables Using cy.table Query Command

Use the `cy.table` to get the HTML table values or slices in your Cypress tests.

Imagine you have a table like this one:

HTML table example

How would you confirm in your Cypress tests the table is showing the right values? Let's start with the column headings. Can you confirm the columns in order show the names, the dates, and the ages?

cypress/e2e/table.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
beforeEach(() => {
cy.visit('app/table.html')
})

it('confirms the headings (plain cy)', () => {
// can you confirm the table has the following headings?
const headings = ['Name', 'Date (YYYY-MM-DD)', 'Age']
cy.get('table thead td').then(($td) => {
const texts = Cypress._.map($td, 'innerText')
expect(texts, 'headings').to.deep.equal(headings)
})
})

The test confirms the column headings

🎁 You can find the source code for this blog post in the repo bahmutov/sorted-table-example. I will also use custom queries like cy.map and cy.table from my bahmutov/cypress-map plugin. You can practice cypress-map commands in my course Cypress Plugins.

Retries

Great, the test works, but it is not very robust. For example, the cy.then(callback) does not retry if the assertion expect(texts, 'headings').to.deep.equal(headings) fails. So we really should (pun intended) use the cy.should(callback) here - maybe the table is dynamic (as we will see later with sorting the values).

table.html
1
2
3
4
5
6
7
8
9
10
<table id="people">
<thead>
<tr>
<td></td>
<td></td>
<td></td>
</tr>
</thead>
<tbody id="people-data"></tbody>
</table>

The application sets the headings row after one second

app.js
1
2
3
4
5
6
7
8
// initialize the headings after a delay
setTimeout(() => {
document.querySelector('table thead tr').innerHTML = `
<td>Name</td>
<td>Date (YYYY-MM-DD)</td>
<td>Age</td>
`
}, 1000)

The cy.then(callback) fails 😞 The text comes in after one second - the test has finished by then.

cypress/e2e/table.cy.js
1
2
3
4
5
const headings = ['Name', 'Date (YYYY-MM-DD)', 'Age']
cy.get('table thead td').then(($td) => {
const texts = Cypress._.map($td, 'innerText')
expect(texts, 'headings').to.deep.equal(headings)
})

The cy.should(callback) on the other hand, succeeds ✅

cypress/e2e/table.cy.js
1
2
3
4
5
6
7
const headings = ['Name', 'Date (YYYY-MM-DD)', 'Age']
// the same test, but with "cy.should(callback)"
// after the cy.get query command
cy.get('table thead td').should(($td) => {
const texts = Cypress._.map($td, 'innerText')
expect(texts, 'headings').to.deep.equal(headings)
})

Tip: read Cypress retry-ability guide and study my Cypress assertions examples to learn more about cy.then vs cy.should.

Better test with cy.map query

In Cypress v12, the querying commands like cy.get got their own "class" of commands, and it is a good thing. To simplify my own tests, I have written a library of extra queries cypress-map. Let's apply cy.map from the plugin cypress-map to the same test. We are mapping each found TD element into its own innerText property:

1
const texts = Cypress._.map($td, 'innerText')

Using cy.map we can simply write:

cypress/e2e/table.cy.js
1
2
3
4
5
6
7
8
9
// https://github.com/bahmutov/cypress-map
import 'cypress-map'

it('confirms the headings (cy.map)', () => {
// can you confirm the table has the following headings?
const headings = ['Name', 'Date (YYYY-MM-DD)', 'Age']
// we could get each table head cell and map to its inner text
cy.get('table thead td').map('innerText').should('deep.equal', headings)
})

Tip: confused about cy.map and mapping a list of DOM elements into its property? Read my blog posts on point-free functional programming.

The 🔑 to understand how Cypress v12 queries are working is to realize that when the assertion fails, it goes back to the start of the entire chain of query commands and re-runs both cy.get and cy.map again and again.

The test retries both query commands when the assertion fails

Get the entire table with cy.table

What if we want to confirm all values in the table? We would map over rows and could map over the cells... It is a query too, since we are NOT changing the table, just getting all cell values. We could use the same logic to get a one dimensional array of cell texts:

cypress/e2e/table.cy.js
1
2
3
it('confirms the cells (cy.map)', () => {
cy.get('table tbody td').map('innerText').then(console.log)
})

All cells from the table body as 1D array

Ughh, we lost the "shape" of the table. We need 2D array of rows and columns. This is what cy.table does. By default it yields an array of arrays (each row is an array of cells).

1
2
3
4
5
6
// https://github.com/bahmutov/cypress-map
import 'cypress-map'

it('prints the cells (cy.table)', () => {
cy.get('table tbody').table().then(console.table)
})

Printing the table of the table body cells

Super, we can confirm the initial table has the values we expect. Let's cy.get the TABLE element and grab all cells. The test will automatically retry until the page sets the headings.

1
2
3
4
5
6
7
8
9
10
11
it('confirms the entire table with retries', () => {
cy.get('table')
.table()
.should('deep.equal', [
['Name', 'Date (YYYY-MM-DD)', 'Age'],
['Joe', '1990-02-25', '20'],
['Anna', '2010-03-26', '37'],
['Dave', '1997-12-23', '25'],
['Joe', '2001-01-24', '30'],
])
})

Slice a column from the table

Sometimes we don't want to confirm the entire table. Instead we want to extract a region or a column. For example, how would you confirm the "Age" column can be sorted when we press the button "Sort by date"? We need to be able to select the a region of the 2D table. Here is how we can define the axis X and Y in our table (if this is the full table including the THEAD)

The 2D axis for the table

The command cy.table(x, y, w, h) can select a region from the table starting at index x,y (both zero-based indices), with w columns and h rows. Let's say we want to confirm the "ages" cells are NOT sorted by default. We can use chai-sorted assertion plugin to make our life easier.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// https://github.com/bahmutov/cypress-map
import 'cypress-map'

// https://www.chaijs.com/plugins/chai-sorted/
chai.use(require('chai-sorted'))

beforeEach(() => {
cy.visit('app/table.html')
})

it('confirms the sorted age column', () => {
cy.get('table tbody')
.table(2, 0, 1)
.map(Number)
.should('not.be.sorted')
})

If we don't specify the h argument, like in cy.table(2, 0, 1), then the entire h will be used, so the entire column is returned.

Confirm the age numbers are not sorted

Print the values

It makes sense to see the values between the steps, which we can do using the cy.print query (also included in the cypress-map library and described in my blog post A Better Cypress Log Command).

1
2
3
4
5
6
7
8
it('confirms the sorted age column', () => {
cy.get('table tbody')
.table(2, 0, 1)
.print()
.map(Number)
.print()
.should('not.be.sorted')
})

Print the intermediate values flowing through the queries

Note: cy.map uses Lodash _.map which flattens the 2D array [["20"],["37"],["25"],["30"]] into [20,37,25,30]. We could have flattened the initial array ourselves:

1
2
3
4
5
6
7
8
9
it('confirms the sorted age column', () => {
cy.get('table tbody')
.table(2, 0, 1)
.invoke('flatMap', Cypress._.identity)
.print()
.map(Number)
.print()
.should('not.be.sorted')
})

Flat map the array to remove nested arrays with 1 element

Sort the ages

Now let's click the "Sort by date" button and confirm the age column becomes sorted eventually.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
it.only('confirms the sorted age column', () => {
cy.get('table tbody')
.table(2, 0, 1)
.print()
.map(Number)
.should('not.be.sorted')
// click the sort button
cy.get('#sort-by-date').click()
// the "Age" column should be sorted in ascending order
cy.get('table tbody')
.table(2, 0, 1)
.print()
.map(Number)
.should('be.ascending')
})

Beautiful. Let's refactor the code a little bit to avoid duplication. Just move getting the ages column numbers into its own function returning the chain.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const ages = () =>
cy
.get('table tbody')
.table(2, 0, 1)
.print()
.map(Number)

it('confirms the sorted age column: refactored', () => {
ages().should('not.be.sorted')
// click the sort button
cy.get('#sort-by-date').click()
// the "Age" column should be sorted in ascending order
ages().should('be.ascending')
})

Nice.

Get cells in the last two rows

Let's confirm a part of the table after sorting.

1
2
3
4
5
6
7
8
9
10
it('confirms the name and dates of the last two sorted rows', () => {
// sort the table by date
cy.get('#sort-by-date').click()
// confirm the last two rows have
// the following name and dates
const values = [
['Joe', '2001-01-24'],
['Anna', '2010-03-26'],
]
})

How do we confirm this region of the table?

If we are looking at the table's body, the region we are interested starts at x = 0 (first column), y = 2 (third row), and extends 2x2.

1
2
3
4
5
6
7
8
9
10
11
12
// sort the table by date
cy.get('#sort-by-date').click()
// confirm the last two rows have
// the following name and dates
const values = [
['Joe', '2001-01-24'],
['Anna', '2010-03-26'],
]
cy.get('table tbody')
.table(0, 2, 2, 2)
.print()
.should('deep.equal', values)

Grabbing the region of the table to confirm the values

What if we don't know the length of the table? Ok, let's grab the full table of the tbody cells, which gives us an array of arrays. We can invoke the array methods using standard Cypress cy.invoke query. Let's grab the last two rows (after it was sorted using cy.wait for now):

1
2
3
4
5
6
7
8
// sort the table by date
cy.get('#sort-by-date').click().wait(2000)
cy.get('table tbody')
.table()
// call the Array.prototype.slice with -2
// to get the last two rows of the table
.invoke('slice', -2)
.print()

Grab the last two row of the table

Now we have an array with 2 rows. Each row is an array of element texts. We want to call slice again on each row. No worries, cypress-map comes with cy.mapInvoke that invokes a given method on every item in the current subject (which could be an array or a jQuery object). Now we can remove the cy.wait(2000) and use an assertion to confirm the values are finally sorted using retries:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
it('confirms the name and dates of the last two sorted rows', () => {
// sort the table by date
cy.get('#sort-by-date').click()
// confirm the last two rows have
// the following name and dates
const values = [
['Joe', '2001-01-24'],
['Anna', '2010-03-26'],
]
cy.get('table tbody')
.table() // [ [row 1], [row 2], ... [row N] ]
.invoke('slice', -2) // [ [row N - 1], [row N] ]
.mapInvoke('slice', 0, 2) // [ [cell1, cell2], [cell1, cell2] ]
.print()
.should('deep.equal', values)
})

Nice, the values from the HTML table flow through our queries, again and again, until the assertion passes.

Ughh, now that I am looking at this, it would be nice if cy.table supported the negative index arguments, so I could get the last two rows of the table like cy.table(0, -2)? I guess, time to open a cypress-map issue.