Once you can write Playwright tests in Java, the next step is a framework a team can grow: page objects, a factory that manages browsers and contexts per thread, configuration, data-driven tests, reporting with screenshots and traces on failure, parallel execution, Cucumber, and pipelines in GitHub Actions, Jenkins or Docker. This guide builds that framework step by step. Start with the Playwright Java tutorial if you're new to the API.
Phase 3 — Framework Design & POM
Duration: 6-12 months | Level: 1 Year Experience
Page Object Model (POM)
POM is a design pattern where each page of the app gets its own Java class holding that page’s locators and actions. Test classes call these methods and contain no locators.
Without POM, if a selector changes you must fix every test that uses it. With POM you fix one page class and every test is corrected. POM makes tests readable, reusable and maintainable.
Project Structure
src/
main/java/
pages/ LoginPage, InventoryPage, CartPage ← locators + actions
components/ HeaderComponent, MenuComponent ← reusable widgets
factory/ PlaywrightFactory ← browser/context setup
utils/ ConfigReader, ExcelUtils, TraceUtils
test/java/
tests/ LoginTest, CheckoutTest ← no locators, only steps
test/resources/
config.properties ← baseUrl, browser, headless, credentials
testdata/ ← excel / json test data
BasePage — shared helpers
package pages;
import com.microsoft.playwright.Page;
public abstract class BasePage {
protected final Page page;
protected BasePage(Page page) { this.page = page; }
public String title() { return page.title(); }
public String url() { return page.url(); }
public void navigate(String url) { page.navigate(url); }
}
LoginPage — a page class
package pages;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.AriaRole;
public class LoginPage extends BasePage {
// Locators — all in ONE place, defined once, resolved lazily
private final Locator username;
private final Locator password;
private final Locator loginButton;
private final Locator errorBanner;
public LoginPage(Page page) {
super(page);
this.username = page.getByPlaceholder("Username");
this.password = page.getByPlaceholder("Password");
this.loginButton = page.getByRole(AriaRole.BUTTON,
new Page.GetByRoleOptions().setName("Login"));
this.errorBanner = page.locator("[data-test='error']");
}
// Action returns the NEXT page object — enables fluent chaining
public InventoryPage loginAs(String user, String pass) {
username.fill(user);
password.fill(pass);
loginButton.click();
return new InventoryPage(page);
}
public String errorText() { return errorBanner.textContent(); }
}
The test — clean and readable
@Test
void standardUserCanLogIn() {
InventoryPage inventory = new LoginPage(page)
.loginAs("standard_user", "secret_sauce");
assertThat(inventory.header()).hasText("Products");
}
PlaywrightFactory — Browser & Context Management
Centralize browser launch and context creation so every test gets a clean, isolated Page. ThreadLocal makes it parallel-safe.
package factory;
import com.microsoft.playwright.*;
public class PlaywrightFactory {
private static final ThreadLocal<Playwright> pw = new ThreadLocal<>();
private static final ThreadLocal<Browser> browser = new ThreadLocal<>();
private static final ThreadLocal<BrowserContext> context = new ThreadLocal<>();
private static final ThreadLocal<Page> page = new ThreadLocal<>();
public static Page initBrowser(String browserName, boolean headless) {
pw.set(Playwright.create());
BrowserType type = switch (browserName.toLowerCase()) {
case "firefox" -> pw.get().firefox();
case "webkit" -> pw.get().webkit();
default -> pw.get().chromium();
};
browser.set(type.launch(new BrowserType.LaunchOptions().setHeadless(headless)));
context.set(browser.get().newContext());
page.set(context.get().newPage());
return page.get();
}
public static Page getPage() { return page.get(); }
public static BrowserContext getContext() { return context.get(); }
public static void tearDown() {
if (context.get() != null) context.get().close();
if (browser.get() != null) browser.get().close();
if (pw.get() != null) pw.get().close();
}
}
Reusing login with storageState()
Log in once, save the session, and reuse it in every test — a huge speed-up.
// 1) After logging in, save the storage state to a file (one-time setup)
context.storageState(new BrowserContext.StorageStateOptions()
.setPath(Paths.get("auth/state.json")));
// 2) In tests, create a context pre-loaded with that session
BrowserContext ctx = browser.newContext(new Browser.NewContextOptions()
.setStorageStatePath(Paths.get("auth/state.json")));
// The new page is already logged in — no login steps needed
Data-Driven Testing
JUnit 5 @ParameterizedTest
@ParameterizedTest
@CsvSource({
"locked_out_user, secret_sauce, Epic sadface: Sorry, this user has been locked out.",
"invalid_user, wrong_pass, Epic sadface: Username and password do not match"
})
void invalidLoginsShowError(String user, String pass, String expected) {
LoginPage login = new LoginPage(page);
login.attemptLogin(user, pass);
assertThat(login.errorLocator()).containsText(expected);
}
TestNG @DataProvider (alternative)
@DataProvider(name = "logins")
public Object[][] logins() {
return new Object[][] {
{"standard_user", "secret_sauce", true},
{"locked_out_user", "secret_sauce", false}
};
}
@Test(dataProvider = "logins")
void loginScenarios(String user, String pass, boolean shouldPass) { ... }
Reading JSON test data with Jackson
record LoginData(String username, String password, boolean valid) {}
ObjectMapper mapper = new ObjectMapper();
List<LoginData> data = mapper.readValue(
new File("src/test/resources/testdata/logins.json"),
new TypeReference<List<LoginData>>() {});
ConfigReader — Centralized Configuration
# config.properties
baseUrl=https://www.saucedemo.com
browser=chromium
headless=true
public class ConfigReader {
private static final Properties props = new Properties();
static {
try (FileInputStream fis = new FileInputStream(
"src/test/resources/config.properties")) {
props.load(fis);
} catch (IOException e) { throw new RuntimeException(e); }
}
public static String get(String key) { return props.getProperty(key); }
public static boolean getBool(String key) {
return Boolean.parseBoolean(props.getProperty(key));
}
}
Component Objects — Reusable UI Pieces
Some UI appears on many pages: a header, a nav bar, a cart widget, a cookie banner. Modeling these as component objects (not full pages) keeps them DRY and lets any page expose them.
package components;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.AriaRole;
public class HeaderComponent {
private final Page page;
public HeaderComponent(Page page) { this.page = page; }
public int cartCount() {
String badge = page.getByTestId("shopping-cart-badge").textContent();
return badge == null ? 0 : Integer.parseInt(badge.trim());
}
public void openCart() { page.getByTestId("shopping-cart-link").click(); }
public void logout() {
page.getByRole(AriaRole.BUTTON,
new Page.GetByRoleOptions().setName("Open Menu")).click();
page.getByText("Logout").click();
}
}
// Any page can expose the shared component
public class InventoryPage extends BasePage {
public InventoryPage(Page page) { super(page); }
public HeaderComponent header() { return new HeaderComponent(page); }
// ... page-specific actions ...
}
// In a test, reuse it cleanly
inventoryPage.header().openCart();
assertEquals(1, inventoryPage.header().cartCount());
If it has its own URL and represents a screen, it is a Page. If it is a widget that shows up across many screens (header, footer, modal, toast), it is a Component. Components are constructed from a Page and returned by page objects.
A Complete POM Flow — Login to Checkout
This shows several page objects cooperating with fluent returns, and a test that reads like plain English.
// --- LoginPage.java ---
public class LoginPage extends BasePage {
public LoginPage(Page page) { super(page); page.navigate("/"); }
public InventoryPage loginAs(String user, String pass) {
page.getByPlaceholder("Username").fill(user);
page.getByPlaceholder("Password").fill(pass);
page.getByRole(AriaRole.BUTTON,
new Page.GetByRoleOptions().setName("Login")).click();
return new InventoryPage(page);
}
}
// --- CartPage.java ---
public class CartPage extends BasePage {
public CartPage(Page page) { super(page); }
public CheckoutPage checkout() {
page.getByRole(AriaRole.BUTTON,
new Page.GetByRoleOptions().setName("Checkout")).click();
return new CheckoutPage(page);
}
}
// --- The test: fluent, readable, zero locators ---
@Test
void endToEndPurchase() {
ConfirmationPage confirmation = new LoginPage(page)
.loginAs("standard_user", "secret_sauce")
.addFirstItemToCart()
.openCart()
.checkout()
.fillDetails("Naveed", "QA", "560001")
.finish();
assertThat(confirmation.banner()).hasText("Thank you for your order!");
}
Same Framework with TestNG (Parity)
These notes lead with JUnit 5, but many SDET teams (and interviews) use TestNG. The Playwright code is identical — only the runner annotations change. Here is the mapping and a base class.
| JUnit 5 | TestNG equivalent |
|---|---|
| @BeforeAll (static) | @BeforeClass |
| @BeforeEach | @BeforeMethod |
| @AfterEach | @AfterMethod |
| @AfterAll (static) | @AfterClass |
| @ParameterizedTest + @CsvSource | @Test + @DataProvider |
| @Tag("smoke") | @Test(groups="smoke") |
| junit-platform.properties parallelism | testng.xml parallel="methods" |
| @ExtendWith(Watcher) | implements ITestListener |
| (RetryingTest / pioneer) | IRetryAnalyzer |
public class BaseTest {
protected Page page;
@BeforeMethod
public void setUp() {
page = PlaywrightFactory.initBrowser(
ConfigReader.get("browser"), ConfigReader.getBool("headless"));
page.navigate(ConfigReader.get("baseUrl"));
}
@AfterMethod
public void tearDown() { PlaywrightFactory.tearDown(); }
}
@Test(dataProvider = "logins", retryAnalyzer = Retry.class, groups = "smoke")
public void login(String user, String pass) {
new LoginPage(page).attemptLogin(user, pass);
assertThat(page.getByText("Products")).isVisible();
}
If the JD says TestNG, present your framework with TestNG annotations and mention testng.xml for parallel + IRetryAnalyzer for retries. The Playwright layer (locators, actions, assertions, factory, POM) is unchanged — emphasize that portability.
Allure Reporting
Allure produces a rich, interactive HTML dashboard. Attach screenshots, traces and steps for clear failure analysis.
<!-- pom.xml: allure + junit5 integration -->
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-junit5</artifactId>
<version>2.29.0</version>
</dependency>
import io.qameta.allure.*;
@Epic("Authentication")
@Feature("Login")
class LoginTest {
@Test
@Story("Valid credentials")
@Description("standard_user should reach the inventory page")
void validLogin() {
step("Enter credentials and submit");
// ...
attachScreenshot();
}
@Step("{0}")
void step(String name) {}
@Attachment(value = "screenshot", type = "image/png")
byte[] attachScreenshot() {
return page.screenshot();
}
}
# Generate and open the report
mvn test
allure serve target/allure-results
Auto-Capturing Screenshots & Traces on Failure
You never want to manually add capture code to every test. A JUnit 5 TestWatcher (or TestNG ITestListener) captures artifacts automatically only when a test fails.
public class FailureWatcher implements TestWatcher,
BeforeEachCallback, AfterEachCallback {
@Override public void beforeEach(ExtensionContext ctx) {
PlaywrightFactory.getContext().tracing().start(
new Tracing.StartOptions().setScreenshots(true)
.setSnapshots(true).setSources(true));
}
@Override public void testFailed(ExtensionContext ctx, Throwable error) {
Page page = PlaywrightFactory.getPage();
// 1) Screenshot attached to Allure
Allure.getLifecycle().addAttachment("screenshot", "image/png", "png",
page.screenshot());
// 2) Trace saved for time-travel debugging
PlaywrightFactory.getContext().tracing().stop(
new Tracing.StopOptions().setPath(
Paths.get("target/traces/" + ctx.getDisplayName() + ".zip")));
}
@Override public void testSuccessful(ExtensionContext ctx) {
PlaywrightFactory.getContext().tracing().stop(); // discard on success
}
}
// Attach to any test class
@ExtendWith(FailureWatcher.class)
public class LoginTest extends BaseTest { ... }
Traces are large. Start tracing before each test but only persist the zip when the test fails (stop with a path). On success, stop() without a path so nothing is written. This keeps CI artifacts small and relevant.
Allure environment & categories
# allure-results/environment.properties (shows in the report header)
Browser=Chromium 1.63.0
Environment=Staging
BaseURL=https://www.saucedemo.com
Java=17
categories.json lets Allure bucket failures (e.g. "Product defects" vs "Test infrastructure") so triage is faster. Add @Severity, @Owner and @Link annotations to enrich each test’s report entry.
Phase 4 — Cross-Browser, Parallel & BDD
Duration: 1-2 years | Level: Mid-level
Cross-Browser Testing
The same test code runs on Chromium, Firefox and WebKit. Parameterize the browser via config or a data provider.
@ParameterizedTest
@ValueSource(strings = {"chromium", "firefox", "webkit"})
void loginWorksOnAllBrowsers(String browserName) {
Page page = PlaywrightFactory.initBrowser(browserName, true);
page.navigate(ConfigReader.get("baseUrl"));
// ... assertions ...
PlaywrightFactory.tearDown();
}
WebKit is the engine behind Safari. Selenium cannot easily test Safari on non-Apple machines; Playwright ships WebKit on every OS, so you can catch Safari-only bugs in CI on Linux.
Headless & Device Emulation
// Headless is the default; force headed for debugging
browserType.launch(new BrowserType.LaunchOptions().setHeadless(false).setSlowMo(300));
// Emulate a mobile device (viewport, user-agent, touch)
BrowserContext iPhone = browser.newContext(new Browser.NewContextOptions()
.setViewportSize(390, 844)
.setUserAgent("Mozilla/5.0 ... iPhone ...")
.setHasTouch(true)
.setIsMobile(true));
Parallel Execution — Contexts, not Grid
Playwright parallelizes with isolated BrowserContexts inside one (or few) browser processes — lightweight compared to spinning up Selenium Grid nodes.
# junit-platform.properties — enable parallel tests
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
Give every test its own Page and BrowserContext (via ThreadLocal). Never share a Page across threads — Playwright is not thread-safe on a single Page/Context. Contexts isolate cookies and storage so parallel tests never collide.
Cucumber BDD with Playwright
# login.feature
Feature: Login
@smoke
Scenario Outline: Login attempts
Given I am on the login page
When I log in as "<user>" with "<pass>"
Then I should see "<result>"
Examples:
| user | pass | result |
| standard_user | secret_sauce | Products |
| locked_out_user | secret_sauce | locked out |
// Step definitions — Page shared via PicoContainer DI
public class LoginSteps {
private final Page page = PlaywrightFactory.getPage();
@Given("I am on the login page")
public void openLogin() { page.navigate(ConfigReader.get("baseUrl")); }
@When("I log in as {string} with {string}")
public void login(String user, String pass) {
new LoginPage(page).attemptLogin(user, pass);
}
@Then("I should see {string}")
public void verify(String text) {
assertThat(page.getByText(text)).isVisible();
}
}
// Hooks — fresh context per scenario, trace on failure
public class Hooks {
@Before public void setUp() { PlaywrightFactory.initBrowser("chromium", true); }
@After public void tearDown(Scenario s) {
if (s.isFailed()) {
s.attach(PlaywrightFactory.getPage().screenshot(), "image/png", "failure");
}
PlaywrightFactory.tearDown();
}
}
The Cucumber Runner & project wiring
With the JUnit Platform engine, you configure Cucumber through junit-platform.properties — no @RunWith runner class needed.
# src/test/resources/junit-platform.properties
cucumber.glue = steps,hooks
cucumber.features = src/test/resources/features
cucumber.plugin = pretty, io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm
cucumber.publish.quiet = true
<!-- pom.xml -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<version>7.20.1</version><scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit-platform-engine</artifactId>
<version>7.20.1</version><scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-picocontainer</artifactId>
<version>7.20.1</version><scope>test</scope>
</dependency>
Hook order & tagged hooks
@Before(order = 0) public void startBrowser() { ... } // runs first
@Before(order = 1) public void seedData() { ... }
@Before("@admin") public void loginAsAdmin() { ... } // only @admin scenarios
@After("@cleanup") public void cleanup() { ... }
Passing structured data with Data Tables
Scenario: Register user
When I register with
| field | value |
| name | Naveed |
| email | naveed@test.com |
@When("I register with")
public void register(io.cucumber.datatable.DataTable table) {
Map<String, String> data = table.asMaps().get(0);
new RegisterPage(ctx.page).fill(data.get("name"), data.get("email"));
}
Soft Assertions
// Collect multiple failures instead of stopping at the first
assertThat(page.getByTestId("cart-badge")).hasText("1"); // web-first (retries)
// For plain value checks, TestNG SoftAssert or AssertJ SoftAssertions:
SoftAssertions softly = new SoftAssertions();
softly.assertThat(price).isEqualTo("$29.99");
softly.assertThat(name).contains("Backpack");
softly.assertAll();
Handling Dynamic Content
Because locators auto-retry, most "dynamic element" problems disappear. For explicit synchronization on network/UI events:
page.waitForURL("**/inventory.html"); // wait for navigation
page.waitForResponse("**/api/products", () -> { // wait for a specific call
page.getByText("Load more").click();
});
page.getByRole(AriaRole.PROGRESSBAR).waitFor( // wait for a state
new Locator.WaitForOptions().setState(WaitForSelectorState.HIDDEN));
Phase 5 — CI/CD & Docker
Duration: 2-3 years | Level: Senior
What is CI/CD?
- CI (Continuous Integration): every push builds and runs the tests automatically.
- CD (Continuous Delivery/Deployment): passing builds flow toward release.
- For automation: tests run headless on every PR, giving fast feedback and blocking bad merges.
GitHub Actions (recommended first CI)
# .github/workflows/tests.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
cache: maven
- name: Install Playwright browsers
run: mvn -q compile exec:java -D exec.mainClass=com.microsoft.playwright.CLI \
-D exec.args="install --with-deps"
- name: Run tests
run: mvn test
- name: Upload traces & report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts
path: |
target/allure-results
**/trace.zip
Jenkins Pipeline
pipeline {
agent any
parameters {
choice(name: "BROWSER", choices: ["chromium","firefox","webkit"])
}
stages {
stage("Checkout") { steps { checkout scm } }
stage("Install browsers") {
steps { sh "mvn compile exec:java -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args='install --with-deps'" }
}
stage("Test") { steps { sh "mvn test -Dbrowser=${params.BROWSER}" } }
}
post {
always { allure includeProperties: false, results: [[path: "target/allure-results"]] }
}
}
Docker — Official Playwright Image
Microsoft publishes an image with browsers and OS dependencies preinstalled — no "missing shared library" pain in CI.
# Dockerfile
FROM mcr.microsoft.com/playwright/java:v1.63.0-jammy
WORKDIR /tests
COPY . .
RUN mvn -q compile
CMD ["mvn", "test"]
# Build and run
docker build -t pw-tests .
docker run --rm pw-tests
Running from Maven
mvn clean test # all tests
mvn test -Dbrowser=firefox # pick a browser via system property
mvn test -Dgroups=smoke # tags (JUnit 5 / Surefire)
mvn test -Dtest=LoginTest#validLogin # a single test
GitLab CI & Azure Pipelines
Beyond GitHub Actions and Jenkins, these two come up often in enterprise SDET roles.
GitLab CI
# .gitlab-ci.yml
image: mcr.microsoft.com/playwright/java:v1.63.0-jammy
stages: [test]
playwright:
stage: test
script:
- mvn -q test
artifacts:
when: always
paths: [target/allure-results, "**/trace.zip"]
expire_in: 1 week
Azure Pipelines
# azure-pipelines.yml
pool: { vmImage: ubuntu-latest }
steps:
- task: JavaToolInstaller@0
inputs: { versionSpec: "17", jdkArchitectureOption: x64, jdkSourceOption: PreInstalled }
- script: mvn compile exec:java -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install --with-deps"
- script: mvn test
- publish: target/allure-results
artifact: allure-results
Matrix Builds, Sharding & Allure History
Cross-browser matrix (GitHub Actions)
jobs:
test:
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
runs-on: ubuntu-latest
steps:
- run: mvn test -Dbrowser=${{ matrix.browser }}
Sharding a big suite across runners
- Split the suite into N shards and run each on a separate runner, then merge the reports.
- In JUnit/TestNG, shard by tags/groups or test classes (e.g. -Dgroups=shard1).
- The matrix strategy above can carry a shard index alongside the browser.
- Result: a 40-minute suite becomes ~10 minutes on 4 runners.
Allure history & trends
- Copy the previous report’s history/ folder into new allure-results before generating — Allure then shows pass/fail trends over time.
- CI plugins (Jenkins Allure, GitHub Pages) persist history automatically.
- Trends turn a report into a dashboard leadership actually looks at — a good thing to mention in interviews.
- Always run headless; use the official Playwright image to avoid missing-dependency errors.
- Upload traces + report as artifacts with if: always() so failures are debuggable.
- Cache Maven/npm dependencies to speed up builds.
- Fail the pipeline on test failure, but still publish the report.
- Shard long suites; parameterize browser/env; keep secrets in CI credentials.
Phase 6 — Architecture, API & Advanced
Duration: 3-5 years | Level: Architect / SDET
Framework Architecture from Scratch
- core — PlaywrightFactory, BaseTest, ConfigReader, ThreadLocal lifecycle
- pages / components — all page objects and reusable widgets
- api — APIRequestContext services + POJOs
- utils — Excel/JSON readers, TraceUtils, ScreenshotUtils
- tests / features — JUnit/TestNG tests or Cucumber features
- resources — config per environment, test data, auth state
- reporting — Allure config, CI publishing, Slack/JIRA hooks
API Testing — Built into Playwright
Playwright ships an HTTP client (APIRequestContext). You can test APIs directly and seed data for UI tests without a separate library.
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.RequestOptions;
APIRequestContext api = playwright.request().newContext(
new APIRequest.NewContextOptions()
.setBaseURL("https://reqres.in"));
// POST
APIResponse res = api.post("/api/users", RequestOptions.create()
.setData(Map.of("name", "naveed", "job", "sdet")));
assertEquals(201, res.status());
// Parse JSON with Jackson
JsonNode body = new ObjectMapper().readTree(res.text());
String id = body.get("id").asText();
API + UI hybrid — seed via API, assert in UI
// 1) Create data fast via API
api.post("/api/products", RequestOptions.create().setData(product));
// 2) Verify it appears in the UI
page.navigate("/inventory");
assertThat(page.getByText(product.name())).isVisible();
Authentication, headers & shared context
// Reusable API context with base URL + default auth headers
APIRequestContext api = playwright.request().newContext(
new APIRequest.NewContextOptions()
.setBaseURL("https://api.example.com")
.setExtraHTTPHeaders(Map.of(
"Authorization", "Bearer " + token,
"Content-Type", "application/json")));
// GET with query params
APIResponse res = api.get("/users", RequestOptions.create()
.setQueryParam("page", 2)
.setQueryParam("per_page", 10));
assertTrue(res.ok()); // 2xx
assertEquals(200, res.status());
Serialization with Jackson POJOs (request & response)
record CreateUser(String name, String job) {}
record UserCreated(String id, String name, String createdAt) {}
ObjectMapper mapper = new ObjectMapper();
// Send a POJO as the JSON body
APIResponse res = api.post("/users", RequestOptions.create()
.setData(mapper.writeValueAsString(new CreateUser("Naveed", "SDET"))));
// Deserialize the response into a POJO
UserCreated created = mapper.readValue(res.text(), UserCreated.class);
assertNotNull(created.id());
Chained scenarios — create, use, delete
// 1) Create
APIResponse post = api.post("/users",
RequestOptions.create().setData(Map.of("name","temp")));
String id = mapper.readTree(post.text()).get("id").asText();
// 2) Read it back
assertEquals(200, api.get("/users/" + id).status());
// 3) Delete (cleanup)
assertEquals(204, api.delete("/users/" + id).status());
Sharing auth between API and UI
Log in via API (fast), inject the session into a browser context, and the UI is already authenticated — no UI login steps.
// The browser context can reuse cookies obtained via API by saving/loading
// storageState, or by setting cookies directly:
context.addCookies(List.of(new Cookie("session", token)
.setDomain("example.com").setPath("/")));
page.navigate("/dashboard"); // already logged in
Network Interception & Mocking
page.route() lets you intercept, modify, mock or block network traffic — perfect for testing error states deterministically.
// Mock an API response
page.route("**/api/products", route -> {
route.fulfill(new Route.FulfillOptions()
.setStatus(200)
.setContentType("application/json")
.setBody("[{\"id\":1,\"name\":\"Mock Item\"}]"));
});
// Simulate a server error to test the UI’s error banner
page.route("**/api/checkout", route ->
route.fulfill(new Route.FulfillOptions().setStatus(500)));
// Block images/analytics to speed up tests
page.route("**/*.{png,jpg,gif}", route -> route.abort());
Tracing & Debugging (Deep Dive)
- Trace viewer: full timeline + DOM snapshot before/after each action, console, network, source.
- Turn on tracing in your base test; save trace.zip only on failure to save space.
- PWDEBUG=1 opens the Playwright Inspector to step through actions live.
- page.pause() drops into the inspector mid-test.
// Save a trace only when a test fails (JUnit 5 example)
@AfterEach
void afterEach(TestInfo info) {
boolean failed = /* track via TestWatcher */ false;
if (failed) {
context.tracing().stop(new Tracing.StopOptions()
.setPath(Paths.get("target/traces/" + info.getDisplayName() + ".zip")));
}
}
Cloud Testing
- BrowserStack / LambdaTest expose a CDP/WebSocket endpoint; connect with chromium().connect(wsEndpoint).
- Run at scale across OS/browser combinations you don’t maintain locally.
- Microsoft Playwright Testing — Microsoft’s own cloud browser service (awareness).
// Connect to a remote browser (concept)
Browser browser = playwright.chromium().connect(WS_ENDPOINT_FROM_CLOUD);
Design Patterns Used in Frameworks
| Pattern | Where it appears |
|---|---|
| Page Object Model | One class per page (foundation) |
| Factory | PlaywrightFactory creating browser/context/page |
| Singleton | ConfigReader loaded once |
| Builder | Test-data builders; Options objects |
| Strategy | Browser choice at runtime |
| Fluent Interface | Actions returning the next page object |
Test Optimization Techniques
- storageState() to skip repeated logins.
- Block heavy resources (images/fonts/analytics) with route abort.
- Parallel contexts + sharding across CI runners.
- Prefer API setup over slow UI setup for preconditions.
- Save traces/videos only on failure.
Debugging & Authoring Toolkit
Beyond traces, Playwright ships tools that make writing and fixing tests dramatically faster. Interviewers love when you mention these.
| Tool | What it does | How to run |
|---|---|---|
| Codegen | Records your clicks and generates Java code + locators | CLI: ...CLI codegen <url> |
| Inspector | Step through actions live, pick locators | set PWDEBUG=1 before mvn test |
| page.pause() | Drops into the Inspector mid-test | add page.pause() in code |
| Trace Viewer | Time-travel a completed run (DOM/net/console) | npx playwright show-trace x.zip |
| Headed + slowMo | Watch the run in a real window | setHeadless(false).setSlowMo(500) |
# Record a test by clicking through the app — great for learning locators
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI \
-D exec.args="codegen https://www.saucedemo.com"
// Pause execution and open the Inspector to debug interactively
page.navigate("/checkout");
page.pause(); // test halts here; step forward action by action
Advanced Techniques
HAR record & replay — fully offline tests
Record all network traffic to a HAR file once, then replay it so tests run without a live backend — fast and deterministic.
// Record
BrowserContext ctx = browser.newContext(new Browser.NewContextOptions()
.setRecordHarPath(Paths.get("network.har")));
// Replay (in another run) — serve responses from the HAR
context.routeFromHAR(Paths.get("network.har"),
new BrowserContext.RouteFromHAROptions().setUpdate(false));
Modifying live responses
// Fetch the real response, then tweak one field before it reaches the app
page.route("**/api/profile", route -> {
APIResponse original = route.fetch();
JsonNode json = mapper.readTree(original.text());
((ObjectNode) json).put("role", "admin"); // force admin to test UI
route.fulfill(new Route.FulfillOptions()
.setResponse(original).setBody(json.toString()));
});
Performance metrics via CDP (Chromium)
// Read Web Vitals / navigation timing straight from the page
Object lcp = page.evaluate("() => performance.getEntriesByType('navigation')[0].loadEventEnd");
System.out.println("Load event end: " + lcp + " ms");
Accessibility & visual checks
- Visual regression: page.screenshot() compared against a baseline image (or Applitools for AI-based diffing that ignores noise).
- Accessibility: getByRole-first tests already assert a11y implicitly; integrate axe-core via page.evaluate for full audits.
- Component testing / API contract testing can layer on the same framework.
- Design a Playwright framework for a large multi-role e-commerce app.
- How do you keep a 2,000-test suite fast and non-flaky?
- How do you architect authentication across roles with storageState?
- How do you combine API and UI in one framework?
- When would you still choose Selenium or Cypress over Playwright?
- How do you use tracing to root-cause an intermittent CI failure?
Phase 6 complete — you now think like an SDET, not just a test writer. 🎭
FAQs
How do you run Playwright Java tests in parallel?
Share one Browser per worker but give every test its own BrowserContext and Page (often held in ThreadLocal), then enable parallel execution in JUnit 5 or TestNG. Contexts are isolated and cheap, so no Grid is needed.
Does Playwright Java work with TestNG?
Yes. Playwright is test-runner agnostic in Java; JUnit 5 and TestNG both work, with the same Page Object and factory structure.
How do you capture traces on failure in Java?
Start tracing on the context before each test and, in the teardown or a failure listener, stop it with a path only when the test failed, then attach the trace zip to the report.