Playwright Test Suite for OrangeHRM

Automate a login, create, and retrieve employee workflow using Playwright.

Introduction

30 Second Summary

Imagine clicking through the same login screen, the same form, and the same search page dozens of times a day just to check that nothing broke. That is the reality of manual software testing.

In this project, you will build a professional test automation suite using Playwright and TypeScript. Your suite will automatically open a browser, log into a live web application, create a new employee, and verify it was saved.

What You'll Build

When you run your finished test suite, a browser launches on its own, logs in as an admin, fills out an employee form, saves it, then searches for that employee to prove it exists. The whole flow takes seconds and produces a polished HTML report you can share with anyone.

By the end of this project, you'll have:

  • A complete automated test suite that opens a browser, logs in, creates an employee, and retrieves them by name without you touching a thing.
  • A professional Page Object Model architecture that keeps your locators in one place so your tests stay maintainable as the application changes.
  • An HTML test report showing pass/fail results and step-by-step execution details you can open in any browser.
  • A GitHub repository containing your entire project as a portfolio piece that demonstrates real QA automation skills.
  • Secret Mission: Add a negative search test that verifies the system correctly handles searching for an employee that does not exist.

Are there any prerequisites?

You need VS Code, Node.js, Git, and a GitHub account. Step 1 walks you through verifying each one is installed and ready.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Playwright Project

You need a working Playwright environment before you can write any tests. This step gets your tools verified, scaffolds the project, and configures it specifically for the OrangeHRM demo site.

You will use Playwright as your test framework because it bundles its own test runner, supports TypeScript out of the box, and handles browser automation with zero extra drivers.

In this step, get ready to:
  • Verify Node.js and Git are ready on your machine.
  • Initialize a Playwright TypeScript project with browser binaries.
  • Configure Playwright to target the OrangeHRM demo site with Chromium only.
Verify your tools and initialize the project

Before creating anything, you need to confirm that Node.js and Git are installed and accessible from your terminal.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open a terminal inside VS Code by clicking Terminal in the top menu bar, then New Terminal.
  • Check your Node.js version by running this command:
node --version

You should see a version number starting with v22, v24, or v26.

Not seeing a version number?

If you see an error or an older version, visit the official Node.js website at https://nodejs.org and download the latest LTS installer for your operating system.

After installing, close and reopen your terminal, then try node --version again. Help me install Node.js for Playwright

  • Confirm Git is available by running:
git --version

You should see a version number printed. Any version is fine for this project.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir orangehrm-playwright
cd orangehrm-playwright
  • Initialize Playwright by running this command:
npm init playwright@latest

What will this command ask you?

This command launches an interactive setup wizard with four prompts. Answer each one as described in the bullets below.

  • When prompted for language, select TypeScript (the default). Press Enter to confirm.
  • When prompted for the tests folder name, keep the default tests. Press Enter.
  • When asked to add a GitHub Actions workflow, type Y and press Enter.
  • When asked to install Playwright browsers, type Y and press Enter. This downloads Chromium, Firefox, and WebKit binaries.

The download may take a minute depending on your internet speed. Wait until you see your terminal prompt return.

  • Open the project folder in VS Code by running:
code .

What does this command do?

The code . command tells VS Code to open the current directory as a workspace. VS Code reloads with orangehrm-playwright as the project root, showing all scaffolded files in the Explorer sidebar.

VS Code asking if you trust the authors?

If you see a trust dialog, click Yes, I trust the authors. This is a standard VS Code security prompt for newly opened folders. Help me open my Playwright project in VS Code

Run the example test to verify the scaffold

Playwright created an example test file during initialization. Running it now proves that the scaffold, browsers, and test runner are all working correctly.

  • Open a new terminal in VS Code by clicking Terminal in the menu bar, then New Terminal.
  • Confirm the scaffolded files exist by running:
ls

You should see playwright.config.ts, package.json, and a tests folder listed.

Before you run the next command, what do you think will happen? The default scaffold is configured to run tests across three browsers.

  • Run the example test by executing this command:
npx playwright test

You should see output showing tests passing across Chromium, Firefox, and WebKit. Each line displays a green checkmark with the browser name and test title.

What should I see?

The terminal output looks something like this:

Running 6 tests using 3 workers
  6 passed (5s)

The exact number of tests and timing may vary. What matters is that all tests pass with no failures.

  • View the HTML test report by running:
npx playwright show-report

A browser tab opens showing a polished HTML report. You can see each test, its duration, and which browser ran it.

Stop the report server

The report command starts a local server that takes over your terminal. Press Ctrl+C in your terminal to stop it and get your prompt back.

Tests failing or command not found?

Make sure you are running the command from inside the orangehrm-playwright folder. Run pwd to confirm your current directory.

If the browsers did not install, run npx playwright install to download them manually. Help me fix my Playwright test run

Configure Playwright for OrangeHRM

The default config runs tests across three browsers and points nowhere specific. You need to customize it to target the OrangeHRM demo site and use only Chromium.

  • In the VS Code Explorer sidebar, click on playwright.config.ts to open it.
  • Select all the existing content by pressing Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
  • Paste the following configuration:
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'https://opensource-demo.orangehrmlive.com',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

