Udemy Playwright: Web Automation Testing From Zero to Hero

From Vincents CV Wiki
Jump to navigation Jump to search

Link to Udemy Udemy Playwright: Web Automation Testing From Zero to Hero

Section 1: Preparation

Playwright vs Cypress

Playwright Pros. Cypress Pros.
  • Faster test execution
  • OOTB free parallel execution
  • Multiple languages (JS/TS, Python, Java, C#
  • Multiple Tabs
  • Better iFrames
  • similar to Selenium
  • Less code - fast to write
  • Better auto-wait mechanism
  • Better documentation
  • Better testrunner (time machine)
  • Dashboard service

Development Environment Configuration

  • node.js => updated => done
  • Git => updated => done
  • VS Code => updated => done
  • Playwright extn for VS Code => installed

Clone Test App

--force needed to accept various warnings

Section 2: JavaScript Fundamentals

I'm familiar with JavaScript - I'm fast forwarding through this without keeping notes

Section 3: Playwright Hands-On Overview

  • create new folder & npm init playwright@latest

Ways to Run & Debug

  • CLI Test Executions
npx playwright test
npx playwright test example.spec.ts --project=chromium --headed
npx playwright test -g "has title"
npx playwright show-report
  • Test Execution with UI - OMG this debug UI is cool!
npx playwright test --ui
  • Test Execution with trace on
npx playwright test --project=chromium --trace on
npx playwright show-report
=> you can now open the trace from the report (which looks similar to the ui tool above)
trace can be generated from CI/CD pipeline too, and then you can view the results saved in a zip file with a trace viewer
  • Test Execution with debug npx playwright test --project=chromium --debug
this opens the Playwright inspector showing the code, debugging controls, and console information
and the browser window
  • Test execution with VS Code Extension => Test Explorer
Navigate to the test you want to debug
set any breakpoint(s)
VS Code shows the code, debugging controls, and console information

Tests Structure

First Test

  1. In VS Code
  2. Open PW-PRACTICE-APP
  3. run npm init playwright@latest --force
  • force is needed to avoid errors
  • package.json updated with new dev dependencies
  • playwright.config.ts is created as well as other files
  1. delete test-examples folder - it's not needed
  2. delete test/example.spec.ts file - it's not needed
  3. create file firsTest.spec.ts
import {test} from '@playwright/test'
test('the first test', async ({page}) => {
    await page.goto('http://localhost:4200/')
    await page.getByText('Forms').click()
    await page.getByText('Form Layouts').click()
})
  • Notice the page fixture, it has a lot of useful methods, eg. page.goto('url') and page.getByText('label')

Hooks & Control Flow

  • tes.describe(' a test suite'...)
  • test.beforeEach() and test.beforeAll()
can be used outside as well as inside a suite
  • .only(..) can be used on tests as well as suites
  • test.afterEach() and test.afterAll()
try to avoid using the after... hooks, better to do it in the before... hooks

Section 4: Interaction with Web Elements

Understanding DOM and Terminology - Review HTML terms

<parent>
   <html_tag_name html-attribute="a value" class="class1 class2" id="unique">
      <child>
        ... html text value
      </child>
   </html_tag_name>
   <sibling></sibling>
</parent>

Locator Syntax Rules

    page.locator('input') //finds all of them
    page.locator('#inputEmail1') // by id
    page.locator('.shape-rectangle')     //by class value
    page.locator('[placeholder="Email"]')     // by attribute
    page.locator('[class="input-full-width size-medium status-basic shape-rectangle nb-transition cdk-focused cdk-mouse-focused"]')     // by class value (full)
    page.locator('input[placeholder="Email"][nbinput].shape-rectangle')     // combine selectors
    page.locator('//*[@id="inputEmail1"]')     // XPath (NOT Recommended because it's testing implementation rather than user visible aspects)
    page.locator(':text("Using")')  // by partial text match
    page.locator(':text-is("Using the Grid")')     // by exact text match

Note:

If you had previously run the test, and the associated browser window is still open, then when the cursor is on a code line with page.locator(...) it highlights the elements selected by the locator (very cool!)
It's blue when a single element is selected, and orange when multiple elements match the locator.

page.locator(...) will always return all matching elements, can use .first() to refine to first element to perform an action.

use npx playwright test --ui and click the watch icon so that test auto-re-runs when you edit the code (I love this)

User Facing Locators

Playwright Best Practices

  • Test user-visible behaviour
    • tests should typically only see/interact with rendered output
    • mimic user behaviour
  • page.getByRole(...)
    • See ARIA roles and attributes (google it?)
  await page.getByRole("textbox", { name: "Email" }).first().click()
  await page.getByRole("button", { name: "Sign in" }).first().click()
  await page.getByLabel("Email").first().click()
  await page.getByPlaceholder('Jane Doe').click()
  await page.getByText('Using the Grid').click()
  await page.getByTestId('SignIn').click() // this expects html attribute=>  data-testid="SignIn"
  await page.getByTitle('IoT Dashboard').click()

Child Elements

test("Locating child elements", async ({ page }) => {
  await page.locator('nb-card nb-radio :text-is("Option 1")').click()
  await page.locator('nb-card').locator('nb-radio').locator(':text-is("Option 2")').click() // this is nicer than line above
  await page.locator('nb-card').getByRole('button', {name: "Sign In"}).first().click() // avoid first last, and nth because the lists enveriably change
  await page.locator('nb-card').nth(3).getByRole('button').click()
})

Parent Elements

test("Locating parent elements", async ({ page }) => {
  await page
    .locator("nb-card", { hasText: "Using the Grid" })
    .getByRole("textbox", { name: "Email" })
    .click()
  await page
    .locator("nb-card", { has: page.locator("#inputEmail1") })
    .getByRole("textbox", { name: "Email" })
    .click()
  await page
    .locator("nb-card")
    .filter({ hasText: "Basic Form" })
    .getByRole("textbox", { name: "Email" })
    .click()
  await page
    .locator("nb-card")
    .filter({ has: page.locator("nb-checkbox") })
    .filter({ hasText: "Sign in" })
    .getByRole("textbox", { name: "Email" })
    .click()
  await page
    .locator(':text-is("Using the Grid")')
    .locator("..")
    .getByRole("textbox", { name: "Email" })
    .click()

  await page
    .getByText("Using the Grid")
    .locator("..")
    .getByRole("textbox", { name: "Email" })
    .click()
})

I think I like best the last one, where you select something that looks like a heading to the user, and then go to the parent that contains the selected element, and the element you want to locate.

Reusing Locators

Stop copying and pasting code ....

  const testEmailAddress = "test@test.com"
  const basicForm = page.locator("nb-card").filter({ hasText: "Basic Form" })
  const emailField = basicForm.getByRole("textbox", { name: "Email" })


  await emailField.fill(testEmailAddress)
  await basicForm.getByRole("textbox", { name: "Password" }).fill("Welcome123")
  await basicForm.getByRole("button").click()

  await expect(emailField).toHaveValue(testEmailAddress)

Extracting Values

  // Single test value
  const basicForm = page.locator("nb-card").filter({ hasText: "Basic Form" })
  const buttonText = await basicForm.locator('button').textContent()
  expect(buttonText).toEqual("Submit")

  // Array of text values
  const allRadioButtonLabels = await page.locator('nb-radio').allTextContents()
  expect(allRadioButtonLabels).toContain('Option 1')

   // input value
   const emailField = basicForm.getByRole('textbox', {name: 'Email'})
   await emailField.fill('test@test.com')
   const emailValue = await emailField.inputValue()
   expect(emailValue).toEqual('test@test.com')

   // attribute
   const placeholderValue = await emailField.getAttribute('placeholder')
   expect(placeholderValue).toEqual('Email')

Assertions

  // General assertions
  const value = 5
  expect(value).toEqual(5)

  const basicFormButton = page
    .locator("nb-card")
    .filter({ hasText: "Basic Form" })
    .locator("button")

  const text = await basicFormButton.textContent()
  expect(text).toEqual("Submit")

  // locator assertion
  await expect(basicFormButton).toHaveText("Submit")

  // soft assertion (continues even if it fails)
  await expect.soft(basicFormButton).toHaveText("Submit")
  await basicFormButton.click()

Auto-waiting

There are some different wait topologies

some auto-wait for 30s by default (but configurable

expect() waits for only 5s

some do not wait at all

and there's a bunch of alternative waitFor methods, eg. wait for a response to an API call

test.beforeEach(async ({ page }) => {
  await page.goto("http://uitestingplayground.com/ajax")
  await page.getByText("Button Triggering AJAX Request").click()
})

test("Auto Waiting", async ({ page }) => {
  const successButton = page.locator(".bg-success")

  //   const text = await successButton.textContent()  // waits automatically (upto default 30s)
  //   expect(text).toEqual('Data loaded with AJAX get request.')

  //   await successButton.waitFor({state: 'attached'}) // waits upto default 30s
  //   const text2 = await successButton.allTextContents() // fails doesn't wait by itself (must use waitFor(...))
  //   expect(text2).toContain('Data loaded with AJAX get request.')

  await expect(successButton).toHaveText("Data loaded with AJAX get request.", {
    timeout: 20000,
  }) // waits default  upto 5s, unless overridden
})

test("alternative waits", async ({ page }) => {
  const successButton = page.locator(".bg-success")

  // wait for element
  //await page.waitForSelector(".bg-success")

  // wait for particular response
  //await page.waitForResponse('http://uitestingplayground.com/ajaxdata')

  // wait for network calls to be completed (NOT RECOMMENDED)
  await page.waitForLoadState("networkidle")

  const text2 = await successButton.allTextContents()
  expect(text2).toContain("Data loaded with AJAX get request.")
})

Timeouts

test("timeouts", async ({ page }) => {
  //test.setTimeout(10000)
  test.slow()
  const successButton = page.locator(".bg-success")
  await successButton.click()
})

There are three layers to timeouts

1. Global Timeout

time limit for whole test suite to run

default: no limit

2. Test Timeout

Within the global timeout, it is the time limit for a single test

default: 30000ms

3. Action, Navigation, Expect

Within the global and test timeout there are the following timeouts

  • Action = click(), fill(), textContent() etc
default: no limit
  • Navigation = page.goto(url...)
default: no limit
  • Expect = locator assertions
default: 5000ms
Note: regular expect assertions execute immediately, only locator exceptions will wait.
You can override the expect timeout await expect(element).toHaveText("some text", { timeout: 20000 })

Overrides inside test case

Use test.slow() to extend timeout to 3x configured value, or test.setTimeout(10000).

Project Timeout settings in playwright.config.ts

export default defineConfig({
  timeout: 10000,   // test case max run time
  globalTimeout: 60000,  // entire test run max run time
  expect:{
    timeout: 2000, // sets locator assertion timeout
  },
  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
  use: {
    actionTimeout: 5000,
    navigationTimeout: 5000,
  },
  ...
});

Timeout settings for a whole spec.ts file

test.beforeEach(async ({ page }, testInfo) => {
  await page.goto("http://uitestingplayground.com/ajax")
  await page.getByText("Button Triggering AJAX Request").click()
  testInfo.setTimeout(testInfo.timeout + 2000)
})

Section 5: UI Components

Input Fields

  await usingTheGridEmailInput.fill('test@test.com')
  await usingTheGridEmailInput.clear()
  await usingTheGridEmailInput.pressSequentially('test2@test.com', {delay:500})

  // generic assertion
  const inputValue = await usingTheGridEmailInput.inputValue()
  expect(inputValue).toEqual('test2@test.com')

  // locator assertion
  await expect(usingTheGridEmailInput).toHaveValue('test2@test.com')

Radio Buttons

//await usingTheGridForm.getByLabel('Option 1').check({force:true})
await usingTheGridForm
    .getByRole("radio", { name: "Option 1" })
    .check({ force: true })

const radioStatus = await usingTheGridForm
    .getByRole("radio", { name: "Option 1" })
    .isChecked()
expect(radioStatus).toBeTruthy()

await expect(
    usingTheGridForm.getByRole("radio", { name: "Option 1" })
).toBeChecked()

await usingTheGridForm
    .getByRole("radio", { name: "Option 2" })
    .check({ force: true })

expect(
    await usingTheGridForm
    .getByRole("radio", { name: "Option 1" })
    .isChecked()
).toBeFalsy()
expect(
    await usingTheGridForm
        .getByRole("radio", { name: "Option 2" })
        .isChecked()
).toBeTruthy()

Note: force needed because class="native-input visually-hidden" ie. the element is hidden, and force suspends Playwright's the actionability checks (ie. Visible, Stable, Receives Events, Enabled)

Checkboxes

await page.getByText("Modal & Overlays").click()
await page.locator(".menu-item").getByText("Toastr").click()
await page
  .getByRole("checkbox", { name: "Hide on click" })
  .uncheck({ force: true })
await page
  .getByRole("checkbox", { name: "Prevent arising of duplicate toast" })
  .check({ force: true })

const checkBoxes = page.getByRole("checkbox")
for (const checkbox of await checkBoxes.all()) {
  await checkbox.uncheck({ force: true })
  expect(await checkbox.isChecked()).toBeFalsy()
}

Note: force needed because class="native-input visually-hidden" ie. the element is hidden, and force suspends Playwright's the actionability checks (ie. Visible, Stable, Receives Events, Enabled)

Also I couldn't get const checkBoxes = await page.getByRole("checkbox").all() and then use array forEach(...) to work, not sure why ... but using the for loop is documented in the method tooltip ...

Lists & Dropdowns

test("lists and dropdowns", async ({ page }) => {
  const dropDownMenu = page.locator("ngx-header nb-select")
  await dropDownMenu.click()

  page.getByRole("list") // for <ul> => parent list container
  page.getByRole("listitem") // for <li> => not always used

  // const optionList = page.getByRole('list').locator('nb-option')

  const optionList = page.locator("nb-option-list nb-option")

  await expect(optionList).toHaveText(["Light", "Dark", "Cosmic", "Corporate"])

  await optionList.filter({ hasText: "Cosmic" }).click()

  const header = page.locator("nb-layout-header")
  await expect(header).toHaveCSS("background-color", "rgb(50, 50, 89)")

  const colors = {
    Light: "rgb(255, 255, 255)",
    Dark: "rgb(34, 43, 69)",
    Cosmic: "rgb(50, 50, 89)",
    Corporate: "rgb(255, 255, 255)",
  }

  for (const color in colors) {
    await dropDownMenu.click()
    await optionList.filter({ hasText: color }).click()
    await expect(header).toHaveCSS("background-color", colors[color])
  }
})

Tooltips

To see the tooltip you may need to go to the dev tools, sources tab, and press F8 (on windows) to pause the debugger whilst showing the tooltip. This will now allow you to investigate the frozen DOM.

test("tooltip", async ({ page }) => {
  await page.getByText("Modal & Overlays").click()
  await page.locator(".menu-item").getByText("Tooltip").click()
  const toolTipCard = page.locator("nb-card", { hasText: "Tooltip Placements" })
  await toolTipCard.locator("button", { hasText: "TOP" }).hover()

  page.getByRole('tooltip')  // only works if the tooltip role was added to the element
  const tooltip = await page.locator('nb-tooltip').textContent()
  expect(tooltip).toEqual('This is a tooltip')
})

Dailog Boxes

test("Dialog box", async ({ page }) => {
  await page.getByText("Tables & Data").click()
  await page.locator(".menu-item").getByText("Smart Table").click()

  page.on("dialog", (dialog) => {
    expect(dialog.message()).toEqual("Are you sure you want to delete?")
    dialog.accept()
  })

  const email = "mdo@gmail.com"
  await page
    .getByRole("table")
    .locator("tr", { hasText: email })
    .locator(".nb-trash")
    .click()

  await expect(page.locator("table tr").first()).not.toHaveText(email)
})

Web Tables

test("Web Table", async ({ page }) => {
  await page.getByText("Tables & Data").click()
  await page.locator(".menu-item").getByText("Smart Table").click()

  // 1 locate a row by a unique value
  const targetRow = page.getByRole("row", { name: "twitter@outlook.com" })
  await targetRow.locator(".nb-edit").click()
  await page.locator("input-editor").getByPlaceholder("Age").clear()
  await page.locator("input-editor").getByPlaceholder("Age").fill("35")
  await page.locator(".nb-checkmark").click()

  // 2 get a row by a value in specific column
  await page.locator(".ng2-smart-pagination-nav").getByText("2").click()
  const targetRowById = page.getByRole('row', {name:'11'}).filter({has: page.locator('td').nth(1).getByText('11')})
  await targetRowById.locator('.nb-edit').click()
  await page.locator("input-editor").getByPlaceholder("E-mail").clear()
  await page.locator("input-editor").getByPlaceholder("E-Mail").fill("test@test.com")
  await page.locator(".nb-checkmark").click()
  await expect(targetRowById.locator('td').nth(5)).toHaveText('test@test.com')

  // 3 test filter of the  table
  const ages = ["20", "30", "40", "200"]

  for (let age of ages) {
    await page.locator("input-filter").getByPlaceholder("Age").clear()
    await page.locator("input-filter").getByPlaceholder("Age").fill(age)
    await page.waitForTimeout(500) // it takes a moment to refresh the list

    const ageRows = page.locator("tbody tr")

    for (let row of await ageRows.all()) {
      const cellValue = await row.locator("td").last().textContent()
      if (age == "200") {
        expect(await page.getByRole("table").textContent()).toContain(
          "No data found"
        )
      } else {
        expect(cellValue).toEqual(age)
      }
    }
  }
})

Not sure I like how one scenario is testing multiple things, and particularly // 3 where the last filter scenario expects to find no data, compared to the first three that do have rows...

Date Picker

test("Date Picker", async ({ page }) => {
  await page.getByText("Forms").click()
  await page.locator(".menu-item").getByText("Datepicker").click()

  const calendarInputfield = page.getByPlaceholder("Form Picker")
  await calendarInputfield.click()

  await page.getByPlaceholder("Form Picker").click()

  let date = new Date()
  date.setDate(date.getDate() + 500) 
  const expectedDate = date.getDate().toString()
  const expectedMonthShort = date.toLocaleString("En-US", { month: "short" })
  const expectedMonthLong = date.toLocaleString("En-US", { month: "long" })
  const expectedYear = date.getFullYear()
  const formattedDate = `${expectedMonthShort} ${expectedDate}, ${expectedYear}`

  let calendarMonthAndYear = await page
    .locator("nb-calendar-view-mode")
    .textContent()
  const expectedMonthAndYear = ` ${expectedMonthLong} ${expectedYear} `

  while (!calendarMonthAndYear.includes(expectedMonthAndYear)) {
    await page.locator(".next-month").click()
    //    await page.locator('nb-calendar-pageable-navigation [data-name="chevron-right"]').click()
    calendarMonthAndYear = await page
      .locator("nb-calendar-view-mode")
      .textContent()
  }

  await page
    .locator('[class="day-cell ng-star-inserted"]')
    .getByText(expectedDate, { exact: true })
    .click()

  await expect(calendarInputfield).toHaveValue(formattedDate)
})

Notes:

  • be careful when adding to a date, it may wrap to the next month
  • things I don't quite like here
    • all the date string variables, and using the expected prefixes
    • this check "sometimes" checks the next month button. but not always - these should be two separate checks (test cases)
    • the code duplication for calendarMonthAndYear = ...
    • the course uses 'nb-calendar-pageable-navigation [data-name="chevron-right"]' when ".next-month" is cleaner and more readable
    • the while loop is dangerous, it assumes that calendarMonthAndYear.includes(expectedMonthAndYear) will be true at some point.
Consider what would happen if the format of the displayed text is changed (eg. the developer changes it to use a short month...)
I think a for loop with conditional exit break would be better,
and using two separate checks, for short month, and then year, would be more resilient.

With suggested changes

test("Date Picker", async ({ page }) => {
  await page.getByText("Forms").click()
  await page.locator(".menu-item").getByText("Datepicker").click()

  const calendarInputfield = page.getByPlaceholder("Form Picker")
  await calendarInputfield.click()

  await page.getByPlaceholder("Form Picker").click()

  let selectDate = new Date()
  const addDays = 500
  selectDate.setDate(selectDate.getDate() + addDays)
  const selectDayOfMonth = selectDate.getDate().toString() // value of "1" to "31"
  const selectMonth = selectDate.toLocaleString("En-US", { month: "short" })
  const selectYear = selectDate.getFullYear().toString()
  const formattedSelectDate = `${selectMonth} ${selectDayOfMonth}, ${selectYear}`

  for (let i = 0; i < addDays / 28; i++) { // divide by shortest month
    let calendarMonthAndYear = await page
      .locator("nb-calendar-view-mode")
      .textContent()

    if (
      calendarMonthAndYear.includes(selectMonth) &&
      calendarMonthAndYear.includes(selectYear)
    ) {
      break
    } else {
      await page.locator(".next-month").click()
    }
  }

  await page
    .locator('[class="day-cell ng-star-inserted"]') 
    .getByText(selectDayOfMonth, { exact: true })
    .click()
    // locator has to be exact match, partial matches would include days from prev and next months

  await expect(calendarInputfield).toHaveValue(formattedSelectDate)
})

Sliders

test("Sliders", async ({ page }) => {
  // Update attribute
  // const tempGaugeDraggerHandle = page.locator('[tabtitle="Temperature"] ngx-temperature-dragger circle')
  // await tempGaugeDraggerHandle.evaluate(node => {
  //     node.setAttribute('cx',"232.103")
  //     node.setAttribute('cy',"232.103")
  // })
  // await tempGaugeDraggerHandle.click()

  // mouse movement
  const tempGauge = page.locator(
    '[tabtitle="Temperature"] ngx-temperature-dragger'
  )

  tempGauge.scrollIntoViewIfNeeded()
  // probably also need to make sure that the bowser window is big enough to show the whole UI control

  const box = await tempGauge.boundingBox()

  const c = {
    x: box.x + box.width / 2,
    y: box.y + box.height / 2,
  }

  await page.mouse.move(c.x, c.y)
  await page.mouse.down()
  await page.mouse.move(c.x + 100, c.y)
  await page.mouse.move(c.x + 100, c.y + 100)
  await page.mouse.up()

  await page.mouse.click(c.x + 100, c.y + 100)
  // this was needed to get the scenario to run in Playwright UI mode viewer

  await expect(tempGauge).toContainText("30")
})

Notes:

  • Had some issues with browser window being too small and interfering with scrolling and mouse actions
  • also the UI Mode view doesn't show the temperature UI component correctly, and didn't seem to update the value shown. I added a page.mouse.click(...location...) at the end to fix this.

Drag & Drop with iFrames

test("drag and drop with iframe", async ({ page }) => {
  await page.goto("https://www.globalsqa.com/demo-site/draganddrop/")
  const frame = page.frameLocator('[rel-title="Photo Manager"] iframe')

  await frame
    .locator("li", { hasText: "High Tatras 2" })
    .dragTo(frame.locator("#trash"))

  // more precise control
  await frame.locator("li", { hasText: "High Tatras 4" }).hover()
  await page.mouse.down()
  await frame.locator("#trash").hover()
  await page.mouse.up()

  await expect(frame.locator("#trash li h5")).toHaveText([
    "High Tatras 2",
    "High Tatras 4",
  ])
})

Section 6: Page Object Model

Intro

  • design pattern to improve maintainability and reusability
  • there's no industry standard way to do it though
  • Core concepts
    • each page has a class
    • with methods for operations
  • Two Important Principles
    • DRY - don't repeat yourself
    • KISS - keep it simple stupid
  • Two Good Practices
    • Descriptive naming
    • Avoid tiny methods => I don't agree, especially if this leads to code repetition.

Note:

For me I like POM's to encapsulate the page (or component) so that the object knows

  • how to find things it contains
  • how to do things that it implements

with the end result being that code consuming the object interacts with it in a way that feels and reads like a real user might.

First Page Object

new file page-objects\navigationPage.ts

import { Page } from "@playwright/test"

export class NavigationPage {
  readonly page: Page

  constructor(page: Page) {
    this.page = page
  }

  async formLayoutsPage() {
    await this.page.getByText("Forms").click()
    await this.page.getByText("Form Layouts").click()
  }
}


new file tests\usePageObjects.spec.ts

import { expect, test } from "@playwright/test"
import { NavigationPage } from "../page-objects/navigationPage"

test.beforeEach(async ({ page }, testInfo) => {
  await page.goto("http://localhost:4200/")
})

test("navigate to form page", async ({ page }) => {
  const navigateTo = new NavigationPage(page)
  await navigateTo.formLayoutsPage()

})

Notes

I don't like a lot of the naming here,

  • navigationPage -> navigationMenu
  • formLayoutsPage -> clickFormLayouts
  • navigateTo -> navigationMenu

Navigation Page Object

As per above section, but adding/modifying

  async datePickerPage() {
    await this.selectGroupMenuItem("Forms")
    await this.page.locator(".menu-item").getByText("Datepicker").click()
  }

  /* other menu items */

  private async selectGroupMenuItem(groupItemtitle: string) {
    const groupMenuItem = this.page.getByTitle(groupItemtitle)
    const expandedState = await groupMenuItem.getAttribute("aria-expanded")

    if (expandedState == "false") {
      await groupMenuItem.click()
    }
  }

Locators in Page Objects

Parameterised Methods

Date Picker Page Object

Page Objects Manager

Page Objects Helper Base