Test Automation with Playwright
Build a UI and API test framework using Playwright and TypeScript.
Introduction
30 Second Summary
Every time you click "add to cart" or submit a form online, automated tests are running behind the scenes to make sure nothing breaks. Someone has to write those tests.
In this project, you will build a professional test automation framework using Playwright and TypeScript that tests both the UI and API layers of a web application.
What You'll Build
When you finish, you'll run a single command and watch your framework launch browsers, interact with a real todo app, validate API responses, and generate a polished HTML report proving everything works.
By the end of this project, you'll have:
- A working test suite that runs across Chromium, Firefox, and WebKit with a single command, proving your tests work on every major browser engine.
- A set of API tests that validate REST endpoints (GET, POST) without ever opening a browser, proving you can test beyond the UI layer.
- A Page Object Model framework structure that mirrors how real QA teams organize production test code.
- Secret Mission: A GitHub Actions CI pipeline that runs your tests automatically on every push to GitHub.
Are there any prerequisites?
None. This guide walks you through installing everything you need, including Node.js and Playwright. You just need a computer with internet access.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. This is your moment to commit to the goal before any hands-on work begins.
Set Up Your Playwright Project
To build a test automation framework, you first need a working environment where tests can run. Playwright provides a scaffolding command that sets up everything in one go: a TypeScript config, example tests, and real browser binaries.
In this step, you will create a project folder, initialize Playwright with TypeScript, and confirm the example tests pass across three different browsers.
In this step, get ready to:
- Verify Node.js is ready for Playwright.
- Initialize a new Playwright project with TypeScript.
- Run example tests and open the HTML report.
Verify your Node.js version
Playwright requires Node.js version 22 or higher. Let's confirm your installation is ready.
- Open the integrated terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Check your Node.js version by running this command:
node --version
You should see a version number starting with v22 or higher (e.g. v22.15.0).
Seeing a version lower than 22, or command not found?
If your version is below 22, download and install the latest LTS from https://nodejs.org.
If you see "command not found", Node.js is not installed yet. Visit the same link above to download and install it. After installing, close and reopen your terminal, then run node --version again.
Need more help? Why isn't my Node.js version showing v22 or higher?
Create your project folder and initialize Playwright
Playwright's scaffolding command creates a complete project structure with a config file, example tests, and downloads browser binaries for Chromium, Firefox, and WebKit.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir playwright-framework
cd playwright-framework
- Confirm you are inside the new folder by running:
pwd
You should see a path ending in playwright-framework.
- Initialize Playwright by running this command:
npm init playwright@latest
What prompts will you see?
This command is interactive. It will ask you four questions. Answer each one as follows:
- TypeScript or JavaScript: press Enter to accept the default (TypeScript).
- Tests folder name: press Enter to accept the default (tests).
- Add a GitHub Actions workflow: type Y and press Enter.
- Install Playwright browsers: press Enter to accept the default (yes).
After you answer the last prompt, Playwright downloads Chromium, Firefox, and WebKit browser binaries. This may take a minute or two depending on your internet speed.
Once it finishes, you will see a success message in the terminal listing the files it created.
- Confirm the scaffold created the expected files by running:
ls
You should see playwright.config.ts, package.json, package-lock.json, and a tests folder in the output.
What did this command create?
The scaffold produced a fully configured project:
- playwright.config.ts controls which browsers to test, how many retries to attempt, and which reporter to use.
- tests/example.spec.ts is a minimal example test that visits the Playwright website and checks its title.
- .github/workflows/playwright.yml is a CI workflow file for GitHub Actions (you will use this in the Secret Mission).
Run the example tests and open the HTML report
The scaffolded project includes an example test that is ready to run. This is your first chance to see Playwright in action.
- Before you run the tests, predict: how many test results do you think you will see, given the config targets three browsers?
- Run the example tests by running this command:
npx playwright test
You will see output showing tests passing across Chromium, Firefox, and WebKit. Each browser runs the same example test, so the total count reflects one test multiplied by three browsers.
What does this output mean?
Playwright ran the test in tests/example.spec.ts once per configured browser (Chromium, Firefox, WebKit). All tests ran in headless mode, meaning no browser window opened on your screen. The green checkmarks confirm every test passed.
- Open the HTML report to see a visual summary by running this command:
npx playwright show-report
Your default browser opens with the Playwright HTML Report. You can filter results by browser, click on individual tests, and see exactly what each test did.
Tests failing or command not found?
If you see "Cannot find module" or a similar error, make sure you are inside the playwright-framework folder (run pwd to check). The npx playwright test command must be run from the folder containing package.json.
If tests fail with a timeout error, your network might be blocking the test site. Try running with a single browser to isolate the issue:
npx playwright test --project=chromium
Still stuck? Help me debug why my Playwright example tests are failing.
Your Playwright environment is ready with browsers installed and tests passing. Next up, you will write your own tests against a real web application.
Write Your First UI Test
Your Playwright environment is set up and the example tests pass across three browsers. Now it is time to write your own tests against a real application.
The scaffolded example tests someone else's code. In this step, you will write tests against Playwright's TodoMVC demo app to learn how locators, actions, and assertions work in TypeScript.
In this step, get ready to:
- Write a test that adds a todo item and asserts it appears.
- Write a test that completes a todo and asserts the count updates.
- Run your tests in headed mode to watch browsers execute in real time.
Create your first test file
Playwright uses role-based locators to find elements the same way a real user would. Instead of relying on brittle CSS selectors, you target elements by their accessible role and name.
- In VS Code, create a new file called tests/todo.spec.ts inside the tests/ folder.
- Paste the following code into the file:
import { test, expect } from '@playwright/test';
test.describe('Todo App', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc/');
});
test('should add a new todo', async ({ page }) => {
await page.getByRole('textbox', { name: 'What needs to be done?' }).fill('Buy groceries');
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
await expect(page.getByText('Buy groceries')).toBeVisible();
});
});
What does this code do?
test.describe('Todo App', ...) groups related tests under a single label. test.beforeEach navigates to the TodoMVC app before every test so you do not repeat the navigation in each test body.
Inside the test, getByRole('textbox', { name: 'What needs to be done?' }) finds the input field by its accessible role and placeholder text. .fill() types the text, .press('Enter') submits it, and expect(...).toBeVisible() asserts the new todo appears on the page.
- Save tests/todo.spec.ts.
- Run your new test to confirm it passes by running this command:
npx playwright test tests/todo.spec.ts
You should see 3 passed tests in the terminal output (your single test runs once per browser: Chromium, Firefox, and WebKit).
Seeing failures instead of 3 passed?
- Check that the file is saved as tests/todo.spec.ts (not test/todo.spec.ts without the s).
- Confirm you have an internet connection. The test navigates to a live URL.
- Make sure you copied the full code block including the import line at the top.
Help me debug my failing Playwright test
Add a test for completing a todo
A real test suite covers more than one behaviour. Your second test will add a todo, check its checkbox to mark it complete, and assert the app updates.
- In tests/todo.spec.ts, find the closing }); of the first test (the line after toBeVisible()).
- Add the following test below it, but still inside the test.describe block:
test('should complete a todo', async ({ page }) => {
await page.getByRole('textbox', { name: 'What needs to be done?' }).fill('Buy groceries');
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
await page.locator('.todo-list li').getByRole('checkbox').click();
await expect(page.locator('.todo-count')).toBeVisible();
});
What does this test do?
This test first adds a todo (the same way as the first test). Then it uses .locator('.todo-list li') to target the list item and .getByRole('checkbox') to find the completion checkbox inside it.
After clicking the checkbox, the test asserts .todo-count is visible. This element appears in the footer of the TodoMVC app once you have active todos, proving the app registered your interaction.
Why does beforeEach help here?
Notice you did not write page.goto(...) inside the second test. The test.beforeEach hook handles navigation automatically before every test in the describe block. This eliminates repeated setup code and keeps each test focused on the behaviour it verifies.
- Save tests/todo.spec.ts.
- Run the tests again to confirm both pass:
npx playwright test tests/todo.spec.ts
You should now see 6 passed (2 tests running across 3 browsers).
Still seeing 3 passed instead of 6?
- Make sure your new test is inside the test.describe block (before the final });).
- Check for a missing closing brace }); on the second test.
- Confirm you saved the file after adding the second test.
Help me figure out why my second test is not running
Watch your tests run in headed mode
So far, tests run in headless mode. This means the browsers execute invisibly in the background. Playwright's --headed flag opens real browser windows so you can watch automation happen in real time.
- Run your tests in headed mode by running this command:
npx playwright test tests/todo.spec.ts --headed
Browser windows flash open, type into the input, check the checkbox, and close. You are watching Playwright control real browsers at machine speed.
Before you run the next command, how many total passed tests do you expect to see?
- Now run the tests in headless mode (the default) to see the speed difference:
npx playwright test tests/todo.spec.ts
You should see 6 passed in the terminal. Two tests across three browsers, all finishing in seconds.
✔️ Awesome, I've got everything!
Great. Double check you have saved tests/todo.spec.ts with both tests inside the test.describe block.
ⓧ I'd like to double check the full code
Here is the complete tests/todo.spec.ts file. Make sure yours matches exactly:
import { test, expect } from '@playwright/test';
test.describe('Todo App', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc/');
});
test('should add a new todo', async ({ page }) => {
await page.getByRole('textbox', { name: 'What needs to be done?' }).fill('Buy groceries');
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
await expect(page.getByText('Buy groceries')).toBeVisible();
});
test('should complete a todo', async ({ page }) => {
await page.getByRole('textbox', { name: 'What needs to be done?' }).fill('Buy groceries');
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
await page.locator('.todo-list li').getByRole('checkbox').click();
await expect(page.locator('.todo-count')).toBeVisible();
});
});
You now have your own test suite that automates a real web application. Next up, you will learn how to debug a failing test using Playwright's Trace Viewer.
Debug a Failing Test with Trace Viewer
Your tests pass today, but in real projects they will fail. A flaky locator, a slow network, a surprise UI redesign. Failures are inevitable.
Playwright includes a Trace Viewer that records every action your test takes during a retry. You can step through the recording like a time-travel debugger, seeing DOM snapshots, network requests, and the exact assertion that broke. In this step, you will intentionally break a test, enable tracing, and use the Trace Viewer to inspect the failure.
In this step, get ready to:
- Break a test intentionally and observe the failure output.
- Enable Playwright's trace recording in your config.
- Use the Trace Viewer to inspect a failure visually, then fix it.
Break a test to trigger a failure
To learn how debugging works, you need a real failure. You will change an assertion to expect text that the app never displays.
- In VS Code, open tests/todo.spec.ts.
- Find this line inside the should add a new todo test:
await expect(page.getByText('Buy groceries')).toBeVisible();
- Replace 'Buy groceries' with 'Buy flowers', so the line now looks like this:
await expect(page.getByText('Buy flowers')).toBeVisible();
Your test still types "Buy groceries" into the input, but now it expects to see "Buy flowers" on the page. That mismatch guarantees a failure.
Before you run this, what do you think will happen? Will the test pass or fail?
- Run the broken test by running this command:
npx playwright test tests/todo.spec.ts
You will see the should add a new todo test fail across all three browsers. The terminal shows an assertion error explaining that Buy flowers was never visible on the page.
Why does only one test fail?
You only changed the assertion in the first test. The second test (should complete a todo) still uses its original assertions, so it continues to pass.
Enable trace recording in the config
Seeing a failure message is useful, but sometimes you need to see exactly what the browser was showing at the moment things went wrong. Playwright's Trace Viewer captures DOM snapshots at every step, but it only records during retries to avoid performance overhead.
You need to enable two settings: retries (so the test runs again after failing) and tracing (so Playwright records what happens during that retry).
- In VS Code, open playwright.config.ts.
- Find this line:
retries: process.env.CI ? 2 : 0,
- Change the 0 to 1, so the line now looks like this:
retries: process.env.CI ? 2 : 1,
- Now find this line:
use: {},
- Replace it with the following to enable trace recording on the first retry:
use: {
trace: 'on-first-retry',
},
What does this config do?
The retries: 1 setting tells Playwright to retry any failing test once before reporting it as failed. The trace: 'on-first-retry' setting tells Playwright to record a full trace during that retry.
This combination is efficient. Traces are not recorded on every run (which would be slow). They are only captured when something goes wrong and Playwright retries it.
- Save playwright.config.ts.
- Run the broken test again by running this command:
npx playwright test tests/todo.spec.ts
The test still fails, but now you will see retry attempts in the terminal output. Playwright ran each failing test twice (original + 1 retry) and recorded a trace during the retry.
Not seeing retries in the output?
Make sure you saved playwright.config.ts after editing. If the terminal still shows only one attempt per test, double-check that the retries line reads process.env.CI ? 2 : 1 (not 0).
Help me debug my Playwright retries config
✔️ Awesome, I've got everything!
Great. Make sure you have saved playwright.config.ts before moving on.
ⓧ I'd like to double check the full code
Here is the full playwright.config.ts as it should look now:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 1,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});
Inspect the failure with Trace Viewer and fix it
Now that Playwright recorded a trace of the failing retry, you can open it in the Trace Viewer to see exactly what happened at each step.
- Open the HTML report by running this command:
npx playwright show-report
Your browser opens with the Playwright HTML report showing all test results.
- Click the failed should add a new todo test to open its detailed view.
- Click the trace icon (or scroll down to the Traces tab) to open the Trace Viewer.
The Trace Viewer shows a timeline of every action your test performed. On the left, you see each step (goto, fill, press, expect). In the middle, a DOM snapshot shows what the page looked like at that moment. On the right, you see action details including timing and parameters.
- Click the last action in the timeline (the failing expect assertion).
- Look at the DOM snapshot in the middle. You can see "Buy groceries" is visible on the page, but the test was expecting "Buy flowers". That is the mismatch.
What can the Trace Viewer show you?
The Trace Viewer creates full DOM snapshots you can interact with. You can open browser DevTools to inspect the HTML and CSS. You can also view console messages, network requests, and the source code of your test at each step.
This is far more powerful than reading an error message in the terminal. You can see the actual state of the page at the exact moment the assertion failed.
Now that you have diagnosed the issue, fix it.
- Switch back to tests/todo.spec.ts in VS Code.
- Find this line:
await expect(page.getByText('Buy flowers')).toBeVisible();
- Change 'Buy flowers' back to 'Buy groceries', so the line reads:
await expect(page.getByText('Buy groceries')).toBeVisible();
- Save tests/todo.spec.ts.
Before you run the tests again, do you think all tests will pass now? Or will something else still fail?
- Confirm the fix by running the tests:
npx playwright test tests/todo.spec.ts
All tests pass again across all three browsers. You have successfully used the Trace Viewer to diagnose a failure and fix it.
Still seeing failures?
Make sure you changed 'Buy flowers' back to 'Buy groceries' and saved the file. If you accidentally edited the wrong line, compare your file against the original test code from Step 2.
Help me fix my failing Playwright test
You now know how to debug any Playwright test failure. Next up, you will add API tests to prove you can test beyond the browser layer.
Add API Tests
Your UI tests prove you can drive a browser and validate what a user sees. But real-world QA roles demand testing beyond the browser layer.
Playwright includes a built-in request fixture that sends HTTP requests directly to APIs without launching a browser. In this step, you will use it to test a REST API with GET and POST requests, proving you can validate data at the service layer.
In this step, get ready to:
- Write a GET test that fetches data from a REST API and validates the response.
- Write a POST test that creates data and asserts the server response.
- Use a TypeScript interface to type-check your API response data.
Create an API test file with a GET request
Your UI tests use the page fixture to control a browser. API tests use the request fixture instead, which sends raw HTTP requests without ever opening a browser window.
- Create a new file called tests/api.spec.ts in VS Code.
- Add the following code to set up your first API test with a GET request:
import { test, expect } from '@playwright/test';
// Define the shape of a Todo response from the API
interface Todo {
id: number;
title: string;
userId: number;
completed: boolean;
}
test.describe('API Layer', () => {
test('should get a todo by ID', async ({ request }) => {
// Send a GET request to fetch a single todo
const response = await request.get('https://jsonplaceholder.typicode.com/todos/1');
expect(response.status()).toBe(200);
// Parse the JSON and validate specific fields
const todo: Todo = await response.json();
expect(todo.id).toBe(1);
expect(todo.completed).toBe(false);
});
});
What does this code do?
The interface Todo block defines the exact shape of the data you expect back from the API. TypeScript uses this to catch typos and wrong field names at write-time, before you even run the test.
The request fixture gives your test an HTTP client. Unlike page, it never launches a browser. It sends the request directly and returns the raw response, making API tests much faster than UI tests.
- Save tests/api.spec.ts.
- Run your new API test by executing this command:
npx playwright test tests/api.spec.ts
You should see the test pass across all three browser projects (Chromium, Firefox, WebKit) with green checkmarks in the terminal.
Why does an API test run across three browsers?
Playwright's request fixture is tied to the project configuration, which defines three browser projects. Each project runs the test using its own HTTP context. Since there is no browser UI involved, the results are identical across all three.
Seeing a connection error?
If you see a timeout or connection refused error, check your internet connection. The test calls jsonplaceholder.typicode.com which requires network access.
If the test fails with a different status code, make sure the URL is exactly https://jsonplaceholder.typicode.com/todos/1 with no trailing slash or extra characters.
Help me debug my failing API test
Add a POST test to create data
A GET request reads data. A POST request creates data. Testing both proves you can validate the full read-write cycle of an API.
- In tests/api.spec.ts, find the closing }); of your GET test (the one right after the expect(todo.completed) assertion).
- Add the following POST test below it, but still inside the test.describe block:
test('should create a new todo', async ({ request }) => {
// Define the data to send in the POST body
const newTodo = {
title: 'Learn Playwright',
userId: 1,
completed: false,
};
// Send a POST request with the todo data
const response = await request.post('https://jsonplaceholder.typicode.com/todos', {
data: newTodo,
});
expect(response.status()).toBe(201);
// Verify the response echoes back our data
const createdTodo: Todo = await response.json();
expect(createdTodo.title).toBe('Learn Playwright');
expect(createdTodo.userId).toBe(1);
});
What does this test prove?
This test sends a JSON body to the API and asserts that the server responds with status 201 (Created). It then checks that the response body echoes back the title and userId you sent.
The data option in request.post() automatically serializes your object to JSON and sets the correct Content-Type header. You do not need to call JSON.stringify() yourself.
- Save tests/api.spec.ts.
- Before you run this, predict: will you see 3 test results or 6 in the terminal output?
- Run all your API tests by executing:
npx playwright test tests/api.spec.ts
You should see 6 passed tests. Each of your 2 tests runs across all 3 browser projects (Chromium, Firefox, WebKit), producing 6 total results.
Seeing a 404 or unexpected status?
Double-check that the POST URL is exactly https://jsonplaceholder.typicode.com/todos (plural, no trailing ID number). A 404 usually means the URL path has a typo.
If you see a status 200 instead of 201, make sure you are using request.post() and not request.get().
Help me fix my POST API test assertion
Verify your complete API test file
You now have a complete API test suite that validates both reading and writing data. The Todo interface gives you type safety on every response, catching field-name typos before the test even runs.
✔️ Awesome, I've got everything!
Great. Make sure you have saved tests/api.spec.ts and that your last run showed 6 passed tests.
ⓧ I'd like to double check the full code
Your tests/api.spec.ts file should contain these sections in order:
- The import { test, expect } line at the top.
- The interface Todo block with four fields: id, title, userId, and completed.
- A test.describe('API Layer', ...) block wrapping both tests.
- The GET test (should get a todo by ID) asserting status 200 and checking todo.id and todo.completed.
- The POST test (should create a new todo) asserting status 201 and checking createdTodo.title and createdTodo.userId.
Scroll up and compare each chunk against your file to confirm everything matches.
Why use a TypeScript interface for API responses?
Without the Todo interface, you could accidentally write todo.complete instead of todo.completed and TypeScript would not warn you. The test would pass (the field would just be undefined) and you would have a false positive.
Interfaces catch that mistake at write-time. In professional frameworks, you define interfaces for every API response shape so your tests stay accurate as the API evolves.
You now have tests covering two layers: UI and API. Next up, you will refactor your UI tests to use the Page Object Model, the industry-standard pattern for organizing test code in professional frameworks.
Refactor to Page Object Model
Your tests work and your API layer is covered. But look at tests/todo.spec.ts closely. Every test repeats the same locator calls to find the input box and todo items.
Professional test frameworks use the Page Object Model pattern to separate test logic from page interaction details. You create a class that wraps all the locators and actions for a page, then your tests call clean methods like todoPage.addTodo('Buy groceries') instead of raw locator chains. Without this separation, tests become unmaintainable as they grow.
In this step, get ready to:
- Create a TodoPage class that encapsulates all page interactions.
- Refactor your UI tests to use the page object instead of raw locators.
- Confirm the full test suite still passes after the refactor.
Create the TodoPage class
The Page Object Model pattern gives each page in your app its own TypeScript class. The class holds locators as properties and exposes actions as methods. Your tests never touch raw selectors directly.
- Create a pages folder in your project root by running this command:
mkdir pages
- Confirm the folder exists by running:
ls pages
You should see the empty pages directory listed in your terminal output.
- Create a new file pages/todo.page.ts and add the following code:
import type { Page, Locator } from '@playwright/test';
export class TodoPage {
readonly inputBox: Locator;
readonly todoItems: Locator;
constructor(public readonly page: Page) {
this.inputBox = this.page.getByRole('textbox', { name: 'What needs to be done?' });
this.todoItems = this.page.locator('.todo-list li');
}
async goto() {
await this.page.goto('https://demo.playwright.dev/todomvc/');
}
async addTodo(text: string) {
await this.inputBox.fill(text);
await this.inputBox.press('Enter');
}
async completeTodo(text: string) {
const todo = this.todoItems.filter({ hasText: text });
await todo.getByRole('checkbox').click();
}
}
What does this code do?
- import type { Page, Locator } imports Playwright's type definitions so TypeScript knows the shape of the page and locator objects.
- readonly inputBox and readonly todoItems store locators once in the constructor. Every test that uses this class points to the same selectors.
- goto(), addTodo(), and completeTodo() are high-level actions. If the app's HTML changes, you fix one class instead of every test.
Why Page Object Model instead of raw locators?
When a test file grows to 50+ tests, repeating page.getByRole('textbox', { name: 'What needs to be done?' }) everywhere means a single UI change forces edits across dozens of lines.
With POM, you change one line in the class and every test picks up the fix automatically. This is why QA job descriptions ask for "framework design" skills.
- Save pages/todo.page.ts.
Seeing a red underline on the import?
- Make sure the file is inside the pages/ folder at your project root, not inside tests/.
- Check that @playwright/test appears in your package.json dependencies. If not, run npm install again.
- help me fix a TypeScript import error in my Playwright page object file
Refactor your tests to use TodoPage
Now that the page object exists, your test file can import it and call clean methods instead of repeating locator code.
- Open tests/todo.spec.ts and replace its entire contents with the following:
import { test, expect } from '@playwright/test';
import { TodoPage } from '../pages/todo.page';
test.describe('Todo App', () => {
let todoPage: TodoPage;
test.beforeEach(async ({ page }) => {
todoPage = new TodoPage(page);
await todoPage.goto();
});
test('should add a new todo', async () => {
await todoPage.addTodo('Buy groceries');
await expect(todoPage.todoItems).toHaveCount(1);
await expect(todoPage.todoItems).toHaveText(['Buy groceries']);
});
test('should complete a todo', async () => {
await todoPage.addTodo('Buy groceries');
await todoPage.completeTodo('Buy groceries');
await expect(todoPage.todoItems).toHaveClass([/completed/]);
});
});
What changed in the test file?
- import { TodoPage } from '../pages/todo.page' brings in your page object class.
- let todoPage: TodoPage declares a variable shared across tests in this describe block.
- beforeEach creates a fresh TodoPage instance and navigates to the app before each test.
- Each test now reads like plain English: todoPage.addTodo('Buy groceries') instead of three lines of locator code.
- Save tests/todo.spec.ts.
Import path showing an error?
- The relative path '../pages/todo.page' goes up one level from tests/ into the project root, then into pages/. Make sure your folder structure matches.
- Confirm the file is named exactly todo.page.ts (not todoPage.ts or TodoPage.ts).
- help me fix the import path for my TodoPage class
Run the full test suite and verify
The refactor should not change any test behaviour. Both your UI tests and API tests should still pass.
- Before you run this, predict: do you expect all tests to pass, or will the refactored tests fail?
- Run the full test suite by running this command:
npx playwright test
You should see all tests pass across Chromium, Firefox, and WebKit. The terminal shows green checkmarks for both your Todo App tests and your API Layer tests.
- Open the HTML report to see all results by running:
npx playwright show-report
The report displays every test grouped by browser. Your UI tests now use todoPage.addTodo('Buy groceries') under the hood, but to Playwright it looks identical to before.
Tests failing after the refactor?
- Check that pages/todo.page.ts exports the class with export class TodoPage. A missing export keyword means the import in your test file cannot find it.
- Verify you removed the { page } destructuring from the individual test functions. With POM, only beforeEach receives { page }.
- help me debug my failing Playwright tests after refactoring to Page Object Model
✔️ Awesome, I've got everything!
Great. Double check you have saved both pages/todo.page.ts and tests/todo.spec.ts.
ⓧ I'd like to double check the full code
Here is pages/todo.page.ts:
import type { Page, Locator } from '@playwright/test';
export class TodoPage {
readonly inputBox: Locator;
readonly todoItems: Locator;
constructor(public readonly page: Page) {
this.inputBox = this.page.getByRole('textbox', { name: 'What needs to be done?' });
this.todoItems = this.page.locator('.todo-list li');
}
async goto() {
await this.page.goto('https://demo.playwright.dev/todomvc/');
}
async addTodo(text: string) {
await this.inputBox.fill(text);
await this.inputBox.press('Enter');
}
async completeTodo(text: string) {
const todo = this.todoItems.filter({ hasText: text });
await todo.getByRole('checkbox').click();
}
}
Here is tests/todo.spec.ts:
import { test, expect } from '@playwright/test';
import { TodoPage } from '../pages/todo.page';
test.describe('Todo App', () => {
let todoPage: TodoPage;
test.beforeEach(async ({ page }) => {
todoPage = new TodoPage(page);
await todoPage.goto();
});
test('should add a new todo', async () => {
await todoPage.addTodo('Buy groceries');
await expect(todoPage.todoItems).toHaveCount(1);
await expect(todoPage.todoItems).toHaveText(['Buy groceries']);
});
test('should complete a todo', async () => {
await todoPage.addTodo('Buy groceries');
await todoPage.completeTodo('Buy groceries');
await expect(todoPage.todoItems).toHaveClass([/completed/]);
});
});
Your test framework now follows the same architecture used by professional QA teams. Next up, you will push this project to GitHub and add a CI pipeline that runs your tests automatically on every push.
Secret mission
Add GitHub Actions CI Pipeline
The job description requires integrating automated tests into a DevOps pipeline. Can you wire up GitHub Actions so your tests run automatically on every push to GitHub?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with a free 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:
- Local project folder (your Playwright test framework files).
- Playwright browser binaries (Chromium, Firefox, WebKit stored in your system cache).
- GitHub repository (free, no ongoing cost).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep this as a portfolio piece.
- Your GitHub repository stays online for free. Share the link with employers as proof of your test automation skills.
- The local project folder and browser binaries take up approximately 500MB of disk space (mostly from the browser binaries).
- Your GitHub Actions workflow will continue to run automatically on any future pushes at no cost.
Pause - I'll come back to this later
There are no running processes or paid resources to pause. Your project is already dormant when you are not actively working on it.
- No action needed. The GitHub repository stays free, and the local files just sit on disk.
- When you return, run npx playwright test from your project folder to pick up where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to clean up completely.
- Delete the local project folder by running this command in your terminal:
cd ~/Desktop
rm -rf playwright-framework
Windows users
If you are on Windows, open PowerShell and run this instead:
cd ~\Desktop
Remove-Item -Recurse -Force playwright-framework
- Remove the Playwright browser binaries from your system cache by running:
npx playwright uninstall --all
- Delete the GitHub repository. Go to your repository page on GitHub, click Settings, scroll to the bottom to the Danger Zone section, and click Delete this repository. Type the full repository name to confirm.
Nice Work!
Nice Work!
You just built a professional test automation framework using Playwright and TypeScript. This is the exact skill set that QA Automation Engineer roles demand: multi-layer testing, framework architecture, and TypeScript proficiency.
You've learned how to:
- Build a test automation framework that tests both UI and API layers using Playwright and TypeScript.
- Structure your tests with the Page Object Model pattern, separating test logic from page interaction details like a professional framework.
- Debug failing tests using Playwright's Trace Viewer, stepping through DOM snapshots and action timelines to pinpoint exactly where an assertion failed.
- Secret Mission: Integrate your tests into a GitHub Actions CI pipeline that runs automatically on every push, completing the DevOps story.
Ready to quiz yourself?