Read and Verify Web Tables with Playwright Python (Rows, Columns, Sorting, Pagination)

Tables are everywhere in business applications: orders, users, invoices, test results. Testing them means reading cells reliably, finding the right row, checking a column, verifying sort order and walking through pages. In this lesson you will build reusable helpers that turn any HTML table into Python data, use them on the SQA Tools practice table, and then test sorting and pagination on a richer table.

Advertisement

📖 What You’ll Learn: the structure of an HTML table and its ARIA roles, reading headers, rows and cells with locators, converting a table to a list of dictionaries, finding a row by a cell value, reading a column by header name, asserting a whole table with to_have_text, fast extraction with evaluate, checking sort order, and collecting data across paginated pages.


🧱 Table Structure and Roles

🖥️ Output

<table>
  <thead>
    <tr> <th>ID</th> <th>Name</th> <th>Role</th> </tr>          role=row with role=columnheader
  </thead>
  <tbody>
    <tr> <td>1</td> <td>Deepesh</td> <td>Trainer</td> </tr>  role=row with role=cell
    ...
  </tbody>
</table>

Playwright gives table parts ARIA roles: table, row, columnheader and cell. A row’s accessible name is its cells’ text joined with spaces, which is why get_by_role("row", name="2 Rahul Tester") works. The practice page table has 3 data rows:

IDNameRole
1DeepeshTrainer
2RahulTester
3AnitaDeveloper

🔎 Reading Headers, Rows and Cells

📄 tests/conftest.py

import pytest
from playwright.sync_api import Page


@pytest.fixture
def practice(page: Page) -> Page:
    page.goto("https://sqatools.in/automation-practice-page/")
    return page

📄 tests/test_table_basics.py

from playwright.sync_api import Page, expect


def test_read_table(practice: Page):
    table = practice.get_by_role("table")

    headers = table.get_by_role("columnheader").all_inner_texts()
    print("\nheaders:", headers)
    assert headers == ["ID", "Name", "Role"]

    rows = table.locator("tbody tr")
    expect(rows).to_have_count(3)

    # One cell: 2nd row, 2nd column (0-based)
    expect(rows.nth(1).locator("td").nth(1)).to_have_text("Rahul")

    # A whole column, checked in one assertion
    expect(rows.locator("td:nth-child(2)")).to_have_text(["Deepesh", "Rahul", "Anita"])
  • get_by_role("columnheader") finds the <th> header cells; all_inner_texts() returns their text as a list.
  • tbody tr selects only data rows, skipping the header row.
  • rows.locator("td:nth-child(2)") selects the second cell of every row; passing a list to to_have_text checks all of them in order and waits until they match. This is the most robust way to assert table content.

🗂️ Turn a Table into Python Data

Assertions on single cells get long. A small helper that converts the table into a list of dictionaries makes tests read like requirements. Put it in utils/tables.py:

📄 utils/tables.py

from playwright.sync_api import Locator


def table_to_dicts(table: Locator) -> list[dict[str, str]]:
    """Read an HTML table into [{header: cell_text, ...}, ...]."""
    headers = [h.strip() for h in table.locator("thead th").all_inner_texts()]
    data = []
    for row in table.locator("tbody tr").all():
        cells = [c.strip() for c in row.locator("td").all_inner_texts()]
        data.append(dict(zip(headers, cells)))
    return data


def column(table: Locator, header: str) -> list[str]:
    """All values of the column with this header text."""
    headers = [h.strip() for h in table.locator("thead th").all_inner_texts()]
    index = headers.index(header) + 1                 # CSS nth-child is 1-based
    return [c.strip() for c in table.locator(f"tbody tr td:nth-child({index})").all_inner_texts()]


def row_by_cell(table: Locator, text: str) -> Locator:
    """The data row that contains a cell with exactly this text."""
    return table.locator("tbody tr").filter(has=table.page.get_by_role("cell", name=text, exact=True))
  • table_to_dicts reads the headers once, then zips each row’s cells with them: dict(zip(["ID", "Name"], ["1", "Deepesh"])) gives {"ID": "1", "Name": "Deepesh"}.
  • column finds the header’s position by name, so tests keep working if a column is added or moved.
  • row_by_cell returns a locator for the row, so a test can click a button inside it or assert on it with auto-waiting.
  • .strip() removes stray whitespace that HTML often adds around cell text.