What does this configuration do?

  • baseURL points all tests at the OrangeHRM demo site. Your tests can now use relative paths like /web/index.php/auth/login instead of the full URL every time.
  • projects is set to Chromium only. This cuts execution time by two-thirds compared to running all three browsers.
  • reporter: 'html' generates a visual HTML report after every test run.
  • trace: 'on-first-retry' captures a full trace if a test fails and retries. This helps you debug failures without re-running manually.
  • Save playwright.config.ts by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Delete the example test file. In the Explorer sidebar, right-click example.spec.ts inside the tests folder and select Delete.
  • Delete the tests-examples folder if it exists. Right-click it in the Explorer sidebar and select Delete.

Now confirm that your new configuration works. What do you think will happen when you run the tests with an empty tests folder?

  • Verify the configuration is valid by running:
npx playwright test

You should see output indicating no tests were found. The important thing is that no errors appear. This confirms your config file parses correctly and Playwright is ready for the tests you will write next.

Seeing a configuration error?

If you see a syntax error, open playwright.config.ts and check that the code matches exactly. A missing comma or bracket is the most common cause.

If tests-examples did not exist in your project, that is fine. Some versions of Playwright do not create it. Help me fix my Playwright config

✔️ Awesome, I've got everything!

Great. Confirm you have saved playwright.config.ts and that the tests folder is empty in the Explorer sidebar.

ⓧ I'd like to double check the full code

Here is the complete playwright.config.ts file you should have:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'https://opensource-demo.orangehrmlive.com',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Your tests folder should be empty with no .spec.ts files inside it. The tests-examples folder should be gone.

Your Playwright project is configured and ready. Next up, you will write your first real login tests that automate the OrangeHRM login page.

Write Your First Login Tests

Your Playwright environment is set up and configured for OrangeHRM. Now it is time to write tests that actually do something.

Login is the gateway to every other action on a web application. Writing login tests first gives you an immediate visible win: a real browser opening and logging in automatically. It also proves your setup works against the live site before you build more complex workflows.

In this step, get ready to:
  • Create tests/login.spec.ts with valid and invalid login tests.
  • Run the tests in headed mode and watch the browser automate.
Create the login test file

Playwright tests live in the tests/ folder. Each .spec.ts file groups related tests together using a test.describe block.

  • In VS Code's Explorer sidebar, right-click the tests folder and select New File.
  • Name the file login.spec.ts and press Enter.
  • Paste the following code into the file to write your valid login test:
import { test, expect } from '@playwright/test';

test.describe('Login Tests', () => {
  // Navigate to the login page before each test
  test.beforeEach(async ({ page }) => {
    await page.goto('/web/index.php/auth/login');
  });

  test('should login with valid credentials', async ({ page }) => {
    // Fill in the username and password fields
    await page.locator('input[name="username"]').fill('Admin');
    await page.locator('input[name="password"]').fill('admin123');
    // Click the login button
    await page.locator('button[type="submit"]').click();
    // Verify we landed on the dashboard
    await expect(page).toHaveURL(/dashboard/);
  });
});

What does this code do?

This file imports Playwright's test and expect functions, then groups related tests inside a test.describe block.

  • test.beforeEach navigates to the OrangeHRM login page before every test, so you do not repeat that step each time.
  • page.locator() finds elements on the page using CSS selectors. Playwright auto-waits for elements to appear before interacting.
  • expect(page).toHaveURL(/dashboard/) asserts that after login, the URL contains "dashboard". This proves the login succeeded.
  • Save login.spec.ts.
  • Run the test to verify it passes by entering this command in your terminal:
npx playwright test

You should see output showing 1 test passed in Chromium.

Test failing or timing out?

  • Make sure the file is saved and named exactly login.spec.ts inside the tests/ folder.
  • Check your internet connection. The test navigates to a live website.
  • Verify your playwright.config.ts has baseURL set to https://opensource-demo.orangehrmlive.com.

Why is my Playwright login test failing against OrangeHRM?

Now that the valid login test passes, you can add a second test that verifies what happens when someone enters wrong credentials.

  • In login.spec.ts, find the closing }); of the first test (the line that reads }); after the toHaveURL assertion).
  • Add the following invalid login test below it, before the final }); that closes the describe block:
  test('should show error with invalid credentials', async ({ page }) => {
    // Fill in valid username but wrong password
    await page.locator('input[name="username"]').fill('Admin');
    await page.locator('input[name="password"]').fill('wrongpassword');
    // Click the login button
    await page.locator('button[type="submit"]').click();
    // Verify the error message appears
    await expect(page.locator('.oxd-alert-content-text')).toContainText('Invalid credentials');
  });

What does this test verify?

This test enters a wrong password on purpose. It then checks that OrangeHRM shows an error message with the text "Invalid credentials" inside the .oxd-alert-content-text element.

