The Testing Academy · Class Notes Thursday, 24 September (IST)
Live class · study guide

Multiple elements, the three text getters, and web tables with dynamic XPath

How to read a locator that matches many elements with allInnerTexts(), allTextContents() and all(), why getByText matched six elements, and how to walk a web table: count rows and columns, build a dynamic XPath, and read the neighbouring cell with following-sibling. Four specs, all pushed to the batch repo.

By Pramod Dutta, The Testing Academy. Study notes from the live Playwright 3x class, built from the session recording and the batch repository, which received the class's specs as it ended (commit "feat: add multiple element and web table modules"). Every spec on this page was run against the live practice pages and the output shown is what they print. The Eraser deck was not reachable while this page was written. One loop bound in the pushed code drops the last table row; that is called out below with the corrected version rather than silently fixed.

01

What this class covered

  • Recap: session state in the default config, the Allure report and the custom reporter
  • One locator for many elements, and reading all their text with allInnerTexts()
  • Why getByText matched six elements, and what first() and nth() do about it
  • all(): an array of locators, for when you need to act on each element
  • all() versus allInnerTexts() versus allTextContents(), and innerText versus textContent
  • page.pause() and --debug for stepping through a spec
  • A reusable spec template, a /start-pw command, and a warning about leaning on AI
  • How a web table is built: table, thead, tbody, tr, th, td
  • The interview question: find Helen Bennett's country with a dynamic XPath
  • A second table read with CSS locators, and an off-by-one in its loop
02

Where this sits

The class opened with a recap of the previous session: a saved session state can be set in the default Playwright config so every test starts logged in, with the usual warnings (states expire, never commit them, regenerate the token, keep the test user stable). The Allure report and the custom reporter added last time were both confirmed working.

The rest of the week covers handling every kind of element: multiple elements, input boxes, web tables, iframes and frames, windows, shadow DOM, file upload and download, scrolling, expectations, hooks and data-driven tests. Two changes were announced with it: every session this week is mandatory, and you can now write code straight into VS Code instead of drafting it in a notebook first, because the JavaScript and TypeScript groundwork is done.

03

One locator, many elements

The practice page has a right-hand panel of links, all sharing the class list-group-item. One CSS locator reaches all of them.

page.locator() itself is synchronous: it returns a Locator and touches nothing. allInnerTexts() is the call that goes to the browser, so it returns a promise and needs await. What comes back is a plain string[], one entry per matched element.

TypeScript
import { test, expect } from '@playwright/test';

test('Basic verify how to handle multiple elements ', async ({ page }) => {

    await page.goto("https://app.thetestingacademy.com/playwright/multiple_element_filter");
    const rightPanelLinksTexts: string[] =  await page.locator('a.list-group-item').allInnerTexts();
    console.log(rightPanelLinksTexts.length);

    for (const link of rightPanelLinksTexts) {
        console.log(link);
    }

    for(const linkText of rightPanelLinksTexts){
        if( linkText === "Forgotten Password"){
             await page.getByText(linkText).first().click();
        }
    }

    const rightPanelLinks = await page.locator('a.list-group-item').all();
    for (const link of rightPanelLinks) {
        console.log(await link.getAttribute("href"));
    }

    await page.pause();

});
Text
13
Login
Register
Forgotten Password
My Account
Address Book
Wish List
Order History
Downloads
Recurring Payments
Reward Points
Returns
Transactions
Newsletter
#login
#register
#forgotten-password
#my-account
#address-book
#wish-list
#order-history
#downloads
#recurring-payments
#reward-points
#returns
#transactions
#newsletter

The count is .length because the result is an ordinary array by then. A learner asked why not count(): count() is a method on a Locator, .length is the property of the array that allInnerTexts() handed back.

04

Why getByText found six elements

The first version of the click did not have .first(), and it failed. getByText('Forgotten Password') matches six elements on that page: the link in the panel, plus the same words in a heading, a code block, a span, and hidden copies further down. Playwright locators are strict, so an action on a locator that matches more than one element throws instead of guessing.

.first() resolves it by taking the first match. .nth(i) takes any other one, counting from zero. This is also why getByText is called brittle: the moment the same words appear anywhere else on the page, it stops pointing at one thing.

