Tired of wrestling with flaky tests and browser tantrums? Meet Playwright, Microsoft’s open-source superhero for end-to-end web testing! This nifty tool laughs in the face of cross-browser chaos, auto-waits like a pro, and debugs with the finesse of a stand-up comedian.
In this Playwright tutorial, we’ll zip through installing this testing tool, crafting slick tests, and mastering locators—without breaking a sweat. Ready to make testing as fun as a barrel of monkeys? Let’s dive in and save your web apps from the buggy abyss!
Playwright is an open-source automation library developed by Microsoft, first released in 2020. It is designed for end-to-end testing of modern web applications across multiple browsers.
Playwright is an open-source automation library, launched by Microsoft in 2020, designed for end-to-end testing of modern web applications across multiple browsers. Additionally, it excels at handling complex Single Page Applications (SPAs) and dynamic web content, delivering fast, reliable, and resilient browser automation.
With a single test suite, Playwright enables you to test on:
Playwright supports several programming languages, including:
Developers and QA teams widely adopt it and address modern web challenges like cross-browser compatibility and flaky tests. As a result, Playwright becomes a go-to tool for building high-quality web applications.
Modern QA teams face the challenge of ensuring a consistent user experience across browsers. Playwright simplifies this with:
Playwright allows QA to focus more on quality assurance than test maintenance.
Here is a brief look at the comparison between Playwright and Selenium:
Feature | Playwright | Selenium |
---|---|---|
Browser support | Chromium, Firefox, WebKit | Chrome, Firefox, Safari, Edge |
Programming language support | JS, TS, Python, Java, C# | Java, JS, Python, C#, Ruby, etc. |
Browser installation | Automatic (npx playwright install) | Manual |
Locators | Role-based (getByRole, getByText) | XPath, CSS |
Built-in auto-waiting | ✅ Yes | ❌ No |
Debugging tools | Codegen, Trace Viewer | Limited |
Execution speed | Fast, supports parallel tests | Varies based on config |
Learning curve | Easier, excellent docs | Steeper, more setup required |
For more details, please read our article about comparing Playwright vs Selenium.
With that said, it’s now time to dive into the Playwright tutorial to see how to install and use it, isn’t it?
The first thing to do in this Playwright tutorial is install it, of course. You can install the tool using either of the following methods.
Use the Playwright Extension in Visual Studio Code to set up and manage your Playwright project with a guided experience.
To get started, install Playwright using npm, yarn, or pnpm. You can also run your tests using the VS Code Extension.
npm init playwright@latest
yarn create playwright
pnpm create playwright
If you're using the Playwright VS Code Extension, here's how to get started:
Step 1: Open a new Terminal window on VS Code
Step 2: Run the installation command from the terminal:
1. Create a new folder for your project and navigate into it.
mkdir {yourFolderName}
cd {yourFolderName}
2. Initialize Playwright by running: npm init playwright@latest
Step 3: During setup, you’ll be prompted to configure the following options:
Step 4: After the installation completes
Playwright will automatically download the required browsers and generate the following files and folders:
playwright.config.ts
package.json
package-lock.json
tests/
example.spec.ts
tests-examples/
demo-todo-app.spec.ts
playwright.config.ts
file allows you to customize the Playwright settings, including which browsers to run your tests on.package.json
file.tests-examples/
folder, which contains tests for a sample todo app.These files give you a solid foundation to begin writing and running your own tests with Playwright.
Install Playwright manually by running commands in your terminal using Node.js and npm. You need to install Node.js.
If you haven’t already, go to nodejs.org and download the latest LTS version.
Step 1: Create a project folder
Open your terminal (or command prompt) and create a new folder for your project.
mkdir {yourFolderName}
cd {yourFolderName}
Step 2: Initialize a Node.js project
Inside your project folder, initialize a new Node.js project. This command will create a package.json file to manage your project dependencies.
npm init -y
The -y
flag automatically accepts all default values. If you prefer to manually fill in details like project name, version, etc., you can omit the -y
.
Step 3: Install Playwright
Now for the main event! Install Playwright and the necessary browsers.
npm install @playwright/test
Or
npx playwright install
npm install @playwright/test
: Installs Playwright’s test runner library.npx playwright install
: This is the coolest part! This command automatically downloads the required browsers (Chromium, Firefox, and WebKit) that Playwright uses for testing. You don’t need to install browsers yourself manually!After running these commands, you’ll see friendly messages confirming that the browsers were installed successfully.
Playwright’s test runner automatically detects and runs files with the following extensions: .spec.js, .spec.ts, .test.js, and .test.ts.
Installing Playwright via the VS Code Extension automatically creates an example test file located at tests/example.spec.ts
or (.js
).
You can run this test to verify that your setup is working correctly.
Use the following command, depending on your package manager, to run the test file:
# Using npm
npx playwright test
# Or using yarn
yarn playwright test
# Or using pnpm
pnpm exec playwright test
By default, tests are executed in headless mode, meaning the browser window does not open during the run. Instead, all test results and logs are output directly to the terminal.
Optionally: Run a specific test file
If you want to run a specific test file, you can do so by providing the path to the test:
npx playwright test tests/example.spec.ts
After the test run completes (headless mode), Playwright automatically generates an HTML report. This report provides a detailed overview of your test results, allowing you to:
If any test fails, the HTML report will open automatically.
To open the test report manually, use the following command based on your package manager:
# Using npm
npx playwright show-report
# Or using yarn
yarn playwright show-report
# Or using pnpm
pnpm exec playwright show-report
Once opened, you will see a detailed visual summary of the test results, as illustrated in the example image below.
For a more interactive development experience — including time-travel debugging, watch mode, and enhanced visual feedback — you can run your tests in UI Mode.
Start UI Mode by running:
# Using npm
npx playwright test --ui
# Or using yarn
yarn playwright test --ui
# Or using pnpm
pnpm exec playwright test --ui
Below are some key features available in UI Mode:
After launching UI Mode, you'll see a list of all test files.
You can filter tests by text, tags (@tag), status (passed, failed, skipped), or by projects defined in your playwright.config.
Note: If you are using project dependencies, make sure to run your setup tests first before running the tests that depend on them. The UI mode will not take into consideration the setup tests, and therefore, you will have to manually run them first.
The timeline shows navigation and actions with color highlights.
The Actions tab lists locators and action durations.
You can pop out the DOM snapshot into a new window for deeper debugging.
The Pick Locator feature helps you easily find and create precise locators by visually inspecting a snapshot of the page during test execution.
This is useful for:
Hover over actions to highlight their corresponding code lines.
View detailed info for each action:
See the internal steps Playwright takes during tests, such as scrolling into view, waiting for visibility, and performing actions.
Failed tests will show detailed error messages here.
View console logs both from the browser and from your test scripts.
Track all network requests made during tests.
The "Attachments" tab lets you explore all the attachments generated during your tests. If you are performing visual regression testing, you can:
🔗 Learn more about attachments and visual comparisons in Playwright: https://playwright.dev/docs/test-snapshots
🔹 Step 1: Create a test file
Create a new test file in your project folder. For example, create a file named demo.spec.ts.
You can add a simple test like this:
🔹 Step 2: Run the test
npx playwright test
Expected output:
Writing tests in Playwright is simple and effective. The core principle of Playwright tests is to:
Playwright’s assertions are designed to describe the expected state that should eventually be met, meaning there’s no need to manually handle timeouts or race conditions in your tests.
Let’s look at a simple example of how to write a Playwright test:
import { test, expect } from '@playwright/test';
test('Go to Devsamurai website', async ({ page }) => {
await page.goto('https://www.devsamurai.com/');
// Navigate to Devsamurai website and verify "Workflow and Collaboration apps" text
await expect(page.getByText('Workflow and Collaboration apps')).toBeVisible();
await expect(page).toHaveScreenshot();
});
test('Go to GanttTable app', async ({ page }) => {
await page.goto('https://www.devsamurai.com/');
// Navigate to GanttTable app page and verify heading and installation button
await page.getByRole('link', { name: 'Solutions' }).click();
await page.getByRole('link', { name: 'GanttTable for Jira' }).click();
await expect(page.locator('h1', { hasText: 'GanttTable for Jira' })).toBeVisible();
await expect(page).toHaveScreenshot();
const page1Promise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Try Now' }).first().click();
const page1 = await page1Promise;
await page1.getByTestId('app-listing__install-app-btn').click();
});
Note: Add // @ts-check
at the start of each test file when using JavaScript in VS Code to get automatic type checking.
Most tests begin with navigating to the target URL. Playwright handles the waiting for the page to load before proceeding, so there is no need for explicit waits.
await page.goto('https://www.devsamurai.com/');
Playwright will automatically wait until the page reaches a "load" state before continuing.
After navigating to the page, the next step is to interact with the page elements. This is done using locators, which are Playwright’s way of identifying elements on the page. Playwright waits for elements to become actionable before performing actions, meaning you don’t need to manually wait for elements to be visible or enabled.
await page.getByRole('link', { name: 'GanttTable for Jira' }).click();
Common actions in Playwright
Here are some of the most common actions you can perform on locators:
Action | Description |
---|---|
locator.check() | Checks a checkbox element. |
locator.click() | Clicks the element. |
locator.uncheck() | Unchecks a checkbox element. |
locator.hover() | Hover the mouse over the element. |
locator.fill() | Fill an input field with text. |
locator.focus() | Focus on the element. |
locator.press() | Press a specific key on the element. |
locator.setInputFiles() | Upload a file using the input file. |
locator.selectOption() | Select an option from a dropdown. |
2.3. Assertions
Assertions in Playwright help validate whether an element or page matches the expected state. Playwright includes a built-in expect()
function, which allows you to write assertions for various conditions.
Here’s an example:
await expect(page.locator('h1', { hasText: 'GanttTable for Jira' })).toBeVisible();
This assertion waits until the heading containing the text "GanttTable for Jira" is visible on the page.
Common Async assertions:
Assertion | Description |
---|---|
expect(locator).toBeChecked() | Asserts that a checkbox is checked. |
expect(locator).toBeEnabled() | Asserts that a control is enabled. |
expect(locator).toBeVisible() | Asserts that an element is visible on the page. |
expect(locator).toContainText() | Asserts that an element contains specific text. |
expect(locator).toHaveAttribute() | Asserts that an element has a specific attribute. |
expect(locator).toHaveCount() | Asserts that a list of elements has a given length. |
expect(locator).toHaveText() | Asserts that an element contains a specific text. |
expect(locator).toHaveValue() | Asserts that an input element has a given value. |
expect(page).toHaveTitle() | Asserts that the page title matches. |
expect(page).toHaveURL() | Asserts that the page URL matches. |
These assertions ensure that the elements are in the expected state before continuing with the test.
Playwright test uses test fixtures for isolation. Each test gets a clean, isolated environment provided by a Browser Context, which behaves like a new browser profile for each test. This ensures that tests do not interfere with each other.
import { test } from '@playwright/test';
test('example test', async ({ page }) => {
// "page" belongs to an isolated BrowserContext, created for this specific test.
});
test('another test', async ({ page }) => {
// "page" in this second test is completely isolated from the first test.
});
Each test will have its own isolated page, even if multiple tests are running in a single browser.
Playwright supports test hooks to set up and tear down tests. You can group tests using test.describe, and run setup and teardown functions with test.beforeEach and test.afterEach.
For example:
test.describe('Navigation Tests', () => {
test.beforeEach(async ({ page }) => {
// Go to the starting URL before each test
await page.goto('https://www.devsamurai.com/');
});
test('Main navigation', async ({ page }) => {
// Assert that the page URL contains 'Devsamurai'
await expect(page).toHaveURL('Devsamurai');
});
});
Test Hooks Explained:
Locators are fundamental in Playwright. They are the only way to reliably interact with elements on a web page. Playwright is designed around the principle that your tests should be resilient, meaning they should keep working even when the page's structure or styling changes slightly.
To achieve this, Playwright provides built-in locators that are stable, powerful, and easy to use.
Role locators include various elements like buttons, checkboxes, headings, links, lists, tables, and more, and they adhere to W3C specifications for ARIA roles, ARIA attributes, and accessible names. Many HTML elements, such as <button>, have an implicitly defined role that the role locator can detect.
Example:
await page.getByRole('link', { name: 'GanttTable for Jira' }).click();
Finds elements that contain a specific text string.
Note: Matching by text automatically normalizes whitespace — multiple spaces are treated as one, line breaks are converted to spaces, and leading/trailing whitespace is ignored, even with exact matching.
When to use text locators: Use text locators primarily for non-interactive elements like <div>, <span>, <p>, etc. For interactive elements such as <button>, <a>, <input>, etc., prefer using role locators for better accuracy and clarity.
Example:
await page.getByText('Workflow and Collaboration').click();
Targets form elements based on the text of their associated label, making tests more accessible and stable.
When to use label locators: Use label locators when targeting form fields that are associated with visible text labels.
Example:
await page.getByLabel('Name your portfolio').fill('My Portfolio Name');
Identifies input fields by matching their placeholder text.
When to use placeholder locators: Use placeholder locators when form elements lack labels but have meaningful placeholder text instead.
Example:
await page.getByPlaceholder('[email protected]').fill('username');
Selects elements — typically images — based on their alt text attribute, supporting accessible image testing.
When to use alt text locators: Use alt text locators when working with elements like <img> and <area> that provide descriptive alt attributes.
Example:
await page.getByAltText('Devsamurai').click();
Finds elements by matching the value of their title attribute.
When to use title locators: Use title locators when your target elements include a title attribute that can be reliably used for identification.
Example:
await page.getByTitle('DevSamurai on Atlassian').first();
Locates elements based on a custom data-testid attribute. This approach is highly resilient, as tests remain stable even if the element’s role or visible text changes.
However, since data-testid is not user-facing, use role or text locators if validating user-visible content is important to you.
Example:
await page.getByTestId('app-header__app-name');
A general-purpose locator that uses CSS or XPath selectors. It's best used as a fallback when semantic locators aren't suitable, though be cautious as it may be more fragile with page structure changes.
Example:
A CSS locator uses CSS selectors — the same ones you use in stylesheets — to locate elements.
Examples:
page.locator('button') // finds all <button> elements
page.locator('#submit') // finds element with id="submit"
page.locator('input[name="email"]') // finds <input> where name="email"
An XPath locator uses XML path expressions to find elements based on their hierarchy and attributes.
Examples:
page.locator('//button') // all <button> elements
page.locator('//*[@id="submit"]') // element with id="submit"
page.locator('//input[@name="email"]') // <input> where name="email"
Locator type | Pros | Cons |
---|---|---|
CSS | Fast, readable, and clean | Can't navigate up the DOM tree |
XPath | More powerful and flexible | Less readable, sometimes slower |
2. Working with Frames and Shadow DOM
Playwright automatically supports Shadow DOM (except for XPath locators). For iframe interaction, wait until the iframe is attached before accessing its content.
const iframeLocator = page.locator('[data-testid="hosted-resources-iframe"]');
await expect(contentFrame.getByRole('heading', { name: 'GanttTable' })).toBeVisible();
await contentFrame.getByRole('heading', { name: 'GanttTable' }).click();
Locators can be filtered by text with the locator.filter() method. It will search for a particular string somewhere inside the element, possibly in a descendant element, case-insensitively.
You can also pass a regular expression. You can refine locators by:
await page.getByRole('listitem')
.filter({ hasText: 'GanttTable' })
.getByRole('button', { name: 'Try out' })
.click();
await expect(
page.getByRole('listitem').filter({ hasNotText: 'GanttTable' })
).toHaveCount(2);
You can filter locators to include or exclude elements based on whether they contain a specific child element, such as one found by getByRole, getByText, or getByTestId
await page.getByRole('listitem')
.filter({ has: page.getByRole('heading', { name: 'Timeplanner' }) })
.getByRole('button', { name: 'Try out' })
.click();
You can chain locator methods like getByRole() or getByText() to narrow down a search within a specific element:
// Select the list item containing "GanttTable" and click its button
const product = page.getByRole('listitem').filter({ hasText: 'GanttTable' });
await product.getByRole('button', { name: 'Try out' }).click();
Use .and() to match an element with multiple attributes:
// Example combining locators with 'and'
const tryOutButton = page.getByRole('button').and(page.getByTitle('Try out this product'));
Use .or() to match any one of multiple locators. Use .first() if both match to avoid strict mode violations:
// Example combining alternative locators with 'or'
const ganttHeading = page.getByRole('heading', { name: 'GanttTable' });
const timeplannerDialog = page.getByText('Timeplanner Details');
await expect(ganttHeading.or(timeplannerDialog).first()).toBeVisible();
Playwright includes a built-in test generator (codegen) that helps you:
npx playwright codegen
// Example: Navigate to devsamurai.com
npx playwright codegen devsamurai.com
Opens two windows:
You can run it with or without a URL and navigate manually in the browser.
Start by running the codegen command and interacting with the browser window. Playwright will automatically generate the corresponding test code when you perform actions like clicking or filling in fields.
The test generator intelligently analyzes the rendered page to select the most suitable locator, giving priority to role, text, and test ID selectors.
You can assert:
Pick Locator:
The Playwright test generator can simulate:
You can record scripts and tests while simulating a mobile device using the --device option, which automatically configures the viewport, user agent, and other relevant settings.
npx playwright codegen --device="iPhone 13" www.devsamurai.com
The Playwright tool opens a browser window with a fixed viewport size rather than a responsive design, allowing tests to be conducted under consistent conditions. To generate tests with a different viewport size, you can use the --viewport option.
npx playwright codegen --viewport-size="800,600" devsamurai.com
You can record scripts and tests while emulating timezone, language, and location using the --timezone, --geolocation, and --lang options.
Once the browser window opens:
npx playwright codegen https://example.com --timezone="Europe/Paris" --geolocation="48.8566,2.3522" --lang="fr-FR"
Use --save-storage=auth.json
when running codegen to save cookies, localStorage, and IndexedDB after login:
npx playwright codegen 'https://marketplace.atlassian.com/search?query=devsamurai' --save-storage=auth.json
After running the command, a browser window and the Playwright Inspector will open. In the browser, click the "Sign in" or "Log in" button to begin the authentication process.
After logging in, and thebrowser closes, the file auth.json will store all authentication-related information.
⚠️ Security Reminder: Make sure you only use the auth.json locally, as it contains sensitive information. Add it to your .gitignore
or delete it once you have finished generating your tests.
Use --load-storage=auth.json to reload the saved state and skip the login step:
npx playwright codegen "https://marketplace.atlassian.com/search?query=devsamurai" --load-storage=auth.json
This resumes the session in a logged-in state, letting you generate tests directly as an authenticated user.
Playwright’s Trace Viewer is a visual debugging tool that explores recorded test traces. It's especially useful for understanding failed tests in CI environments.
You can open a saved trace using the Playwright CLI. Replace path/to/trace.zip with the actual path to your trace file.
npx playwright show-trace path/to/trace.zip
In Browser: Or, you can go to trace.playwright.dev and drag/drop your .zip file or paste a URL to view a remote trace. No data is uploaded — everything runs in your browser.
On CI: In playwright.config.ts, enable tracing only on test retries:
use: { trace: 'on-first-retry', }
Other options include:
Locally: To record a trace during development mode, set the --trace flag to on when running your tests
npx playwright test --trace on
This command does two things:
You can then open the HTML report and click on the trace icon to open the trace.
npx playwright show-report
Playwright performs a series of actionability checks on elements before executing actions to ensure those actions behave reliably. It automatically waits for all relevant checks to pass before proceeding. If the checks do not pass within the specified timeout, the action fails with a TimeoutError.
Example: locator.click()
Before performing a click(), Playwright ensures that:
Action | Visible | Stable | Receives Events | Enabled | Editable |
---|---|---|---|---|---|
locator.check() | Yes | Yes | Yes | Yes | - |
locator.click() | Yes | Yes | Yes | Yes | - |
locator.dblclick() | Yes | Yes | Yes | Yes | - |
locator.setChecked() | Yes | Yes | Yes | Yes | - |
locator.tap() | Yes | Yes | Yes | Yes | - |
locator.uncheck() | Yes | Yes | Yes | Yes | - |
locator.hover() | Yes | Yes | Yes | - | - |
locator.dragTo() | Yes | Yes | Yes | - | - |
locator.screenshot() | Yes | Yes | - | - | - |
locator.fill() | Yes | - | - | Yes | Yes |
locator.clear() | Yes | - | - | Yes | Yes |
locator.selectOption() | Yes | - | - | Yes | - |
locator.selectText() | Yes | - | - | - | - |
locator.scrollIntoViewIfNeeded() | - | Yes | - | - | - |
locator.blur() | - | - | - | - | - |
locator.dispatchEvent() | - | - | - | - | - |
locator.focus() | - | - | - | - | - |
locator.press() | - | - | - | - | - |
locator.pressSequentially() | - | - | - | - | - |
locator.setInputFiles() | - | - | - | - | - |
3.3. Forcing actions
Some actions, like locator.click(), support a force option that disables non-essential actionability checks. For example, using force: true will skip the check for whether the element can receive events at the action point.
Playwright also includes auto-retrying assertions, which help eliminate flakiness by waiting until the expected condition is met, just like it auto-waits before actions.
An element is considered visible if it has a non-empty bounding box and its computed style does not include visibility: hidden.
Notes:
An element is stable if it maintains the same bounding box across at least two animation frames (i.e., it is not moving or animating).
An element is enabled if it does not meet any conditions for being disabled. It is considered disabled if:
[aria-disabled="true"]
.An element is editable if it is enabled and not readonly.
An element is readonly if:
[aria-readonly="true"]
attribute and its role supports it.An element can receive pointer events if it is the topmost element at the action point. For example, when clicking at coordinates (10, 10), Playwright checks whether the element at that point is the one being targeted, or if something else (like an overlay) is capturing the event.
Example: Clicking a "Sign Up" Button After Validation
Let's say you have a sign-up form where the "Sign Up" button is initially disabled until the system validates that the username is unique.
Playwright will auto-wait for:
You don’t need to manually wait for the server response or the button to become enabled. Then, it will proceed with the click.
Playwright Test utilizes the concept of test fixtures to establish isolated environments for each test. Fixtures provide tests with the necessary resources while ensuring no interference between them. This approach allows for grouping tests based on their purpose rather than shared setup.
Playwright provides several core fixtures out of the box:
4. Using built-in fixtures
From this example (Part 6 of this document), you can try using Built-in Fixtures:
✅ Step 1: Create a custom fixture
Create a file named: devsamurai-fixture.ts
import { test as base, expect, Page } from '@playwright/test';
type MyFixtures = {
devsamuraiPage: Page;
};
export const test = base.extend<MyFixtures>({
devsamuraiPage: async ({ page }, use) => {
// Navigate to Devsamurai website
await page.goto('https://www.devsamurai.com/');
// Pass the prepared page to the test
await use(page);
// Teardown (if needed) can be added here
await page.close();
},
});
export { expect } from '@playwright/test';
✅ Step 2: Use the fixture in tests
Create a file named: devsamurai.spec.ts
import { test, expect } from './devsamurai-fixture';
test('Verify Devsamurai homepage content', async ({ devsamuraiPage }) => {
await expect(devsamuraiPage).toHaveScreenshot();
await expect(devsamuraiPage.getByText('Workflow and Collaboration apps')).toBeVisible();
});
test('Navigate to GanttTable app page and install', async ({ devsamuraiPage }) => {
await devsamuraiPage.getByRole('link', { name: 'Solutions' }).click();
await devsamuraiPage.getByRole('link', { name: 'GanttTable for Jira' }).click();
await expect(devsamuraiPage.locator('h1', { hasText: 'GanttTable for Jira' })).toBeVisible();
await expect(devsamuraiPage).toHaveScreenshot();
const popupPromise = devsamuraiPage.waitForEvent('popup');
await devsamuraiPage.getByRole('link', { name: 'Try Now' }).first().click();
const popup = await popupPromise;
await popup.getByTestId('app-listing__install-app-btn').click();
});
Explanation
Component | Role |
---|---|
devsamuraiPage fixture | Provides a Page instance already navigated to https://www.devsamurai.com/ |
test.extend() | Creates a custom version of the test that includes the new fixture |
await use(page) | Runs the test with the prepared page; optional teardown comes after |
Fixture extraction | Keeps test code DRY (Don’t Repeat Yourself) and easier to maintain |
To use a custom fixture, simply include it as an argument in your test function. Playwright will handle its setup and teardown.
Instead of overriding the page to add a baseURL, we will override a custom fixture to add a specific action before it's used.
import { test as base, expect, Page } from '@playwright/test';
type DevsamuraiFixtures = {
loggedInPage: Page;
};
const test = base.extend<DevsamuraiFixtures>({
loggedInPage: async ({ page }, use) => {
await page.goto('https://www.devsamurai.com/login');
await page.fill('#username', 'testuser');
await page.fill('#password', 'password');
await page.click('#login-button');
await use(page);
// [Optional] Teardown after the test
},
});
test('Check after login', async ({ loggedInPage }) => {
await expect(loggedInPage.locator('.dashboard')).toBeVisible();
});
Explanation: We create a new fixture called loggedInPage based on the page fixture. The loggedInPage fixture performs login steps before providing the page to the test. The test case Check after login uses loggedInPage to have an already logged-in page.
We will create a worker-scoped fixture to initialize a shared environment variable.
import { test as base } from '@playwright/test';
type WorkerData = {
apiEndpoint: string;
};
export const test = base.extend<{}, WorkerData>({
apiEndpoint: [async ({}, use) => {
const endpoint = 'https://api.devsamurai.com/v1';
await use(endpoint);
console.log('Worker finished.');
}, { scope: 'worker' }],
});
test('Test 1 uses API endpoint', async ({ apiEndpoint }) => {
console.log(`Test 1 calls API at: ${apiEndpoint}/data1`);
// ... API call
});
test('Test 2 uses the same API endpoint', async ({ apiEndpoint }) => {
console.log(`Test 2 calls API at: ${apiEndpoint}/data2`);
// ... API call
});
Explanation: The apiEndpoint is a worker-scoped fixture. The value of the apiEndpoint is initialized once per worker and is shared by all tests within that worker.
We will create an automatic fixture to log the title of each test case.
import { test as base, TestInfo } from '@playwright/test';
export const test = base.extend<{ logTitle: void }>({
logTitle: [async ({}, use, testInfo: TestInfo) => {
console.log(`Starting test: ${testInfo.title}`);
await use();
console.log(`Finishing test: ${testInfo.title}`);
}, { auto: true }],
});
test('Test automatically logs title 1', async ({ page }) => {
await page.goto('https://www.devsamurai.com/');
await expect(page).toHaveScreenshot();
});
test('Test automatically logs title 2', async ({ page }) => {
await page.getByText('Solutions').click();
});
Explanation: logTitle is an automatic fixture. It will automatically run before and after each test case to log the title without needing to be declared in the test parameters.
We will set a separate timeout for a fixture with a long setup time.
import { test as base, expect } from '@playwright/test';
const test = base.extend<{ slowFixture: string }>({
slowFixture: [async ({}, use) => {
// ... perform a slow operation ...
await use('hello');
}, { timeout: 60000 }]
});
test('example test', async ({ slowFixture }) => {
// ...
});
Explanation: The slowFixture is a custom fixture defined using test.extend(). It has its own specific timeout set to 60000 milliseconds (60 seconds) using the { timeout: 60000 }
option in the fixture definition.
We will create an option fixture to configure the website language.
import { test as base } from '@playwright/test';
export type MyOptions = {
language: 'en' | 'vi';
};
export const test = base.extend<MyOptions>({
language: ['en', { option: true }], // Default value is 'en'
});
test('Test with language option', async ({ page, language }) => {
console.log(`Current language: ${language}`);
await page.goto(`https://www.devsamurai.com/${language}`);
await expect(page.locator('html')).toHaveAttribute('lang', language);
});
Explanation: language is an option fixture with a default value of 'en'. Users can change this value in playwright.config.ts
. The test case uses the language value to navigate to the website with the corresponding language.
We will combine two simple fixtures from two different files.
Create a fixture1.ts
file:
import { test as base1 } from '@playwright/test';
export const test1 = base1.extend<{ theme: string }>({
theme: async ({}, use) => {
await use('light');
},
});
Create a fixture2.ts file:
import { test as base2 } from '@playwright/test';
export const test2 = base2.extend<{ version: string }>({
version: async ({}, use) => {
await use('1.0');
},
});
Create a combined-fixtures.ts
file:
import { mergeTests } from '@playwright/test';
import { test1 } from './fixture1';
import { test2 } from './fixture2';
export const test = mergeTests(test1, test2);
// my-test.spec.ts
import { test, expect } from './combined-fixtures';
test('Test combining fixtures', async ({ theme, version }) => {
console.log(`Theme: ${theme}, Version: ${version}`);
expect(theme).toBe('light');
expect(version).toBe('1.0');
});
Explanation: test1 defines the theme fixture. test2 defines the version fixture. mergeTests combines test1 and test2 to create a new test with both fixtures.
We will box a fixture that performs a minor auxiliary action that is unimportant in the report.
import { test as base } from '@playwright/test';
export const test = base.extend({
logStep: [async ({}, use) => {
console.log('Performing an auxiliary step...');
await use();
}, { box: true }],
});
test('Test with boxed fixture', async ({ logStep, page }) => {
await page.goto('https://www.devsamurai.com/');
await expect(page).toHaveScreenshot();
});
Explanation: The logStep fixture is boxed, so the "Performing an auxiliary step..." step will not be displayed in detail in the report.
We will set a custom title for an important fixture.
import { test as base } from '@playwright/test';
export const test = base.extend({
apiClient: [async ({}, use) => {
const client = { call: (url: string) => console.log(`Calling API: ${url}`) };
await use(client);
}, { title: 'API Client' }],
});
Explanation: The apiClient
fixture will be displayed with the title "API Client" in the report instead of "apiClient".
We will create a global beforeEach hook to log the URL before each test.
Create a for-each-fixture.ts
file:
import { test as base } from '@playwright/test';
export const test = base.extend<{ forEachTest: void }>({
forEachTest: [async ({ page }, use) => {
// This code runs before every test.
await page.goto('https://www.google.com/');
await use();
// This code runs after every test.
console.log('Last URL:', page.url());
}, { auto: true }], // automatically starts for every test.
});
Create a mytest.spec.ts file:
import { test } from './for-each-fixture.ts';
import { expect } from '@playwright/test';
test('basic', async ({ page }) => {
expect(page).toHaveURL('https://www.google.com/');
await page.goto('https://www.devsamurai.com/');
});
Explanation:
test.beforeAll() and test.afterAll() run once before or after all tests in the same file (or within the same test.describe() block). If you want hooks to run once for all tests across multiple files, you should define them as auto fixtures with scope: 'worker', like this:
Create a global-setup-teardown.ts
file:
import { test as base } from '@playwright/test';
export const test = base.extend<{}, { logWorkerInfo: void }>({
logWorkerInfo: [async ({}, use, workerInfo) => {
console.log(`Worker ${workerInfo.workerIndex} started.`);
await use();
console.log(`Worker ${workerInfo.workerIndex} finished.`);
}, { scope: 'worker', auto: true }],
});
Create a another-test.spec.ts file:
import { test, expect } from './global-setup-teardown';
test('Test in worker', async ({ page }) => {
await page.goto('https://www.devsamurai.com/');
await expect(page).toHaveScreenshot();
});
Explanation: logWorkerInfo
is an automatic fixture with worker scope, running once when the worker starts and ends.
With Playwright, you can interact with your application's REST API. Instead of always testing through the web browser, you can send direct requests to the server using Node.js.
This is helpful for:
Playwright's APIRequestContext feature makes this possible within your tests.
In this Playwright tutorial, you’ll learn how to:
my-project/
│
├── tests/
│ └── API/
│ ├── api-fixtures.ts # 🔧 Custom API fixture definitions
│ └── api-testing.spec.ts # ✅ Test file using the fixtures
│
├── .env # 🔐 Stores GitHub API_TOKEN
└── playwright.config.ts # ⚙️ Playwright global configuration
.env
API_TOKEN=ghp_XXXXXXXXXXXXXXXXXXXXXXX
playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
// All requests we send go to this API endpoint.
baseURL: 'https://api.github.com',
extraHTTPHeaders: {
// We set this header per GitHub guidelines.
'Accept': 'application/vnd.github.v3+json',
// Add authorization token to all requests.
// Assuming personal access token available in the environment.
'Authorization': `token ${process.env.API_TOKEN}`,
},
}
});
If behind a proxy:
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
proxy: {
server: 'http://my-proxy:8080',
username: 'user',
password: 'secret'
},
}
});
In tests/API/api-fixtures.ts
file
import { test as base, expect, APIRequestContext } from '@playwright/test';
import dotenv from 'dotenv';
dotenv.config();
const REPO = 'test-repo';
const USER = 'your-github-username'; // Replace with your GitHub username
type MyFixtures = {
apiContext: APIRequestContext;
repoName: string;
githubUser: string;
};
export const test = base.extend<MyFixtures>({
apiContext: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: 'https://api.github.com',
extraHTTPHeaders: {
'Authorization': `token ${process.env.API_TOKEN}`,
'Accept': 'application/vnd.github+json',
},
});
await use(context);
await context.dispose();
},
repoName: async ({ apiContext }, use) => {
const res = await apiContext.post('/user/repos', {
data: { name: REPO },
});
expect(res.ok()).toBeTruthy();
await use(REPO);
const del = await apiContext.delete(`/repos/${USER}/${REPO}`);
expect(del.ok()).toBeTruthy();
},
githubUser: async (_, use) => {
await use(USER);
},
});
export { expect };
In tests/api-testing.spec.ts
file
import { test, expect } from './api-fixtures';
test('create a bug issue via API', async ({ apiContext, githubUser, repoName }) => {
const res = await apiContext.post(`/repos/${githubUser}/${repoName}/issues`, {
data: {
title: 'Bug issue',
body: 'Something is broken',
},
});
expect(res.ok()).toBeTruthy();
});
test('issue appears in UI after API create', async ({ apiContext, githubUser, repoName, page }) => {
const issueTitle = 'New feature';
const res = await apiContext.post(`/repos/${githubUser}/${repoName}/issues`, {
data: { title: issueTitle },
});
expect(res.ok()).toBeTruthy();
await page.goto(`https://github.com/${githubUser}/${repoName}/issues`);
const firstIssue = page.locator('a[data-hovercard-type="issue"]').first();
await expect(firstIssue).toHaveText(issueTitle);
});
api-fixtures.ts
Note:
Even though you're configuring baseURL and headers in api-fixtures.ts, it's still important to define them in playwright.config.ts because:
api-fixtures.ts
, you're creating a custom request context, which overrides the global config for that specific fixture.playwright.config.ts
for common setup, and fixture config when you need custom behavior or isolation (e.g. cleanup or custom headers).File uploads are common in web applications—think profile pictures, documents, or spreadsheets. Playwright makes it easy to test these workflows.
Example:
import { test, expect } from '@playwright/test';
test('upload a profile picture', async ({ page }) => {
await page.goto('https://your-app.com/profile');
// Select the file input and set the file
const fileChooserPromise = page.waitForEvent('filechooser');
await page.click('button#upload-avatar'); // Triggers the file input
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles('tests/assets/avatar.png');
// Submit the form and verify the upload
await page.click('button#submit');
await expect(page.locator('img.profile-pic')).toHaveAttribute('src', /avatar\.png/);
});
By default, Playwright runs your tests across all three major browsers—Chromium, Firefox, and WebKit—using three parallel workers to speed up execution.
To further optimize performance, Playwright supports parallel test execution across browsers, workers, and browser contexts. This is especially helpful for large test suites.
You can fine-tune these settings in the playwright.config
file to match your project's specific needs.
Enable Parallelism in playwright.config.ts
:
import { defineConfig } from '@playwright/test';
export default defineConfig({
workers: 4, // Adjust based on CPU cores
use: {
headless: true,
},
});
You can also define multiple projects to run across different browsers:
Running Playwright in your CI pipelines ensures your app works with every commit and deployment.
Popular CI tools:
Example: GitHub Actions CI config:
# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install
- run: npx playwright install --with-deps
- run: npx playwright test
You've stepped into the vast universe of Playwright! From setup and writing your first tests to mastering locators and diving into the powerful Trace Viewer, you've built a solid foundation in modern web automation.
Playwright isn’t just a tool—it’s a reliable ally, offering speed, stability, and solutions to the pain points of older frameworks. It makes testing today’s complex, interactive apps easier and more enjoyable.
Now it’s your turn to shine. Create, explore, and push the limits with custom scripts. Use Codegen to uncover hidden paths, and let Trace Viewer help you quickly solve any issues.
This is just the beginning. Keep learning, stay curious, and enjoy the journey toward becoming a true Playwright expert.
Happy testing!