A complete Playwright Java cheat sheet: everything from launching a browser to network mocking and API testing, with the Java method names and options objects you'll actually type. It ends with a one-page quick reference card you can print. For the TypeScript version, see the Playwright cheat sheet.
PLAYWRIGHT (JAVA)
Complete API Reference & Cheat-Sheet
Every locator, action, assertion, option & command in one place
Playwright 1.63 | Java 17 | JUnit 5 & TestNG | Maven & Gradle
This is a lookup document, not a tutorial — keep it open while you code. Everything is grouped by object (Locator, Page, assertions, network, options). The last page is a one-glance cheat-sheet. For the "why" and worked examples, use the Phase notes; for interview practice, use the Question Bank.
Java Bindings vs the TypeScript Test Runner — Read This First
Most Playwright content online uses the TypeScript "Playwright Test" runner. The Java binding is the SAME browser-automation library but does NOT include that runner — you use JUnit 5 or TestNG instead. Knowing this prevents a lot of confusion and is a common interview trap.
| Feature | TypeScript (Playwright Test) | Java |
|---|---|---|
| Browser automation API | Yes | Yes — identical (Page, Locator, etc.) |
| Locators / auto-wait / assertions | Yes | Yes — identical |
| Tracing / video / network mocking | Yes | Yes — identical |
| Built-in test runner | playwright.config.ts | No — use JUnit 5 / TestNG |
| Fixtures (test.use, worker fixtures) | Yes | No — use @BeforeEach / factory |
| Projects (config matrix) | Yes | No — use params / testng.xml |
| Built-in parallelism & sharding | Yes | Via JUnit/TestNG + Surefire |
| UI Mode / test.step / expect.poll | Yes | No — use trace viewer / helpers |
| Codegen, Inspector, Trace Viewer | Yes | Yes — via CLI |
| Web-first assertions | expect(locator) | assertThat(locator) (PlaywrightAssertions) |
In Java: Playwright gives you the browser engine + locators + assertions; JUnit 5 or TestNG gives you the test lifecycle, parameterization, parallelism and reporting. When a tutorial mentions config files, projects or fixtures, that is the TS runner — the Java equivalent is your JUnit/TestNG base class and factory.
Setup & CLI Commands
| Command | Purpose |
|---|---|
| mvn compile | Compile before running the CLI |
| ...CLI -Dexec.args="install" | Download Chromium, Firefox, WebKit |
| ...CLI -Dexec.args="install --with-deps" | Also install OS dependencies (CI/Linux) |
| ...CLI -Dexec.args="install chromium" | Install one browser only |
| ...CLI -Dexec.args="codegen <url>" | Record actions → generate Java code |
| ...CLI -Dexec.args="open <url>" | Open a page with the Inspector |
| npx playwright show-trace trace.zip | Open the Trace Viewer |
| PWDEBUG=1 mvn test | Run with the Inspector (step through) |
CLI mainClass is com.microsoft.playwright.CLI — full form:
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install"
Core Objects — Key Methods
Playwright / BrowserType / Browser
| Method | Returns / Purpose |
|---|---|
| Playwright.create() | Entry point (close in try-with-resources) |
| playwright.chromium() / firefox() / webkit() | A BrowserType |
| playwright.request() | APIRequest (for API testing) |
| playwright.selectors() | Custom/testId selector config |
| browserType.launch(LaunchOptions) | Launch a Browser |
| browserType.launchPersistentContext(dir, opts) | Reuse a real profile dir |
| browserType.connect(wsEndpoint) | Connect to a remote/cloud browser |
| browser.newContext(NewContextOptions) | Create an isolated context |
| browser.newPage() | Shortcut: context + page |
| browser.close() | Close the browser |
BrowserContext
| Method | Purpose |
|---|---|
| context.newPage() | Open a new tab/page |
| context.storageState(opts) | Save cookies+localStorage to JSON |
| context.addCookies(list) | Inject cookies |
| context.clearCookies() | Clear cookies |
| context.grantPermissions(list) | e.g. geolocation, clipboard |
| context.route(url, handler) | Intercept network for all pages |
| context.routeFromHAR(path, opts) | Replay recorded network |
| context.tracing() | Start/stop tracing |
| context.setDefaultTimeout(ms) | Default action timeout |
| context.setDefaultNavigationTimeout(ms) | Default navigation timeout |
| context.close() | Close context (saves video) |
Locators — Creation
All of these exist on both Page (page.getBy...) and Locator (for scoping inside another locator).
| Locator | Finds by | Example |
|---|---|---|
| getByRole(role, opts) | ARIA role + accessible name | getByRole(BUTTON, name("Save")) |
| getByText(text/regex) | Visible text (substring) | getByText("Products") |
| getByLabel(text) | Associated <label> | getByLabel("Email") |
| getByPlaceholder(text) | Placeholder attribute | getByPlaceholder("Search") |
| getByAltText(text) | Image alt | getByAltText("Logo") |
| getByTitle(text) | title attribute | getByTitle("Close") |
| getByTestId(id) | data-testid (configurable) | getByTestId("submit") |
| locator(selector) | CSS / XPath / text engine | locator("#id"), locator("xpath=//a") |
getByRole options
new Page.GetByRoleOptions()
.setName("Login") // accessible name (String or Pattern)
.setExact(true) // exact name match
.setChecked(true) // checkbox/radio state
.setPressed(true) // toggle buttons
.setSelected(true) // options
.setExpanded(true) // expandable widgets
.setLevel(2) // heading level h2
.setDisabled(false);
Refining & combining locators
| Method | Purpose |
|---|---|
| .filter(setHasText / setHasNotText) | Keep matches with/without text |
| .filter(setHas / setHasNot(locator)) | Keep matches containing/not a child |
| .and(locator) | Must match both |
| .or(locator) | Match either (e.g. success OR error) |
| .first() / .last() / .nth(i) | Pick from a list (0-based) |
| .locator(sel) / .getByRole(...) | Scope a search inside |
| .count() | Number of matches (no wait) |
| .all() | List<Locator> of all matches |
| .allTextContents() / .allInnerTexts() | List<String> of texts |
Selector Engines & Pseudo-Classes
| Selector | Meaning |
|---|---|
| css=div.card / #id / .class | CSS (default engine) |
| xpath=//button[text()="OK"] | XPath engine |
| text=Add to cart | Text engine (substring, case-insensitive) |
| id=submit / data-testid=go | Attribute engines |
| :has-text("Total") | Element containing text (substring) |
| :text("OK") | Smallest element with that text |
| :text-is("OK") | Exact text |
| :has(button) | Element that contains a match |
| :nth-match(:text("Add"), 2) | The 2nd match overall |
| :visible | Only visible elements |
| a >> span | Chaining engines |
| :near(:text("Price")) / :right-of() / :below() | Layout selectors (by position) |
// Configure the test-id attribute name (default: data-testid)
playwright.selectors().setTestIdAttribute("data-qa");
Locator Actions (all auto-wait)
| Action | Purpose |
|---|---|
| click() / dblclick() | Click / double-click |
| click(new ClickOptions().setButton(RIGHT)) | Right-click, modifiers, position, force |
| fill(value) | Clear + set value (fast) |
| clear() | Empty an input |
| pressSequentially(text, opts) | Type key-by-key (autocomplete) |
| press("Control+A") | Key / combo |
| check() / uncheck() / setChecked(b) | Checkbox / radio |
| selectOption(value/label/index) | Native <select> |
| selectText() | Select element text |
| hover() | Mouse over |
| dragTo(target) | Drag & drop |
| tap() | Touch tap (mobile) |
| focus() / blur() | Focus management |
| setInputFiles(path) | File upload |
| scrollIntoViewIfNeeded() | Scroll to element |
| dispatchEvent("click") | Fire a DOM event directly |
| highlight() | Debug: highlight the element |
| screenshot(opts) | Element screenshot |
Locator State & Info (no auto-wait)
These return immediately — use for reading data or branching, NOT for assertions (use web-first assertions for those).
| Method | Returns |
|---|---|
| textContent() / innerText() | Raw / rendered text (single element) |
| innerHTML() | Inner HTML string |
| inputValue() | Value of input/textarea/select |
| getAttribute("href") | Attribute value or null |
| isVisible() / isHidden() | boolean (immediate) |
| isEnabled() / isDisabled() | boolean |
| isEditable() / isChecked() | boolean |
| boundingBox() | x/y/width/height |
| evaluate(js) / evaluateAll(js) | Run JS on element / all matches |
| ariaSnapshot() | YAML accessibility snapshot |
Web-First Assertions (auto-retrying)
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
Locator assertions
| Assertion | Passes when the element... |
|---|---|
| isVisible() / isHidden() | is / is not visible |
| isEnabled() / isDisabled() | is enabled / disabled |
| isEditable() | is editable |
| isChecked() / isChecked(setChecked(false)) | checkbox is checked / unchecked |
| isFocused() | has focus |
| isEmpty() | has no text/children |
| isAttached() / isInViewport() | is in DOM / in viewport |
| hasText(t) / containsText(t) | exact / substring text (t may be regex/List) |
| hasValue(v) / hasValues(list) | input value / multiselect values |
| hasAttribute(name, value) | attribute equals value |
| hasClass(c) / hasId(id) | class / id matches |
| hasCSS(prop, value) | computed CSS matches |
| hasJSProperty(name, value) | JS property equals value |
| hasCount(n) | exactly n elements match |
| matchesAriaSnapshot(yaml) | a11y tree matches snapshot |
Page & APIResponse assertions; negation & timeout
assertThat(page).hasTitle("Swag Labs");
assertThat(page).hasURL(Pattern.compile(".*inventory.*"));
assertThat(apiResponse).isOK(); // 2xx
// Negation with not()
assertThat(page.getByText("Error")).not().isVisible();
// Per-assertion timeout
assertThat(locator).isVisible(
new LocatorAssertions.IsVisibleOptions().setTimeout(10000));
// Global assertion timeout
PlaywrightAssertions.setDefaultAssertionTimeout(10000);
Waiting & Timeouts
| Call | Waits for |
|---|---|
| (auto-wait) | Actionability before every action |
| locator.waitFor(setState(VISIBLE/HIDDEN/ATTACHED/DETACHED)) | An element state |
| page.waitForURL("**/x") | Navigation to a URL |
| page.waitForResponse("**/api", () -> action) | A specific network response |
| page.waitForRequest("**/api", () -> action) | A specific request |
| page.waitForLoadState(LOAD/DOMCONTENTLOADED/NETWORKIDLE) | A load state |
| page.waitForFunction("() => window.ready") | A JS predicate to be true |
| page.waitForPopup(() -> action) | A new tab/popup |
| page.waitForDownload(() -> action) | A download to start |
| page.waitForCondition(() -> boolean) | A Java predicate |
Page — Advanced APIs
Running JavaScript (the JavascriptExecutor equivalent)
// Return a value from the page
String title = (String) page.evaluate("() => document.title");
int height = (int) page.evaluate("() => document.body.scrollHeight");
// Pass arguments
page.evaluate("([a, b]) => a + b", java.util.List.of(2, 3));
// Operate on a specific element
page.getByTestId("row").evaluate("el => el.scrollIntoView()");
// Run script on EVERY new document (e.g. stub a global before app loads)
page.addInitScript("window.isAutomated = true;");
// Wait until a JS condition holds
page.waitForFunction("() => document.querySelectorAll('.row').length > 10");
Page events (listen to the browser)
| Event handler | Fires on |
|---|---|
| page.onConsoleMessage(m -> ...) | console.log/warn/error |
| page.onPageError(e -> ...) | Uncaught JS exceptions |
| page.onRequest / onResponse / onRequestFailed | Network activity |
| page.onDialog(d -> ...) | alert/confirm/prompt |
| page.onDownload(d -> ...) | File download |
| page.onPopup(p -> ...) | New tab/popup |
| page.onWebSocket(ws -> ...) | WebSocket opened |
| page.onFileChooser(fc -> ...) | File input opened |
// Example: fail the test if the page logs a JS error
page.onPageError(err -> { throw new RuntimeException("JS error: " + err); });
page.onConsoleMessage(msg -> {
if ("error".equals(msg.type())) System.out.println("Console error: " + msg.text());
});
Emulation, clock & misc
// Dark mode / print media
page.emulateMedia(new Page.EmulateMediaOptions()
.setColorScheme(ColorScheme.DARK));
// Freeze / control time (clock API)
page.clock().install();
page.clock().setFixedTime(java.time.Instant.parse("2026-01-01T00:00:00Z"));
page.clock().fastForward("30:00"); // advance 30 minutes
page.setViewportSize(1280, 720);
page.addStyleTag(new Page.AddStyleTagOptions().setContent("* {animation:none!important}"));
page.bringToFront();
Keyboard & Mouse
| Call | Purpose |
|---|---|
| page.keyboard().press("Enter") | Press a key/combo |
| page.keyboard().type("hello") | Type text |
| page.keyboard().down("Shift") / up("Shift") | Hold / release |
| page.keyboard().insertText("é") | Insert without key events |
| page.mouse().move(x, y) / click(x, y) | Move / click at coords |
| page.mouse().down() / up() | Press / release |
| page.mouse().wheel(0, 500) | Scroll |
Common keys: Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown, F1-F12. Modifiers: Control, Shift, Alt, Meta (e.g. "Control+Shift+K").
Options Objects (most-used setters)
LaunchOptions
new BrowserType.LaunchOptions()
.setHeadless(false).setSlowMo(300)
.setChannel("chrome") // chrome / msedge
.setArgs(List.of("--start-maximized"))
.setProxy("http://proxy:8080")
.setDownloadsPath(Paths.get("downloads"))
.setTracesDir(Paths.get("traces"))
.setTimeout(30000);
NewContextOptions
new Browser.NewContextOptions()
.setViewportSize(1920, 1080)
.setBaseURL("https://site.com")
.setLocale("en-US").setTimezoneId("Asia/Kolkata")
.setGeolocation(12.97, 77.59).setPermissions(List.of("geolocation"))
.setColorScheme(ColorScheme.DARK)
.setIsMobile(true).setHasTouch(true).setDeviceScaleFactor(2)
.setUserAgent("...")
.setHttpCredentials("user", "pass") // basic auth
.setOffline(true)
.setStorageStatePath(Paths.get("auth/state.json"))
.setRecordVideoDir(Paths.get("videos"))
.setRecordHarPath(Paths.get("net.har"))
.setIgnoreHTTPSErrors(true)
.setExtraHTTPHeaders(Map.of("X-Env", "qa"));
API Testing — APIRequestContext
APIRequestContext api = playwright.request().newContext(
new APIRequest.NewContextOptions()
.setBaseURL("https://api.site.com")
.setExtraHTTPHeaders(Map.of("Authorization", "Bearer " + token)));
| Call | Purpose |
|---|---|
| api.get / post / put / patch / delete / head / fetch | HTTP methods |
| RequestOptions.create().setData(obj/json) | JSON/raw body |
| .setForm(FormData) / .setMultipart(...) | Form / file upload body |
| .setQueryParam(k, v) | Query string |
| .setHeader(k, v) / .setTimeout(ms) | Per-request header/timeout |
| response.status() / statusText() / ok() | Status info |
| response.text() / body() | Body as String / bytes (parse w/ Jackson) |
| response.headers() / headersArray() | Response headers |
| response.dispose() | Free the response |
Network & Routing
| Call | Purpose |
|---|---|
| page.route(glob, handler) | Intercept matching requests |
| context.route(glob, handler) | Intercept for whole context |
| page.unroute(glob) | Remove a route |
| route.fulfill(FulfillOptions) | Return a mock response |
| route.abort() / abort("failed") | Block the request |
| route.resume() / fallback() | Continue / defer to next handler |
| route.fetch() | Get the real response (to modify) |
| context.routeFromHAR(path, opts) | Replay recorded traffic |
| page.waitForResponse / waitForRequest | Sync on a call |
route.fulfill(new Route.FulfillOptions()
.setStatus(200).setContentType("application/json")
.setBody("{\"ok\":true}")); // or .setPath(file) / .setResponse(apiResp)
TestNG Reference (Selenium-shop favourite)
| Annotation / Feature | Purpose |
|---|---|
| @Test(priority=, groups=, enabled=) | A test; ordering / grouping |
| @BeforeMethod / @AfterMethod | Per-test setup/teardown (context+page) |
| @BeforeClass / @AfterClass | Per-class (browser) |
| @BeforeSuite / @AfterSuite | Once per suite |
| @DataProvider | Data-driven Object[][] |
| dependsOnMethods / dependsOnGroups | Ordering by dependency |
| @Test(invocationCount=, threadPoolSize=) | Repeat / concurrency |
| IRetryAnalyzer | Retry failed tests |
| ITestListener / ISuiteListener | Hooks for reporting/screenshots |
| ITestContext / ITestResult | Runtime info in listeners |
<!-- testng.xml — parallel across methods with a thread pool -->
<suite name="pw" parallel="methods" thread-count="4">
<test name="regression">
<classes><class name="tests.LoginTest"/></classes>
</test>
</suite>
@DataProvider(name = "logins", parallel = true)
public Object[][] logins() {
return new Object[][] {{"standard_user","secret_sauce"}, {"locked_out_user","secret_sauce"}};
}
@Test(dataProvider = "logins", retryAnalyzer = Retry.class)
public void login(String u, String p) { ... }
JUnit 5 Reference
| Annotation / Feature | Purpose |
|---|---|
| @Test / @DisplayName | A test + readable name |
| @BeforeEach / @AfterEach | Per-test (context+page) |
| @BeforeAll / @AfterAll | Per-class (browser) — static |
| @ParameterizedTest | Data-driven test |
| @ValueSource / @CsvSource / @CsvFileSource | Inline / CSV data |
| @MethodSource / @ArgumentsSource | Programmatic data (Excel/JSON) |
| @Tag("smoke") | Group & filter (-Dgroups=smoke) |
| @RepeatedTest(n) / @Order | Repeat / order |
| @ExtendWith(Watcher.class) | Extensions (failure capture) |
| @Timeout / assertAll | Timeout / soft-ish grouped asserts |
# junit-platform.properties — parallel execution
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.config.strategy = fixed
junit.jupiter.execution.parallel.config.fixed.parallelism = 4
Maven & Gradle
Maven — run commands
mvn clean test
mvn test -Dbrowser=firefox -Dheadless=true
mvn test -Dgroups=smoke # JUnit tags via Surefire
mvn test -Dtest=LoginTest#validLogin # single method
mvn test -DsuiteXmlFile=testng.xml # TestNG suite
Gradle — equivalent setup
// build.gradle
dependencies {
implementation "com.microsoft.playwright:playwright:1.63.0"
testImplementation "org.junit.jupiter:junit-jupiter:5.11.3"
}
test {
useJUnitPlatform()
systemProperty "browser", System.getProperty("browser", "chromium")
}
// Install browsers:
// gradle -q compileJava; then run the CLI main class, or use a Playwright Gradle exec task
One-Glance Cheat-Sheet
- Playwright pw = Playwright.create();
- Browser b = pw.chromium().launch(new LaunchOptions().setHeadless(false));
- Page page = b.newContext(new NewContextOptions().setBaseURL(url)).newPage();
- page.navigate("/");
- page.getByRole(BUTTON, name("Login")).click();
- assertThat(page.getByText("Products")).isVisible();
Locator picks (in priority order)
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Save"))
page.getByLabel("Email") page.getByPlaceholder("Search") page.getByText("Products")
page.getByTestId("submit")
page.locator("#id") page.locator("xpath=//a") // last resort
Top assertions
assertThat(loc).isVisible(); assertThat(loc).hasText("x");
assertThat(loc).hasCount(3); assertThat(loc).isEnabled();
assertThat(input).hasValue("x"); assertThat(page).hasURL(pattern);
Handle anything
Dropdown : loc.selectOption("value")
Checkbox : loc.check() / assertThat(loc).isChecked()
Dialog : page.onDialog(d -> d.accept()); // register BEFORE click
Frame : page.frameLocator("#f").getByRole(...)
Tab : Page p = page.waitForPopup(() -> click)
Upload : loc.setInputFiles(Paths.get("f.pdf"))
Download : page.waitForDownload(() -> click).saveAs(path)
Network : page.route("**/api", r -> r.fulfill(...))
JS : page.evaluate("() => document.title")
Trace : context.tracing().start(...) / stop(setPath("t.zip"))
Golden rules
- Prefer getByRole/getByTestId over XPath.
- Use web-first assertThat(...) for UI state; never Thread.sleep().
- One BrowserContext + Page per test; ThreadLocal for parallel.
- storageState() to skip logins; API to seed data.
- Debug failures with the Trace Viewer, not print statements.
Keep this open while you build. 📖
One-Page Quick Reference Card
PLAYWRIGHT (JAVA) — QUICK REFERENCE CARD
Playwright 1.63 | Java 17 | print & pin to your desk
CLI & RUN
| Install browsers | ...CLI -Dexec.args="install --with-deps" |
|---|---|
| Codegen (record) | ...CLI -Dexec.args="codegen <url>" |
| Run all | mvn test |
| One browser | mvn test -Dbrowser=firefox |
| One method | mvn test -Dtest=LoginTest#valid |
| View trace | npx playwright show-trace trace.zip |
| Inspector | PWDEBUG=1 mvn test / page.pause() |
BOOT (launch → act → assert)
Playwright pw = Playwright.create();
Browser b = pw.chromium().launch(new LaunchOptions().setHeadless(false));
Page page = b.newContext(new NewContextOptions().setBaseURL(url)).newPage();
page.navigate("/");
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Login")).click();
assertThat(page.getByText("Products")).isVisible();
LOCATORS (priority: role → label/text → testId → css → xpath)
| By role | getByRole(AriaRole.BUTTON, opts.setName("Save")) |
|---|---|
| By label/ph | getByLabel("Email") / getByPlaceholder("Search") |
| By text | getByText("Products", opts.setExact(true)) |
| By test id | getByTestId("submit") |
| CSS / XPath | locator("#id") / locator("xpath=//a") |
| Filter | loc.filter(new FilterOptions().setHasText("X")) |
| Pick | loc.first() / last() / nth(i) / count() |
| Combine | loc.and(x) / loc.or(x) |
ACTIONS (all auto-wait)
| Type/clear | fill(v) / clear() / pressSequentially(v) |
|---|---|
| Click/key | click() / dblclick() / press("Control+A") |
| Check | check() / uncheck() / setChecked(b) |
| Dropdown | selectOption(value|label|index) |
| Mouse | hover() / dragTo(t) / scrollIntoViewIfNeeded() |
| Files | setInputFiles(Paths.get("f.pdf")) |
WEB-FIRST ASSERTIONS (assertThat(...), auto-retry)
| Visibility | isVisible() / isHidden() / isEnabled() / isDisabled() |
|---|---|
| State | isChecked() / isEditable() / isFocused() / isEmpty() |
| Text | hasText(t) / containsText(t) |
| Value/attr | hasValue(v) / hasAttribute(n,v) / hasClass(c) / hasCSS(p,v) |
| Count | hasCount(n) |
| Page | assertThat(page).hasTitle(t) / hasURL(regex) |
| Negate/timeout | not().isVisible() / isVisible(opts.setTimeout(ms)) |
WAITING (never Thread.sleep)
| URL / load | page.waitForURL("**/x") / waitForLoadState(...) |
|---|---|
| Response | page.waitForResponse("**/api", () -> click()) |
| State | loc.waitFor(opts.setState(HIDDEN)) |
| JS predicate | page.waitForFunction("() => window.ready") |
| Defaults | context.setDefaultTimeout(ms) |
HANDLE ANYTHING
| Dialog | page.onDialog(d -> d.accept()) // before trigger |
|---|---|
| Frame | page.frameLocator("#f").getByRole(...) |
| New tab | Page p = page.waitForPopup(() -> click) |
| Download | page.waitForDownload(() -> click).saveAs(path) |
| Upload | loc.setInputFiles(Paths.get("f.pdf")) |
| Run JS | page.evaluate("() => document.title") |
| Network mock | page.route("**/api", r -> r.fulfill(opts)) |
| Block | page.route("**/*.png", r -> r.abort()) |
| Trace | context.tracing().start(...) / stop(setPath("t.zip")) |
| Screenshot | page.screenshot(opts.setFullPage(true)) |
FRAMEWORK SKELETON (JUnit 5)
@BeforeAll static launch browser
@BeforeEach context = browser.newContext(); page = context.newPage();
@Test new LoginPage(page).loginAs(u,p); assertThat(...).isVisible();
@AfterEach context.close();
@AfterAll static browser.close(); playwright.close();
POM: locators as fields, actions as methods, return next page.
Parallel: ThreadLocal<Page/Context>. Skip login: storageState().
API (built-in)
APIRequestContext api = pw.request().newContext(opts.setBaseURL(base));
APIResponse r = api.post("/users", RequestOptions.create().setData(map));
assertEquals(201, r.status()); JsonNode j = mapper.readTree(r.text());
GOLDEN RULES: getByRole/testId over XPath • web-first assertThat over sleep • one Context+Page per test • debug with traces