Testing both the happy path (valid login) and the error path (invalid login) is a core QA practice. It proves the application handles failures correctly.

  • Save login.spec.ts.

✔️ Awesome, I've got everything!

Great. Make sure you have saved your file before moving on.

ⓧ I'd like to double check the full code

Your tests/login.spec.ts should look exactly like this:

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

test.describe('Login Tests', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/web/index.php/auth/login');
  });

  test('should login with valid credentials', async ({ page }) => {
    await page.locator('input[name="username"]').fill('Admin');
    await page.locator('input[name="password"]').fill('admin123');
    await page.locator('button[type="submit"]').click();
    await expect(page).toHaveURL(/dashboard/);
  });

  test('should show error with invalid credentials', async ({ page }) => {
    await page.locator('input[name="username"]').fill('Admin');
    await page.locator('input[name="password"]').fill('wrongpassword');
    await page.locator('button[type="submit"]').click();
    await expect(page.locator('.oxd-alert-content-text')).toContainText('Invalid credentials');
  });
});
Run the tests headed to see them in action

So far you have run tests in headless mode (no visible browser). Headed mode opens a real browser window so you can watch Playwright click through the UI.

Before you run, predict: what do you think you will see in the browser for each test?

  • Run both tests in headed mode by entering this command:
npx playwright test --headed

Watch the browser open twice. The first test logs in and lands on the dashboard. The second test enters a wrong password and the login page shows an error. Both tests should show green (passing) in your terminal output.

What should I see?

Your terminal should show output similar to:

  2 passed

Both tests run in Chromium. Each one opens a fresh browser context, navigates to the login page, performs its actions, and closes.

Tests not passing?

  • If you see a timeout error, the OrangeHRM demo site may be temporarily slow. Wait a moment and try again.
  • If the invalid credentials test fails, make sure the password in your test is exactly wrongpassword (not the real password admin123).
  • If you see 0 tests, confirm your file is named login.spec.ts (not login.ts). Playwright only picks up files matching the .spec.ts pattern.

Help me debug why my Playwright headed tests are not passing.

You just watched Playwright automate a real browser and verify both success and failure paths. Next up, you will write a test that creates a new employee in the PIM module.

Write the Create Employee Test

Your login tests prove that Playwright can interact with forms and verify page navigation. The next challenge is automating a real business workflow that spans multiple pages.

In this step, you'll write a test that logs into OrangeHRM, navigates to the PIM module, creates a new employee, and verifies the system saved them. This proves Playwright can handle multi-page form interactions beyond simple login.

In this step, get ready to:
  • Create a new test file that automates the full employee creation flow.
  • Run the test headed and watch the browser automate a real business workflow.
Create the employee test file

OrangeHRM's PIM (Personnel Information Management) module is where administrators create and manage employees. Your test will fill out the Add Employee form with a unique name and confirm the system redirects to the new employee's profile page.

  • In VS Code's Explorer sidebar, right-click the tests folder and select New File.
  • Name the file employee.spec.ts.
  • Paste the following code into tests/employee.spec.ts:
import { test, expect } from '@playwright/test';

test.describe('Employee Workflow Tests', () => {
  // Generate a unique suffix so each test run creates a distinct employee
  const uniqueId = Date.now().toString().slice(-4);
  const firstName = `Test${uniqueId}`;
  const lastName = 'Employee';

  test('should create a new employee successfully', async ({ page }) => {
    // Log in as Admin (same steps as the login test)
    await page.goto('/web/index.php/auth/login');
    await page.locator('input[name="username"]').fill('Admin');
    await page.locator('input[name="password"]').fill('admin123');
    await page.locator('button[type="submit"]').click();
    await expect(page).toHaveURL(/dashboard/);

    // Navigate to PIM Add Employee and fill the form
    await page.goto('/web/index.php/pim/addEmployee');
    await page.getByPlaceholder('First Name').fill(firstName);
    await page.getByPlaceholder('Last Name').fill(lastName);
    await page.getByRole('button', { name: 'Save' }).click();

    // Verify redirect to the new employee's profile page
    await expect(page).toHaveURL(/viewPersonalDetails/, { timeout: 10000 });
  });
});

What does this code do?

This test automates a complete employee creation workflow in three stages.

  • uniqueId grabs the last 4 digits of the current timestamp so each test run generates a unique employee name like Test3847. This avoids duplicate-name conflicts on the live demo site.
  • The login block repeats the same locators from your login test to authenticate as Admin.
  • page.goto('/web/index.php/pim/addEmployee') navigates directly to the Add Employee form in the PIM module.
  • getByPlaceholder('First Name') and getByPlaceholder('Last Name') find the form fields by their placeholder text, which is a resilient locator strategy.
  • The final assertion checks that the URL contains viewPersonalDetails with a 10-second timeout. OrangeHRM redirects to the employee's profile page after a successful save, so this URL change is your proof the employee was created.
  • Save tests/employee.spec.ts.

File not appearing in the tests folder?

Make sure you created the file inside the tests folder, not in the project root. Playwright's config has testDir: './tests' so it only finds test files inside that folder.

