├── .env ├── app-structure ├── components │ ├── common-components │ │ ├── button │ │ │ └── Button.ts │ │ ├── link │ │ │ └── Link.ts │ │ ├── input │ │ │ └── Input.ts │ │ └── title │ │ │ └── Title.ts │ ├── books │ │ ├── Book.ts │ │ ├── BookLink.ts │ │ └── BooksList.ts │ ├── login │ │ └── LoginForm.ts │ └── BaseComponent.ts └── pages │ ├── login │ └── LoginPage.ts │ ├── BasePage.ts │ └── books │ ├── BooksPage.ts │ └── BookPage.ts ├── utils ├── utils.ts └── fixtures │ ├── custom-fixtures.ts │ ├── App.ts │ └── Mock.ts ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── package.json ├── tests ├── global.setup.ts ├── api-tests.spec.ts ├── example.spec.ts └── page-object.spec.ts ├── mocks └── book-mocks.ts ├── .github └── workflows │ └── playwright.yml ├── playwright.config.ts └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | LOGIN='Playwright Demo' 2 | PASSWORD=ThePassword@123 3 | BASE_URL=https://demoqa.com/ -------------------------------------------------------------------------------- /app-structure/components/common-components/button/Button.ts: -------------------------------------------------------------------------------- 1 | import {BaseComponent} from "../../BaseComponent"; 2 | 3 | export class Button extends BaseComponent { 4 | } -------------------------------------------------------------------------------- /app-structure/components/common-components/link/Link.ts: -------------------------------------------------------------------------------- 1 | import {BaseComponent} from "../../BaseComponent"; 2 | 3 | export class Link extends BaseComponent { 4 | 5 | } -------------------------------------------------------------------------------- /utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function splitStringByCapitalLetters(stringWithCapitalLetters: string) { 2 | return stringWithCapitalLetters.split(/(?=[A-Z])/).join(' ') 3 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /test-results/ 2 | /playwright-report/ 3 | /allure-report/ 4 | /allure-results/ 5 | /blob-report/ 6 | /playwright/.cache/ 7 | .idea/ 8 | .auth/ 9 | *.zip 10 | .DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /test-results/ 3 | /playwright-report/ 4 | /allure-report/ 5 | /allure-results/ 6 | /blob-report/ 7 | /playwright/.cache/ 8 | .idea/ 9 | .auth/ 10 | *.zip 11 | .DS_Store -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/playwright:v1.40.0-jammy 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | RUN npm ci 8 | RUN npx playwright install --with-deps 9 | ENV CI=true 10 | 11 | CMD ["npm", "test", "&&", "cp", "-r", "allure-results", "/app/allure-results"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Sample of Playwright tests with: 2 | 3 | 1. Page Object Model 4 | 2. Allure report 5 | 3. Github Actions pipeline 6 | 4. Runs in Docker 7 | 5. Report automatically publishes on Github Actions 8 | 9 | On NodeJS + TypeScript 10 | 11 | Last report can be checked at: https://stanislavivanovqa.github.io/playwright_sample/ 12 | -------------------------------------------------------------------------------- /app-structure/components/common-components/input/Input.ts: -------------------------------------------------------------------------------- 1 | import {BaseComponent} from "../../BaseComponent"; 2 | import {test} from "@playwright/test"; 3 | 4 | export class Input extends BaseComponent { 5 | public async enterText(text: string) { 6 | await test.step(`Entering text ${text} to ${this.name}`, async () => { 7 | await this.locator.fill(text) 8 | }) 9 | } 10 | } -------------------------------------------------------------------------------- /app-structure/components/common-components/title/Title.ts: -------------------------------------------------------------------------------- 1 | import {BaseComponent} from "../../BaseComponent"; 2 | import {expect} from "@playwright/test"; 3 | import {test} from "../../../../utils/fixtures/custom-fixtures"; 4 | 5 | export class Title extends BaseComponent { 6 | public async shouldBe(expectedTitle: string) { 7 | await test.step(`Checking that ${this.name} text eqals: ${expectedTitle}`, async () => { 8 | await this.shouldHaveText(expectedTitle) 9 | }) 10 | } 11 | } -------------------------------------------------------------------------------- /utils/fixtures/custom-fixtures.ts: -------------------------------------------------------------------------------- 1 | import {test as base} from "@playwright/test"; 2 | import {App} from "./App"; 3 | import {Mock} from "./Mock"; 4 | import {mock} from "node:test"; 5 | 6 | type CustomFixtures = { 7 | app: App 8 | mock: Mock 9 | } 10 | 11 | export const test = base.extend({ 12 | app: async ({page}, use) => { 13 | await use(new App(page)) 14 | }, 15 | 16 | mock: async ({page}, use) => { 17 | await use(new Mock(page)) 18 | } 19 | }); 20 | 21 | export {expect} from '@playwright/test'; -------------------------------------------------------------------------------- /app-structure/pages/login/LoginPage.ts: -------------------------------------------------------------------------------- 1 | import {BasePage} from "../BasePage"; 2 | import {expect, Page} from "@playwright/test"; 3 | import {Button} from "../../components/common-components/button/Button"; 4 | import {Input} from "../../components/common-components/input/Input"; 5 | import {LoginForm} from "../../components/login/LoginForm"; 6 | 7 | export class LoginPage extends BasePage { 8 | public loginForm = new LoginForm({ 9 | locator: this.page.locator('#userForm') 10 | }) 11 | 12 | constructor(page: Page) { 13 | super({name: 'Login Page', url: 'login', page}); 14 | } 15 | } -------------------------------------------------------------------------------- /utils/fixtures/App.ts: -------------------------------------------------------------------------------- 1 | import {Page} from "@playwright/test"; 2 | import {LoginPage} from "../../app-structure/pages/login/LoginPage"; 3 | import {BooksPage} from "../../app-structure/pages/books/BooksPage"; 4 | 5 | export class App { 6 | constructor(protected page: Page) { 7 | } 8 | 9 | public loginPage = new LoginPage(this.page) 10 | public booksPage = new BooksPage(this.page) 11 | 12 | /** 13 | * @deprecated 14 | * for debug purpose only 15 | * don't forget to delete all executions before commit 16 | */ 17 | public async pause() { 18 | await this.page.pause() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pw_demo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "allure:generate": "npx allure generate ./allure-results --clean", 8 | "allure:serve": "npx allure serve", 9 | "test": "npx playwright test && npm run allure:generate" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "@playwright/test": "^1.40.1", 16 | "@types/node": "^20.11.0", 17 | "allure-commandline": "^2.25.0" 18 | }, 19 | "dependencies": { 20 | "allure-playwright": "^2.10.0", 21 | "dotenv": "^16.3.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /utils/fixtures/Mock.ts: -------------------------------------------------------------------------------- 1 | import {Page} from "@playwright/test"; 2 | 3 | export class Mock { 4 | constructor(private page: Page) { 5 | } 6 | 7 | public async route(url: string, body: Record) { 8 | await this.page.route(url, async (route, request) => { 9 | // Can be filtered, for example by method like below: 10 | // 11 | // if (request.method() === 'POST') { 12 | // await route.continue() 13 | // return 14 | // } 15 | await route.fulfill({ 16 | json: body, 17 | status: 200 18 | }) 19 | }) 20 | } 21 | } -------------------------------------------------------------------------------- /app-structure/pages/BasePage.ts: -------------------------------------------------------------------------------- 1 | import {Page} from "@playwright/test"; 2 | import {test} from "../../utils/fixtures/custom-fixtures"; 3 | 4 | export type PageObjectProps = { 5 | name: string, 6 | url: string, 7 | page: Page, 8 | } 9 | 10 | export abstract class BasePage { 11 | protected name: string; 12 | protected url: string; 13 | protected page: Page; 14 | 15 | protected constructor({name, url, page}: PageObjectProps 16 | ) { 17 | this.name = name 18 | this.url = url 19 | this.page = page 20 | } 21 | 22 | public async visit() { 23 | await test.step(`Going to ${this.name} by URL: ${this.url}`, async () => { 24 | await this.page.goto(this.url, {waitUntil: 'domcontentloaded'}) 25 | }) 26 | } 27 | } -------------------------------------------------------------------------------- /app-structure/pages/books/BooksPage.ts: -------------------------------------------------------------------------------- 1 | import {BasePage} from "../BasePage"; 2 | import {expect, Page} from "@playwright/test"; 3 | import {Title} from "../../components/common-components/title/Title"; 4 | import {BooksList} from "../../components/books/BooksList"; 5 | 6 | export class BooksPage extends BasePage { 7 | public userNameTitle = new Title({ 8 | name: 'User Name Title', 9 | locator: this.page.locator('#userName-value') 10 | }) 11 | public headerTitle = new Title({ 12 | name: 'Header Title', 13 | locator: this.page.locator('.main-header') 14 | }) 15 | public books = new BooksList({ 16 | locator: this.page.locator('//div[@role="gridcell"]//a[@href]/ancestor::div[@class="rt-tr-group"]') 17 | }) 18 | 19 | constructor(page: Page) { 20 | super({name: 'Books Page', url: 'books', page}); 21 | } 22 | } -------------------------------------------------------------------------------- /app-structure/pages/books/BookPage.ts: -------------------------------------------------------------------------------- 1 | import {BasePage} from "../BasePage"; 2 | import {Page} from "@playwright/test"; 3 | import {Title} from "../../components/common-components/title/Title"; 4 | 5 | export class BookPage extends BasePage { 6 | public isbn = new Title({ 7 | name: 'ISBN Title', 8 | locator: this.page.locator('#ISBN-wrapper #userName-value') 9 | }) 10 | public bookTitle = new Title({ 11 | name: 'Book Title', 12 | locator: this.page.locator('#title-wrapper #userName-value') 13 | }) 14 | public description = new Title({ 15 | name: 'Description', 16 | locator: this.page.locator('#description-wrapper #userName-value') 17 | }) 18 | 19 | constructor(page: Page, bookId: number) { 20 | super({ 21 | name: 'Concrete Book Page', 22 | url: `books?book=${bookId}`, 23 | page: page, 24 | }); 25 | } 26 | } -------------------------------------------------------------------------------- /app-structure/components/books/Book.ts: -------------------------------------------------------------------------------- 1 | import {BaseComponent} from "../BaseComponent"; 2 | import {BookLink} from "./BookLink"; 3 | import {Title} from "../common-components/title/Title"; 4 | 5 | export type BookContent = { 6 | ISBN: number, 7 | Title: string, 8 | SubTitle: string, 9 | Author: string, 10 | Publisher: string, 11 | TotalPages: number, 12 | Description: string, 13 | Website: string, 14 | } 15 | 16 | export class Book extends BaseComponent { 17 | public link = new BookLink({ 18 | name: 'Book Link', 19 | locator: this.locator.getByRole('link') 20 | }) 21 | public title = new Title({ 22 | name: 'Book Link', 23 | locator: this.locator.getByRole('link') 24 | }) 25 | public author = new Title({ 26 | name: 'Book Author Title', 27 | locator: this.locator.locator('//div[3]') 28 | }) 29 | public publisher = new Title({ 30 | name: 'Book Publisher Title', 31 | locator: this.locator.locator('//div[4]') 32 | }) 33 | } -------------------------------------------------------------------------------- /tests/global.setup.ts: -------------------------------------------------------------------------------- 1 | import {expect, test as setup} from '@playwright/test'; 2 | import {STORAGE_STATE} from "../playwright.config"; 3 | 4 | // Example of how login can be performed before all tests 5 | // (Should be in separate project and then included in dependencies to another projects) 6 | 7 | setup.skip('Setup', async ({page}) => { 8 | const login = 'Playwright Demo' 9 | const password = 'ThePassword@123' 10 | 11 | const loginInput = page.locator('#userName') 12 | const passwordInput = page.locator('#password') 13 | const loginButton = page.locator('#login') 14 | const userNameTitle = page.locator('#userName-value') 15 | 16 | await page.goto('login', { 17 | waitUntil: 'domcontentloaded' 18 | }) 19 | await loginInput.fill(login) 20 | await passwordInput.fill(password) 21 | await loginButton.click() 22 | 23 | 24 | await expect(userNameTitle).toBeVisible() 25 | await expect(userNameTitle).toHaveText(login) 26 | 27 | await page.context().storageState({ path: STORAGE_STATE }); 28 | }) -------------------------------------------------------------------------------- /app-structure/components/books/BookLink.ts: -------------------------------------------------------------------------------- 1 | import {Link} from "../common-components/link/Link"; 2 | import {BookPage} from "../../pages/books/BookPage"; 3 | import {test} from "../../../utils/fixtures/custom-fixtures"; 4 | 5 | export class BookLink extends Link { 6 | public async openInNewTab(): Promise { 7 | const bookId = await this.getBookId() 8 | 9 | return test.step(`Opening Book with id ${bookId} on new page by clicking middle mouse button`, 10 | async () => { 11 | const newPagePromise = this.locator.page().context().waitForEvent('page') 12 | await this.click({button: 'middle'}) 13 | const newPage = await newPagePromise 14 | 15 | return new BookPage(newPage, bookId) 16 | }) 17 | } 18 | 19 | // private 20 | 21 | private async getBookId(): Promise { 22 | return test.step(`Retrieving book id`, async () => { 23 | const bookLink = await this.locator.getAttribute('href') 24 | const bookIdString = bookLink?.split('=')[1] 25 | 26 | if (!bookIdString) throw new Error('href attribute value is not valid') 27 | 28 | return +bookIdString 29 | }) 30 | } 31 | } -------------------------------------------------------------------------------- /mocks/book-mocks.ts: -------------------------------------------------------------------------------- 1 | export const GET_BOOKS = 'BookStore/v1/Books' 2 | 3 | export type BookListMockProps = { 4 | books: BookMock[] 5 | } 6 | 7 | export type BookMock = { 8 | isbn: string 9 | title: string 10 | subTitle: string 11 | author: string 12 | publish_date: string 13 | publisher: string 14 | pages: number 15 | description: string 16 | website: string 17 | } 18 | 19 | export const ONE_BOOK_MOCK: BookListMockProps = { 20 | books: [{ 21 | isbn: '9781449325862', 22 | title: 'The Darkness That Comes Before', 23 | subTitle: 'The Prince of Nothing, Book One', 24 | publish_date: "2008-09-02T08:48:39.000Z", 25 | author: 'Scott Bakker', 26 | publisher: 'Orbit', 27 | pages: 350, 28 | description: 'The Darkness That Comes Before is the first book in R. Scott Bakker’s epic fantasy trilogy The Prince of Nothing. Set in a world scarred by an apocalyptic past, four people are swept up in the launch of an imminent crusade, during which they are ensnared by mysterious traveler Anasûrimbor Kellhus, whose magical, philosophical, and military talents have origins in a distant time.', 29 | website: 'https://www.amazon.com/Darkness-that-Comes-Before-Nothing/dp/1590201183' 30 | }] 31 | } -------------------------------------------------------------------------------- /app-structure/components/books/BooksList.ts: -------------------------------------------------------------------------------- 1 | import {BaseComponent} from "../BaseComponent"; 2 | import {expect} from "@playwright/test"; 3 | import {Book} from "./Book"; 4 | import {test} from "../../../utils/fixtures/custom-fixtures"; 5 | 6 | export class BooksList extends BaseComponent { 7 | public async shouldHaveCount(expectedCountOfBooks: number): Promise { 8 | await test.step(`Checking that book list have count of books equal ${expectedCountOfBooks}`, 9 | async () => { 10 | await expect(this.locator).toHaveCount(expectedCountOfBooks) 11 | }) 12 | } 13 | 14 | public async bookByNumber(bookNumber: number): Promise { 15 | return test.step(`Searching the book in list by number ${bookNumber}`, () => { 16 | return new Book({ 17 | locator: this 18 | .locator 19 | .locator('//div[@role="row"]', { 20 | has: this.locator.getByRole('link') 21 | }).nth(bookNumber - 1) 22 | }) 23 | }) 24 | } 25 | 26 | public bookWithTitle(bookTitle: string): Book { 27 | return new Book({ 28 | locator: this 29 | .locator 30 | .locator('//div[@role="row"]', { 31 | has: this.locator.getByRole('link', {name: bookTitle}) 32 | }) 33 | }) 34 | } 35 | } -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | 3 | on: 4 | push: 5 | branches: [main, master] 6 | pull_request: 7 | branches: [main, master] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v3 18 | 19 | - name: Build Docker image 20 | run: docker build -t pw-tests-image . 21 | 22 | - name: Run Playwright tests inside Docker container 23 | run: docker run --rm -v "${{ github.workspace }}/allure-results:/app/allure-results" pw-tests-image 24 | 25 | - uses: actions/upload-artifact@master 26 | with: 27 | name: allure-results 28 | path: allure-results 29 | retention-days: 1 30 | 31 | - name: Get Allure history 32 | uses: actions/checkout@v3 33 | if: always() 34 | continue-on-error: true 35 | with: 36 | ref: gh-pages 37 | path: gh-pages 38 | 39 | - name: Generate allure 40 | uses: simple-elf/allure-report-action@master 41 | if: always() 42 | id: allure-report 43 | with: 44 | allure_results: allure-results 45 | gh_pages: gh-pages 46 | allure_report: allure-report 47 | allure_history: allure-history 48 | keep_reports: 1 49 | 50 | - name: Deploy report to Github Pages 51 | if: always() 52 | uses: peaceiris/actions-gh-pages@v3 53 | with: 54 | github_token: ${{ secrets.GITHUB_TOKEN }} 55 | publish_dir: allure-history 56 | publish_branch: gh-pages 57 | -------------------------------------------------------------------------------- /app-structure/components/login/LoginForm.ts: -------------------------------------------------------------------------------- 1 | import {BaseComponent} from "../BaseComponent"; 2 | import {Input} from "../common-components/input/Input"; 3 | import {Button} from "../common-components/button/Button"; 4 | import {test} from "../../../utils/fixtures/custom-fixtures"; 5 | 6 | export class LoginForm extends BaseComponent { 7 | private loginInput = new Input({ 8 | name: 'LoginPage Input', 9 | locator: this.locator.locator('#userName') 10 | }) 11 | private passwordInput = new Input({ 12 | name: 'Password Input', 13 | locator: this.locator.locator('#password') 14 | }) 15 | private loginButton = new Button({ 16 | name: 'LoginPage Button', 17 | locator: this.locator.locator('#login') 18 | }) 19 | 20 | public async loginWithDefaultCredentials() { 21 | const login = process.env.LOGIN 22 | const password = process.env.PASSWORD 23 | 24 | if (!login || !password) throw new Error( 25 | `login (${login}) or password (${password}) was not provided from env file` 26 | ) 27 | 28 | await test.step(`Logging in with default credentials from env file`, async () => { 29 | await this.loginWithCredentials(login, password) 30 | }) 31 | } 32 | 33 | public async loginWithCredentials(login: string, password: string) { 34 | await test.step(` 35 | Logging in with login: ${login} 36 | and password: ${password}`, 37 | async () => { 38 | await this.loginInput.enterText(login) 39 | await this.passwordInput.enterText(password) 40 | await this.loginButton.click() 41 | }) 42 | } 43 | } -------------------------------------------------------------------------------- /tests/api-tests.spec.ts: -------------------------------------------------------------------------------- 1 | import {test, expect} from "../utils/fixtures/custom-fixtures"; 2 | 3 | export type Person = { 4 | name: string 5 | height: string 6 | mass: string 7 | hair_color: string 8 | skin_color: string 9 | eye_color: string 10 | birth_year: string 11 | gender: string 12 | homeworld: string 13 | films: string[] 14 | species: string[] 15 | vehicles: string[] 16 | starships: string[] 17 | created: string 18 | edited: string 19 | url: string 20 | } 21 | 22 | export type Planet = { 23 | name: string 24 | rotation_period: string 25 | orbital_period: string 26 | diameter: string 27 | climate: string 28 | gravity: string 29 | terrain: string 30 | surface_water: string 31 | population: string 32 | residents: string[] 33 | films: string[] 34 | created: string 35 | edited: string 36 | url: string 37 | } 38 | 39 | // Very simple and raw example of api tests with Playwright: 40 | // just doing a request and validating some random fields 41 | 42 | test.describe('Simple example API tests on PW', () => { 43 | // Redefining base URL 44 | test.use({baseURL: 'https://swapi.dev/api/'}) 45 | 46 | test('Luke Skywalker Test', async ({request}) => { 47 | const response = await request.get('people/1/') 48 | 49 | // type should be checked in proper way, for example with Zod 50 | // https://zod.dev/ 51 | const person = (await response.json()) as unknown as Person 52 | 53 | expect(person.name).toEqual("Luke Skywalker") 54 | expect(person.height).toEqual("172") 55 | }) 56 | 57 | test('Tatooine test', async ({request}) => { 58 | const response = await request.get('planets/1/') 59 | const planet = (await response.json()) as unknown as Planet 60 | 61 | expect(planet.name).toEqual("Tatooine") 62 | expect(planet.population).toEqual("200000") 63 | }) 64 | }) -------------------------------------------------------------------------------- /tests/example.spec.ts: -------------------------------------------------------------------------------- 1 | import {test, expect} from '@playwright/test'; 2 | 3 | test.describe.skip('Example of tests without Page Object', () => { 4 | test('Have correct title', async ({page}) => { 5 | await page.goto('books', { 6 | waitUntil: 'domcontentloaded' 7 | }) 8 | 9 | const headerElement = page.locator('.main-header') 10 | 11 | await expect(headerElement).toHaveText('BookPage Store') 12 | }) 13 | 14 | test('BookPage count test', async ({page}) => { 15 | const bookNames = [ 16 | 'Git Pocket Guide', 17 | 'Learning JavaScript Design Patterns', 18 | 'Designing Evolvable Web APIs with ASP.NET', 19 | 'Speaking JavaScript', 20 | "You Don't Know JS", 21 | 'Programming JavaScript Applications', 22 | 'Eloquent JavaScript, Second Edition', 23 | 'Understanding ECMAScript 6', 24 | ] 25 | 26 | await page.goto('books', { 27 | waitUntil: 'domcontentloaded' 28 | }) 29 | 30 | const books = page.getByRole('gridcell').getByRole('link') 31 | await expect(books).toHaveCount(8) 32 | 33 | for (const [index, book] of (await books.all()).entries()) { 34 | await expect(book).toHaveText(bookNames[index]) 35 | console.log(`Element have an expected name: ${bookNames[index]}`) 36 | } 37 | }) 38 | 39 | test('BooksPage test', async ({page}) => { 40 | const login = 'Playwright Demo' 41 | const password = 'ThePassword@123' 42 | 43 | const loginInput = page.locator('#userName') 44 | const passwordInput = page.locator('#password') 45 | const loginButton = page.locator('#login') 46 | const userNameTitle = page.locator('#userName-value') 47 | 48 | await page.goto('login', { 49 | waitUntil: 'domcontentloaded' 50 | }) 51 | await loginInput.fill(login) 52 | await passwordInput.fill(password) 53 | await loginButton.click() 54 | 55 | await expect(userNameTitle).toBeVisible() 56 | await expect(userNameTitle).toHaveText(login) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /app-structure/components/BaseComponent.ts: -------------------------------------------------------------------------------- 1 | import {expect, Locator} from "@playwright/test"; 2 | import {test} from "../../utils/fixtures/custom-fixtures"; 3 | import {splitStringByCapitalLetters} from "../../utils/utils"; 4 | 5 | export type ComponentProps = { 6 | name?: string 7 | locator: Locator 8 | } 9 | 10 | export abstract class BaseComponent { 11 | protected name: string; 12 | protected locator: Locator; 13 | 14 | public constructor({name, locator}: ComponentProps) { 15 | this.name = name ? name : this.convertComponentNameToString() 16 | this.locator = locator 17 | } 18 | 19 | // actions 20 | 21 | public async click(...[args]: Parameters) { 22 | await test.step(`Clicking on ${this.name} with ${args?.button ?? 'left'} mouse button`, 23 | async () => { 24 | await this.locator.click(args) 25 | }) 26 | } 27 | 28 | // assertions 29 | 30 | public async shouldBeVisible() { 31 | await test.step(`Checking that ${this.name} is visible`, async () => { 32 | await expect(this.locator).toBeVisible() 33 | }) 34 | } 35 | 36 | public async shouldHaveText(expectedText: number): Promise; 37 | public async shouldHaveText(expectedText: string): Promise; 38 | public async shouldHaveText(expectedTextOrNumber: string | number): Promise { 39 | await test.step(`Checking that ${this.name} have text: ${expectedTextOrNumber}`, 40 | async () => { 41 | let expectedText 42 | switch (typeof expectedTextOrNumber) { 43 | case 'number': 44 | expectedText = expectedTextOrNumber.toString() 45 | break 46 | case "string": 47 | expectedText = expectedTextOrNumber 48 | break 49 | default: 50 | throw new Error(`Invalid arg: ${expectedTextOrNumber}`) 51 | } 52 | 53 | await expect(this.locator).toHaveText(expectedText) 54 | }) 55 | } 56 | 57 | // private 58 | 59 | private convertComponentNameToString() { 60 | return splitStringByCapitalLetters(this.constructor.name) 61 | } 62 | } -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig, devices} from '@playwright/test'; 2 | import * as path from "node:path"; 3 | import dotenv from 'dotenv'; 4 | 5 | export const STORAGE_STATE = path.join(__dirname, '/.auth/user.json'); 6 | 7 | dotenv.config(); 8 | export default defineConfig({ 9 | testDir: './tests', 10 | /* Run tests in files in parallel */ 11 | fullyParallel: true, 12 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 13 | forbidOnly: !!process.env.CI, 14 | /* Retry on CI only */ 15 | retries: process.env.CI ? 2 : 0, 16 | /* Opt out of parallel tests on CI. */ 17 | workers: process.env.CI ? 5 : undefined, 18 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 19 | reporter: [['html', {open: 'never'}], ["allure-playwright"], ["line"]], 20 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 21 | use: { 22 | baseURL: process.env.BASE_URL, 23 | screenshot: 'only-on-failure', 24 | video: 'on-first-retry', 25 | trace: 'on-first-retry', 26 | }, 27 | 28 | /* Configure projects for major browsers */ 29 | projects: [ 30 | { 31 | name: 'setup', 32 | testMatch: /global\.setup\.ts/, 33 | }, 34 | { 35 | name: 'chromium', 36 | use: { 37 | ...devices['Desktop Chrome'], 38 | viewport: {width: 1600, height: 900}, 39 | // headless: false, 40 | // storageState: STORAGE_STATE 41 | }, 42 | // dependencies: ['setup'] 43 | }, 44 | 45 | // { 46 | // name: 'firefox', 47 | // use: { ...devices['Desktop Firefox'] }, 48 | // }, 49 | // 50 | // { 51 | // name: 'webkit', 52 | // use: { ...devices['Desktop Safari'] }, 53 | // }, 54 | 55 | /* Test against mobile viewports. */ 56 | // { 57 | // name: 'Mobile Chrome', 58 | // use: { ...devices['Pixel 5'] }, 59 | // }, 60 | // { 61 | // name: 'Mobile Safari', 62 | // use: { ...devices['iPhone 12'] }, 63 | // }, 64 | 65 | /* Test against branded browsers. */ 66 | // { 67 | // name: 'Microsoft Edge', 68 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 69 | // }, 70 | // { 71 | // name: 'Google Chrome', 72 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 73 | // }, 74 | ], 75 | 76 | /* Run your local dev server before starting the tests */ 77 | // webServer: { 78 | // command: 'npm run start', 79 | // url: 'http://127.0.0.1:3000', 80 | // reuseExistingServer: !process.env.CI, 81 | // }, 82 | }); 83 | -------------------------------------------------------------------------------- /tests/page-object.spec.ts: -------------------------------------------------------------------------------- 1 | import {test} from '../utils/fixtures/custom-fixtures'; 2 | import {BookContent} from "../app-structure/components/books/Book"; 3 | import {GET_BOOKS, ONE_BOOK_MOCK} from "../mocks/book-mocks"; 4 | 5 | const expectedBookNames = [ 6 | 'Git Pocket Guide', 7 | 'Learning JavaScript Design Patterns', 8 | 'Designing Evolvable Web APIs with ASP.NET', 9 | 'Speaking JavaScript', 10 | "You Don't Know JS", 11 | 'Programming JavaScript Applications', 12 | 'Eloquent JavaScript, Second Edition', 13 | 'Understanding ECMAScript 6', 14 | ] 15 | 16 | const expectedBook: BookContent = { 17 | ISBN: 9781449325862, 18 | Title: 'Git Pocket Guide', 19 | SubTitle: 'A Working Introduction', 20 | Author: 'Richard E. Silverman', 21 | Publisher: "O'Reilly Media", 22 | TotalPages: 234, 23 | Description: 'This pocket guide is the perfect on-the-job companion to Git, the distributed version control system. It provides a compact, readable introduction to Git for new users, as well as a reference to common commands and procedures for those of you with Git exp', 24 | Website: 'http://chimera.labs.oreilly.com/books/1230000000561/index.html' 25 | } 26 | 27 | test.describe('Example tests with Page Object Model and custom fixtures', () => { 28 | test('BooksPage test', async ({app}) => { 29 | const userName = process.env.LOGIN as string 30 | 31 | await app.loginPage.visit() 32 | await app.loginPage.loginForm.loginWithDefaultCredentials() 33 | 34 | await app.booksPage.userNameTitle.shouldBeVisible() 35 | await app.booksPage.userNameTitle.shouldHaveText(userName) 36 | }) 37 | 38 | test('Have correct title', async ({app}) => { 39 | await app.booksPage.visit() 40 | 41 | await app.booksPage.headerTitle.shouldBeVisible() 42 | await app.booksPage.headerTitle.shouldHaveText('Book Store') 43 | }) 44 | 45 | test('BookPage count test', async ({app}) => { 46 | const expectedBooksCount = expectedBookNames.length 47 | 48 | await app.booksPage.visit() 49 | 50 | await app.booksPage.books.shouldHaveCount(expectedBooksCount) 51 | }) 52 | 53 | for (const bookTitle of expectedBookNames) { 54 | test(`Checking that book with name ${bookTitle} is present on page`, async ({app}) => { 55 | await app.booksPage.visit() 56 | 57 | await app.booksPage.books.bookWithTitle(bookTitle).shouldBeVisible() 58 | }) 59 | } 60 | 61 | test('Book Content Test (Working with more than one tab)', async ({app}) => { 62 | await app.booksPage.visit() 63 | const bookToTest = await app.booksPage.books.bookByNumber(1) 64 | const bookPage = await bookToTest.link.openInNewTab() 65 | 66 | await bookPage.isbn.shouldHaveText(expectedBook.ISBN) 67 | await bookPage.bookTitle.shouldHaveText(expectedBook.Title) 68 | await bookPage.description.shouldHaveText(expectedBook.Description) 69 | }) 70 | 71 | test('Mock book list test', async ({app, mock}) => { 72 | await mock.route(GET_BOOKS, ONE_BOOK_MOCK) 73 | 74 | await app.booksPage.visit() 75 | await app.booksPage.books.shouldHaveCount(1) 76 | const testedBook = await app.booksPage.books.bookByNumber(1) 77 | 78 | await testedBook.title.shouldBe('The Darkness That Comes Before') 79 | await testedBook.author.shouldBe('Scott Bakker') 80 | await testedBook.publisher.shouldBe('Orbit') 81 | }) 82 | 83 | 84 | test('Failing test', async ({app}) => { 85 | await app.booksPage.visit() 86 | const bookToTest = await app.booksPage.books.bookByNumber(1) 87 | const bookPage = await bookToTest.link.openInNewTab() 88 | 89 | await bookPage.isbn.shouldHaveText(expectedBook.ISBN) 90 | await bookPage.bookTitle.shouldHaveText(expectedBook.Title) 91 | await bookPage.description.shouldHaveText('Fail') 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "resolveJsonModule": true, /* Enable importing .json files. */ 39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 40 | 41 | /* JavaScript Support */ 42 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 45 | 46 | /* Emit */ 47 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 52 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 53 | // "removeComments": true, /* Disable emitting comments. */ 54 | // "noEmit": true, /* Disable emitting files from a compilation. */ 55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 57 | "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 63 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 70 | 71 | /* Interop Constraints */ 72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 77 | 78 | /* Type Checking */ 79 | "strict": true, /* Enable all strict type-checking options. */ 80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 81 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 88 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 98 | 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | } 103 | } 104 | --------------------------------------------------------------------------------