Udemy Playwright: Web Automation Testing From Zero to Hero
- My GitHub Playwright Udemy Course my repo #1 & my repo #2
- Started implementing a suite for this CV Wiki
Note: This page is now massive, but it's handy to have it all in one place so that I can use ctrl-f to find notes... I'll be using this page as my cheatsheet for Playwright
Section 1: Preparation
Playwright vs Cypress
Playwright Pros. | Cypress Pros. |
---|---|
|
|
Development Environment Configuration
- node.js => updated => done
- Git => updated => done
- VS Code => updated => done
- Playwright extn for VS Code => installed
Clone Test App
- From https://github.com/bondar-artem/pw-practice-app
- Cloned it in VS Code
npm install --force
- --force needed to accept various warnings
npm start
http://localhost:4200/
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
- install browsers
npx playwright install
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 CI=true npx playwright test
- 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
- In VS Code
- Open PW-PRACTICE-APP
- 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
- delete test-examples folder - it's not needed
- delete test/example.spec.ts file - it's not needed
- 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')
andpage.getByText('label')
Hooks & Control Flow
tes.describe(' a test suite'...)
test.beforeEach()
andtest.beforeAll()
- can be used outside as well as inside a suite
.only(..)
can be used on tests as well as suitestest.afterEach()
andtest.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
- 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
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() 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.
- all the date string variables, and using the
- 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() 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
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
Modifying the previous example
readonly formLayoutsMenuItem: Locator readonly datePickerMenuItem: Locator ... constructor(page: Page) { this.page = page this.formLayoutsMenuItem = page.getByText("Form Layouts") this.datePickerMenuItem = page.locator(".menu-item").getByText("Datepicker") ... } async formLayoutsPage() { await this.selectGroupMenuItem("Forms") await this.formLayoutsMenuItem.click() } ...
Presenter pointed out that you can end up with long lists of fields, and constructor lines, resulting in hard to review and maintain code. You're probably better off just leaving them hard coded in the action methods.
Parameterised Methods
File: tests\usepageObjects.spec.ts
import { expect, test } from "@playwright/test" import { NavigationPage } from "../page-objects/navigationPage" import { FormLayoutsPage } from "../page-objects/formLayoutsPage" test.beforeEach(async ({ page }, testInfo) => { await page.goto("http://localhost:4200/") }) test("navigate to all pages", async ({ page }) => { const navigateTo = new NavigationPage(page) await navigateTo.formLayoutsPage() await navigateTo.datePickerPage() await navigateTo.smartTablepage() await navigateTo.toastrPage() await navigateTo.tooltipPage() }) test("parameterised methods", async ({ page }) => { const navigateTo = new NavigationPage(page) const onFormLayoutsPage = new FormLayoutsPage(page) await navigateTo.formLayoutsPage() await onFormLayoutsPage.submitUsingTheGridFormWithCredentialsAndSelectOption( "test@test.com", "Welcome1", "Option 2" ) await onFormLayoutsPage.submitInLineFormWithNameEmailAndCheckbox( "John Smith", "john@test.com", true ) })
File: page-objects\formLayoutsPage.ts
import { Page } from "@playwright/test" export class FormLayoutsPage { private readonly page: Page constructor(page: Page) { this.page = page } async submitUsingTheGridFormWithCredentialsAndSelectOption( email: string, password: string, optionText: string ) { const usingTheGridForm = this.page.locator("nb-card", { hasText: "Using the Grid", }) await usingTheGridForm.getByRole("textbox", { name: "Email" }).fill(email) await usingTheGridForm .getByRole("textbox", { name: "Password" }) .fill(password) await usingTheGridForm .getByRole("radio", { name: optionText }) .check({ force: true }) await usingTheGridForm.getByRole("button").click() } /** * This method fills out the inline form with user details * * @param name - first and last name * @param email - valid email for the test user * @param rememberMe - if the user session is to be saved */ async submitInLineFormWithNameEmailAndCheckbox( name: string, email: string, rememberMe: boolean ) { const inlineForm = this.page.locator("nb-card", { hasText: "Inline form", }) await inlineForm.getByRole("textbox", { name: "Jane Doe" }).fill(name) await inlineForm.getByRole("textbox", { name: "Email" }).fill(email) rememberMe && (await inlineForm.getByRole("checkbox").check({ force: true })) await inlineForm.getByRole("button").click() } }
Date Picker Page Object
test("DatePickerPage Object", async ({page}) => { const navigateTo = new NavigationPage(page) const onDatepickerPage = new DatePickerPage(page) await navigateTo.datePickerPage() await onDatepickerPage.selectCommonDatePickerDateFromToday(10) await onDatepickerPage.selectDatePickerWithRangeFromToday(6 ,15) })
File: page-objects\datePickerPage.ts
import { Page, expect } from "@playwright/test" export class DatePickerPage { private readonly page: Page constructor(page: Page) { this.page = page } async selectCommonDatePickerDateFromToday(numberOfDaysFromToday: number) { const calendarInputfield = this.page.getByPlaceholder("Form Picker") await calendarInputfield.click() const formattedSelectDate = await this.selectDateInTheCalendar( numberOfDaysFromToday ) await expect(calendarInputfield).toHaveValue(formattedSelectDate) } async selectDatePickerWithRangeFromToday( startDayFromToday: number, endDayFromToday: number ) { if(startDayFromToday > endDayFromToday) throw new Error('startDayFromToday must not be after endDayFromToday') const calendarInputfield = this.page.getByPlaceholder("Range Picker") await calendarInputfield.click() const formattedStartDate = await this.selectDateInTheCalendar( startDayFromToday ) const formattedEndDate = await this.selectDateInTheCalendar(endDayFromToday) await expect(calendarInputfield).toHaveValue(`${formattedStartDate} - ${formattedEndDate}`) } private async selectDateInTheCalendar(numberOfDaysFromToday: number) { let selectDate = new Date() selectDate.setDate(selectDate.getDate() + numberOfDaysFromToday) 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}` // check if selectDate is in this month, or whether we need to click next to a future month for (let i = 0; i < numberOfDaysFromToday / 28; i++) { // divide by shortest month let calendarMonthAndYear = await this.page .locator("nb-calendar-view-mode") .textContent() if ( calendarMonthAndYear.includes(selectMonth) && calendarMonthAndYear.includes(selectYear) ) { break } else { await this.page.locator(".next-month").click() } } await this.page .locator('.day-cell.ng-star-inserted:not(.bounding-month)') .getByText(selectDayOfMonth, { exact: true }) .click() // locator has to be exact match, partial matches would include days from prev and next months return formattedSelectDate } }
Notes:
- I feel that the page object should not have
expect(...)
in it, IMHO these ought to be in the tests themselves, with possibly helper methods in the page object to obtain the values to assert on. - I added a little input validation to check startDayFromToday is not be after endDayFromToday
- Not sure I like a few of the names used, e.g. method name
selectDatePickerWithRangeFromToday(...)
is not entirely self evident what it is, and I'd probably just want to use Date object for start and end input parameters, rather than the number of days from today.
Page Objects Manager
File: page-objects\pageManager.ts
import { Page, expect } from "@playwright/test" import { NavigationPage } from "../page-objects/navigationPage" import { FormLayoutsPage } from "../page-objects/formLayoutsPage" import { DatePickerPage } from "../page-objects/datePickerPage" export class PageManager { private readonly page: Page private readonly navigationPage: NavigationPage private readonly formLayoutsPage: FormLayoutsPage private readonly datePickerPage: DatePickerPage constructor(page: Page) { this.page = page this.navigationPage = new NavigationPage(this.page) this.formLayoutsPage = new FormLayoutsPage(this.page) this.datePickerPage = new DatePickerPage(this.page) } navigateTo = () => this.navigationPage onFormLayoutsPage = () => this.formLayoutsPage onDatePickerPage = () => this.datePickerPage }
Notes:
- I swapped to using arrow notation for specifying the PageManager's methods. (Make it a lot more concise and has less verbiage)
test("Page Manger", async ({ page }) => { const pm = new PageManager(page) await pm.navigateTo().formLayoutsPage() await pm.onFormLayoutsPage().submitUsingTheGridFormWithCredentialsAndSelectOption( "test@test.com", "Welcome1", "Option 2" ) await pm.onFormLayoutsPage().submitInLineFormWithNameEmailAndCheckbox( "John Smith", "john@test.com", true ) await pm.navigateTo().datePickerPage() await pm.onDatePickerPage().selectCommonDatePickerDateFromToday(10) await pm.onDatePickerPage().selectDatePickerWithRangeFromToday(6 ,15) })
Page Objects Helper Base
File page-objects\helperBase.ts
import { Page } from "@playwright/test" export class HelperBase { readonly page: Page constructor(page: Page) { this.page = page } async waitForNumberOfSeconds(timeInSeconds: number) { await this.page.waitForTimeout(timeInSeconds * 1000) } }
File page-objects\navigationPage.ts
... async formLayoutsPage() { await this.selectGroupMenuItem("Forms") await this.page.getByText("Form Layouts").click() await this.waitForNumberOfSeconds(2) } ...
Which is used in tests\usePageObjects.spec.ts
test("Page Manger", async ({ page }) => { const pm = new PageManager(page) await pm.navigateTo().formLayoutsPage() await pm .onFormLayoutsPage() .submitUsingTheGridFormWithCredentialsAndSelectOption( "test@test.com", "Welcome1", "Option 2" ) await pm .onFormLayoutsPage() .submitInLineFormWithNameEmailAndCheckbox( "John Smith", "john@test.com", true ) await pm.navigateTo().datePickerPage() await pm.onDatePickerPage().selectCommonDatePickerDateFromToday(10) await pm.onDatePickerPage().selectDatePickerWithRangeFromToday(6, 15) })
Section 7: Working With APIs
What is an API?
I'm reasonably familiar with API's. Just making some cursory notes for this lesson.
Methods
- GET
- POST
- PUT
- DELETE
Parts
- URL
- Headers
- Method
- Body
Response Status Codes
- 200's - Success
- 300's - Redirection
- 400's - Client Error
- 500's - Server Error
Playwright API Mocking
- powerful
- fast
- risky when API contracts are changing
Setup New Project
- goto https://conduit.bondaracademy.com/
- open dev tools to observe network traffic
- create account
- create an article
- delete the article
- download & install Postman
- VS Code
- Create new project folder
~/Repositories/PlaywrightUdemyCourse/pw-APITest-app
- open new terminal and run
npm init playwright@latest
- delete
test-examples
folder - rename
tests\example.spec.ts
totests\workingWithAPI.spec.ts
- Create new project folder
Mocking an API Response
- Goto https://conduit.bondaracademy.com/
- check network tab in dev tools
- mocking the tags api call
file tests\workingWithAPI.spec.ts
import { test, expect } from "@playwright/test" import ressponseBody from "../test-data/tags.json" test.beforeEach(async ({ page }) => { await page.route( "*/**/api/tags", async (route) => { await route.fulfill({ body: JSON.stringify(ressponseBody), }) } ) await page.goto("https://conduit.bondaracademy.com/") }) test("has title", async ({ page }) => { await expect(page.locator(".navbar-brand")).toHaveText("conduit") await expect( page.locator(".tag-default.tag-pill", { hasText: "Playwright" }) ).toBeVisible() })
file test-data\tags.json
{ "tags": ["Automation", "Playwright"] }
Modifying an API Response
Add the following code the the previous test.beforeEach(...)
call
await page.route("*/**/api/articles*", async (route) => { const response = await route.fetch() const responseBody = await response.json() responseBody.articles[0].title = "This is a test title" responseBody.articles[0].description = "This is a description" await route.fulfill({ body: JSON.stringify(responseBody) }) })
Performing API Requests
To test the delete article function we need to first use the API to
- Login to obtain access token
- create an article
And then we can use web ui to
- Login
- go to Global Feed tab
- find & click the article we created
- delete the article
- refresh the Global Feed tab
- Confirm the article is no longer listed
test("Delete Article", async ({ page, request }) => { const response = await request.post( "https://conduit-api.bondaracademy.com/api/users/login", { data: { user: { email: "conduit@dirksonline.net", password: "qB85R86#ZMKME$jVEVq#vJMDr*A!cJk", }, }, } ) const responseBody = await response.json() const accessToken = responseBody.user.token const articleResponse = await request.post( "https://conduit-api.bondaracademy.com/api/articles/", { data: { article: { title: "Test Title", description: "Test description", body: "Test body", tagList: [], }, }, headers: { Authorization: `Token ${accessToken}`, }, } ) expect(articleResponse.status()).toEqual(201) await page.getByText('Global Feed').click() await page.getByText('Test Title').click() await page.getByRole('button',{name:'Delete Article'}).first().click() await page.getByText('Global Feed').click() await expect(page.locator("app-article-list h1").first()).not.toContainText( "Test Title" ) })
Intercept Browser API Response
test("create article", async ({ page, request }) => { // Use the web ui to create the article await page.getByText("New Article").click() await page .getByRole("textbox", { name: "Article Title" }) .fill("Playwright is awesome") await page .getByRole("textbox", { name: "What's this article about?" }) .fill("About Playwright") await page .getByRole("textbox", { name: "Write your article (in markdown)" }) .fill("We like to use Playwright for automation") await page.getByRole("button", { name: "Publish Article" }).click() // intercept the API response to extract the slug which is needed for clean up later const articleResponse = await page.waitForResponse( "https://conduit-api.bondaracademy.com/api/articles/" ) const articleresponseBody = await articleResponse.json() const slugID = articleresponseBody.article.slug // Assert that article was created await expect(page.locator(".article-page h1")).toContainText( "Playwright is awesome" ) // Go to global feed await page.getByText("Home").click() await page.getByText("Global Feed").click() // Assert new article is listed await expect(page.locator("app-article-list h1").first()).toContainText( "Playwright is awesome" ) // Clean up // Obtain access token for API call const response = await request.post( "https://conduit-api.bondaracademy.com/api/users/login", { data: { user: { email: "conduit@dirksonline.net", password: "qB85R86#ZMKME$jVEVq#vJMDr*A!cJk", }, }, } ) const responseBody = await response.json() const accessToken = responseBody.user.token // delete the article using the slug extracted earlier const articleDeleteResponse = await request.delete( `https://conduit-api.bondaracademy.com/api/articles/${slugID}`, { headers: { Authorization: `Token ${accessToken}`, }, } ) expect(articleDeleteResponse.status()).toEqual(204) })
Notes:
- should really only login once and re-use the access token
- should be refactored using page objects to improve readability
Sharing Authentication State
Create new folder .auth
Create new file tests\auth.setup.ts
import { test as setup } from "@playwright/test" const authfile = ".auth/user.json" setup("authentication", async ({ page }) => { await page.goto("https://conduit.bondaracademy.com/") await page.getByText("Sign in").click() await page .getByRole("textbox", { name: "Email" }) .fill("conduit@dirksonline.net") await page .getByRole("textbox", { name: "Password" }) .fill("qB85R86#ZMKME$jVEVq#vJMDr*A!cJk") await page.getByRole("button").click() await page.waitForResponse('https://conduit-api.bondaracademy.com/api/tags') await page.context().storageState({ path: authfile }) })
In fileplaywright.config.ts
update projects
/* Configure projects for major browsers */ projects: [ { name: "setup", testMatch: "auth.setup.ts" }, { name: "chromium", use: { ...devices["Desktop Chrome"], storageState: ".auth/user.json" }, dependencies: ["setup"], }, { name: "firefox", use: { ...devices["Desktop Firefox"], storageState: ".auth/user.json" }, dependencies: ["setup"], }, { name: "webkit", use: { ...devices["Desktop Safari"], storageState: ".auth/user.json" }, dependencies: ["setup"], },
Delete the login steps from test.beforeEach(...)
, but leave the page.goto(...)
Notes:
- this all seems a little spooky, I'd prefer something in a beforeXXX(...) hook that saves something that is recalled .... will need to look a bit deeper into the
page.context().storageState()
stuff - something is broken in v1.44.0 and needed to down grade to 1.43.0 (
- update package.json "@playwright/test": "1.43.0"
- delete package-lock.json
- run
npm install
- run
npx playwright install
API Authentication
File tests/auth.setup.ts
change to use API and save to file .auth\user.json
env variable ACCESS_TOKEN
setup("authentication", async ({ request }) => { const response = await request.post( "https://conduit-api.bondaracademy.com/api/users/login", { data: { user: { email: "conduit@dirksonline.net", password: "qB85R86#ZMKME$jVEVq#vJMDr*A!cJk", }, }, } ) const responseBody = await response.json() const accessToken = responseBody.user.token user.origins[0].localStorage[0].value = accessToken fs.writeFileSync(authfile, JSON.stringify(user)) process.env['ACCESS_TOKEN'] = accessToken
File tests/workingWithAPI.spec.ts
remove all code for obtaining access token, and for specifying the access token header
File playwright.config.ts
add extraHTTPHeaders
node
export default defineConfig({ ... use: { ... extraHTTPHeaders: { 'Authorization': `Token ${process.env.ACCESS_TOKEN}` }
Notes:
- tried playwright@1.44.1 which still gives issues with the setup settings/file etc. continued using 1.43.0
- This relies on
.auth\user.json
existing with the correct format, but it is in .gitignore so a clean clone of repo will not work - I do like how this now removes a lot of boiler code from test cases.
- I wonder, given that the auth header is now always included via the env variable, is the
.auth\user.json
still needed? (Might also need removing storageState values from theplaywright.config.ts
Section 8: Advanced
npm Scripts and CLI Commands
npx playwright test usePageObjects.spec.ts --project=chromium npx playwright show-report npx playwright test usePageObjects.spec.ts --project=firefox
In file: package.json
add these to scripts node
"pageObjects-chrome": "npx playwright test usePageObjects.spec.ts --project=chromium", "pageObjects-firefox": "npx playwright test usePageObjects.spec.ts --project=firefox", "pageObjects-all": "npm run pageObjects-chrome & npm run pageObjects-firefox"
npm run pageObjects-chrome npm run pageObjects-firefox npm run pageObjects-all
Notes:
- && runs sequentially
- & runs parallely
- On windows npm scripts are executed in cmd.exe by default, use this to change it to bash
npm config set script-shell "C:/Program Files/Git/bin/bash.exe"
Test Data Generator
Install faker with npm i @faker-js/faker --save-dev --force
(force because it has several CVE's)
Update test in tests\usePageObjects.spec.ts
test("Page Manger", async ({ page }) => { const pm = new PageManager(page) const randomFullName = faker.person.fullName({ // sex: "male", // specify sex (optional) // lastName: "Johns", // specify last name (optional) }) const randomEmail = `${randomFullName.replace(/ /g, "")}${faker.number.int( 1000 )}@test.com` await pm.navigateTo().formLayoutsPage() await pm .onFormLayoutsPage() .submitUsingTheGridFormWithCredentialsAndSelectOption( "test@test.com", "Welcome1", "Option 2" ) await pm .onFormLayoutsPage() .submitInLineFormWithNameEmailAndCheckbox(randomFullName, randomEmail, true) await pm.navigateTo().datePickerPage() await pm.onDatePickerPage().selectCommonDatePickerDateFromToday(10) await pm.onDatePickerPage().selectDatePickerWithRangeFromToday(6, 15) })
Notes:
- The faker fullName() method can sometimes add title prefixes, suffixes, etc., which look very odd in email addresses ....
Test Retries
- workers are clean incognito browser windows
- worker is re-used after completing a passing test
- a new worker is started after a failed test
- with retries on the failed test is retried in the new worker
- with retries off the next test is started in the new worker
In file playwright.config.ts
export default defineConfig({ ... /* Retry on CI only */ retries: process.env.CI ? 2 : 1, use: { ... /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", //['on-first-retry', 'on-all-retries', 'off', 'on', 'retain-on-failure'] },
In file tests\uiComponents.spec.ts
test.describe.only("Forms Layouts page", () => { test.describe.configure({ retries: 2, }) test.beforeEach(async ({ page }, testInfo) => { if(testInfo.retry){ // can clean up from previous failed attempt } ... }) ...
Notes:
- Having trace on-first-retry is very cool
Parallel Execution
- worker per
*.spec.ts
file - inside
*.spec.ts
files test cases are execute sequentially
In file playwright.config.ts
fullyParallel: false, workers: process.env.CI ? 1 : undefined,
fullyParallel
is used to guide within a spec file- undefined workers allows playwright to decide, usually one per spec file
Can override config values inside *.spec.ts
files
test.describe.configure({mode:'parallel'}) // spec file root level ... test.describe.parallel("Forms Layouts page", () => { // in test suite ... })
Screenshots and Videos
In Code
await page.screenshot({ path: "screenshots/formsLayoutsPage.png" }) const buffer = await page.screenshot() console.log(buffer.toString('base64')) await page.locator("nb-card", {hasText: "Inline form" }).screenshot({ path: "screenshots/inlineForm.png" })
In file playwright.config.ts
export default defineConfig({ ... /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { ... video: 'on', .. or .. video: { mode: "on", size: { width: 1920, height: 1080 }, }, }, /* Configure projects for major browsers */ projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"], viewport: { width: 1920, height: 1080 }, }, }, ...
Environment Variables
There are many ways to specify environment specific values
In file playwright.config.ts
export default defineConfig<TestOptions>({ ... use: { /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: 'http://localhost:4200', .. or .. baseURL: process.env.DEV ? 'http://the.dev.env.com' : process.env.STAGING ? 'http://the.staging.env.com' : 'http://localhost:4200', ... }, .. or .. /* Configure projects for major browsers */ projects: [ { name: "Dev", use: { baseURL: 'http://the.dev.env.com', }, }, { name: "Staging", use: { baseURL: 'http://the.staging.env.com', }, }, ...
Or Extend
Create file test-options.ts
import {test as base} from '@playwright/test' export type TestOptions = { globalsQaURL: string } export const test = base.extend<TestOptions>({ globalsQaURL: ['', {option:true}] })
import type { TestOptions } from "./test-options" ... export default defineConfig<TestOptions>({ ... use: { ... globalsQaURL: 'https://www.globalsqa.com/demo-site/draganddrop/', ... }, .. or .. /* Configure projects for major browsers */ projects: [ { name: "Dev", use: { ... globalsQaURL: 'https://dev.globalsqa.com/demo-site/draganddrop/', ... }, }, ...
Then in *.spec.ts
test("drag and drop with iframe", async ({ page, globalsQaURL }) => { await page.goto(globalsQaURL) ...
Using process.env
OS environment variables (but there'll be shell and OS variations though)
Assuming bash shell.
Specify variable in file package.json
"scripts": { ... "pageObjects-chrome": "npx playwright test usePageObjects.spec.ts --project=chromium", "autoWait-dev": "URL=http://uitestingplayground.com/ajax npm run pageObjects-chrome" },
Then in *.spec.ts
test.beforeEach(async ({ page }) => { await page.goto(process.env.URL) ... }) ...
Or use a .env
file
In file playwright.config.ts
... /** * Read environment variables from file. * https://github.com/motdotla/dotenv */ require('dotenv').config(); //or ...config({ path: '/custom/path/to/.env' }) ...</nowiki. <nowiki> URL=http://uitestingplayground.com/ajax TESTUSERNAME=test@test.com PASSWORD=Welcome1
Making sure to add .env
to .gitignore
to avoid leaking credentials to repo.
I like to create a .env.template
file
URL=http://uitestingplayground.com/ajax TESTUSERNAME=test@test.com PASSWORD=***REPLACE-ME***
Configuration File
Cleaning up playwright.config.ts
- removing default settings
- removing comments
There are
- global settings, and
- global runtime settings in the
use:{...}
block.
Then inside the projects[{...},{...} ...]
array each node has a name, and can then override any of the global settings, and runtime settings in a use:{...}
subblock.
You can also create and use entirely separate *.config.ts
files and run them with npx playwright test --config=playwright-prod.config.ts
Also see test configuration and test use options in the Playwright documentation.
Fixtures
- Power tool to setup the test environment
- custom fixtures extend the base test object
- you can auto load fixtures
- you can create dependencies between fixtures
- you can also specify tear down code
Update file test-options.ts
import { test as base } from "@playwright/test" import { PageManager } from "./page-objects/pageManager" export type TestOptions = { globalsQaURL: string formLayoutsPage: string pageManager: PageManager } export const test = base.extend<TestOptions>({ globalsQaURL: ["", { option: true }], formLayoutsPage: async ({ page }, use) => { await page.goto("/") await page.getByText("Forms").click() await page.getByText("Form Layouts").click() await use("") console.log('Tear Down formLayoutsPage') }, // { auto: true }], pageManager: async ({ page, formLayoutsPage }, use) => { // pageManager depends on formLayoutsPage const pm = new PageManager(page) await use(pm) console.log('Tear Down pageManager') }, })
Create new file tests\testWithFictures.spec.ts
import { test } from "../test-options" import { faker } from "@faker-js/faker" //test("parameterised methods", async ({ page, formLayoutsPage }) => { // using formLayoutsPage fixture //test("parameterised methods", async ({ page }) => { // using {auto:true} in formLayoutsPage fixture test("parameterised methods", async ({ pageManager }) => { // using new pageManager fixture const randomFullName = faker.person.fullName() const randomEmail = `${randomFullName.replace(/ /g, "")}${faker.number.int( 1000 )}@test.com` await pageManager .onFormLayoutsPage() .submitUsingTheGridFormWithCredentialsAndSelectOption( process.env.TESTUSERNAME, process.env.PASSWORD, "Option 2" ) await pageManager .onFormLayoutsPage() .submitInLineFormWithNameEmailAndCheckbox(randomFullName, randomEmail, true) })
Project Setup and Teardown
1. Create tests\newArticle.setup.ts
to create a new article via API
import { expect, test as setup } from "@playwright/test" setup("create new article", async ({ request }) => { const articleResponse = await request.post( "https://conduit-api.bondaracademy.com/api/articles/", { data: { article: { title: "Likes Test Article", description: "Test description", body: "Test body", tagList: [], }, }, } ) expect(articleResponse.status()).toEqual(201) const response = await articleResponse.json() const slugId = response.article.slug process.env["SLUGID"] = slugId })
2. Create tests\likesCounter.spec.ts
to test the like counter
... test("Like counter increase", async ({ page }) => { await page.goto("https://conduit.bondaracademy.com/") await page.getByText("Global Feed").click() const firstLikeButton = page .locator("app-article-preview") .first() .locator("button") await expect(firstLikeButton).toContainText("0") await firstLikeButton.click() await expect(firstLikeButton).toContainText("1") })
3. Create tests\articleCleanUp.setup.ts
to clean up (ie. delete) the article created via API
... setup("delete article", async ({ request }) => { // Clean up // delete the article using the slug extracted earlier const articleDeleteResponse = await request.delete( `https://conduit-api.bondaracademy.com/api/articles/${process.env.SLUGID}` ) expect(articleDeleteResponse.status()).toEqual(204) })
4. Update playwright.config.ts
to create new projects
- articleSetup (with dependency on setup to fetch auth token, and using teardown)
- likeCounter (with dependency on articleSetup)
- articleCleanUp
... export default defineConfig({ ... projects: [ { name: "setup", testMatch: "auth.setup.ts" }, { name: "articleSetup", testMatch: "newArticle.setup.ts", dependencies: ["setup"], teardown: "articleCleanUp", }, { name: "articleCleanUp", testMatch: "articleCleanUp.setup.ts", }, { name: "likeCounter", testMatch: "likesCounter.spec.ts", use: { ...devices["Desktop Chrome"], storageState: ".auth/user.json" }, dependencies: ["articleSetup"], }, ], })
Global Setup and Teardown
1. Create file global-setup.ts
with more or less same code as file tests\newArticle.setup.ts
import { expect, request } from "@playwright/test" import user from "./.auth/user.json" import fs from "fs" async function globalSetup() { const authfile = ".auth/user.json" const context = await request.newContext() const responseToken = await context.post(... ) const responseBody = await responseToken.json() const accessToken = responseBody.user.token user.origins[0].localStorage[0].value = accessToken fs.writeFileSync(authfile, JSON.stringify(user)) process.env["ACCESS_TOKEN"] = accessToken const articleResponse = await context.post(...) expect(articleResponse.status()).toEqual(201) const articleResponseBody = await articleResponse.json() const slugId = articleResponseBody.article.slug process.env["SLUGID"] = slugId } export default globalSetup
2. Create file global-teardown.ts
import { request, expect } from "@playwright/test" async function globalTeardown() { const context = await request.newContext() // Clean up // delete the article using the slug extracted earlier const articleDeleteResponse = await context.delete( `https://conduit-api.bondaracademy.com/api/articles/${process.env.SLUGID}`, { headers: { Authorization: `Token ${process.env.ACCESS_TOKEN}` }, } ) expect(articleDeleteResponse.status()).toEqual(204) } export default globalTeardown
3. Create file tests\likesCounterGlobal.spec.ts
import { test, expect, request } from "@playwright/test" test("Like counter increase", async ({ page }) => { await page.goto("https://conduit.bondaracademy.com/") await page.getByText("Global Feed").click() const firstLikeButton = page .locator("app-article-preview") .first() .locator("button") await expect(firstLikeButton).toContainText("0") await firstLikeButton.click() await expect(firstLikeButton).toContainText("1") })
4. Update file playwright.config.ts
export default defineConfig({ ... globalSetup: require.resolve("./global-setup.ts"), globalTeardown: require.resolve("./global-teardown.ts"), projects: [ ... { name: "likeCounterGlobal", testMatch: "likesCounterGlobal.spec.ts", use: { ...devices["Desktop Chrome"], storageState: ".auth/user.json" }, }, ], })
Test Tags
test("navigate to all pages @smoke @regression", async ({ page }) => { ... test("parameterised methods @smoke", async ({ page }) => { ... test.describe("Forms Layouts page @block", () => { test.describe.configure({ retries: 2 }) test("input fields", async ({ page }) => { ...
npx playwright test --project=chromium --grep @smoke npx playwright test --project=chromium --grep @regression npx playwright test --project=chromium --grep @block npx playwright test --project=chromium --grep "@block|@smoke"
Mobile Device Emulator
1. Create new file tests/testMobile.spec.ts
import { test } from "@playwright/test" test("input fields", async ({ page }, testInfo) => { await page.goto("/") const isMobile = testInfo.project.name === 'mobile' isMobile && await page.locator('.sidebar-toggle').click() // to show the sidebar which is hidden for mobile devices await page.getByText("Forms").click() await page.getByText("Form Layouts").click() isMobile && await page.locator('.sidebar-toggle').click() const usingTheGridEmailInput = page .locator("nb-card", { hasText: "Using the Grid" }) .getByRole("textbox", { name: "Email" }) await usingTheGridEmailInput.fill("test@test.com") await usingTheGridEmailInput.clear() await usingTheGridEmailInput.fill("test2@test.com") })
2. Update playwright.config.ts
export default defineConfig<TestOptions>({ ... projects: [ ... { name: "mobile", testMatch: "testMobile.spec.ts", use: { ...devices["iPhone 13 Pro"], // ..OR.. // viewport: { width: 414, height: 800 }, }, }, ], })
Reporters
Configure reporters in playwright.config.ts
as per
... export default defineConfig<TestOptions>({ ... reporter: [ ["json", { outputFile: "test-results/jsonReport.json" }], ["junit", { outputFile: "test-results/junitReport.xml" }], ['allure-playwright'] ], ...
Allure
Setup for windows
- java download (FYI: I did not need to set JAVA_HOME)
- Scoop
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
scoop install allure
npm i -D @playwright/test allure-playwright --force
Now you can run some tests followed by
allure generate allure-results -o allure-report --clean allure open allure-report
Don't forget to add allure-results
and allure-report
to .gitignore
Allure looks cool but also looks like a bit of a learning curve ...
PS. it seems pretty straight forward to create your own custom reporter as per these instructions Playwright class reporter
Visual Testing
This is so cool! Simply call await expect(locator).toHaveScreenshot(...)
on an element. The first run through creates screenshot(s) inside a subfolder from tests
folder. Second run compares current actual with previously expected. If there's any significant differences they are saved to subfolders in the test-results
folder, and the html reporter has a very nice way to see and compare snapshots.
You can control settings in the code
await expect(locator).toHaveScreenshot({maxDiffPixels:150, maxDiffPixelRatio: 0.01})
as well as in file playwright.config.ts
export default defineConfig<TestOptions>({ expect: { ... toMatchSnapshot: { maxDiffPixels: 50 }, }, ...
If you need to update a lot of snapshots use
npx playwright test --update-snapshots
I would like to try doing visual testing of small parts on the screen, and then test the integration of those parts but masking the smaller parts so that the larger integration test only checks that the sub parts are there, but not test the sub part internals. The mask option of .toHaveScreenshot({mask: [maskedElement1,maskedElement2]})
can take an array of locators. However, this would apply the same colour to all of them, might try using CSS through .toHaveScreenshot({ stylePath: path.join(__dirname, 'screenshot.css') })
to render the elements with a solid block with different colours.
To do this I need to identify the child elements and set their visibility: hidden;
and then the element to have background-color: #909090;
ngx-form-layouts > div.row:nth-child(2) > div.col-md-6:nth-child(2) > nb-card:nth-child(1) > nb-card-body { visibility:hidden; } ngx-form-layouts > div.row:nth-child(2) > div.col-md-6:nth-child(2) > nb-card:nth-child(1) { background-color: #909090; }
for the course's test web app.
Could consider assigning colours programmatically at run time...
Playwright with Docker Container
- Make sure Docker Desktop is installed
docker
file allows you to build a docker image
FROM mcr.microsoft.com/playwright:v1.44.1-jammy RUN mkdir /app WORKDIR /app COPY . /app/ RUN npm install --force RUN npx playwright install
Commands
docker build -t pw-pageobject-test . docker images docker run -it pw-pageobject-test
The last command starts the container and you can execute commands such as
npm run pageObjects-chrome
compose.yaml
file allows you to build and execute an image as well as obtain files/folder from a running instance
services: playwright-test: image: playwright-test build: context: . dockerfile: ./dockerfile command: npm run pageObjects-chrome volumes: - ./playwright-report/:/app/playwright-report - ./test-results/:/app/test-results
Commands
docker-compose up --build docker-compose up
Will run (& build) the image in the container, execute the command, and at the end copy the generated files back to the host computer.
Github Actions and Argos CI
GitHub Actions
- Link to instructions on Playwright
- Create file
.github\workflows\playwright.yml
copy from instructions above, then some small edits
name: Playwright Tests on: push: branches: [ main, master ] pull_request: branches: [ main, master ] jobs: test: timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - name: Install dependencies run: npm ci --force - name: Install Playwright Browsers run: npx playwright install --with-deps --force - name: Run Playwright tests run: npm run pageObjects-chrome - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: name: playwright-report path: playwright-report/ retention-days: 30
- update node version, add --force x2, change final run command
run: npm run pageObjects-chrome
- commit & push to GitHub
- see actions tab for progress and to see test artifacts saved
argos CI
- Go to argos-CI to create account, best to select to continue with GitHub. (selecting Hobby - I'm working on personal projects)
- Create a new project, and select the repo to integrate
- Go to Playwright Quickstart for instructions
- install
npm i --save-dev @argos-ci/playwright --force
- Setup Argos in your Playwright config (no need to provide token value when integrating through GitHub)
- Take screenshots
- install
eg. In file tests\usePageObjects.spec.ts
... import { argosScreenshot } from "@argos-ci/playwright" ... test.only("Testing with argos CI", async ({ page }) => { const pm = new PageManager(page) await pm.navigateTo().formLayoutsPage() await argosScreenshot(page,"form layouts page") await pm.navigateTo().datePickerPage() await argosScreenshot(page,"date picker page") })
- commit & push to GitHub
- Verify the GitHub action has been processed
- Verify the reference build in argo-ci
- Implement some "design changes" to the web app being tested
- Create new branch, make (visual) changes, commit, push to GitHub, create PR to master
- This will trigger GitHub action and argos-CI will detect the visual changes
- verify the argos-CI verification step has failed on GitHub
- review the visual changes in argos-CI, approve them
- pull request can now be merged
Note: checked the documentation for argos-CI and you can also mask and apply CSS prior to taking the screenshot.