help me figure out why Playwright can't find my test file

Run the test and watch it in action

Time to see your employee creation test run in a real browser. Headed mode lets you watch every click and navigation as it happens.

  • Before you run this, predict what you'll see in the browser. How many pages will the test navigate through?
  • Run the employee test in headed mode so you can watch the browser:
npx playwright test tests/employee.spec.ts --headed

The browser opens and you'll see three distinct page transitions: the login page, the Add Employee form, and finally the employee's Personal Details profile. Your terminal shows 1 test passing in green.

Why does the test take longer than login?

The employee test interacts with more pages and waits for OrangeHRM to process the form submission and redirect. The timeout: 10000 in the assertion gives the server up to 10 seconds to complete the save and redirect.

Take a look at your two test files side by side. The login code in employee.spec.ts is an exact copy of what you wrote in login.spec.ts. If you add more tests that need login, you'll be copying those same five lines every time.

Why is duplication a problem?

If OrangeHRM changes the login button from button[type="submit"] to something else, you'd have to update every single test file that logs in. In a real project with dozens of tests, that's a maintenance nightmare. You'll fix this in the next step.

Test failing with a timeout error?

The OrangeHRM demo site can be slow or occasionally down. Try running the test again. If it consistently fails, check that the URL https://opensource-demo.orangehrmlive.com loads in your browser.

Also verify your playwright.config.ts has baseURL set correctly. The test uses relative paths like /web/index.php/pim/addEmployee which combine with the base URL.

help me debug why my employee creation test is timing out

✔️ Awesome, I've got everything!

Great. Double check you've saved tests/employee.spec.ts and the test passes in your terminal.

ⓧ I'd like to double check the full code

Here is the complete tests/employee.spec.ts file:

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

test.describe('Employee Workflow Tests', () => {
  const uniqueId = Date.now().toString().slice(-4);
  const firstName = `Test${uniqueId}`;
  const lastName = 'Employee';

  test('should create a new employee successfully', async ({ page }) => {
    await page.goto('/web/index.php/auth/login');
    await page.locator('input[name="username"]').fill('Admin');
    await page.locator('input[name="password"]').fill('admin123');
    await page.locator('button[type="submit"]').click();
    await expect(page).toHaveURL(/dashboard/);

    await page.goto('/web/index.php/pim/addEmployee');
    await page.getByPlaceholder('First Name').fill(firstName);
    await page.getByPlaceholder('Last Name').fill(lastName);
    await page.getByRole('button', { name: 'Save' }).click();

    await expect(page).toHaveURL(/viewPersonalDetails/, { timeout: 10000 });
  });
});

You just automated a complete business workflow across multiple pages. Next up, you'll fix that duplicated login code by introducing the Page Object Model pattern that professional QA teams use in production.

Refactor to Page Object Model

Your employee creation test works, and that proves Playwright can handle multi-page flows on OrangeHRM. But look at your two test files right now.

The login code is duplicated across login.spec.ts and employee.spec.ts, and selectors like input[name="username"] are scattered everywhere. If OrangeHRM changes a single input name, you would need to hunt through every test file to fix it. The Page Object Model solves this by putting all selectors and actions for each page in ONE place.

In this step, get ready to:
  • Create page object classes for LoginPage and AddEmployeePage.
  • Rewrite both test files to use page objects instead of inline selectors.
  • Verify all tests still pass with the new architecture.
Create the page object files

A page object is a class that represents one page of your application. It stores all the locators for that page's elements and exposes methods for the actions a user can take on that page.

Why Page Object Model instead of inline selectors?

With inline selectors, every test file contains its own copy of every locator string. When a page element changes, you fix it in ten places. With page objects, each locator lives in exactly one file. When a page element changes, you fix it in one place and every test that uses that page object picks up the change automatically.

Professional QA teams use this pattern because test suites grow fast. A login page might be used by 50 different test files. One-place maintenance is the difference between a sustainable suite and a fragile mess.

  • Create a pages folder in your project root by running this command in the terminal:
mkdir pages
  • Create a new file inside the pages folder called LoginPage.ts and paste the following code:
import { type Locator, type Page } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.locator('input[name="username"]');
    this.passwordInput = page.locator('input[name="password"]');
    this.loginButton = page.locator('button[type="submit"]');
    this.errorMessage = page.locator('.oxd-alert-content-text');
  }

  async goto() {
    await this.page.goto('/web/index.php/auth/login');
  }

  async login(username: string, password: string) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }
}

What does this code do?

  • The constructor accepts a Playwright page object and sets up all the locators once. Every test that creates a LoginPage gets those same locators.
  • The goto() method navigates to the login URL. Tests no longer need to know or repeat that path.
  • The login() method fills both fields and clicks submit in one call. This replaces the three separate locator lines you had in every test.
  • Save pages/LoginPage.ts.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved, then continue to the next page object.

ⓧ I'd like to double check the full code