Tests using the helpers (tests/test_table_helpers.py):

📄 tests/test_table_helpers.py

from playwright.sync_api import Page, expect

from utils.tables import column, row_by_cell, table_to_dicts


def test_table_as_dicts(practice: Page):
    table = practice.get_by_role("table")
    expect(table.locator("tbody tr")).to_have_count(3)      # make sure rows are loaded first

    data = table_to_dicts(table)
    print("\n", data)
    assert data[1] == {"ID": "2", "Name": "Rahul", "Role": "Tester"}
    assert [row["Role"] for row in data] == ["Trainer", "Tester", "Developer"]


def test_column_and_row_lookup(practice: Page):
    table = practice.get_by_role("table")
    expect(table.locator("tbody tr")).to_have_count(3)

    assert column(table, "Name") == ["Deepesh", "Rahul", "Anita"]

    anita = row_by_cell(table, "Anita")
    expect(anita.locator("td").last).to_have_text("Developer")
    expect(anita.locator("td").first).to_have_text("3")

🖥️ Output

 [{'ID': '1', 'Name': 'Deepesh', 'Role': 'Trainer'}, {'ID': '2', 'Name': 'Rahul', 'Role': 'Tester'}, {'ID': '3', 'Name': 'Anita', 'Role': 'Developer'}]

⚠️ Watch out: all() and all_inner_texts() read the table immediately, without waiting. Always wait for the table to be ready first, for example with expect(rows).to_have_count(...) or expect(rows.first).to_be_visible(), or you may read a half-loaded table.


⚡ Fast Extraction with evaluate

Reading a large table cell by cell sends one request to the browser per cell. For hundreds of rows, extract everything in a single call with JavaScript running inside the page:

📄 tests/test_table_helpers.py

def test_extract_with_evaluate(practice: Page):
    table = practice.get_by_role("table")
    expect(table.locator("tbody tr")).to_have_count(3)

    rows = table.evaluate(
        "t => [...t.querySelectorAll('tbody tr')].map(r => [...r.cells].map(c => c.innerText.trim()))"
    )
    print("\nrows:", rows)
    assert rows == [["1", "Deepesh", "Trainer"], ["2", "Rahul", "Tester"], ["3", "Anita", "Developer"]]

locator.evaluate(js) runs the function with the matched element as its argument (t) and returns the result to Python as lists and strings. r.cells is the browser’s list of a row’s cells.


↕️ Sorting and Pagination

The practice table is static, so for sorting and paging we build a richer table with set_content(): 7 products, a sortable Price header and pages of 3 rows. The page logic is plain JavaScript, just like a real app.

📄 tests/product_table.py

HTML = """
<table id="products">
  <thead><tr><th>Product</th><th><button id="sort-price">Price</button></th></tr></thead>
  <tbody></tbody>
</table>
<button id="prev">Previous</button> <span id="page-info"></span> <button id="next">Next</button>
<script>
  const items = [["Backpack", 29.99], ["Bike Light", 9.99], ["Bolt T-Shirt", 15.99],
                 ["Fleece Jacket", 49.99], ["Onesie", 7.99], ["Red T-Shirt", 15.99], ["Water Bottle", 12.50]];
  let page = 0, sorted = items.slice();
  function render() {
    const rows = sorted.slice(page * 3, page * 3 + 3);
    document.querySelector("#products tbody").innerHTML =
      rows.map(([n, p]) => `<tr><td>${n}</td><td>$${p.toFixed(2)}</td></tr>`).join("");
    document.querySelector("#page-info").textContent = `Page ${page + 1} of 3`;
    document.querySelector("#prev").disabled = page === 0;
    document.querySelector("#next").disabled = page === 2;
  }
  document.querySelector("#next").onclick = () => { page++; render(); };
  document.querySelector("#prev").onclick = () => { page--; render(); };
  document.querySelector("#sort-price").onclick = () => {
    sorted.sort((a, b) => a[1] - b[1]); page = 0; render();
  };
  render();
</script>
"""