Text or href to pick the link? The class answer was that either works. The href (#forgotten-password) has the advantage that nobody else on the page is likely to share it, which is exactly the property that text lacked here.

05

all() hands back locators, not text

When you need to do something to each element, strings are no use: you cannot click a string or read its attributes. all() returns the matched elements as an array of Locator objects. It returns a promise that resolves to that array, so you await it once; after that, each item is a normal locator. getAttribute() is another trip to the browser, so it needs its own await.

TypeScript
import { test, expect, Locator } from '@playwright/test';

test('Basic verify how to handle multiple elements ', async ({ page }) => {

    await page.goto("https://app.thetestingacademy.com/playwright/multiple_element_filter");
    const rightPanelLinksTexts: Locator[] =  await page.locator('a.list-group-item').all();
    console.log(rightPanelLinksTexts.length);

    for (const link of rightPanelLinksTexts) {
        console.log(await link.getAttribute('href'));
    }


    await page.pause();

});

This prints 13 followed by the same thirteen hrefs as above. The variable name says "texts" but the type annotation says Locator[], and the type is the one that is right.

06

Three getters, and what each returns

ONE LOCATOR, THIRTEEN MATCHES, THREE WAYS TO UNPACK IT page.locator( 'a.list-group-item') a Locator, nothing fetched yet all() Locator[] when you need to act on each one: click, getAttribute, fill allInnerTexts() string[], rendered what a user sees: whitespace collapsed, CSS applied, a br becomes \n allTextContents() string[], raw the DOM text as authored, hidden text included None of the three wait. Each reads whatever is in the page at that instant, so a list that is still loading comes back short.
Strings when you want to read, locators when you want to act.

The comparison table the class built:

all() allInnerTexts() allTextContents()
Returns Locator[] string[] string[]
Can click or read attributes yes no no
Whitespace not applicable collapsed and trimmed raw, as authored
Hidden text not applicable ignored included
A br tag not applicable becomes a newline ignored
Waits for elements no no no
Round trips one, plus one per action one one

The difference is the one between the DOM's own innerText and textContent. innerText is the rendered text, what is visible on screen. textContent is the raw text in the markup, including anything hidden. Use allInnerTexts() when you care about what a user sees, allTextContents() when you need the source text exactly.

On the thirteen links above, both getters return identical arrays, because the markup is clean. The difference shows up on the second web table later in this page, where each row ends in a cell holding nothing but a <br>. Same row, both getters:

Text
allInnerTexts  : ["UAE","Dubai","829m","2010","1","\n"]
allTextContents: ["UAE","Dubai","829m","2010","1",""]

innerText turns the <br> into a newline; textContent has no text there at all.

Keep a running Q&A file. Questions like "innerText or textContent?" come up in class and come back as interview questions. The advice was to write each answer down as it comes up, rather than hoping to remember it.

07

Debugging, templates, and the AI warning

page.pause() stops the run and opens the Playwright Inspector, so you can step through the spec and try locators against the live page. Running with --debug does the same from the command line. How you debug a Playwright test is a common interview question, and these two are the answer.

Every spec starts with the same few lines, so the class saved them as a template in the repo:

TypeScript
import { test, expect, Locator } from '@playwright/test';

test('Verify the TestCase', async ({ page }) => {
   await page.goto("https://app.thetestingacademy.com/playwright/multiple_element_filter");

    // Code

   await page.pause();
});

Then GitHub Copilot was asked to turn it into a /start-pw command that drops the template into a new spec with a title. The command itself lives in the editor, not the repo, but the template file is in template/template.spec.ts. Remove page.pause() before you commit, because it halts the run.

Why this is the first AI shortcut in the course. The JavaScript and TypeScript modules were written entirely by hand, and this is AI taking over a repetitive typing job only. The warning that came with it: in interviews, the candidates being rejected are overwhelmingly the ones who depend on AI to write their tests. Write the code yourself first.

08

How a web table is built

A web table is an HTML table. Inside it: thead for the header rows, tbody for the data, sometimes tfoot for a footer. Every row is a tr. Inside a row, td is a data cell and th is a header cell.

THE SAMPLE TABLE, OPENED UP thead th Structure th Country th City th ... th tbody th Burj Khalifa td UAE td Dubai td ... td <br> th Clock Tower Hotel td Saudi Arabia td Mecca ... th Taipei 101 td Taiwan td Taipei ... th Financial Center td China td Shanghai ... tfoot th Total td 4 buildings the name is a th, not a td, so locator('td') skips it: why a learner asked where the first column went a cell holding only a br innerText gives "\n" textContent gives ""
Header cells can sit inside tbody too. Knowing which cells are th and which are td decides what your locator returns.

The rule for any web table: count the rows and columns first. Every loop you write afterwards depends on those two numbers.

09

The interview question: whose country is it

The class framed this as an interview question from Zeta, and said it has also come up at BrowserStack and iDrive. A table of customers has three columns: company, contact and country. Find Helen Bennett and report which country she is in.

Inspecting the table shows it has an id, customers, which helps. The header row sits inside tbody here, so tbody/tr counts seven rows including the header. Helen Bennett is in row 5, column 2, so the hard-coded XPath is //table[@id='customers']/tbody/tr[5]/td[2].

Hard-coding that is useless, because the data can move. The trick is to split the XPath around the two numbers and inject them from loops:

DIVIDE THE XPATH, INJECT THE NUMBERS //table[@id='customers']/tbody/tr[ i ]/td[ j ] first part second part third part i: rows 2 to 7 row 1 is the header, and XPath counts from 1 j: columns 1 to 3 every data row has the same three `${firstPart}${i}${secondPart}${j}${thirdPart}` // tr[5]/td[2] when i = 5, j = 2
Two loops, one template literal, and every cell in the table is reachable.

Once a cell's text matches, the country is the cell to its right. following-sibling walks right from a matched element; preceding-sibling walks left.

SIBLINGS: THE CELLS BESIDE A MATCH Island Trading Helen Bennett UK company the matched cell country preceding-sibling::td following-sibling::td Preceding walks left, following walks right. Singular "sibling": the axis names do not take an s.
Find the anchor you can match, then step sideways to the cell you actually want.

The spec, from after the page.goto line (the full file is 238_TestCase.spec.ts in the batch repo):

TypeScript
    const firstPart = "//table[@id='customers']/tbody/tr[";
    const secondPart = "]/td[";
    const thirdPart = "]";

    const rows = await page.locator("//table[@id='customers']/tbody/tr").count();
    const cols = await page.locator("//table[@id='customers']/tbody/tr[2]/td").count();

   for (let i = 2; i <= rows; i++) {

      for (let j = 1; j <= cols; j++) {

         const dynamicPath = `${firstPart}${i}${secondPart}${j}${thirdPart}`;
         // console.log(dynamicPath);
         const data = await page.locator(dynamicPath).innerText();
         // console.log(data);

          if (data.includes('Helen Bennett')) {
             const countryPath = `${dynamicPath}/following-sibling::td`; 
             const countryText = await page.locator(countryPath).innerText();
             console.log('------');
             console.log(`Helen Bennett is In - ${countryText}`);
          }

      }
   }
Text
------
Helen Bennett is In - UK

Three details that the class stopped on:

  • count() is a promise, so both counts need await.
  • The column count comes from row 2, not row 1, because row 1 is the header and holds th cells, not td.
  • XPath is 1-based. There is no row 0, which is why i starts at 2 and ends at <= rows.

The recap the class ended on: divide the XPath, count the rows and columns, loop, and step sideways with a sibling axis once you have a match.

What the repo README adds. This nested loop reads one cell per round trip, which is slow on a large table. It is taught because it makes the row and column maths visible. The README for this class shows the same answer as a single chained locator, which also prints UK.

TypeScript
const country = await page.locator('#customers tbody tr')
    .filter({ hasText: 'Helen Bennett' })
    .locator('td')
    .last()
    .innerText();
10

A second table, read the Playwright way

The second example reads every row of the sample table from the anatomy figure. Here the class converted the XPath to a CSS selector (//table/tbody/tr and table tbody tr describe the same thing) and read each row in one call with allInnerTexts().

The spec, from after the page.goto line (full file: 237_TestCase.spec.ts):

TypeScript
   const rows = page.locator('table[summary="Sample Table"] tbody tr');
   const rowCount = await rows.count();

   for(let i=0;i<rowCount-1;i++){
      // const colsheader = await rows.nth(1).locator('th').allInnerTexts();
      const rowsData = await rows.nth(i).locator('td').allInnerTexts();
      console.log(`Row ${i + 1}:`, rowsData);

   }
Text
Row 1: [ 'UAE', 'Dubai', '829m', '2010', '1', '\n' ]
Row 2: [ 'Saudi Arabia', 'Mecca', '601m', '2012', '2', '\n' ]
Row 3: [ 'Taiwan', 'Taipei', '509m', '2004', '3', '\n' ]

Two things in that output are explained by the anatomy figure. The building names are missing because they are th cells and the locator asks for td. And every row ends in '\n' because the last cell holds only a <br>.

The loop drops the last row. This table has four rows in its tbody, but the output shows three: Financial Center is missing. nth() counts from 0, so the four rows are nth(0) to nth(3), and the loop should run while i < rowCount. In class, the first version ran one step too far and printed an empty extra row. The fix removed the = but kept the - 1, which overcorrects by one.

With i < rowCount, all four rows print:

Text
Row 1: [ 'UAE', 'Dubai', '829m', '2010', '1', '\n' ]
Row 2: [ 'Saudi Arabia', 'Mecca', '601m', '2012', '2', '\n' ]
Row 3: [ 'Taiwan', 'Taipei', '509m', '2004', '3', '\n' ]
Row 4: [ 'China', 'Shanghai', '492m', '2008', '4', '\n' ]

Note the contrast with the first table: there, XPath positions run from 1 and the loop is i <= rows. Here, nth() runs from 0 and the loop is i < rowCount. Mixing the two conventions is exactly how this slip happens.

11

Tasks and announcements

  • Practise both web table examples until you can write them without looking.
  • Next class: pagination and filtering on web tables, where the real difficulty is.
  • Doubt thread is open for questions from this session.
  • No masterclass this week.

Homework: select Rohan Mehta. On the web table practice page, find the row for Rohan Mehta and click to select him. This one needs preceding-sibling, not following-sibling: the control you want sits to the left of the cell you can match on. Use the same approach as the Helen Bennett problem: find the anchor, then step sideways.