import { type Locator, type Page } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.locator('input[name="username"]');
    this.passwordInput = page.locator('input[name="password"]');
    this.loginButton = page.locator('button[type="submit"]');
    this.errorMessage = page.locator('.oxd-alert-content-text');
  }

  async goto() {
    await this.page.goto('/web/index.php/auth/login');
  }

  async login(username: string, password: string) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }
}
  • Create another new file inside the pages folder called AddEmployeePage.ts and paste the following code:
import { type Locator, type Page } from '@playwright/test';

export class AddEmployeePage {
  readonly page: Page;
  readonly firstNameInput: Locator;
  readonly lastNameInput: Locator;
  readonly saveButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.firstNameInput = page.getByPlaceholder('First Name');
    this.lastNameInput = page.getByPlaceholder('Last Name');
    this.saveButton = page.getByRole('button', { name: 'Save' });
  }

  async goto() {
    await this.page.goto('/web/index.php/pim/addEmployee');
  }

  async createEmployee(firstName: string, lastName: string) {
    await this.firstNameInput.fill(firstName);
    await this.lastNameInput.fill(lastName);
    await this.saveButton.click();
  }
}

What does this code do?

  • The locators use Playwright's getByPlaceholder() and getByRole() methods. These are more resilient than CSS selectors because they match how a user sees the page.
  • The createEmployee() method fills both name fields and clicks Save. Your tests will call this one method instead of repeating three locator lines.
  • Save pages/AddEmployeePage.ts.
  • Confirm both files exist by running:
ls pages/

You should see AddEmployeePage.ts and LoginPage.ts listed in the output.

Don't see both files?

  • Make sure you created both files inside the pages folder, not in the project root or the tests folder.
  • Check the file names are exactly LoginPage.ts and AddEmployeePage.ts (capital letters matter in imports).

My pages folder is empty or missing, what did I do wrong?

✔️ Awesome, I've got everything!

Great. Both page objects are ready. Now let's update the test files to use them.

ⓧ I'd like to double check the full code

import { type Locator, type Page } from '@playwright/test';

export class AddEmployeePage {
  readonly page: Page;
  readonly firstNameInput: Locator;
  readonly lastNameInput: Locator;
  readonly saveButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.firstNameInput = page.getByPlaceholder('First Name');
    this.lastNameInput = page.getByPlaceholder('Last Name');
    this.saveButton = page.getByRole('button', { name: 'Save' });
  }

  async goto() {
    await this.page.goto('/web/index.php/pim/addEmployee');
  }

  async createEmployee(firstName: string, lastName: string) {
    await this.firstNameInput.fill(firstName);
    await this.lastNameInput.fill(lastName);
    await this.saveButton.click();
  }
}
Update your test files to use page objects

Now that the page objects exist, you can strip out all the inline selectors from your test files and replace them with clean method calls.

  • Open tests/login.spec.ts and replace the entire file contents with the following code:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test.describe('Login Tests', () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

  test('should login with valid credentials', async ({ page }) => {
    await loginPage.login('Admin', 'admin123');
    await expect(page).toHaveURL(/dashboard/);
  });

  test('should show error with invalid credentials', async ({ page }) => {
    await loginPage.login('Admin', 'wrongpassword');
    await expect(loginPage.errorMessage).toContainText('Invalid credentials');
  });
});

What changed here?

  • The import at line 2 pulls in LoginPage from the pages folder using a relative path (../pages/LoginPage).
  • The beforeEach hook creates a fresh LoginPage instance and calls goto() before each test. No test needs to know the login URL.
  • The three inline locator lines (fill username, fill password, click submit) are now a single loginPage.login() call.
  • The error assertion uses loginPage.errorMessage directly instead of calling page.locator('.oxd-alert-content-text').
  • Save tests/login.spec.ts.

✔️ Awesome, I've got everything!

Great. Now let's update the employee test file the same way.

ⓧ I'd like to double check the full code

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test.describe('Login Tests', () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

  test('should login with valid credentials', async ({ page }) => {
    await loginPage.login('Admin', 'admin123');
    await expect(page).toHaveURL(/dashboard/);
  });

  test('should show error with invalid credentials', async ({ page }) => {
    await loginPage.login('Admin', 'wrongpassword');
    await expect(loginPage.errorMessage).toContainText('Invalid credentials');
  });
});
  • Open tests/employee.spec.ts and replace the entire file contents with the following code:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { AddEmployeePage } from '../pages/AddEmployeePage';

test.describe('Employee Workflow Tests', () => {
  const uniqueId = Date.now().toString().slice(-4);
  const firstName = `Test${uniqueId}`;
  const lastName = 'Employee';

  test('should create a new employee successfully', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('Admin', 'admin123');
    await expect(page).toHaveURL(/dashboard/);

    const addEmployeePage = new AddEmployeePage(page);
    await addEmployeePage.goto();
    await addEmployeePage.createEmployee(firstName, lastName);

    await expect(page).toHaveURL(/viewPersonalDetails/, { timeout: 10000 });
  });
});