📄 tests/test_sort_and_pages.py

from playwright.sync_api import Page, expect

from tests.product_table import HTML


def prices_on_page(page: Page) -> list[float]:
    texts = page.locator("#products tbody td:nth-child(2)").all_inner_texts()
    return [float(t.replace("$", "")) for t in texts]


def test_sort_by_price(page: Page):
    page.set_content(HTML)
    page.get_by_role("button", name="Price").click()

    expect(page.locator("#products tbody tr").first).to_contain_text("Onesie")   # wait for re-render
    prices = prices_on_page(page)
    print("\nfirst page after sort:", prices)
    assert prices == sorted(prices)


def test_collect_all_pages(page: Page):
    page.set_content(HTML)
    names = []

    while True:
        expect(page.locator("#products tbody tr").first).to_be_visible()
        names += page.locator("#products tbody td:nth-child(1)").all_inner_texts()
        next_button = page.get_by_role("button", name="Next")
        if next_button.is_disabled():
            break
        info = page.locator("#page-info").inner_text()
        next_button.click()
        expect(page.locator("#page-info")).not_to_have_text(info)   # wait until the page changed

    print("all products:", names)
    assert len(names) == 7
    expect(page.locator("#page-info")).to_have_text("Page 3 of 3")

What the tests do:

  • Sorting: click the header, wait until the table re-rendered (the cheapest item, Onesie, is now first), read the prices, and compare them with Python’s sorted() version of the same list.
  • Pagination: loop: read the current page, stop when Next is disabled, otherwise remember the page label, click Next, and wait until the label changes before reading again. Waiting for a visible change is what makes the loop reliable; reading immediately after the click could read the old page twice.
  • is_disabled() returns the button’s state right now; inside the loop the page has already been confirmed as updated, so that is safe.

⌨️ Terminal

pytest -q -s tests

🖥️ Output

first page after sort: [7.99, 9.99, 12.5]
.all products: ['Backpack', 'Bike Light', 'Bolt T-Shirt', 'Fleece Jacket', 'Onesie', 'Red T-Shirt', 'Water Bottle']
.
headers: ['ID', 'Name', 'Role']
.
 [{'ID': '1', 'Name': 'Deepesh', 'Role': 'Trainer'}, {'ID': '2', 'Name': 'Rahul', 'Role': 'Tester'}, {'ID': '3', 'Name': 'Anita', 'Role': 'Developer'}]
..
rows: [['1', 'Deepesh', 'Trainer'], ['2', 'Rahul', 'Tester'], ['3', 'Anita', 'Developer']]
.
6 passed, 1 warning in 10.08s

The pagination loop collected all 7 products across 3 pages, each exactly once.


❓ Frequently Asked Questions

🤔 Should I read tables with locators or with evaluate?

Use locators and expect(...).to_have_text([...]) for assertions, because they wait and retry. Use evaluate to extract large amounts of data quickly once the table is loaded.

🤔 How do I click a button in a specific row?

Find the row first, then search inside it: row_by_cell(table, "Anita").get_by_role("button", name="Edit").click().

🤔 My table has no thead. How do I find the headers?

Use the first row: table.locator("tr").first.locator("th, td").all_inner_texts(), and read data rows with table.locator("tr").nth(i) starting at 1.

🤔 How do I verify that a table is sorted by a date column?

Read the column, convert each text to a datetime with datetime.strptime(text, "%d/%m/%Y"), then compare the list with sorted(...) of itself.


🏁 Summary

  • Tables expose row, columnheader and cell roles; tbody tr selects data rows.
  • Assert columns with to_have_text([...]); convert tables to lists of dicts with small helpers; find rows by cell value and columns by header name.
  • locator.evaluate() extracts large tables in one call; always wait for the table before reading.
  • For sorting, compare the read list with sorted(); for pagination, wait for a visible page change before reading each page.

Next, you will upload and download files.

🎬 Watch the Playwright tutorial series on YouTube!

Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page

2 thoughts on “Read and Verify Web Tables with Playwright Python (Rows, Columns, Sorting, Pagination)”

Leave a Comment