Playwright for Java — Overview & Architecture
A complete mental model of Playwright's internals, hierarchy, and philosophy before writing a single line of test code.
Playwright is a modern end-to-end testing framework developed by Microsoft that enables reliable automation of Chromium, Firefox, and WebKit browsers using a single unified API. Unlike older tools, it communicates with browsers over the CDP / WebSocket, allowing atomic multi-page, multi-origin, and multi-tab scenarios without flakiness.
The Java bindings (playwright-java) wrap Playwright's Node.js core via a JVM-native process bridge — meaning Java calls are translated to and from the underlying Node process automatically, so you get full Java IDE support, Maven/Gradle integration, and TestNG/JUnit compatibility.
Big Picture: Object Hierarchy
Every Playwright automation lives inside a strict parent→child hierarchy. Understanding this is the key to understanding lifecycle, isolation, and cleanup:
Playwright.create(). Exposes chromium(), firefox(), webkit(). Must be closed after use.launch() a browser or connect() to a remote one.BrowserContext instances — like incognito windows.How Playwright Works Internally
This is the "how" that makes Playwright different from Selenium's WebDriver protocol:
page.click(selector) in Java, the JVM sends a JSON message via stdio to the spawned Node process, which then issues a CDP command to the browser. The response travels back the same way. This is transparent to you as a developer but explains why Playwright.create() must be called once and shared — spawning a new Node process per test is expensive.Playwright vs Selenium — Key Differences
| Aspect | Playwright (Java) | Selenium (Java) |
|---|---|---|
| Protocol | CDP/WebSocket Direct browser control | WebDriver Protocol HTTP REST to driver binary |
| Auto-waiting | Built-in Waits for actionable state automatically | Manual Requires explicit WebDriverWait |
| Browser Support | Chromium, Firefox, WebKit (Safari engine) | Chrome, Firefox, Edge, Safari (via drivers) |
| Test Isolation | BrowserContext — cheap, fast, per-test isolation | New WebDriver — heavier per-test setup |
| Network Interception | Native page.route() |
Via proxy only (BrowserMob, etc.) |
| Shadow DOM | Transparent — pierce by default | Manual JS execution needed |
| iFrames | frameLocator() first-class API | driver.switchTo().frame() context switch |
| Multi-tab | Native multiple Page objects |
Window handles — cumbersome |
| Screenshots | Full-page, element-level, video, trace | Screenshot only (no full-page natively) |
| Learning Curve | Moderate — new API concepts | Low initially, high for reliability |
Core Concepts at a Glance
- Auto-waiting: Before any action (click, fill, etc.), Playwright waits for the element to be visible, stable, not obscured, enabled, and editable. You rarely need
Thread.sleep(). - Actionability Checks: The 6 checks Playwright performs before acting — attached to DOM, visible, stable (not animating), receives events (not pointer-events:none), enabled (not disabled), editable (not readonly).
- Locator: A lazy selector description. Calling
page.locator("button")does NOT query the DOM — it creates a descriptor. The query happens at action time. - BrowserContext as Test Scope: Each test should get its own
BrowserContextfor complete isolation — cookies, storage, auth state are all scoped to a context. - Synchronous Java API: Unlike JavaScript Playwright (which is
async/await), the Java API is synchronous by default. All calls block until complete.
WebDriver ≈ Browser, WebElement ≈ Locator, WebDriverWait is built-in (no equivalent class needed), and instead of driver.findElement(By.xpath(...)) you write page.locator("xpath=..."). The fundamental shift is that Locators are lazy and always re-query — there are no StaleElementReferenceExceptions.Setup & Installation
Getting Playwright running in a Java Maven/Gradle project with TestNG and proper project structure.
Maven Dependency
Playwright Java is a single dependency on Maven Central. Always use the latest stable version:
<!-- playwright-java: single artifact, bundles the Node.js runtime --> <dependency> <groupId>com.microsoft.playwright</groupId> <artifactId>playwright</artifactId> <version>1.44.0</version> </dependency> <!-- TestNG for test runner (or use JUnit 5 — see section 15) --> <dependency> <groupId>org.testng</groupId> <artifactId>testng</artifactId> <version>7.9.0</version> <scope>test</scope> </dependency> <!-- AssertJ for fluent non-Playwright assertions (optional) --> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.25.3</version> <scope>test</scope> </dependency>
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.2.5</version> <configuration> <!-- Point to TestNG XML for suite/parallel config --> <suiteXmlFiles> <suiteXmlFile>testng.xml</suiteXmlFile> </suiteXmlFiles> </configuration> </plugin>
Gradle Setup
dependencies {
testImplementation 'com.microsoft.playwright:playwright:1.44.0'
testImplementation 'org.testng:testng:7.9.0'
}
test {
useTestNG() // tell Gradle to run via TestNG
}
Installing Browser Binaries
Playwright downloads its own browser binaries — separate from any system browsers. Run this once after adding the dependency:
# Via Maven exec plugin: mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI \ -D exec.args="install" # Install only Chromium (smaller CI footprint): mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI \ -D exec.args="install chromium" # With system dependencies (needed on Ubuntu CI images): mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI \ -D exec.args="install --with-deps"
~/.cache/ms-playwright/ on Linux/macOS and %USERPROFILE%\AppData\Local\ms-playwright\ on Windows. In CI, cache this directory to avoid re-downloading on every run.Recommended Project Structure
my-playwright-project/ ├── pom.xml ├── testng.xml ← suite configuration └── src/ ├── main/java/ │ └── com/example/ │ └── pages/ ← Page Object classes │ ├── BasePage.java │ ├── LoginPage.java │ └── DashboardPage.java └── test/java/ └── com/example/ ├── base/ │ └── BaseTest.java ← Playwright setup/teardown ├── tests/ │ ├── LoginTest.java │ └── DashboardTest.java └── utils/ └── TestUtils.java
First Test — Hello Playwright
import com.microsoft.playwright.*; import com.microsoft.playwright.options.*; public class HelloPlaywright { public static void main(String[] args) { // try-with-resources auto-closes Playwright (and all children) try (Playwright playwright = Playwright.create()) { // Launch Chromium in headless mode (default) Browser browser = playwright.chromium().launch(); // Create an isolated browsing session BrowserContext context = browser.newContext(); // Open a new tab Page page = context.newPage(); // Navigate to URL — blocks until navigation completes page.navigate("https://playwright.dev"); // Print page title System.out.println(page.title()); // → "Fast and reliable end-to-end testing..." // Close in reverse order (though try-with-resources handles this) context.close(); browser.close(); } // Playwright.close() called here automatically } }
Playwright, Browser, BrowserContext, Page) implement Java's AutoCloseable interface. Using try-with-resources ensures cleanup even if an exception occurs — essential in tests. Closing a parent closes all its children: closing Browser closes all its BrowserContexts and their Pages.TestNG Integration — testng.xml
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd"> <suite name="Playwright Suite" parallel="tests" <!-- each <test> runs in its own thread --> thread-count="4"> <test name="Chrome Tests"> <parameter name="browser" value="chromium"/> <classes> <class name="com.example.tests.LoginTest"/> <class name="com.example.tests.DashboardTest"/> </classes> </test> <test name="Firefox Tests"> <parameter name="browser" value="firefox"/> <classes> <class name="com.example.tests.LoginTest"/> </classes> </test> </suite>
Browser Management
Deep dive into the Playwright → BrowserType → Browser → BrowserContext chain — the foundation of every test.
The Playwright Interface — Entry Point
Think of Playwright as the factory that gives you access to browser types. In Java, it's the first object you create and the last you close.
// Interface: com.microsoft.playwright.Playwright // Implements: AutoCloseable Playwright pw = Playwright.create(); // spawns Node.js process Playwright pw2 = Playwright.create( new Playwright.CreateOptions() .setEnv(Map.of("PLAYWRIGHT_BROWSERS_PATH", "/custom/path")) ); // Access browser types: BrowserType chromium = pw.chromium(); // Chromium-based BrowserType firefox = pw.firefox(); // Gecko engine BrowserType webkit = pw.webkit(); // WebKit (Safari engine) pw.close(); // kills Node process, closes all browsers
BrowserType — Launching & Connecting
BrowserType is an interface representing one browser engine. Its most important method is launch() which spawns a browser process with the given options.
BrowserType.LaunchOptions options = new BrowserType.LaunchOptions() .setHeadless(false) // true = no browser window (CI default) .setSlowMo(100) // add 100ms delay between actions (debugging) .setTimeout(30000) // max time to launch browser (ms) .setChannel("chrome") // use installed Chrome instead of bundled Chromium // channel options: "chrome", "chrome-beta", "msedge", "msedge-dev" .setArgs(List.of( "--disable-gpu", // useful in Docker/CI "--no-sandbox", // required in some Linux CI environments "--window-size=1920,1080" )) .setDevtools(false) // open DevTools panel automatically .setExecutablePath(Paths.get("/usr/bin/google-chrome")); // custom binary Browser browser = playwright.chromium().launch(options);
// Connect to a Playwright server (e.g. playwright-grid or docker container) Browser browser = playwright.chromium().connect("ws://localhost:9222"); // Connect via Chrome DevTools Protocol directly // (useful with existing Chrome --remote-debugging-port=9222) Browser browser2 = playwright.chromium().connectOverCDP( "http://localhost:9222", new BrowserType.ConnectOverCDPOptions() .setTimeout(10000) ); // Get existing contexts on the connected browser List<BrowserContext> contexts = browser2.contexts();
// Creates a context that persists to disk (like a real user profile) // Useful when you need existing logins, extensions, etc. BrowserContext context = playwright.chromium().launchPersistentContext( Paths.get("/tmp/user-data-dir"), // path to store profile data new BrowserType.LaunchPersistentContextOptions() .setHeadless(false) .setViewportSize(1280, 720) ); // Note: returns BrowserContext directly (no separate Browser object) Page page = context.newPage();
BrowserContext — The Isolation Unit
This is the most important isolation concept in Playwright. Each BrowserContext is completely independent — it has its own cookies, localStorage, sessionStorage, and authentication state. Always create a new context per test.
Browser.NewContextOptions ctxOptions = new Browser.NewContextOptions() // ── Viewport ────────────────────────────────────────────────── .setViewportSize(1920, 1080) // set browser window size .setViewportSize(null) // null = no viewport (real browser size) // ── Auth: Basic HTTP ────────────────────────────────────────── .setHttpCredentials("user", "pass") // for HTTP Basic Auth popups // ── Ignore HTTPS errors ──────────────────────────────────────── .setIgnoreHTTPSErrors(true) // ignore SSL cert errors // ── Locale / Timezone ────────────────────────────────────────── .setLocale("en-US") .setTimezoneId("America/New_York") // ── Geolocation ─────────────────────────────────────────────── .setGeolocation(new Geolocation(40.7128, -74.0060)) // NYC .setPermissions(List.of("geolocation")) // ── User Agent ──────────────────────────────────────────────── .setUserAgent("Mozilla/5.0 (custom bot)") // ── Record Video ───────────────────────────────────────────── .setRecordVideoDir(Paths.get("videos/")) .setRecordVideoSize(1280, 720) // ── Load saved auth state ───────────────────────────────────── .setStorageStatePath(Paths.get("auth.json")) // reuse login session // ── Extra HTTP headers for all requests ─────────────────────── .setExtraHTTPHeaders(Map.of("X-Test-Header", "playwright")); BrowserContext context = browser.newContext(ctxOptions);
// Create multiple pages in same context (share cookies/storage) Page page1 = context.newPage(); Page page2 = context.newPage(); // List all pages in this context List<Page> pages = context.pages(); // Cookie management context.addCookies(List.of( new Cookie("session_id", "abc123") .setDomain("example.com") .setPath("/") .setHttpOnly(true) )); List<Cookie> cookies = context.cookies("https://example.com"); context.clearCookies(); // Save storage state (cookies + localStorage) for auth reuse context.storageState(new BrowserContext.StorageStateOptions() .setPath(Paths.get("auth.json"))); // Set default navigation timeout for all pages in this context context.setDefaultNavigationTimeout(30000); context.setDefaultTimeout(10000); // all other timeouts
Headed vs Headless — When to Use Each
| Mode | How to Set | Use Case | Performance |
|---|---|---|---|
| Headless (default) | .setHeadless(true) |
CI/CD, production test runs, parallel tests | Faster (~20-30%) |
| Headed | .setHeadless(false) |
Local debugging, visual verification | Slower — renders full UI |
| Headless New | .setChannel("chrome") + headless |
New headless mode — same as headed, no differences | Chrome 112+ |
@BeforeSuite for Playwright/Browser and @BeforeMethod for BrowserContext/Page in TestNG.Page API — The Workhorse
The Page interface is where all the real work happens — navigation, element interaction, JavaScript evaluation, and event handling.
The Page interface represents a single browser tab. It is the central object you interact with in every test. Every navigation, element query, and action goes through it. Understanding the Page API thoroughly is the difference between brittle and rock-solid tests.
Navigation Methods
// ── Basic navigate ──────────────────────────────────────────── page.navigate("https://example.com"); // ── With options ────────────────────────────────────────────── page.navigate("https://example.com", new Page.NavigateOptions() .setTimeout(60000) // override default 30s timeout .setWaitUntil(WaitUntilState.NETWORKIDLE) // WaitUntilState options: // COMMIT → wait for network response received (fastest) // DOMCONTENTLOADED → HTML parsed, scripts not yet loaded // LOAD → all resources loaded (default) // NETWORKIDLE → no network requests for 500ms (slowest, fragile) ); // ── Browser history ─────────────────────────────────────────── page.goBack(); // like pressing browser Back page.goBack(new Page.GoBackOptions().setTimeout(5000)); page.goForward(); page.reload(); page.reload(new Page.ReloadOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
Page State & Metadata
// URL and title String url = page.url(); // current URL String title = page.title(); // <title> tag content boolean closed = page.isClosed(); // has page been closed? // Full page HTML content String html = page.content(); // returns full HTML string // Set HTML directly (great for testing static HTML) page.setContent("<h1>Hello</h1>"); page.setContent(htmlString, new Page.SetContentOptions() .setWaitUntil(WaitUntilState.LOAD)); // Viewport page.setViewportSize(1280, 720); ViewportSize vp = page.viewportSize(); System.out.println(vp.width + "x" + vp.height); // Close this tab (not the browser) page.close(); // The BrowserContext this page belongs to BrowserContext ctx = page.context();
JavaScript Evaluation
One of Playwright's superpowers — execute arbitrary JavaScript inside the browser page context. This is the escape hatch for anything not natively supported.
// evaluate() — runs JS, returns a serializable Java value Object result = page.evaluate("() => document.title"); String title = (String) result; // Pass arguments to the JS function Object sum = page.evaluate("([a, b]) => a + b", Arrays.asList(3, 4)); // → 7 // evaluateHandle() — returns a JSHandle (reference to JS object in page) // Use when the return value is not serializable (DOM node, complex object) JSHandle handle = page.evaluateHandle("() => window"); // Get an ElementHandle from evaluateHandle ElementHandle el = (ElementHandle) page.evaluateHandle( "() => document.querySelector('#submit-btn')" ); // addScriptTag() — inject a script file or content into the page page.addScriptTag(new Page.AddScriptTagOptions() .setContent("window.myHelper = () => 'hello';") ); page.addScriptTag(new Page.AddScriptTagOptions() .setUrl("https://cdn.example.com/script.js") ); // addInitScript() — runs BEFORE page loads (on every navigation) // Perfect for mocking APIs before page code runs page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})"); // Expose a Java function to the page's JavaScript context page.exposeFunction("sha256", args -> { // args is Object[] from JS, return value goes back to JS return computeHash((String) args[0]); });
Page Events
Pages emit events for browser-level things like console logs, errors, requests, and dialogs. You listen to them with page.onXxx() methods.
// Console messages from the page (console.log, .error, etc.) page.onConsoleMessage(msg -> { System.out.println("CONSOLE [" + msg.type() + "] " + msg.text()); // msg.type() → "log", "warn", "error", "debug", "info" }); // Uncaught JS errors in the page page.onPageError(error -> { System.err.println("JS Error: " + error.message()); }); // New page opened (popup or window.open) page.onPopup(popup -> { popup.waitForLoadState(); System.out.println("New page: " + popup.url()); }); // Download events page.onDownload(download -> { Path savedPath = download.saveAs(Paths.get("downloads/" + download.suggestedFilename())); }); // Network request/response events page.onRequest(req -> System.out.println("→ " + req.url())); page.onResponse(res -> System.out.println("← " + res.status() + " " + res.url()));
waitFor* Methods on Page
// Wait for navigation to complete (use AFTER triggering navigation) page.waitForNavigation(() -> { page.click("#submit"); // action that triggers navigation }); // With options: page.waitForNavigation(new Page.WaitForNavigationOptions() .setWaitUntil(WaitUntilState.NETWORKIDLE) .setUrl("**/dashboard**"), // glob pattern — wait for this URL () -> page.click("#login-btn") ); // Wait for URL to match (simpler than waitForNavigation) page.waitForURL("**/dashboard"); page.waitForURL(Pattern.compile("https://example.com/user/\\d+")); // Wait for load state (DOMContentLoaded, load, networkidle) page.waitForLoadState(); // default: LOAD page.waitForLoadState(LoadState.DOMCONTENTLOADED); page.waitForLoadState(LoadState.NETWORKIDLE); // Wait for a custom JS condition to be truthy page.waitForFunction("() => window.myApp.loaded === true"); page.waitForFunction("selector => !!document.querySelector(selector)", "#dynamic-content"); // Wait for a network request matching pattern Request req = page.waitForRequest("**/api/users", () -> { page.click("#load-users"); }); // Wait for a network response Response resp = page.waitForResponse("**/api/data", () -> { page.click("#fetch-data"); }); System.out.println(resp.status()); // 200
page.waitForTimeout(2000) is a hardcoded sleep — it makes tests slow and flaky. Only acceptable for debugging. Always prefer waitForLoadState(), waitForURL(), assertThat(locator).isVisible(), or waitForResponse() instead.Locators — Finding Elements
Playwright's Locator API is fundamentally different from Selenium's WebElement. Understanding this distinction is critical for writing stable tests.
The Locator Mental Model
page.locator() over page.querySelector() / page.$$(). Locators are lazy (no DOM query at creation), auto-retry on failure, and immune to stale element issues. ElementHandle exists for backward compatibility — avoid it in new code.Locator Types — The Full Arsenal
1. CSS Selectors (most flexible)
// Basic CSS — same syntax as document.querySelector Locator btn = page.locator("button"); Locator byId = page.locator("#submit-btn"); Locator byClass = page.locator(".primary-btn"); Locator byAttr = page.locator("input[type='email']"); Locator child = page.locator("form .submit-row button"); // Playwright CSS extensions (not in standard CSS) // :has() — element that contains another Locator rowWithCheck = page.locator("tr:has(input[type='checkbox']:checked)"); // :has-text() — element containing specific text Locator deleteBtn = page.locator("button:has-text('Delete')"); // :text() — shorthand for text content match Locator saveBtn = page.locator(":text('Save Changes')"); // :text-is() — exact text match Locator exactBtn = page.locator(":text-is('Save')"); // won't match "Save Changes" // :visible — only visible elements Locator visible = page.locator("button:visible"); // :nth-match() — pick nth element from selector group (1-indexed) Locator second = page.locator(":nth-match(.item, 2)");
2. XPath
// Prefix with "xpath=" to use XPath syntax Locator byXpath = page.locator("xpath=//button[@id='submit']"); Locator byText = page.locator("xpath=//button[contains(text(),'Login')]"); Locator parent = page.locator("xpath=//input[@name='email']/ancestor::form"); // XPath shorthand (no prefix needed if starts with //) Locator shortXp = page.locator("//div[@class='container']//button"); // AVOID overly brittle XPath like: // /html/body/div[1]/div[2]/button[3] ← breaks on DOM changes
3. Role-Based / ARIA Locators (Recommended)
These are Playwright's recommended first-choice locators. They find elements by how they're perceived by assistive technologies — much more resilient to CSS/HTML changes.
// page.getByRole(AriaRole, options) // AriaRole mirrors ARIA roles: BUTTON, LINK, HEADING, CHECKBOX, etc. Locator submitBtn = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")); // matches: <button>Submit</button> or <button aria-label="Submit"> Locator nav = page.getByRole(AriaRole.NAVIGATION); Locator link = page.getByRole(AriaRole.LINK, new Page.GetByRoleOptions().setName("Home")); Locator exactHeading = page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions() .setName("Sign in") .setExact(true) // exact match (case-sensitive) .setLevel(1)); // h1 only // Common ARIA roles: // BUTTON, LINK, CHECKBOX, RADIO, TEXTBOX, COMBOBOX // HEADING, BANNER, NAVIGATION, MAIN, DIALOG // LIST, LISTITEM, TABLE, ROW, CELL // MENUITEM, TAB, TABPANEL, OPTION
4. Text Locators
// getByText() — matches visible text content (substring by default) Locator msg = page.getByText("Welcome back"); Locator exact = page.getByText("Sign in", new Page.GetByTextOptions().setExact(true)); Locator byRegex = page.getByText(Pattern.compile("Welcome.*")); // getByLabel() — finds form input associated with a <label> // Works with: for/id association, aria-label, aria-labelledby, wrapper label Locator email = page.getByLabel("Email address"); Locator pwd = page.getByLabel("Password"); email.fill("[email protected]"); // getByPlaceholder() — match by placeholder attribute Locator search = page.getByPlaceholder("Search products..."); // getByAltText() — for <img alt="..."> Locator logo = page.getByAltText("Company Logo");
5. Test ID Locators (Most Stable in Large Apps)
// HTML: <button data-testid="submit-order">Place Order</button> Locator orderBtn = page.getByTestId("submit-order"); // Default test ID attribute is "data-testid" // Change it globally in BrowserContext options: BrowserContext ctx = browser.newContext(new Browser.NewContextOptions() .setTestIdAttribute("data-cy") // for Cypress-style test IDs );
Filtering Locators
Chain .filter() to narrow down a set of matched elements:
// Find all rows, then filter to only those containing "John" Locator johnRow = page.getByRole(AriaRole.ROW) .filter(new Locator.FilterOptions().setHasText("John")); // Filter by contained locator (has another element inside) Locator checkedItems = page.getByRole(AriaRole.LISTITEM) .filter(new Locator.FilterOptions() .setHas(page.getByRole(AriaRole.CHECKBOX, new Page.GetByRoleOptions().setChecked(true)))); // Combine: items containing text "Active" AND having a checked checkbox Locator activeChecked = page.locator(".item") .filter(new Locator.FilterOptions().setHasText("Active")) .filter(new Locator.FilterOptions() .setHas(page.locator("input:checked")));
Chaining & Scoping Locators
// Find a button WITHIN a specific form (scoped search) Locator form = page.locator("#login-form"); Locator submitBtn = form.locator("button[type='submit']"); // ↑ Only searches within #login-form, not entire page // Find a row and then a cell within that row Locator row = page.getByRole(AriaRole.ROW).nth(1); Locator nameCell = row.getByRole(AriaRole.CELL).first(); // and() — additional constraints on same element Locator visibleBtn = page.getByRole(AriaRole.BUTTON) .and(page.locator(":visible"));
Nth Element Selection
Locator items = page.locator(".product-card"); // first(), last(), nth(index) — 0-based index Locator first = items.first(); Locator last = items.last(); Locator third = items.nth(2); // 0-indexed, so 2 = third item // Count matched elements int count = items.count(); // Iterate over all matched elements for (int i = 0; i < items.count(); i++) { String text = items.nth(i).innerText(); System.out.println("Item " + i + ": " + text); }
Auto-Waiting — The Secret to Playwright's Reliability
Before performing any action, Playwright automatically checks these 6 actionability conditions. If any fails, it retries until the timeout expires. This eliminates most waitForElement boilerplate from Selenium.
| Check | What it verifies | Actions that check it |
|---|---|---|
| Attached | Element exists in DOM | All actions |
| Visible | Not hidden, opacity > 0, dimensions > 0 | All actions |
| Stable | No CSS animations/transitions running | click, hover, dblclick |
| Receives events | Not covered by another element (pointer-events: none) | click, hover |
| Enabled | Not disabled (no disabled attribute) | click, fill, check |
| Editable | Not readonly | fill, type, clear |
Prefer user-visible attributes (role, label, text) because they reflect how real users find elements. Fall back to test IDs for dynamic content and CSS/XPath only when nothing else works.
Actions & Interactions
Every way to interact with page elements — clicks, typing, keyboard, drag & drop, file upload, and more.
Click Variations
Locator btn = page.locator("#submit"); // Basic click (left click, single) btn.click(); // Click with options btn.click(new Locator.ClickOptions() .setButton(MouseButton.RIGHT) // RIGHT, MIDDLE, or LEFT (default) .setClickCount(2) // double click via click count .setDelay(100) // ms delay between mousedown/mouseup .setPosition(new Position(10, 20)) // click at offset from element top-left .setModifiers(List.of(KeyboardModifier.SHIFT)) // Shift+click .setForce(true) // skip actionability checks (use carefully!) .setTimeout(5000)); // override wait timeout // Dedicated methods for common click types btn.dblclick(); // double click btn.dblclick(new Locator.DblclickOptions().setDelay(50)); page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Context Menu")) .click(new Locator.ClickOptions().setButton(MouseButton.RIGHT)); // right-click // tap() — for touch/mobile scenarios btn.tap();
Text Input — fill() vs type() vs pressSequentially()
| Method | Behavior | Use When |
|---|---|---|
fill(text) | Clears field, sets value atomically via JS event | Default choice — fast, reliable |
pressSequentially(text) | Types char by char, fires keydown/keypress/keyup | Apps with key-by-key handlers (typeahead) |
clear() | Clears input value only | Clear without re-fill |
type(text) | Deprecated alias for pressSequentially() | Avoid in new code |
Locator emailInput = page.getByLabel("Email"); // fill() — atomic: clears then sets value using native input events emailInput.fill("[email protected]"); emailInput.fill(""); // clear the field // pressSequentially() — simulates real keystroke by keystroke // Good for autocomplete, masked inputs, key listeners emailInput.pressSequentially("[email protected]", new Locator.PressSequentiallyOptions().setDelay(50)); // 50ms between keys // clear() — clears the input field emailInput.clear(); // inputValue() — get current value of input/textarea/select String currentVal = emailInput.inputValue(); // Getting text content String innerText = page.locator("h1").innerText(); // visible text String innerHTML = page.locator("div").innerHTML(); // HTML content String textContent = page.locator("p").textContent(); // all text including hidden // getAttribute() — read any attribute String href = page.locator("a.logo").getAttribute("href");
Keyboard Interactions
// press() on a locator — press key while element is focused page.getByRole(AriaRole.TEXTBOX).press("Enter"); page.locator("input").press("Tab"); // move to next field page.locator("input").press("Control+A"); // select all page.locator("input").press("Control+C"); // copy page.locator("input").press("Backspace"); // delete last char page.locator("input").press("Escape"); // dismiss page.locator("input").press("ArrowDown"); // navigate list // page.keyboard — low-level keyboard control page.keyboard().press("F5"); // refresh page page.keyboard().press("Control+Shift+I"); // DevTools page.keyboard().down("Shift"); // hold Shift page.keyboard().press("ArrowRight"); // while Shift held page.keyboard().up("Shift"); // release Shift page.keyboard().type("Hello World"); // type text (focused element) // Key names (Playwright follows DOM KeyboardEvent.key convention): // Single chars: "a", "A", "1", "@" // Special: "Enter", "Tab", "Escape", "Space", "Backspace", "Delete" // Arrows: "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight" // Function: "F1" - "F12" // Modifiers: "Control", "Shift", "Alt", "Meta" (Cmd on Mac) // Combinations: "Control+A", "Shift+Tab", "Meta+Enter"
Hover, Focus, Blur
// hover() — move mouse over element (triggers :hover CSS, tooltip, etc.) page.locator(".menu-item").hover(); page.locator("#tooltip-trigger").hover(new Locator.HoverOptions() .setPosition(new Position(5, 5)) // hover at offset .setForce(true)); // skip actionability checks // focus() / blur() — programmatically focus/unfocus an element page.locator("input[name='email']").focus(); page.locator("input[name='email']").blur(); // triggers blur/focusout events // Scroll into view page.locator("#footer-section").scrollIntoViewIfNeeded(); // page.mouse — low-level mouse control page.mouse().move(400, 300); // move to (x, y) page.mouse().down(); // press left button page.mouse().up(); // release page.mouse().wheel(0, 300); // scroll down 300px page.mouse().click(400, 300); // click at absolute coords
Drag & Drop
// Method 1: dragTo() — simplest, drag one locator to another Locator source = page.locator("#drag-item"); Locator target = page.locator("#drop-zone"); source.dragTo(target); // With options: source.dragTo(target, new Locator.DragToOptions() .setSourcePosition(new Position(10, 10)) // grab point on source .setTargetPosition(new Position(20, 20)) // drop point on target .setForce(true)); // skip actionability checks // Method 2: Manual drag using page.mouse (for complex DnD libraries) BoundingBox srcBox = source.boundingBox(); // get element coordinates BoundingBox tgtBox = target.boundingBox(); page.mouse().move(srcBox.x + srcBox.width/2, srcBox.y + srcBox.height/2); page.mouse().down(); // Slow move — triggers dragover events page.mouse().move(tgtBox.x + tgtBox.width/2, tgtBox.y + tgtBox.height/2, new Mouse.MoveOptions().setSteps(10)); // move in 10 steps (smooth) page.mouse().up();
Checkboxes, Radio Buttons & Dropdowns
// ── Checkboxes ──────────────────────────────────────────────── Locator checkbox = page.getByRole(AriaRole.CHECKBOX, new Page.GetByRoleOptions().setName("Terms and Conditions")); checkbox.check(); // check (no-op if already checked) checkbox.uncheck(); // uncheck (no-op if already unchecked) checkbox.setChecked(true); // set to specific state boolean checked = checkbox.isChecked(); // ── Select / Dropdown ───────────────────────────────────────── Locator dropdown = page.locator("select#country"); // Select by value attribute dropdown.selectOption("US"); // Select by visible label text dropdown.selectOption(new SelectOption().setLabel("United States")); // Select by index (0-based) dropdown.selectOption(new SelectOption().setIndex(2)); // Select multiple options (for multi-select) dropdown.selectOption(new String[]{ "US", "CA", "GB" }); // Get selected value(s) List<String> selected = dropdown.inputValue() != null ? List.of(dropdown.inputValue()) : List.of();
File Upload
// Method 1: setInputFiles() — directly set on <input type="file"> page.locator("input[type='file']").setInputFiles(Paths.get("test-files/invoice.pdf")); // Multiple files page.locator("input[type='file']").setInputFiles(new Path[] { Paths.get("file1.jpg"), Paths.get("file2.jpg") }); // Upload from byte array (when file is in memory, not disk) page.locator("input[type='file']").setInputFiles(new FilePayload( "test.txt", // filename "text/plain", // mimeType "File content here".getBytes() )); // Clear file input page.locator("input[type='file']").setInputFiles(new Path[0]); // Method 2: Use filechooser event for hidden file inputs FileChooser fileChooser = page.waitForFileChooser(() -> { page.getByText("Upload Document").click(); }); fileChooser.setFiles(Paths.get("document.pdf"));
locator.scrollIntoViewIfNeeded() — scrolls the element to viewport. For scrolling the page: page.mouse().wheel(deltaX, deltaY) or page.evaluate("window.scrollBy(0, 500)").Assertions
Playwright's built-in assertThat() API provides auto-retrying assertions — the difference between flaky and reliable test verification.
Playwright assertions in Java come from the com.microsoft.playwright.assertions.PlaywrightAssertions class. Unlike standard JUnit/TestNG assertions that check once and fail immediately, Playwright assertions auto-retry until the condition is met or the timeout expires (default 5 seconds). This makes them immune to timing issues like late renders.
Import: import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
Element (Locator) Assertions
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; Locator btn = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit")); // ── Visibility ──────────────────────────────────────────────── assertThat(btn).isVisible(); // element is visible in DOM assertThat(btn).isHidden(); // element is not visible (hidden or not in DOM) assertThat(btn).isAttached(); // element exists in DOM (may be hidden) assertThat(btn).not().isVisible(); // negation: NOT visible // ── Enabled/Disabled State ──────────────────────────────────── assertThat(btn).isEnabled(); // not disabled assertThat(btn).isDisabled(); // has disabled attribute assertThat(btn).isEditable(); // input/textarea not readonly // ── Text Content ────────────────────────────────────────────── assertThat(page.locator("h1")).hasText("Welcome"); // hasText: normalizes whitespace, substring match on single element assertThat(page.locator("h1")).hasText(Pattern.compile("Welc.*")); // regex assertThat(page.locator("p.intro")).containsText("hello"); // containsText: substring match (vs hasText which is full match for single) // For a locator matching MULTIPLE elements, pass an array: assertThat(page.locator(".todo-item")).hasText(new String[] { "Buy groceries", "Walk dog", "Read book" }); // asserts all 3 items in order assertThat(page.locator(".tag")).containsText(new String[] { "Java", "UI" }); // ── Form Values ─────────────────────────────────────────────── assertThat(page.getByLabel("Email")).hasValue("[email protected]"); assertThat(page.getByLabel("Email")).hasValue(Pattern.compile(".*@.*\\.com")); // For multi-select, checkbox groups: assertThat(page.locator("select#size")).hasValues(new String[] { "M", "L" }); // ── Checkbox state ──────────────────────────────────────────── assertThat(page.getByRole(AriaRole.CHECKBOX)).isChecked(); assertThat(page.getByRole(AriaRole.CHECKBOX)).not().isChecked(); // ── Count ───────────────────────────────────────────────────── assertThat(page.locator(".product-card")).hasCount(6); assertThat(page.locator(".error-msg")).hasCount(0); // assert none exist // ── Attributes & CSS ────────────────────────────────────────── assertThat(page.locator("a.home")).hasAttribute("href", "/home"); assertThat(page.locator("a.home")).hasAttribute("href", Pattern.compile(".*home.*")); assertThat(page.locator("button.danger")).hasClass("btn btn-danger"); assertThat(page.locator("div")).hasCSS("display", "flex"); assertThat(page.locator("p")).hasCSS("color", "rgb(255, 0, 0)");
Page Assertions
// URL assertions — most commonly needed after navigation assertThat(page).hasURL("https://example.com/dashboard"); assertThat(page).hasURL(Pattern.compile(".*dashboard.*")); assertThat(page).hasURL("**/dashboard"); // glob pattern // Title assertion assertThat(page).hasTitle("My App — Dashboard"); assertThat(page).hasTitle(Pattern.compile(".*Dashboard.*"));
APIResponse Assertions
APIResponse response = page.request().get("https://api.example.com/users"); assertThat(response).isOK(); // status code 200-299 assertThat(response).hasStatus(200); // exact status code assertThat(response).hasURL("**/users"); assertThat(response).hasHeader("content-type", "application/json");
Assertion Options — Timeout & Custom Message
// Change timeout for one assertion (default: 5000ms) assertThat(page.locator("#spinner")).isHidden( new LocatorAssertions.IsHiddenOptions().setTimeout(15000) ); // Change global assertion timeout (affects all assertThat calls) PlaywrightAssertions.setDefaultAssertionTimeout(10000); // 10 seconds
Soft Assertions — Don't Stop on First Failure
Standard assertions fail the test immediately on first failure. Soft assertions (via SoftAssertions) collect all failures and report them together at the end. Ideal for validating multiple fields on a single page without aborting at the first issue.
import com.microsoft.playwright.assertions.SoftAssertions; @Test public void validateOrderSummaryPage() { page.navigate("https://shop.example.com/order/12345"); // Create soft assertions scoped to this test SoftAssertions softly = SoftAssertions.create(); // All of these run even if one fails softly.assertThat(page).hasTitle("Order #12345"); softly.assertThat(page.locator(".order-status")).hasText("Confirmed"); softly.assertThat(page.locator(".item-count")).hasText("3 items"); softly.assertThat(page.locator(".total-price")).hasText("$149.99"); softly.assertThat(page.locator("#shipping-address")).isVisible(); softly.assertThat(page.locator("#tracking-number")).isVisible(); // assertAll() throws with ALL failures aggregated if any failed softly.assertAll(); // Error message lists EVERY failed assertion, not just the first }
| assertThat() (Hard) | SoftAssertions |
|---|---|
| Fails immediately on first error | Collects all errors, throws at assertAll() |
| Fast feedback on first defect | Full picture of all failures in one run |
| Best for single-concern tests | Best for form validation, summary pages |
assertThat(locator)... | softly.assertThat(locator)... |
assertThat() assertions retry until timeout. This means you don't need to add explicit waits before assertions. assertThat(locator).isVisible() will keep retrying until the element appears or 5 seconds pass. Compare with assertTrue(locator.isVisible()) — that's a one-shot check that can fail if called too early.Waiting Strategies
Understanding when and how Playwright waits — auto-waiting, explicit waits, and patterns to eliminate flakiness.
Waiting is the #1 source of flakiness in browser tests. Playwright's auto-waiting covers ~90% of cases. The remaining 10% needs explicit strategies — knowing which to use is the mark of a Playwright expert.
Auto-Waiting Decision Tree
waitForLoadState() — Page Load Events
// DOMCONTENTLOADED → HTML parsed, sync scripts run, deferred scripts queued // LOAD → all resources (images, scripts) fetched (default) // NETWORKIDLE → no network activity for 500ms (use with caution) page.navigate("https://app.example.com"); page.waitForLoadState(LoadState.NETWORKIDLE); // wait for SPA to finish loading // After a click that triggers async content load: page.locator("#load-more").click(); page.waitForLoadState(LoadState.DOMCONTENTLOADED); // Frame-level wait: page.frameLocator("#my-iframe").locator("body").waitFor();
locator.waitFor() — Wait for Element State
// Wait for element to appear (default state = VISIBLE) page.locator("#success-message").waitFor(); // Wait for specific state: page.locator("#spinner").waitFor( new Locator.WaitForOptions() .setState(WaitForSelectorState.HIDDEN) // wait to disappear .setTimeout(10000) ); // WaitForSelectorState options: // ATTACHED — element in DOM (may be hidden) // DETACHED — element removed from DOM // VISIBLE — element visible (default) // HIDDEN — element not visible or not in DOM // Equivalent using assertThat (preferred — gives better error messages): assertThat(page.locator("#spinner")).isHidden( new LocatorAssertions.IsHiddenOptions().setTimeout(10000));
waitForResponse() and waitForRequest()
// Wait for a specific API call to complete, then act on the response Response apiResp = page.waitForResponse( resp -> resp.url().contains("/api/checkout") && resp.status() == 200, () -> page.locator("#checkout-btn").click() // action that triggers request ); // Parse response body as JSON JsonObject body = apiResp.json().getAsJsonObject(); // Simpler form with glob URL pattern: Response r = page.waitForResponse("**/api/checkout", () -> { page.locator("#checkout-btn").click(); }); // Wait for request to be SENT (not response received): Request req = page.waitForRequest("**/api/search*", () -> { page.getByPlaceholder("Search...").fill("playwright"); page.keyboard().press("Enter"); }); // Inspect what was sent: System.out.println(req.url()); // URL with query params System.out.println(req.postData()); // POST body (if any)
waitForFunction() — Custom JS Conditions
// Wait until a JavaScript expression returns truthy // Playwright polls this every 100ms until truthy or timeout page.waitForFunction("() => window.appReady === true"); // With timeout: page.waitForFunction("() => document.querySelectorAll('.item').length > 5", null, new Page.WaitForFunctionOptions() .setTimeout(15000) .setPollingInterval(500) // check every 500ms (default: raf = every animation frame) ); // Pass a Java value as argument to JS: page.waitForFunction("(count) => document.querySelectorAll('.row').length >= count", 10);
LoadState.NETWORKIDLE waits for 500ms of no network activity. This can be very slow if the app has background polling, analytics, or WebSocket connections. Prefer waitForResponse() for specific API calls or assertThat(locator).isVisible() for specific UI states instead.Network & API Testing
Intercept, mock, and assert on network traffic — one of Playwright's most powerful features.
Request Interception with route()
The page.route() method registers a handler for all requests matching a URL pattern. This is the foundation for mocking, blocking, and modifying network traffic.
// ── Block all image requests (speed up tests) ────────────────── page.route("**/*.{png,jpg,jpeg,gif,webp,svg}", route -> route.abort()); // ── Mock a specific API endpoint ────────────────────────────── page.route("**/api/users", route -> { route.fulfill(new Route.FulfillOptions() .setStatus(200) .setContentType("application/json") .setBody(""" [ {"id": 1, "name": "Alice", "role": "admin"}, {"id": 2, "name": "Bob", "role": "user"} ] """) ); }); page.navigate("https://app.example.com"); // → page receives mocked user data, not real API response // ── Modify an outgoing request (add auth header) ────────────── page.route("**/api/**", route -> { Map<String, String> headers = new HashMap<>(route.request().headers()); headers.put("Authorization", "Bearer test-token"); route.continue(new Route.ContinueOptions().setHeaders(headers)); }); // ── Simulate error response ─────────────────────────────────── page.route("**/api/payment", route -> { route.fulfill(new Route.FulfillOptions() .setStatus(503) .setBody("{\"error\": \"Service Unavailable\"}") ); }); // ── Simulate network failure ────────────────────────────────── page.route("**/api/slow-endpoint", route -> route.abort("connectionreset")); // abort() error types: "aborted", "accessdenied", "connectionclosed", // "connectionfailed", "connectionrefused", "connectionreset", // "failed", "timedout"
// Fetch from real server, then modify the response page.route("**/api/products", route -> { // Let the real request go through APIResponse realResponse = route.fetch(); // Parse JSON String body = realResponse.text(); // Modify it (e.g., inject a test product) String modified = body.replace("]", ",{\"id\":9999,\"name\":\"Test Product\",\"price\":0}]"); // Return modified response route.fulfill(new Route.FulfillOptions() .setResponse(realResponse) // inherit status/headers from real .setBody(modified) // but override body ); }); // Remove a route handler Consumer<Route> handler = route -> route.continue(); page.route("**/api/**", handler); page.unroute("**/api/**", handler); // remove specific handler page.unrouteAll(); // remove all routes // Route once (fires only for the next matching request) page.routeOnce("**/api/login", route -> { route.fulfill(new Route.FulfillOptions().setStatus(401)); });
BrowserContext-Level Routing
Context-level routes apply to ALL pages in a context — useful for auth headers or global mocks:
// Apply to all pages in this context context.route("**/api/**", route -> { Map<String, String> headers = new HashMap<>(route.request().headers()); headers.put("X-Test-Mode", "true"); route.continue(new Route.ContinueOptions().setHeaders(headers)); }); // Block all analytics/tracking (good base for all tests) context.route("**/{analytics,tracking,telemetry}/**", route -> route.abort());
APIRequestContext — Direct HTTP Calls
For pure API testing (no browser UI needed) or for test setup/teardown via API (e.g., seeding test data):
// Get APIRequestContext from page (shares cookies/auth with browser) APIRequestContext api = page.request(); // Or create standalone context (no browser needed) APIRequestContext standaloneApi = playwright.request().newContext( new APIRequest.NewContextOptions() .setBaseURL("https://api.example.com") .setExtraHTTPHeaders(Map.of( "Authorization", "Bearer " + token, "Accept", "application/json" )) ); // ── GET request ─────────────────────────────────────────────── APIResponse getResp = standaloneApi.get("/users"); assertThat(getResp).isOK(); String body = getResp.text(); // ── POST request ────────────────────────────────────────────── APIResponse postResp = standaloneApi.post("/users", RequestOptions.create() .setData(Map.of("name", "Alice", "email", "[email protected]")) ); assertThat(postResp).hasStatus(201); // ── PUT / PATCH / DELETE ────────────────────────────────────── standaloneApi.put("/users/1", RequestOptions.create().setData(Map.of("name", "Bob"))); standaloneApi.patch("/users/1", RequestOptions.create().setData(Map.of("role", "admin"))); standaloneApi.delete("/users/1"); // ── Query parameters ────────────────────────────────────────── APIResponse filtered = standaloneApi.get("/products", RequestOptions.create() .setQueryParam("category", "electronics") .setQueryParam("page", 1) ); // → GET /products?category=electronics&page=1 // ── File upload via API ──────────────────────────────────────── standaloneApi.post("/upload", RequestOptions.create().setMultipart( FormData.create() .append("file", Paths.get("test.pdf")) .append("description", "Test document") ) ); standaloneApi.dispose(); // clean up the context
Frames & iFrames
Playwright's frameLocator() makes iFrame interaction first-class — no context-switching needed like in Selenium.
Unlike Selenium's driver.switchTo().frame() which changes the global driver context, Playwright keeps you scoped. You create a FrameLocator that represents the iframe, and then use it exactly like a page locator — all queries are scoped inside that frame.
frameLocator() — The Playwright Way
// ── Basic iframe access ──────────────────────────────────────── // HTML: <iframe id="payment-frame" src="https://payment.example.com"> FrameLocator payFrame = page.frameLocator("#payment-frame"); // Now interact with elements INSIDE the iframe: payFrame.getByLabel("Card Number").fill("4111 1111 1111 1111"); payFrame.getByLabel("Expiry").fill("12/25"); payFrame.getByLabel("CVV").fill("123"); payFrame.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Pay")).click(); // ── Find frame by URL (useful for dynamic IDs) ───────────────── FrameLocator byUrl = page.frameLocator("iframe[src*='payment']"); // ── Nested frames ────────────────────────────────────────────── // Page → iframe#outer → iframe#inner FrameLocator inner = page .frameLocator("#outer-frame") .frameLocator("#inner-frame"); inner.getByRole(AriaRole.TEXTBOX).fill("nested iframe content"); // ── Use locator().contentFrame() for Frame object ────────────── // When you need the Frame object (not FrameLocator) Frame frame = page.locator("iframe#my-frame").contentFrame(); String frameUrl = frame.url(); frame.waitForLoadState(LoadState.LOAD);
Frame Object API (Low-Level)
// Get all frames in the page List<Frame> frames = page.frames(); // Get frame by name attribute: <iframe name="checkout"> Frame byName = page.frame("checkout"); // Get frame by URL Frame byUrl = page.frameByUrl("**/checkout/**"); // Main frame (the page itself) Frame main = page.mainFrame(); // Frame info System.out.println(frame.name()); // iframe name attribute System.out.println(frame.url()); // frame's current URL System.out.println(frame.title()); // frame's document title System.out.println(frame.parentFrame() == null ? "main" : "child");
FrameLocator (from page.frameLocator()) is the preferred modern API — it uses the Locator model with auto-waiting. Frame (from page.frames()) is the lower-level object representing the actual frame. Use FrameLocator for element interaction, Frame for frame metadata (URL, name) or JavaScript evaluation inside the frame.Dialogs & Pop-ups
Handling browser-native dialogs (alert/confirm/prompt) and multi-page pop-ups the Playwright way.
Dialog Handling — alert, confirm, prompt
Native browser dialogs block JavaScript execution. Playwright handles them via the dialog event. You must register the handler BEFORE the action that triggers the dialog.
// ── Alert (only dismiss/accept, no input) ───────────────────── page.onDialog(dialog -> { System.out.println("Alert says: " + dialog.message()); dialog.accept(); // dismiss the alert (OK) }); page.locator("#trigger-alert").click(); // ── Confirm dialog ──────────────────────────────────────────── page.onDialog(dialog -> { System.out.println("Dialog type: " + dialog.type()); // "confirm" System.out.println("Message: " + dialog.message()); dialog.dismiss(); // Click "Cancel" // dialog.accept(); → Click "OK" }); page.locator("#delete-btn").click(); // ── Prompt dialog (has text input) ─────────────────────────── page.onDialog(dialog -> { System.out.println("Default value: " + dialog.defaultValue()); dialog.accept("My custom input"); // type into prompt and confirm }); // ── dialog.type() possible values ─────────────────────────── // "alert" → window.alert("message") // "confirm" → window.confirm("message") → true/false // "prompt" → window.prompt("message", "default") → string // "beforeunload" → triggered on page close // ── One-time dialog handler ─────────────────────────────────── // Use once() to auto-remove after first dialog page.once("dialog", dialog -> dialog.accept());
New Page / Pop-up Windows
When a click opens a new tab or window.open() triggers, Playwright emits a popup event on the originating page. You must set up the listener BEFORE the triggering action.
// Method 1: waitForPopup() — clean synchronous approach Page popup = page.waitForPopup(() -> { page.locator("a[target='_blank']").click(); // link opens new tab }); popup.waitForLoadState(LoadState.LOAD); assertThat(popup).hasURL("**/terms"); popup.close(); // Method 2: onPopup() event listener page.onPopup(newPage -> { newPage.waitForLoadState(); System.out.println("Popup URL: " + newPage.url()); // interact with the popup... newPage.close(); }); page.locator("#open-help").click(); // Multiple pop-ups — handle each via context // New pages also appear in context.pages() List<Page> allPages = context.pages(); // allPages.get(0) → original page // allPages.get(1) → first popup // allPages.get(2) → second popup // context.waitForPage() — similar to page.waitForPopup() but on context level Page newTab = context.waitForPage(() -> { page.keyboard().press("Control+T"); // open new tab via keyboard });
Download Handling
// waitForDownload() — use BEFORE the download-triggering action Download download = page.waitForDownload(() -> { page.locator("#export-csv").click(); }); // File info System.out.println(download.suggestedFilename()); // "report.csv" System.out.println(download.url()); // download URL // Save to specific path download.saveAs(Paths.get("downloads/report.csv")); // Get path in temp folder (available until context closes) Path tempPath = download.path(); // Cancel in-progress download download.cancel(); // Delete temp file (cleanup) download.delete();
Screenshots, Video & Tracing
Playwright's built-in diagnostics — from simple screenshots to full trace files you can inspect in a browser.
Screenshots
// ── Full page screenshot (scrolls entire page) ──────────────── page.screenshot(new Page.ScreenshotOptions() .setPath(Paths.get("screenshots/full-page.png")) .setFullPage(true) ); // ── Viewport screenshot (just visible area) ─────────────────── page.screenshot(new Page.ScreenshotOptions() .setPath(Paths.get("screenshots/viewport.png")) ); // ── Element-level screenshot ────────────────────────────────── page.locator("#order-summary").screenshot(new Locator.ScreenshotOptions() .setPath(Paths.get("screenshots/order-summary.png")) ); // ── Screenshot as byte array (for test reporters / base64) ──── byte[] screenshotBytes = page.screenshot( new Page.ScreenshotOptions().setType(ScreenshotType.JPEG).setQuality(80) ); // Attach to TestNG report or allure: Reporter.log("<img src='data:image/jpeg;base64," + Base64.getEncoder().encodeToString(screenshotBytes) + "'/>"); // ── Clip to specific region ─────────────────────────────────── page.screenshot(new Page.ScreenshotOptions() .setClip(new Clip(0, 0, 800, 400)) // x, y, width, height .setPath(Paths.get("header-only.png")) ); // ── Screenshot options ──────────────────────────────────────── // .setType(ScreenshotType.PNG) ← default, lossless // .setType(ScreenshotType.JPEG) ← smaller files, lossy // .setQuality(80) ← JPEG quality 0-100 // .setOmitBackground(true) ← transparent background (PNG only) // .setAnimations(AnimationsPolicy.DISABLED) ← stop CSS animations // .setCaret(CaretVisibility.HIDE) ← hide text cursor // .setScale(ScreenshotScale.CSS) ← CSS pixels (default) or DEVICE // .setTimeout(5000) ← max wait for screenshot
Video Recording
Configure at BrowserContext level — all pages in the context get recorded:
BrowserContext context = browser.newContext( new Browser.NewContextOptions() .setRecordVideoDir(Paths.get("videos/")) .setRecordVideoSize(1280, 720) // optional resolution ); Page page = context.newPage(); // ... run your test ... // Video is finalized when page OR context is closed // Save path BEFORE closing (path() returns null after close) Path videoPath = page.video().path(); page.close(); // After page close, the video is written to disk // Rename/move it: page.video().saveAs(Paths.get("videos/test-login.webm")); page.video().delete(); // delete temp file
Trace Viewer — Playwright's Superpower for Debugging
Traces capture a full record of a test run: screenshots, network activity, console logs, action calls, and DOM snapshots. You can replay and inspect them in a browser — the ultimate debugging tool.
// ── Start tracing ───────────────────────────────────────────── context.tracing().start(new Tracing.StartOptions() .setScreenshots(true) // capture screenshot on each action .setSnapshots(true) // capture DOM snapshot (enables timeline inspection) .setSources(true) // include source files in trace .setTitle("Login Test") // label in Trace Viewer ); Page page = context.newPage(); // ... run test ... // ── Stop and save trace ─────────────────────────────────────── context.tracing().stop(new Tracing.StopOptions() .setPath(Paths.get("traces/login-test.zip")) ); // ── Chunks: start once, take snapshot per test ──────────────── context.tracing().start(new Tracing.StartOptions().setScreenshots(true).setSnapshots(true)); // Before each test: context.tracing().startChunk(new Tracing.StartChunkOptions().setTitle("Test 1")); // ... test runs ... context.tracing().stopChunk(new Tracing.StopChunkOptions().setPath(Paths.get("traces/test1.zip"))); context.tracing().startChunk(new Tracing.StartChunkOptions().setTitle("Test 2")); // ... next test ... context.tracing().stopChunk(new Tracing.StopChunkOptions().setPath(Paths.get("traces/test2.zip")));
# Open trace file in Playwright Trace Viewer (opens a browser): mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI \ -D exec.args="show-trace traces/login-test.zip" # Or online (no install needed): # Go to https://trace.playwright.dev and drag-drop your .zip file
Storage & Authentication State
Managing cookies, localStorage, sessionStorage, and reusing auth state across tests for massive speed gains.
Cookies
// Add cookies to context (affects all pages) context.addCookies(List.of( new Cookie("session_token", "abc123xyz") .setDomain("example.com") .setPath("/") .setHttpOnly(true) .setSecure(true) .setSameSite(SameSiteAttribute.LAX) .setExpires(-1) // -1 = session cookie (no expiry) )); // Read cookies (optionally filtered by URL) List<Cookie> cookies = context.cookies(); List<Cookie> forSite = context.cookies(List.of("https://example.com")); cookies.forEach(c -> System.out.println(c.name + "=" + c.value)); // Clear all cookies context.clearCookies();
localStorage & sessionStorage
// localStorage persists across browser sessions (for a given origin) // sessionStorage lives only for the current tab session // Set localStorage item page.evaluate("() => localStorage.setItem('theme', 'dark')"); page.evaluate("([key, val]) => localStorage.setItem(key, val)", Arrays.asList("user_prefs", "{\"lang\":\"en\"}")); // Get localStorage item String theme = (String) page.evaluate("() => localStorage.getItem('theme')"); // Clear all localStorage page.evaluate("() => localStorage.clear()"); // sessionStorage — same API page.evaluate("() => sessionStorage.setItem('token', 'xyz')");
StorageState — Reuse Auth Across Tests (Critical Pattern)
The most impactful performance optimization in Playwright. Log in once, save state, and reuse it. Your tests never log in again — saving potentially seconds per test.
// ── Step 1: Create auth.json once (run before your test suite) ─ public void saveAuthState() { try (Playwright pw = Playwright.create()) { Browser browser = pw.chromium().launch(); BrowserContext context = browser.newContext(); Page page = context.newPage(); // Perform login once page.navigate("https://app.example.com/login"); page.getByLabel("Email").fill("[email protected]"); page.getByLabel("Password").fill("securepassword"); page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Login")).click(); assertThat(page).hasURL("**/dashboard"); // Save cookies + localStorage to file context.storageState(new BrowserContext.StorageStateOptions() .setPath(Paths.get("auth.json")) ); // auth.json contains: {"cookies":[...], "origins":[{"origin":"...","localStorage":[...]}]} } } // ── Step 2: Load saved state in every test ──────────────────── public BrowserContext createLoggedInContext() { return browser.newContext(new Browser.NewContextOptions() .setStorageStatePath(Paths.get("auth.json")) // inject saved auth ); // Test opens already-logged-in — no login page needed }
auth.json is a plain JSON file containing cookies and localStorage entries for each origin. It's safe to commit to version control (if it's a test account), making it easy for all team members and CI pipelines to share auth state without re-logging in on every run.Configuration
Complete reference for LaunchOptions, ContextOptions, device emulation, proxy, and environment-specific settings.
Proxy Configuration
// Context-level proxy (affects all requests from this context) BrowserContext context = browser.newContext(new Browser.NewContextOptions() .setProxy(new Proxy("http://proxy.example.com:8080") .setUsername("proxyUser") .setPassword("proxyPass") .setBypass("localhost,127.0.0.1") // bypass proxy for these ) ); // Launch-level proxy (for all contexts) Browser browser = playwright.chromium().launch( new BrowserType.LaunchOptions() .setProxy(new Proxy("socks5://proxy:1080")) );
Viewport, Locale & Timezone
Browser.NewContextOptions opts = new Browser.NewContextOptions() // Viewport: window size seen by the page's CSS media queries .setViewportSize(1440, 900) // Device Scale Factor (2.0 = Retina / HiDPI) .setDeviceScaleFactor(2.0) // isMobile: adds mobile UA, touch events, meta viewport tag .setIsMobile(true) .setHasTouch(true) // Locale: affects Accept-Language header, date/number formats .setLocale("fr-FR") // Timezone: affects Date() objects in the page .setTimezoneId("Europe/Paris") // Full list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones // Color scheme: prefers-color-scheme media query .setColorScheme(ColorScheme.DARK) // Options: DARK, LIGHT, NO_PREFERENCE // Reduced motion: prefers-reduced-motion media query .setReducedMotion(ReducedMotion.REDUCE) // Forced colors: Windows high-contrast mode .setForcedColors(ForcedColors.ACTIVE); // Emulate print media page.emulateMedia(new Page.EmulateMediaOptions() .setMedia(Media.PRINT));
Default Timeouts
// Timeout hierarchy (lower overrides higher): // PlaywrightAssertions global → Context → Page → Per-call // Global assertion timeout (default 5000ms) PlaywrightAssertions.setDefaultAssertionTimeout(10000); // Context-level: all pages inherit these context.setDefaultTimeout(15000); // all operations (click, fill, etc.) context.setDefaultNavigationTimeout(30000); // navigate/goto only // Page-level (overrides context) page.setDefaultTimeout(10000); page.setDefaultNavigationTimeout(20000); // Per-operation (overrides all above) page.locator("#slow-element").click( new Locator.ClickOptions().setTimeout(5000) );
Test Framework Integration
Production-ready TestNG and JUnit 5 integration patterns with proper lifecycle management and parallel execution.
BaseTest Pattern with TestNG
The gold standard setup: one Playwright + Browser per suite, one BrowserContext + Page per test method. Uses ThreadLocal for thread-safe parallel execution.
package com.example.base; import com.microsoft.playwright.*; import org.testng.annotations.*; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; public class BaseTest { // ── Thread-local storage for parallel test safety ────────────────── // ThreadLocal means each thread (test) gets its own isolated instance private static ThreadLocal<Playwright> playwrightTL = new ThreadLocal<>(); private static ThreadLocal<Browser> browserTL = new ThreadLocal<>(); private static ThreadLocal<BrowserContext> contextTL = new ThreadLocal<>(); private static ThreadLocal<Page> pageTL = new ThreadLocal<>(); // Convenience accessors for subclasses protected Page page() { return pageTL.get(); } protected BrowserContext context() { return contextTL.get(); } protected Browser browser() { return browserTL.get(); } // ── One browser per thread (created once per test/thread) ─────────@BeforeMethod (alwaysRun = true)@Parameters ({"browser"}) // optional: read from testng.xml public void setUp(@Optional ("chromium") String browserName) { Playwright pw = Playwright.create(); playwrightTL.set(pw); // Select browser from parameter BrowserType browserType; switch (browserName.toLowerCase()) { case "firefox": browserType = pw.firefox(); break; case "webkit": browserType = pw.webkit(); break; default: browserType = pw.chromium(); break; } Browser browser = browserType.launch( new BrowserType.LaunchOptions() .setHeadless(Boolean.parseBoolean( System.getProperty("headless", "true") )) ); browserTL.set(browser); // New isolated context per test BrowserContext context = browser.newContext( new Browser.NewContextOptions() .setViewportSize(1280, 720) .setIgnoreHTTPSErrors(true) ); contextTL.set(context); // Start tracing for every test context.tracing().start(new Tracing.StartOptions() .setScreenshots(true).setSnapshots(true)); Page page = context.newPage(); pageTL.set(page); }@AfterMethod (alwaysRun = true) public void tearDown(org.testng.ITestResult result) { try { // Save trace only on failure (saves disk space) if (!result.isSuccess()) { String traceName = result.getName() + "-" + System.currentTimeMillis() + ".zip"; contextTL.get().tracing().stop( new Tracing.StopOptions().setPath(Paths.get("traces/" + traceName))); } else { contextTL.get().tracing().stop(); // stop without saving } } finally { // Always clean up in reverse order contextTL.get().close(); browserTL.get().close(); playwrightTL.get().close(); // Remove from ThreadLocal to avoid memory leaks contextTL.remove(); browserTL.remove(); playwrightTL.remove(); pageTL.remove(); } } }
package com.example.tests; import com.example.base.BaseTest; import org.testng.annotations.Test; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; public class LoginTest extends BaseTest {@Test (description = "Valid credentials navigate to dashboard") public void validLoginNavigatesToDashboard() { page().navigate("https://app.example.com/login"); page().getByLabel("Email").fill("[email protected]"); page().getByLabel("Password").fill("password123"); page().getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Login")).click(); assertThat(page()).hasURL("**/dashboard"); assertThat(page().getByRole(AriaRole.HEADING)).containsText("Dashboard"); }@Test (description = "Invalid credentials show error message") public void invalidLoginShowsError() { page().navigate("https://app.example.com/login"); page().getByLabel("Email").fill("[email protected]"); page().getByLabel("Password").fill("wrongpass"); page().getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Login")).click(); assertThat(page().getByRole(AriaRole.ALERT)).containsText("Invalid credentials"); assertThat(page()).hasURL("**/login"); } }
JUnit 5 Integration
import org.junit.jupiter.api.*; import com.microsoft.playwright.*; public class BaseTestJUnit5 { // @BeforeAll → @BeforeEach for full isolation (or share browser) static Playwright playwright; static Browser browser; BrowserContext context; Page page;@BeforeAll static void launchBrowser() { playwright = Playwright.create(); browser = playwright.chromium().launch( new BrowserType.LaunchOptions().setHeadless(true) ); }@BeforeEach void createContextAndPage() { context = browser.newContext(); page = context.newPage(); }@AfterEach void closeContext() { context.close(); }@AfterAll static void closeBrowser() { browser.close(); playwright.close(); } } // Test class: class DashboardTest extends BaseTestJUnit5 {@Test void dashboardLoads() { page.navigate("https://app.example.com/dashboard"); assertThat(page.getByRole(AriaRole.HEADING)).isVisible(); } }
Parallel Execution Setup
<!-- parallel="tests" → each <test> block in its own thread --> <!-- parallel="methods" → each @Test method in its own thread --> <!-- parallel="classes" → each test class in its own thread --> <suite name="Playwright Suite" parallel="methods" thread-count="4"> <test name="All Tests"> <classes> <class name="com.example.tests.LoginTest"/> <class name="com.example.tests.ProductTest"/> <class name="com.example.tests.CheckoutTest"/> </classes> </test> </suite>
parallel="methods", multiple test methods run simultaneously in different threads. Without ThreadLocal, they'd share the same Page object and interfere with each other. ThreadLocal<Page> gives each thread its own isolated Page instance, making parallel tests completely safe.Page Object Model (POM)
Structuring Playwright Java tests for maintainability — from basic POM to Component Objects and advanced patterns.
Page Object Model is a design pattern that wraps each page (or significant part of a page) in a Java class. Test code interacts with the page object's methods, not with raw Playwright locators. This means UI changes only require updating one class, not every test that touches that page.
Basic Page Object
package com.example.pages; import com.microsoft.playwright.*; public abstract class BasePage { protected final Page page; public BasePage(Page page) { this.page = page; } // Utility: wait for page to fully load protected void waitForPageLoad() { page.waitForLoadState(LoadState.DOMCONTENTLOADED); } // Utility: get page title public String getTitle() { return page.title(); } // Utility: take a screenshot for debugging public void takeScreenshot(String name) { page.screenshot(new Page.ScreenshotOptions() .setPath(Paths.get("screenshots/" + name + ".png"))); } }
package com.example.pages; import com.microsoft.playwright.*; import com.microsoft.playwright.options.*; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; public class LoginPage extends BasePage { // ── Locators defined once, used many times ───────────────────── // Private final: locators are lazy — safe to define at field level private final Locator emailInput; private final Locator passwordInput; private final Locator loginButton; private final Locator errorMessage; private final Locator forgotPasswordLink; public LoginPage(Page page) { super(page); // All locators initialized here — lazy, no DOM query yet emailInput = page.getByLabel("Email"); passwordInput = page.getByLabel("Password"); loginButton = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Login")); errorMessage = page.locator("[role='alert']"); forgotPasswordLink = page.getByText("Forgot password?"); } // ── Navigation ──────────────────────────────────────────────── public LoginPage navigate() { page.navigate("/login"); return this; // fluent chaining } // ── Actions ─────────────────────────────────────────────────── public LoginPage enterEmail(String email) { emailInput.fill(email); return this; } public LoginPage enterPassword(String password) { passwordInput.fill(password); return this; } public DashboardPage clickLoginExpectingSuccess() { loginButton.click(); // Return the NEXT page object — models navigation flow return new DashboardPage(page); } public LoginPage clickLoginExpectingFailure() { loginButton.click(); return this; // stay on login page on failure } // ── Compound action (full login flow) ───────────────────────── public DashboardPage loginAs(String email, String password) { return navigate() .enterEmail(email) .enterPassword(password) .clickLoginExpectingSuccess(); } // ── Assertions (page-level verifications) ───────────────────── public LoginPage assertErrorMessageContains(String text) { assertThat(errorMessage).containsText(text); return this; } public LoginPage assertOnLoginPage() { assertThat(page).hasURL("**/login"); assertThat(loginButton).isVisible(); return this; } }
public class LoginTest extends BaseTest {@Test public void successfulLogin() { new LoginPage(page()) .loginAs("[email protected]", "password123") .assertOnDashboard(); // Clean! No raw locators in test code. }@Test public void invalidCredentialsShowError() { new LoginPage(page()) .navigate() .enterEmail("[email protected]") .enterPassword("wrongpass") .clickLoginExpectingFailure() .assertErrorMessageContains("Invalid credentials") .assertOnLoginPage(); } }
Component Objects — Reusable Sub-Components
When a UI component (like a data table, a modal, or a navbar) appears on multiple pages, extract it into a Component Object. It takes a scoped Locator instead of a whole Page.
public class DataTableComponent { private final Locator root; // the table element // Takes scoped locator — searches only inside the table public DataTableComponent(Locator tableRoot) { this.root = tableRoot; } public int rowCount() { return root.locator("tbody tr").count(); } public String getCellText(int row, int col) { return root.locator("tbody tr").nth(row) .locator("td").nth(col) .innerText(); } public void clickAction(int row, String actionName) { root.locator("tbody tr").nth(row) .getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName(actionName)) .click(); } public void assertHasRow(String text) { assertThat(root.locator("tbody tr") .filter(new Locator.FilterOptions().setHasText(text))) .isVisible(); } } // Usage in a page object: public class UsersPage extends BasePage { public DataTableComponent usersTable() { return new DataTableComponent(page.locator("#users-table")); } } // Usage in test: new UsersPage(page) .usersTable() .assertHasRow("Alice");
Mobile & Device Emulation
Emulate mobile devices, geolocation, permissions, and media types without needing real devices.
Device Emulation
// playwright.devices() returns a Map of preset device descriptors Map<String, DeviceDescriptor> devices = playwright.devices(); // Common device names: // "iPhone 14", "iPhone 14 Pro", "iPhone 14 Pro Max" // "iPad Pro 11", "iPad (gen 7)" // "Pixel 7", "Galaxy S8" // "Desktop Chrome", "Desktop Firefox", "Desktop Safari" DeviceDescriptor iphone = playwright.devices().get("iPhone 14"); // Each DeviceDescriptor has: viewport, userAgent, deviceScaleFactor, // isMobile, hasTouch (all the right settings for that device) BrowserContext mobileCtx = browser.newContext( new Browser.NewContextOptions() .setViewportSize(iphone.viewport().width, iphone.viewport().height) .setUserAgent(iphone.userAgent()) .setDeviceScaleFactor(iphone.deviceScaleFactor()) .setIsMobile(iphone.isMobile()) .setHasTouch(iphone.hasTouch()) .setLocale("en-US") );
Geolocation & Permissions
// Grant geolocation permission and set coordinates BrowserContext ctx = browser.newContext( new Browser.NewContextOptions() .setGeolocation(new Geolocation(51.5074, -0.1278)) // London .setPermissions(List.of("geolocation")) ); // Change geolocation mid-test ctx.setGeolocation(new Geolocation(48.8566, 2.3522)); // Paris // Grant permissions on context ctx.grantPermissions(List.of("notifications"), "https://example.com"); ctx.grantPermissions(List.of("camera", "microphone")); // Available permissions: "geolocation", "midi", "midi-sysex", // "notifications", "push", "camera", "microphone", // "background-sync", "clipboard-read", "clipboard-write" ctx.clearPermissions(); // revoke all granted permissions
Debugging & Codegen
Playwright Inspector, PWDEBUG mode, Codegen, and slow motion — your debugging toolkit.
PWDEBUG — Interactive Inspector
PWDEBUG=1 is an environment variable that launches the Playwright Inspector alongside your test. It pauses execution and lets you step through actions, inspect locators, and explore the DOM.
# Run test with Inspector (opens browser + inspector side-by-side) PWDEBUG=1 mvn test -Dtest=LoginTest # On Windows: set PWDEBUG=1 && mvn test -Dtest=LoginTest # PWDEBUG=console: log all Playwright calls to browser console PWDEBUG=console mvn test -Dtest=LoginTest # Slow motion via system property in your BaseTest: # .setSlowMo(500) — adds 500ms delay after each action mvn test -DslowMo=500
// Pause test execution — opens Inspector and waits for "Resume" click page.pause(); // ← add to your test to pause at a specific point // Set in headless=false + slowMo for visual debugging: Browser browser = playwright.chromium().launch( new BrowserType.LaunchOptions() .setHeadless(false) .setSlowMo(500) // 500ms between each action — easy to follow visually ); // Console message logging (catch JS errors): page.onConsoleMessage(msg -> { if (msg.type().equals("error")) { System.err.println("Browser console error: " + msg.text()); } }); // Page errors (uncaught exceptions): page.onPageError(e -> System.err.println("JS Exception: " + e.message()));
Codegen — Record Tests from Browser Actions
# Launch Codegen — browser + code panel side-by-side # As you click/type in the browser, Java code is generated mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI \ -D exec.args="codegen https://example.com" # Save generated code to a file: mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI \ -D exec.args="codegen --target java --output MyTest.java https://example.com" # With viewport size: mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI \ -D exec.args="codegen --viewport-size 1280,720 https://example.com" # With saved auth state: mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI \ -D exec.args="codegen --load-storage auth.json https://app.example.com"
CI/CD Integration
Running Playwright in Docker, GitHub Actions, and optimizing CI runs with caching and parallelism.
GitHub Actions
name: Playwright Tests on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' cache: 'maven' - name: Cache Playwright browsers uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles('pom.xml') }} - name: Install Playwright browsers run: mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install --with-deps chromium" - name: Run Playwright tests run: mvn test -Dheadless=true env: BASE_URL: https://staging.example.com TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }} TEST_USER_PASS: ${{ secrets.TEST_USER_PASS }} - name: Upload traces on failure if: failure() uses: actions/upload-artifact@v4 with: name: playwright-traces path: traces/ retention-days: 7 - name: Upload test reports if: always() uses: actions/upload-artifact@v4 with: name: surefire-report path: target/surefire-reports/
Docker Setup
# Use Playwright's official base image (includes all browser deps) FROM mcr.microsoft.com/playwright/java:v1.44.0-jammy WORKDIR /app COPY pom.xml . COPY src ./src # Install Maven RUN apt-get update && apt-get install -y maven # Download dependencies RUN mvn dependency:go-offline -q # Run tests CMD ["mvn", "test", "-Dheadless=true"]
~/.cache/ms-playwright — expensive to re-download (~300MB per browser).2. Headless always in CI: Never run headed in CI — no display server.
3. --with-deps on Ubuntu: GitHub Actions Ubuntu images need system deps:
install --with-deps.4. Retry flaky tests: Use TestNG's
IRetryAnalyzer or @Test(retryAnalyzer=...) for intermittent failures.5. Upload traces: Save trace zips on failure for post-mortem debugging —
trace.playwright.dev needs no install.
Best Practices & Anti-Patterns
The patterns that separate expert-level Playwright from beginner-level. Critical for interviews and production code.
Locator Best Practices
| ✅ Do This | ❌ Avoid This | Why |
|---|---|---|
getByRole(BUTTON, name("Submit")) |
locator(".btn-primary") |
Role reflects user intent; CSS classes are implementation detail |
getByLabel("Email") |
locator("input#email") |
Labels are tied to visible text; IDs can change |
getByTestId("checkout-form") |
locator("xpath=/html/body/div[2]/form") |
testid is stable; absolute XPath breaks on any DOM change |
locator("tr").filter(hasText("Alice")) |
locator("tr:nth-child(3)") |
Filter by content is stable; position-based breaks on reorder |
Anti-Patterns to Avoid
// ❌ ANTI-PATTERN 1: Hardcoded sleep page.waitForTimeout(3000); // Makes test 3s slower always // ✅ CORRECT: Wait for the condition that matters assertThat(page.locator("#result")).isVisible(); // ↑ Waits up to 5s but returns immediately when visible // ───────────────────────────────────────────────────────────── // ❌ ANTI-PATTERN 2: One BrowserContext for all tests (no isolation) static BrowserContext sharedContext; // shared across ALL tests → cookies pollute // ✅ CORRECT: New context per test@BeforeMethod void setUp() { context = browser.newContext(); }@AfterMethod void tearDown() { context.close(); } // ───────────────────────────────────────────────────────────── // ❌ ANTI-PATTERN 3: Storing ElementHandle (stale reference risk) ElementHandle el = page.querySelector("button"); // snapshot of DOM // ... DOM changes ... el.click(); // May throw StaleElementException // ✅ CORRECT: Use Locator (always re-queries) Locator btn = page.locator("button"); // lazy, re-queries every time btn.click(); // always fresh // ───────────────────────────────────────────────────────────── // ❌ ANTI-PATTERN 4: Using page.waitForSelector() (old API) page.waitForSelector("#result"); // returns ElementHandle — legacy // ✅ CORRECT: Locator.waitFor() or assertThat page.locator("#result").waitFor(); // OR (better, with assertion message): assertThat(page.locator("#result")).isVisible(); // ───────────────────────────────────────────────────────────── // ❌ ANTI-PATTERN 5: Using NetworkIdle for all navigation page.navigate("...", new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE)); // Adds seconds to every navigation if app has background requests // ✅ CORRECT: Default LOAD + wait for specific element page.navigate("..."); // waits for 'load' event assertThat(page.getByRole(AriaRole.HEADING)).isVisible(); // wait for actual content
Performance Tips
- Reuse browser across tests in a suite — creating a Browser is the slow part. Creating a context is fast.
- Block unnecessary resources (images, fonts, analytics) with
context.route()to speed up page loads in tests that don't care about visuals. - Use StorageState for auth — never log in via UI in every test. Save state once, reuse it.
- Parallel execution — Playwright's per-context isolation makes parallel tests safe. Use TestNG
parallel="methods"withThreadLocal. - Use DOMCONTENTLOADED instead of NETWORKIDLE/LOAD when you only need the HTML structure.
- Avoid full-page screenshots in every test — they're slow. Use element-level screenshots for failure capture.
Interview Key Points
MCQ Practice Quiz
50 questions across all topics — 10% Easy · 70% Medium · 20% Hard. Click an option to reveal the answer and explanation.
Complete Playwright Java Cheatsheet
Every important pattern, API, tip, and gotcha — condensed for rapid revision. Print-ready, interview-ready.
Playwright.create() → spawns Node.js process. Close last.pw.chromium() / pw.firefox() / pw.webkit()browserType.launch(opts). One per JVM (share it).context.newPage(). Where all actions happen..setHeadless(false) for debug; default true in CIpage.pause() pauses at that point.mvn exec:java ... "codegen https://example.com" — record clicks → Java code| WaitUntilState | Meaning | Use when |
|---|---|---|
COMMIT | First byte received | You only need URL change |
DOMCONTENTLOADED | HTML parsed | SPA apps, fast navigation checks |
LOAD (default) | All resources loaded | Most cases — good default |
NETWORKIDLE | No requests for 500ms | ⚠️ Fragile — avoid if app polls |
nth(0)=first. first() ≡ nth(0). last() ≡ last match.parent.locator("child") — searches only within parent.locator.count() — number of matched elements. No timeout.disabled attribute (applies to click, fill, check)readonly (applies to fill, type, clear)this — enables loginPage.fill("email").fill("pass").submit()page.locator() directlyLocator root not PageassertOnPage() methods for chain validation| ❌ Anti-Pattern | ✅ Correct | Why |
|---|---|---|
page.waitForTimeout(3000) | assertThat(loc).isVisible() | Hardcoded sleep is always 3s; assertThat stops when ready |
| Shared BrowserContext across tests | New context per @BeforeMethod | Cookie/storage pollution causes cross-test interference |
page.querySelector() → ElementHandle | page.locator() → Locator | ElementHandle can go stale; Locator always re-queries |
| WaitUntilState.NETWORKIDLE always | LOAD + assert on content | NETWORKIDLE times out or is slow with polling apps |
| Absolute XPath | getByRole / getByLabel / testid | Absolute XPath breaks on any DOM restructuring |
| Shared Page across parallel threads | ThreadLocal<Page> | Race conditions — threads interfere with each other's page |
| Login via UI in every test | storageState → reuse auth | Wastes seconds per test; login is tested once separately |
page.onDialog() after action | page.onDialog() BEFORE action | Dialog blocks JS — must handle before trigger |
| Close context before video().path() | path() then close() then saveAs() | Video only finalizes on close; path accessible after |
assertTrue(loc.isVisible()) | assertThat(loc).isVisible() | assertTrue is one-shot; assertThat auto-retries up to 5s |
<button>, role="button"<a href><input>, <textarea><input type="checkbox"><input type="radio"><select><h1>–<h6> (use setLevel for specific)<nav><main><header><dialog>, role="dialog"role="alert"<table>, <tr>, <td><ul>/<ol>, <li>role="tab", role="tabpanel"role="menuitem"~/.cache/ms-playwright — key on pom.xml hash in GitHub Actionsinstall --with-deps chromium — --with-deps installs OS libs on Ubuntuheadless=true — no display server availableif: failure() + upload-artifact with traces/ dir — view on trace.playwright.devmcr.microsoft.com/playwright/java:v1.44.0-jammy — pre-installed browsers + deps@Test(retryAnalyzer=RetryAnalyzer.class) — up to N retries on failurewaitForResponse(pattern, () -> click()) — action INSIDE the waiter lambda.Interview Questions
30 most commonly asked Playwright Java interview questions — click any question to expand the full answer, underlying concepts, and tips.
Playwright is a modern end-to-end testing framework by Microsoft that controls browsers via the Chrome DevTools Protocol (CDP) / WebSocket, giving it direct, low-level access to browser internals. Selenium communicates through the W3C WebDriver HTTP protocol to language-specific driver binaries (chromedriver, geckodriver) that act as intermediaries.
Key architectural difference: In Playwright Java, the JVM spawns a Node.js child process, communicates via stdio JSON-RPC, and that Node process talks to the browser over CDP. This tight coupling enables features Selenium cannot match natively.
- Auto-waiting — built-in actionability checks before every action
- BrowserContext isolation — cheap, fast, per-test isolation without new browser
- Native network interception —
page.route()without proxy setup - Shadow DOM piercing — automatic, no extra code
- Multi-tab / multi-window — first-class
Pageobjects - Trace Viewer — full test replay with DOM snapshots
- No stale element — Locators re-query on every action
- Larger ecosystem and community maturity
- Selenium Grid for large distributed testing
- More browser/driver combinations
- Wider organizational adoption (legacy)
Playwright — entry point via Playwright.create(), spawns Node.js process. Exposes chromium(), firefox(), webkit().
BrowserType — represents one browser engine. Used to launch() a Browser (OS process). Creating a Browser is expensive (~1-2s).
BrowserContext — the isolation unit. Own cookies, localStorage, auth state. Fast to create (~ms). Create one per test.
Page — a single browser tab. All actions happen here. Belongs to a context.
Lifecycle rule: closing a parent closes all children. playwright.close() → all Browsers → all Contexts → all Pages. All implement AutoCloseable — try-with-resources works perfectly.
@AfterMethod(alwaysRun=true).A Locator is a lazy, re-queryable element descriptor. Calling page.locator("button") does NOT touch the DOM — it records the selector. The actual DOM query fires only when an action is triggered, and it re-queries fresh on every action call.
An ElementHandle / WebElement is a snapshot — a reference to a specific DOM node at the time of query. If the DOM changes (SPA re-render, animation), that reference goes stale.
- Lazy — no DOM query at creation
- Re-queries DOM on each action
- Never stale
- Supports auto-waiting
- Supports filter(), and(), nth()
- Eager — queries DOM immediately
- Fixed snapshot reference
- Can go stale after DOM change
- No auto-waiting on handle itself
- Avoid in new code
page.evaluate(). For everything else, use Locator.Before executing any action, Playwright automatically polls the element against actionability conditions — retrying until all pass or timeout expires. This eliminates ~90% of explicit waits.
Six checks for click():
- Attached — element exists in DOM tree
- Visible — not hidden (display:none, visibility:hidden, opacity:0), non-zero dimensions
- Stable — no CSS animation or transition running (position not changing)
- Receives events —
document.elementFromPoint(x,y)returns the target or descendant; pointer-events ≠ none - Enabled — no disabled attribute
- Editable — not readonly (for fill/type/clear)
document.elementFromPoint() at the click coordinates. A loading spinner covering the button causes Playwright to keep retrying until the overlay disappears. This prevents silent wrong-element clicks..setForce(true) skips ALL actionability checks and fires the event directly. Use only to intentionally interact with hidden/disabled elements in edge-case tests.Set parallel="methods" and thread-count="N" in testng.xml. The core problem: Playwright objects are NOT thread-safe. Multiple threads sharing a single Page cause race conditions — wrong navigations, wrong fills, cross-assertions.
Solution: ThreadLocal<T> — gives each thread its own private instance.
ThreadLocal.remove(), the next test on that thread picks up the previous test's closed Playwright instance and throws.parallel="tests" with separate <test> blocks per browser — simpler for cross-browser runs but less granular parallelism.Use Playwright's StorageState mechanism: log in once via UI, save the resulting cookies + localStorage to auth.json, then inject that file into every test's BrowserContext.
auth-admin.json, auth-editor.json, auth-viewer.json. Load the appropriate one per test class.page.route(urlPattern, handler) registers a network interceptor. The handler can: fulfill (mock response), continue (pass through, optionally modified), or abort (simulate failure).
locator.isVisible() is a one-shot query — returns a boolean at that exact instant. If called before the element appears, returns false immediately.
assertThat(locator).isVisible() is an auto-retrying assertion — polls repeatedly until the element becomes visible or timeout expires (default 5s). Doesn't fail until the full timeout period passes.
PlaywrightAssertions.setDefaultAssertionTimeout(10_000) in @BeforeSuite for consistent timeout across all assertions.POM wraps each application page in a Java class with locators as fields and user-intent methods. Tests call page object methods, never raw locators.
Playwright uses page.frameLocator(selector) to get a scoped context. All subsequent locator calls execute inside the iframe. No global context-switching needed.
Trace Viewer captures a complete test run record: screenshots at each action, DOM snapshots (full HTML before/after each action), network requests/responses, console logs, and source code location. Output is a .zip file you open in a browser.
Returns custom response — real server never contacted. You define status, headers, body. Use for: mocking APIs, simulating errors, testing without backend.
Lets request reach real server, optionally with modified URL, method, headers, body. Real response returned. Use for: adding auth headers, logging, changing request params.
Blocks request entirely — page gets network error. Error types: "aborted", "connectionreset", "timedout". Use for: blocking trackers, simulating offline, testing error recovery UI.
Contacts real server, modifies response, returns to page. Use for: injecting test data into real data without full mock.
page.routeOnce(pattern, handler) fires only for the very next matching request then auto-removes. Perfect for testing retry logic: first request fails (429), second succeeds.Regular assertThat() (hard assertions) stop the test immediately on first failure. SoftAssertions collect all failures and report them all together at assertAll().
Browser dialogs block JavaScript execution. Playwright handles them via the dialog event. Critical rule: register the handler BEFORE the action that triggers the dialog.
page.onDialog(d -> d.accept()) before page.navigate("/other") handles the "Leave page?" dialog if the page has unsaved changes protection.page.waitForResponse(pattern, action) registers a network response listener, executes the action lambda, then blocks until a matching response arrives. The listener MUST be registered before the action fires the request — otherwise the response may arrive before the listener is active.
Playwright recommends locators that mirror how users and assistive technologies perceive elements:
- getByRole() — ARIA role + accessible name. Most resilient.
getByRole(AriaRole.BUTTON, opts.setName("Submit")) - getByLabel() — associates input with visible label (for/id, aria-label, aria-labelledby, wrapper)
- getByPlaceholder() — for inputs without visible labels
- getByText() — visible text content. Good for links, menu items
- getByAltText() — for images
- getByTestId() — data-testid attributes. Stable but requires dev cooperation
- CSS selectors — flexible but tied to implementation. Prefer id/attribute over class
- XPath — last resort. Powerful but verbose; absolute paths are brittle
A BrowserContext is a fully isolated browsing session — own cookies, localStorage, sessionStorage, IndexedDB, network state, and auth state. Not shared between contexts even within the same Browser.
page.evaluate() executes JavaScript now, after page load, and returns the serialized result to Java. page.addInitScript() registers JS that runs BEFORE the page's own scripts on every navigation.
page.exposeFunction("name", javaLambda) exposes a Java method to the page's JavaScript — the page can call window.name(arg) and Playwright routes it to your Java code. Useful for test hooks in the app.Shadow DOM (used by Web Components) encapsulates internal DOM structure. Playwright automatically pierces open shadow DOM for all CSS-based and semantic locators — no special handling needed.
Clears the field first, then sets the entire value atomically using native input events (input, change). Does NOT fire individual key events. Fast and reliable for most inputs.
Types character by character, firing keydown/keypress/input/keyup for each character. Does NOT clear first. Use when app listens to individual keystrokes: autocomplete, masked inputs, OTP fields.
- Check the Trace: Open trace .zip in Trace Viewer. Look at failing action's before/after screenshots. Was the element there? Covered? Was there an overlay?
- Identify bad wait patterns: Is the test using waitForTimeout()? assertTrue(isVisible()) instead of assertThat().isVisible()? Action before animation completes?
- PWDEBUG=1: Run with
PWDEBUG=1 mvn test -Dtest=FlakyTest— opens Inspector. Step through actions one by one. Inspect locators live. - SlowMo: Add
.setSlowMo(500)and run headed. Watch in slow motion — timing issues become visible. - Check network: Add console listeners to catch unexpected API failures.
- Check console errors: JS errors may explain UI not updating.
for i in $(seq 1 20); do mvn test -Dtest=FlakyTest; done. If 2-3/20 fail, you have a real timing issue. Fix by replacing waits with assertThat() or waitForResponse().Returns when network response received (first byte). DOM not parsed yet. Use for: redirect verification, download endpoints.
Returns after HTML parsed and sync scripts run, before images/async scripts. Good for SPAs — follow with assertThat(element).isVisible().
Returns after load event — all resources loaded. Safe default for most apps.
Returns after 500ms with no network requests. Fragile with analytics/polling/WebSockets. Adds seconds unnecessarily.
assertThat(specificKeyElement).isVisible(). Waits for exactly the UI state you care about — not a synthetic timing signal.- waitForTimeout(N) → Replace with assertThat().isVisible(), waitForResponse(), waitForURL(). Sleeps are slow AND still flaky.
- Shared BrowserContext across tests → New context per test. Shared contexts leak session state causing order-dependent failures.
- ElementHandle instead of Locator → page.querySelector() returns stale snapshots. Always use page.locator().
- Absolute XPath → Breaks on any DOM restructuring. Use getByRole/getByLabel/relative XPath.
- assertTrue(locator.isVisible()) → One-shot check. Replace with assertThat(locator).isVisible() (auto-retries).
- No ThreadLocal for parallel tests → Race conditions when threads share Page objects.
- UI login in every test → Use storageState auth reuse.
- NetworkIdle for all navigations → Use LOAD + element assertion instead.
- Not closing resources → Memory/process leaks. Use try-with-resources or @AfterMethod(alwaysRun=true).
A production-grade framework needs these layers:
- BaseTest with ThreadLocal — handles Playwright/Browser/Context/Page lifecycle. Tracing, screenshot-on-failure, browser selection via TestNG parameter.
- POM + Component Objects — pages in src/main/java/pages/, tests in src/test/java/tests/. Component objects for shared UI (nav, modal, table).
- Environment config — base URL, credentials from system properties / env vars. Enables dev/staging/prod without code changes.
- Auth state management — role-based auth-*.json files generated in global setup. Loaded per test class.
- API-based test data — setup/teardown via APIRequestContext (fast). Builder patterns for test object creation.
- Parallel execution — TestNG parallel="methods" with ThreadLocal. 4-8 threads locally, 8-16 in CI.
- Reporting — Allure/Extent with embedded screenshots and trace links. Traces saved on failure as CI artifacts.