What changed here?

  • The test now imports both LoginPage and AddEmployeePage. Each page object handles its own navigation and form-filling.
  • The login block went from 4 lines of locator code to 3 clean method calls: create the page object, go to it, and log in.
  • The employee creation block uses addEmployeePage.createEmployee(firstName, lastName) instead of three separate locator fills and a click.
  • Save tests/employee.spec.ts.

✔️ Awesome, I've got everything!

Great. Both test files are updated. Time to verify everything still works.

ⓧ I'd like to double check the full code

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { AddEmployeePage } from '../pages/AddEmployeePage';

test.describe('Employee Workflow Tests', () => {
  const uniqueId = Date.now().toString().slice(-4);
  const firstName = `Test${uniqueId}`;
  const lastName = 'Employee';

  test('should create a new employee successfully', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('Admin', 'admin123');
    await expect(page).toHaveURL(/dashboard/);

    const addEmployeePage = new AddEmployeePage(page);
    await addEmployeePage.goto();
    await addEmployeePage.createEmployee(firstName, lastName);

    await expect(page).toHaveURL(/viewPersonalDetails/, { timeout: 10000 });
  });
});
Verify all tests still pass

You changed the internal structure of your tests but not their behaviour. The browser should still do exactly the same things it did before.

Before you run the suite, think about what you expect to see. Should the number of passing tests change, or stay the same?

  • Run the full test suite by running:
npx playwright test

You should see all 3 tests passing (2 login tests and 1 employee test). The same green results as before, but the code behind them is far more maintainable.

Seeing import errors or test failures?

  • Check that your import paths use ../pages/LoginPage (with two dots). A single dot would look in the wrong folder.
  • Verify the class names and file names match exactly. LoginPage in the import must match the class name in pages/LoginPage.ts.
  • Make sure you exported the classes with export class (not just class). Without the export keyword, the import fails.

My tests fail after refactoring to page objects, help me debug the imports.

Your code is now structured the way professional QA teams organize their automation projects. Next up, you will add a retrieve test that searches for the employee you created, generate an HTML report, and push everything to GitHub.

Add Retrieve Test, Generate Report, and Push to GitHub

In the previous step, you refactored your test code into a professional Page Object Model structure. Now your selectors live in one place, and your tests are clean and readable.

The final piece of the business workflow is verifying you can search for and find the employee you created. Combined with an HTML report and a GitHub push, this completes your portfolio piece.

In this step, get ready to:
  • Create an EmployeeListPage page object and a retrieve test that searches for the created employee.
  • Generate a polished HTML test report you can share with anyone.
  • Push the complete project to GitHub as a portfolio piece.
Create the EmployeeListPage page object

The Employee List page in OrangeHRM uses an autocomplete search field. Your page object needs to handle typing a name, waiting for the dropdown suggestions, and selecting the first match.

  • Create a new file pages/EmployeeListPage.ts and add the class structure with locators and the goto() method:
import { type Locator, type Page } from '@playwright/test';

export class EmployeeListPage {
  readonly page: Page;
  readonly employeeNameInput: Locator;
  readonly searchButton: Locator;
  readonly resultTable: Locator;
  readonly noRecordsMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.employeeNameInput = page.getByPlaceholder('Type for hints...').first();
    this.searchButton = page.getByRole('button', { name: 'Search' });
    this.resultTable = page.locator('.oxd-table-body');
    this.noRecordsMessage = page.getByText('No Records Found');
  }

  async goto() {
    await this.page.goto('/web/index.php/pim/viewEmployeeList');
  }
}

What does this code do?

This page object maps the Employee List page in OrangeHRM's PIM module.

  • employeeNameInput targets the autocomplete search field using its placeholder text. The .first() call ensures you get the first matching input if multiple exist.
  • resultTable targets the table body where search results appear using the .oxd-table-body class from OrangeHRM's component library.
  • noRecordsMessage targets the text that appears when no employees match a search. You will use this in the Secret Mission later.
  • Save pages/EmployeeListPage.ts.
  • Confirm the file exists by running:
ls pages/

You should see AddEmployeePage.ts, EmployeeListPage.ts, and LoginPage.ts listed.

Don't see EmployeeListPage.ts?

  • Make sure you created the file inside the pages/ folder, not in the project root.
  • Check for typos in the filename. It must be EmployeeListPage.ts with exact casing.

help me find where my EmployeeListPage.ts file ended up

Now add the two search methods to the class. One handles the autocomplete dropdown, and the other searches directly without waiting for suggestions.

  • In pages/EmployeeListPage.ts, add these methods inside the class, just above the closing }:
  async searchByName(name: string) {
    await this.employeeNameInput.fill(name);
    const dropdown = this.page.locator('.oxd-autocomplete-dropdown');
    await dropdown.waitFor({ timeout: 5000 });
    await this.page.locator('.oxd-autocomplete-option').first().click();
    await this.searchButton.click();
  }

  async searchByNameWithoutSelect(name: string) {
    await this.employeeNameInput.fill(name);
    await this.searchButton.click();
  }

Why two search methods?

searchByName() types a name and waits for the autocomplete dropdown to appear, then clicks the first suggestion. This is for searching employees that exist in the system.

searchByNameWithoutSelect() types a name and clicks Search immediately without waiting for suggestions. This is for testing searches where no employee matches (the dropdown never appears).

  • Save pages/EmployeeListPage.ts.
Add the retrieve test to employee.spec.ts

With the page object ready, you can write a test that logs in, navigates to the Employee List, and searches for the employee by name.

  • In tests/employee.spec.ts, find the import lines at the top of the file:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { AddEmployeePage } from '../pages/AddEmployeePage';
  • Add the EmployeeListPage import so the imports now look like this:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { AddEmployeePage } from '../pages/AddEmployeePage';
import { EmployeeListPage } from '../pages/EmployeeListPage';
  • Now scroll to the bottom of the test.describe block, just after the closing }); of the create employee test. Add this new test above the final }); that closes the describe block:
  test('should retrieve the created employee from employee list', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('Admin', 'admin123');
    await expect(page).toHaveURL(/dashboard/);

    const employeeListPage = new EmployeeListPage(page);
    await employeeListPage.goto();
    await employeeListPage.searchByName(firstName);

    await expect(employeeListPage.resultTable).toContainText(firstName);
  });

What does this test do?

This test verifies the retrieve step of the business workflow. It logs in, navigates to the Employee List, types the unique first name into the search field, selects the autocomplete suggestion, clicks Search, and asserts the results table contains that name.

The firstName variable uses the same timestamp-based name defined at the top of the describe block. This ensures the retrieve test searches for an employee that was previously created.

  • Save tests/employee.spec.ts.
  • Before you run this, what do you think will happen? Will both employee tests pass?
  • Run the employee tests in headed mode to watch the retrieve flow:
npx playwright test tests/employee.spec.ts --headed

You should see the browser open twice: once for the create test (fills the form, saves) and once for the retrieve test (searches by name, finds the employee in the table). Both tests should show green in terminal output.

Retrieve test failing?

  • If you see a timeout error on the autocomplete dropdown, the employee may not exist yet. The OrangeHRM demo site resets periodically. Run the create test first, then the retrieve test.
  • Check that your import line says '../pages/EmployeeListPage' with the correct relative path.
  • Make sure the new test is inside the test.describe block so it has access to firstName.

help me debug why my retrieve employee test is failing

✔️ Awesome, I've got everything!

Great. Double check you have saved both pages/EmployeeListPage.ts and tests/employee.spec.ts.

ⓧ I'd like to double check the full code

Here is the complete pages/EmployeeListPage.ts:

import { type Locator, type Page } from '@playwright/test';

export class EmployeeListPage {
  readonly page: Page;
  readonly employeeNameInput: Locator;
  readonly searchButton: Locator;
  readonly resultTable: Locator;
  readonly noRecordsMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.employeeNameInput = page.getByPlaceholder('Type for hints...').first();
    this.searchButton = page.getByRole('button', { name: 'Search' });
    this.resultTable = page.locator('.oxd-table-body');
    this.noRecordsMessage = page.getByText('No Records Found');
  }

  async goto() {
    await this.page.goto('/web/index.php/pim/viewEmployeeList');
  }

And the search methods (still inside the class):

  async searchByName(name: string) {
    await this.employeeNameInput.fill(name);
    const dropdown = this.page.locator('.oxd-autocomplete-dropdown');
    await dropdown.waitFor({ timeout: 5000 });
    await this.page.locator('.oxd-autocomplete-option').first().click();
    await this.searchButton.click();
  }

  async searchByNameWithoutSelect(name: string) {
    await this.employeeNameInput.fill(name);
    await this.searchButton.click();
  }
}

Here is the complete tests/employee.spec.ts. Scroll through and confirm your imports and both tests match:

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { AddEmployeePage } from '../pages/AddEmployeePage';
import { EmployeeListPage } from '../pages/EmployeeListPage';

test.describe('Employee Workflow Tests', () => {
  const uniqueId = Date.now().toString().slice(-4);
  const firstName = `Test${uniqueId}`;
  const lastName = 'Employee';
  test('should create a new employee successfully', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('Admin', 'admin123');
    await expect(page).toHaveURL(/dashboard/);

    const addEmployeePage = new AddEmployeePage(page);
    await addEmployeePage.goto();
    await addEmployeePage.createEmployee(firstName, lastName);

    await expect(page).toHaveURL(/viewPersonalDetails/, { timeout: 10000 });
  });
  test('should retrieve the created employee from employee list', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('Admin', 'admin123');
    await expect(page).toHaveURL(/dashboard/);

    const employeeListPage = new EmployeeListPage(page);
    await employeeListPage.goto();
    await employeeListPage.searchByName(firstName);

    await expect(employeeListPage.resultTable).toContainText(firstName);
  });
});
Generate the HTML report

Playwright's built-in HTML reporter creates a polished, interactive report you can open in any browser. It shows every test, its duration, and lets you click into individual steps.

  • Run all tests across both test files to generate the report:
npx playwright test

You should see all tests passing (login tests and employee tests).

  • Open the HTML report in your browser by running:
npx playwright show-report

A browser tab opens showing the HTML report. You can click on individual tests to see step-by-step execution details and timing information.

What is this report?

The HTML report lives in the playwright-report/ folder. It is a standalone HTML file that anyone can open in a browser without installing anything.

QA teams use these reports to show stakeholders which tests pass, which fail, and how long each one takes. It is the proof that your automation works.

  • Press Ctrl+C in your terminal to stop the report server when you are done exploring.

Report not opening?

  • If the browser does not open automatically, look for a URL in the terminal output (something like http://localhost:9323) and paste it into your browser manually.
  • Make sure you stopped any previous show-report process before running it again.

help me get the Playwright HTML report to open

Push the project to GitHub

Your test suite is complete and generating reports. The last step is version-controlling the project and pushing it to GitHub so it lives as a portfolio piece.

  • Create a .gitignore file in your project root with the following content:
node_modules/
test-results/
playwright-report/
playwright/.cache/

What does .gitignore do?

This file tells Git which files and folders to ignore. You do not want to push node_modules/ (hundreds of megabytes of dependencies) or generated reports to your repository. Anyone who clones your project can install dependencies themselves with npm install.

  • Save .gitignore.
  • Initialize a Git repository and make your first commit by running these commands:
git init
git add .
git commit -m "feat: playwright test suite for OrangeHRM employee workflow"

You should see output listing all the files being committed.

  • Open your browser and go to github.com.
  • Click the + icon in the top right corner, then select New repository.
  • Enter orangehrm-playwright as the repository name.
  • Leave it as Public and do NOT check any boxes (no README, no .gitignore, no license).
  • Click Create repository.

GitHub shows you a page with setup commands. You will use the commands for pushing an existing repository.

  • Connect your local repository to GitHub and push by running these commands:
git remote add origin https://github.com/[[YOUR_USERNAME="your GitHub username"]]/orangehrm-playwright.git
git branch -M main
git push -u origin main

You should see output confirming the push succeeded with a reference to your new main branch.

Push failed or asking for credentials?

  • If Git asks for a username and password, GitHub no longer accepts passwords. You need to use a Personal Access Token or set up SSH keys. GitHub's setup page shows instructions for both.
  • If you see a "remote already exists" error, run git remote remove origin and try the git remote add command again.

help me push my Playwright project to GitHub

  • Refresh your GitHub repository page in the browser.

What do you expect to see? You should see all your project files listed: playwright.config.ts, the pages/ folder, the tests/ folder, your .gitignore, and package.json. The node_modules/ folder should NOT appear because your .gitignore excluded it.

You now have a complete, professional Playwright test automation project on GitHub. It demonstrates login testing, employee creation, employee retrieval, and a clean Page Object Model architecture. Next up, the Secret Mission challenges you to add defensive testing.

Secret mission

Add a Negative Search Test

Your test suite covers the happy path: creating and retrieving employees that exist. But real QA engineers also test what happens when things go wrong. Can you write a test that searches for a non-existent employee and verifies the system correctly displays a 'No Records Found' message?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with a free-tier GitHub repository, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The orangehrm-playwright project folder on your local machine (free, no ongoing costs).
  • A GitHub repository named orangehrm-playwright (free tier).
  • Playwright browser binaries cached locally (approximately 500MB disk space).

Keep everything running

No action needed. Choose this if you plan to keep building on this test suite or want to reference it later.

  • Your project folder stays on your Desktop.
  • Your GitHub repository remains accessible at your profile.
  • Playwright browser binaries remain cached for fast test runs.

Pause - I'll come back to this later

Free up disk space by removing cached browsers while keeping your project files and GitHub repository intact.

  • Remove the cached Playwright browsers by running this command in your terminal from the orangehrm-playwright folder:
  • Your project files and GitHub repository stay untouched.
  • When you return, run npx playwright install to re-download browsers.
npx playwright uninstall

Delete - I don't want to use this again

Remove all project resources from your machine and GitHub to start fresh.

  • Remove the cached Playwright browsers by running:
npx playwright uninstall
  • Delete the local project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf orangehrm-playwright

On Windows, run:

cd ~\Desktop
Remove-Item -Recurse -Force orangehrm-playwright
  • Delete the GitHub repository:
  • Open your orangehrm-playwright repository on GitHub.
  • Click Settings in the repository navigation bar.
  • Scroll down to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

Nice Work!

Nice Work!

You just built a complete Playwright test automation project from scratch using TypeScript. Your test suite automates a real business workflow on a live web application and produces a professional HTML report you can share with anyone.

What you learned:

  • Automated a full business workflow (login, create employee, retrieve employee) against a live web application using Playwright's test runner and assertion library.
  • Implemented the Page Object Model pattern that professional QA teams use in production, separating UI locators from test logic for maintainable code.
  • Wrote both positive and negative tests, generated an HTML test report, and pushed the complete project to GitHub as a portfolio piece.
  • Secret Mission: Added defensive testing with a negative search case that verifies the system correctly handles non-existent employee lookups.

Ready to quiz yourself?