feat: add playwright-java skill for enterprise E2E test automation (#243)

This commit is contained in:
amalsam
2026-03-09 16:20:42 +05:30
committed by GitHub
parent 901c220dfc
commit e7501f1dce
7 changed files with 1542 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
package com.company.tests.base;
import com.company.tests.config.ConfigReader;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.AriaRole;
import com.microsoft.playwright.options.LoadState;
import com.microsoft.playwright.options.WaitForSelectorState;
import io.qameta.allure.Step;
/**
* BasePage foundation for all Page Object classes.
*
* Responsibilities:
* - Inject Page via constructor (never create Playwright here)
* - Provide common action helpers with Allure @Step reporting
* - Enforce strict locator strategy (role/label/testid first)
* - Declare getUrl() so pages can self-navigate
*/
public abstract class BasePage {
protected final Page page;
public BasePage(Page page) {
this.page = page;
}
// ─── Navigation ────────────────────────────────────────────────────────────
/** Navigate to this page's URL relative to baseUrl */
@Step("Navigate to {this.getUrl()}")
public <T extends BasePage> T navigate() {
page.navigate(ConfigReader.getBaseUrl() + getUrl());
waitForPageLoad();
//noinspection unchecked
return (T) this;
}
/** Each page declares its relative URL path, e.g. "/login" */
protected abstract String getUrl();
@Step("Wait for network idle")
protected void waitForPageLoad() {
page.waitForLoadState(LoadState.NETWORKIDLE);
}
// ─── Locator Shortcuts ──────────────────────────────────────────────────────
protected Locator byRole(AriaRole role, String name) {
return page.getByRole(role, new Page.GetByRoleOptions().setName(name));
}
protected Locator byLabel(String label) {
return page.getByLabel(label);
}
protected Locator byTestId(String testId) {
return page.getByTestId(testId);
}
protected Locator byText(String text) {
return page.getByText(text);
}
protected Locator byPlaceholder(String placeholder) {
return page.getByPlaceholder(placeholder);
}
// ─── Action Helpers ─────────────────────────────────────────────────────────
@Step("Fill '{locatorDesc}' with value")
protected void fill(Locator locator, String value) {
locator.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(10_000));
locator.clear();
locator.fill(value);
}
@Step("Click and wait for navigation")
protected void clickAndWaitForNav(Locator locator) {
page.waitForNavigation(() -> locator.click());
}
@Step("Click '{locatorDesc}'")
protected void click(Locator locator) {
locator.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(10_000));
locator.click();
}
@Step("Wait for element to be visible")
protected void waitForVisible(Locator locator, int timeoutMs) {
locator.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(timeoutMs));
}
@Step("Wait for element to be hidden")
protected void waitForHidden(Locator locator) {
locator.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.HIDDEN)
.setTimeout(15_000));
}
@Step("Select option '{value}' from dropdown")
protected void selectOption(Locator locator, String value) {
locator.selectOption(value);
}
@Step("Check checkbox")
protected void check(Locator locator) {
if (!locator.isChecked()) locator.check();
}
@Step("Uncheck checkbox")
protected void uncheck(Locator locator) {
if (locator.isChecked()) locator.uncheck();
}
// ─── Scroll ─────────────────────────────────────────────────────────────────
protected void scrollIntoView(Locator locator) {
locator.scrollIntoViewIfNeeded();
}
// ─── Utility ────────────────────────────────────────────────────────────────
public String getCurrentUrl() {
return page.url();
}
public String getPageTitle() {
return page.title();
}
}

View File

@@ -0,0 +1,126 @@
package com.company.tests.base;
import com.company.tests.config.ConfigReader;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.LoadState;
import io.qameta.allure.Allure;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestInfo;
import java.io.ByteArrayInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* Thread-safe BaseTest for parallel Playwright execution.
* Handles Playwright/Browser/Context/Page lifecycle.
* Captures traces and screenshots on test completion.
*/
public abstract class BaseTest {
private static final ThreadLocal<Playwright> playwrightTL = new ThreadLocal<>();
private static final ThreadLocal<Browser> browserTL = new ThreadLocal<>();
private static final ThreadLocal<BrowserContext> contextTL = new ThreadLocal<>();
private static final ThreadLocal<Page> pageTL = new ThreadLocal<>();
/** Access the Page in test/page-object classes */
protected Page page() {
return pageTL.get();
}
@BeforeEach
void setUpPlaywright(TestInfo testInfo) throws Exception {
// Ensure trace/video output directories exist
Files.createDirectories(Paths.get("target/traces"));
Files.createDirectories(Paths.get("target/videos"));
Playwright playwright = Playwright.create();
playwrightTL.set(playwright);
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
.setHeadless(ConfigReader.isHeadless())
.setSlowMo(Integer.parseInt(System.getProperty("slowMo", "0")));
Browser browser = resolveBrowser(playwright).launch(launchOptions);
browserTL.set(browser);
Browser.NewContextOptions contextOptions = new Browser.NewContextOptions()
.setViewportSize(1920, 1080)
.setLocale("en-US")
.setTimezoneId("Asia/Kolkata")
.setRecordVideoDir(Paths.get("target/videos/"));
// Load saved auth state if available
Path authState = Paths.get("target/auth/user-state.json");
if (Files.exists(authState)) {
contextOptions.setStorageStatePath(authState);
}
BrowserContext context = browser.newContext(contextOptions);
context.setDefaultTimeout(ConfigReader.getDefaultTimeout());
context.setDefaultNavigationTimeout(60_000);
// Start tracing
context.tracing().start(new Tracing.StartOptions()
.setScreenshots(true)
.setSnapshots(true)
.setSources(true));
contextTL.set(context);
Page page = context.newPage();
pageTL.set(page);
}
@AfterEach
void tearDownPlaywright(TestInfo testInfo) {
String safeName = testInfo.getDisplayName()
.replaceAll("[^a-zA-Z0-9._-]", "_")
.substring(0, Math.min(80, testInfo.getDisplayName().length()));
try {
// Capture screenshot for Allure
if (pageTL.get() != null) {
byte[] screenshot = pageTL.get().screenshot(
new Page.ScreenshotOptions().setFullPage(true));
Allure.addAttachment("Final Screenshot", "image/png",
new ByteArrayInputStream(screenshot), "png");
}
} catch (Exception ignored) {}
try {
// Stop and save trace
if (contextTL.get() != null) {
contextTL.get().tracing().stop(new Tracing.StopOptions()
.setPath(Paths.get("target/traces/" + safeName + ".zip")));
}
} catch (Exception ignored) {}
// Close resources in order
closeQuietly(pageTL.get());
closeQuietly(contextTL.get());
closeQuietly(browserTL.get());
closeQuietly(playwrightTL.get());
pageTL.remove();
contextTL.remove();
browserTL.remove();
playwrightTL.remove();
}
private BrowserType resolveBrowser(Playwright playwright) {
return switch (System.getProperty("browser", "chromium").toLowerCase()) {
case "firefox" -> playwright.firefox();
case "webkit" -> playwright.webkit();
default -> playwright.chromium();
};
}
private void closeQuietly(AutoCloseable closeable) {
if (closeable != null) {
try { closeable.close(); } catch (Exception ignored) {}
}
}
}

View File

@@ -0,0 +1,385 @@
---
name: playwright-java
description: "Scaffold, write, debug, and enhance enterprise-grade Playwright E2E tests in Java using Page Object Model, JUnit 5, Allure reporting, and parallel execution."
category: test-automation
risk: safe
source: community
date_added: "2025-03-08"
author: amalsam18
tags: [playwright, java, e2e-testing, junit5, page-object-model, allure, selenium-alternative]
tools: [claude, cursor,antigravity]
---
# Playwright Java Advanced Test Automation
## Overview
This skill produces production-quality, enterprise-grade Playwright Java test code.
It enforces the Page Object Model (POM), strict locator strategies, thread-safe parallel
execution, and full Allure reporting integration. Targets Java 17+ and Playwright 1.44+.
Supporting reference files are available for deeper topics:
| Topic | File |
|-------|------|
| Maven POM, ConfigReader, Docker/CI setup | `references/config.md` |
| Component pattern, dropdowns, uploads, waits | `references/page-objects.md` |
| Full assertion API, soft assertions, visual testing | `references/assertions.md` |
| Fixtures, test data factory, auth state, retry | `references/fixtures.md` |
| Drop-in base class templates | `templates/BaseTest.java`, `templates/BasePage.java` |
---
## When to Use This Skill
- Use when scaffolding a new Playwright Java project from scratch
- Use when writing Page Object classes or JUnit 5 test classes
- Use when the user asks about cross-browser testing, parallel execution, or Allure reports
- Use when fixing flaky tests or replacing `Thread.sleep()` with proper waits
- Use when setting up Playwright in CI/CD pipelines (GitHub Actions, Jenkins, Docker)
- Use when combining API calls and UI assertions in a single test (hybrid testing)
- Use when the user mentions "POM pattern", "BrowserContext", "Playwright fixtures", or "traces"
---
## How It Works
### Step 1: Decide the Approach
Use this matrix to pick the right pattern before writing any code:
| User Request | Approach |
|---|---|
| New project from scratch | Full scaffold — see `references/config.md` |
| Single feature test | POM page class + JUnit5 test class |
| API + UI hybrid | `APIRequestContext` alongside `Page` |
| Cross-browser | `@MethodSource` parameterized over browser names |
| Flaky test fix | Replace `sleep` with `waitFor` / `waitForResponse` |
| CI integration | `playwright install --with-deps` in pipeline |
| Parallel execution | `junit-platform.properties` + `ThreadLocal` |
| Rich reporting | Allure + Playwright trace + video recording |
---
### Step 2: Scaffold the Project Structure
Always use this layout when creating a new project:
```
src/
├── test/
│ ├── java/com/company/tests/
│ │ ├── base/
│ │ │ ├── BaseTest.java ← templates/BaseTest.java
│ │ │ └── BasePage.java ← templates/BasePage.java
│ │ ├── pages/
│ │ │ └── LoginPage.java
│ │ ├── tests/
│ │ │ └── LoginTest.java
│ │ ├── utils/
│ │ │ ├── TestDataFactory.java
│ │ │ └── WaitUtils.java
│ │ └── config/
│ │ └── ConfigReader.java
│ └── resources/
│ ├── test.properties
│ ├── junit-platform.properties
│ └── testdata/users.json
pom.xml
```
---
### Step 3: Set Up Thread-Safe BaseTest
```java
public class BaseTest {
protected static ThreadLocal<Playwright> playwrightTL = new ThreadLocal<>();
protected static ThreadLocal<Browser> browserTL = new ThreadLocal<>();
protected static ThreadLocal<BrowserContext> contextTL = new ThreadLocal<>();
protected static ThreadLocal<Page> pageTL = new ThreadLocal<>();
protected Page page() { return pageTL.get(); }
@BeforeEach
void setUp() {
Playwright playwright = Playwright.create();
playwrightTL.set(playwright);
Browser browser = resolveBrowser(playwright).launch(
new BrowserType.LaunchOptions()
.setHeadless(ConfigReader.isHeadless()));
browserTL.set(browser);
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setViewportSize(1920, 1080)
.setRecordVideoDir(Paths.get("target/videos/"))
.setLocale("en-US"));
context.tracing().start(new Tracing.StartOptions()
.setScreenshots(true).setSnapshots(true));
contextTL.set(context);
pageTL.set(context.newPage());
}
@AfterEach
void tearDown(TestInfo testInfo) {
String name = testInfo.getDisplayName().replaceAll("[^a-zA-Z0-9]", "_");
contextTL.get().tracing().stop(new Tracing.StopOptions()
.setPath(Paths.get("target/traces/" + name + ".zip")));
pageTL.get().close();
contextTL.get().close();
browserTL.get().close();
playwrightTL.get().close();
}
private BrowserType resolveBrowser(Playwright pw) {
return switch (System.getProperty("browser", "chromium").toLowerCase()) {
case "firefox" -> pw.firefox();
case "webkit" -> pw.webkit();
default -> pw.chromium();
};
}
}
```
---
### Step 4: Build Page Object Classes
```java
public class LoginPage extends BasePage {
// Declare ALL locators as fields — never inline in action methods
private final Locator emailInput;
private final Locator passwordInput;
private final Locator loginButton;
private final Locator errorMessage;
public LoginPage(Page page) {
super(page);
emailInput = page.getByLabel("Email address");
passwordInput = page.getByLabel("Password");
loginButton = page.getByRole(AriaRole.BUTTON,
new Page.GetByRoleOptions().setName("Sign in"));
errorMessage = page.getByTestId("login-error");
}
@Override protected String getUrl() { return "/login"; }
// Navigation methods return the next Page Object — enables fluent chaining
public DashboardPage loginAs(String email, String password) {
fill(emailInput, email);
fill(passwordInput, password);
clickAndWaitForNav(loginButton);
return new DashboardPage(page);
}
public LoginPage loginExpectingError(String email, String password) {
fill(emailInput, email);
fill(passwordInput, password);
loginButton.click();
errorMessage.waitFor();
return this;
}
public String getErrorMessage() { return errorMessage.textContent(); }
}
```
---
### Step 5: Write Tests with Allure Annotations
```java
@ExtendWith(AllureJunit5.class)
class LoginTest extends BaseTest {
private LoginPage loginPage;
@BeforeEach
void openLoginPage() {
loginPage = new LoginPage(page());
loginPage.navigate();
}
@Test
@Severity(SeverityLevel.BLOCKER)
@DisplayName("Valid credentials redirect to dashboard")
void shouldLoginWithValidCredentials() {
User user = TestDataFactory.getDefaultUser();
DashboardPage dash = loginPage.loginAs(user.email(), user.password());
assertThat(page()).hasURL(Pattern.compile(".*/dashboard"));
assertThat(dash.getWelcomeBanner()).containsText("Welcome, " + user.firstName());
}
@Test
void shouldShowErrorOnInvalidCredentials() {
loginPage.loginExpectingError("bad@test.com", "wrongpass");
SoftAssertions softly = new SoftAssertions();
softly.assertThat(loginPage.getErrorMessage()).contains("Invalid email or password");
softly.assertThat(page()).hasURL(Pattern.compile(".*/login"));
softly.assertAll();
}
@ParameterizedTest
@MethodSource("provideInvalidCredentials")
void shouldRejectInvalidCredentials(String email, String password, String expectedError) {
loginPage.loginExpectingError(email, password);
assertThat(loginPage.getErrorMessage()).containsText(expectedError);
}
static Stream<Arguments> provideInvalidCredentials() {
return Stream.of(
Arguments.of("", "password123", "Email is required"),
Arguments.of("user@test.com", "", "Password is required"),
Arguments.of("notanemail", "pass", "Invalid email format")
);
}
}
```
---
## Examples
### Example 1: API + UI Hybrid Test
```java
@Test
void shouldDisplayNewlyCreatedOrder() {
// Arrange via API — faster than navigating through UI
APIRequestContext api = page().context().request();
APIResponse response = api.post("/api/orders",
RequestOptions.create()
.setHeader("Authorization", "Bearer " + authToken)
.setData(Map.of("productId", "SKU-001", "quantity", 2)));
assertThat(response).isOK();
String orderId = new JsonParser().parse(response.text())
.getAsJsonObject().get("id").getAsString();
OrdersPage orders = new OrdersPage(page());
orders.navigate();
assertThat(orders.getOrderRowById(orderId)).isVisible();
}
```
### Example 2: Network Mocking
```java
@Test
void shouldHandleApiFailureGracefully() {
page().route("**/api/products", route -> route.fulfill(
new Route.FulfillOptions()
.setStatus(503)
.setBody("{\"error\":\"Service Unavailable\"}")
.setContentType("application/json")));
ProductsPage products = new ProductsPage(page());
products.navigate();
assertThat(products.getErrorBanner())
.hasText("We're having trouble loading products. Please try again.");
}
```
### Example 3: Parallel Cross-Browser Test
```java
@ParameterizedTest
@MethodSource("browsers")
void shouldRenderCheckoutOnAllBrowsers(String browserName) {
System.setProperty("browser", browserName);
new CheckoutPage(page()).navigate();
assertThat(page().locator(".checkout-form")).isVisible();
}
static Stream<String> browsers() {
return Stream.of("chromium", "firefox", "webkit");
}
```
### Example 4: Parallel Execution Config
```properties
# src/test/resources/junit-platform.properties
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
```
### Example 5: GitHub Actions CI Pipeline
```yaml
- name: Install Playwright browsers
run: mvn exec:java -e -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install --with-deps"
- name: Run tests
run: mvn test -Dbrowser=${{ matrix.browser }} -Dheadless=true
- name: Upload traces on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-traces
path: target/traces/
- name: Upload Allure results
uses: actions/upload-artifact@v4
if: always()
with:
name: allure-results
path: target/allure-results/
```
---
## Best Practices
- ✅ Use `ThreadLocal<Page>` for every parallel-safe test suite
- ✅ Declare all `Locator` fields at the top of the Page Object class
- ✅ Return the next Page Object from navigation methods (fluent chaining)
- ✅ Use `assertThat(locator)` — it auto-retries until timeout
- ✅ Use `getByRole`, `getByLabel`, `getByTestId` as first-choice locators
- ✅ Start tracing in `@BeforeEach` and stop with a file path in `@AfterEach`
- ✅ Use `SoftAssertions` when validating multiple fields on a single page
- ✅ Set up saved auth state (`storageState`) to skip login across test classes
- ❌ Never use `Thread.sleep()` — replace with `waitFor()` or `waitForResponse()`
- ❌ Never hardcode base URLs — always use `ConfigReader.getBaseUrl()`
- ❌ Never create a `Playwright` instance inside a Page Object
- ❌ Never use XPath for dynamic or frequently changing elements
---
## Common Pitfalls
- **Problem:** Tests fail randomly in parallel mode
**Solution:** Ensure every test creates its own `Playwright → Browser → BrowserContext → Page` chain via `ThreadLocal`. Never share a `Page` across threads.
- **Problem:** `assertThat(locator).isVisible()` times out even when the element appears
**Solution:** Increase timeout with `.setTimeout(10_000)` or raise `context.setDefaultTimeout()` in `BaseTest`.
- **Problem:** `Thread.sleep(2000)` was added but tests are still flaky
**Solution:** Replace with `page.waitForResponse("**/api/endpoint", () -> action())` or `assertThat(locator).hasText("Done")` which polls automatically.
- **Problem:** Playwright trace zip is empty or missing
**Solution:** Ensure `tracing().start()` is called before test actions and `tracing().stop()` is in `@AfterEach` — not `@AfterAll`.
- **Problem:** Allure report is blank or missing steps
**Solution:** Add the AspectJ agent to `maven-surefire-plugin` `<argLine>` in `pom.xml` — see `references/config.md` for the exact snippet.
- **Problem:** `storageState` auth file is stale and tests redirect to login
**Solution:** Re-run `AuthSetup` to regenerate `target/auth/user-state.json` before the suite, or add a `@BeforeAll` that conditionally refreshes it.
---
## Related Skills
- `@rest-assured-java` — Use for pure API test suites without any UI interaction
- `@selenium-java` — Legacy alternative; prefer Playwright for all new projects
- `@allure-reporting` — Deep-dive into Allure annotations, categories, and history trends
- `@testcontainers-java` — Use alongside this skill when tests need a live database or service
- `@github-actions-ci` — For building complete multi-browser matrix CI pipelines

View File

@@ -0,0 +1,167 @@
# Playwright Java Assertions Reference
## Import Statement
```java
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
import org.assertj.core.api.SoftAssertions;
```
---
## Locator Assertions (Auto-Retry)
Playwright's `assertThat(locator)` polls automatically (up to `defaultTimeout`).
**Always prefer these over `locator.isVisible()` + `assertTrue`.**
```java
// Visibility
assertThat(locator).isVisible();
assertThat(locator).isHidden();
// Enabled / Disabled
assertThat(locator).isEnabled();
assertThat(locator).isDisabled();
// Text content (exact or partial)
assertThat(locator).hasText("Exact text");
assertThat(locator).containsText("partial");
assertThat(locator).hasText(Pattern.compile("Order #\\d+"));
// Multiple elements
assertThat(locator).hasCount(5);
assertThat(locator).hasText(new String[]{"Item A", "Item B", "Item C"});
// Attribute
assertThat(locator).hasAttribute("aria-expanded", "true");
assertThat(locator).hasAttribute("href", Pattern.compile(".*\\/dashboard"));
// CSS class
assertThat(locator).hasClass("active");
assertThat(locator).hasClass(Pattern.compile("btn-.*"));
// Input value
assertThat(locator).hasValue("expected input value");
assertThat(locator).hasValue(Pattern.compile("\\d{4}-\\d{2}-\\d{2}")); // date pattern
// Checked state (checkbox/radio)
assertThat(locator).isChecked();
assertThat(locator).not().isChecked();
// Focused
assertThat(locator).isFocused();
// Editable
assertThat(locator).isEditable();
```
---
## Page Assertions
```java
// URL
assertThat(page).hasURL("https://example.com/dashboard");
assertThat(page).hasURL(Pattern.compile(".*/dashboard"));
// Title
assertThat(page).hasTitle("Dashboard MyApp");
assertThat(page).hasTitle(Pattern.compile(".*Dashboard.*"));
```
---
## Negation
```java
// Add .not() for inverse
assertThat(locator).not().isVisible();
assertThat(locator).not().hasText("Error");
assertThat(page).not().hasURL(Pattern.compile(".*/login"));
```
---
## Custom Timeout on Assertion
```java
assertThat(locator)
.hasText("Loaded", new LocatorAssertions.HasTextOptions().setTimeout(10_000));
```
---
## Soft Assertions (AssertJ)
Collect all failures before reporting — critical for form validation tests:
```java
@Test
void shouldDisplayAllProfileFields() {
ProfilePage profile = new ProfilePage(page());
profile.navigate();
SoftAssertions soft = new SoftAssertions();
soft.assertThat(profile.getNameField().inputValue()).isEqualTo("Amal");
soft.assertThat(profile.getEmailField().inputValue()).contains("@");
soft.assertThat(profile.getRoleLabel().textContent()).isEqualTo("Engineer");
soft.assertAll(); // throws at end with ALL failures listed
}
```
---
## Response Assertions
```java
APIResponse response = page().context().request().get("/api/health");
assertThat(response).isOK(); // status 200-299
assertThat(response).hasStatus(201);
assertThat(response).hasHeader("content-type", "application/json");
assertThat(response).hasJSON("{\"status\":\"UP\"}"); // exact JSON match
```
---
## Screenshot Comparison (Visual Testing)
```java
// Full page screenshot
assertThat(page).hasScreenshot(new PageAssertions.HasScreenshotOptions()
.setName("dashboard.png")
.setFullPage(true)
.setThreshold(0.2));
// Locator screenshot
assertThat(page.locator(".chart-container"))
.hasScreenshot(new LocatorAssertions.HasScreenshotOptions()
.setName("revenue-chart.png"));
```
Update golden files: run with `PLAYWRIGHT_UPDATE_SNAPSHOTS=true mvn test`
---
## Common Anti-Patterns to Avoid
```java
// ❌ WRONG — no auto-retry, brittle
assertTrue(page.locator(".spinner").isHidden());
Thread.sleep(2000);
// ✅ CORRECT — auto-retry until timeout
assertThat(page.locator(".spinner")).isHidden();
// ❌ WRONG — getText() can return stale value
String text = locator.textContent();
assertEquals("Done", text);
// ✅ CORRECT — assertion retries until text matches
assertThat(locator).hasText("Done");
// ❌ WRONG — count check without waiting
assertEquals(5, page.locator("li").count());
// ✅ CORRECT — waits until count stabilizes
assertThat(page.locator("li")).hasCount(5);
```

View File

@@ -0,0 +1,240 @@
# Playwright Java Project Configuration
## Maven POM (`pom.xml`)
```xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.company</groupId>
<artifactId>playwright-tests</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<playwright.version>1.44.0</playwright.version>
<junit.version>5.10.2</junit.version>
<allure.version>2.27.0</allure.version>
<aspectj.version>1.9.22</aspectj.version>
</properties>
<dependencies>
<!-- Playwright -->
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>${playwright.version}</version>
</dependency>
<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<!-- Allure JUnit5 -->
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-junit5</artifactId>
<version>${allure.version}</version>
<scope>test</scope>
</dependency>
<!-- AssertJ (for SoftAssertions) -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
<!-- Jackson for JSON test data -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.1</version>
</dependency>
<!-- Faker for dynamic test data -->
<dependency>
<groupId>net.datafaker</groupId>
<artifactId>datafaker</artifactId>
<version>2.2.2</version>
</dependency>
<!-- SLF4J + Logback -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.6</version>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Surefire with Allure agent + parallel support -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>
-javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar"
</argLine>
<systemPropertyVariables>
<browser>${browser}</browser>
<headless>${headless}</headless>
<baseUrl>${baseUrl}</baseUrl>
<allure.results.directory>${project.build.directory}/allure-results</allure.results.directory>
</systemPropertyVariables>
<properties>
<configurationParameters>
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
</configurationParameters>
</properties>
</configuration>
<dependencies>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>${aspectj.version}</version>
</dependency>
</dependencies>
</plugin>
<!-- Allure Maven plugin -->
<plugin>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-maven</artifactId>
<version>2.12.0</version>
<configuration>
<reportVersion>${allure.version}</reportVersion>
</configuration>
</plugin>
</plugins>
</build>
</project>
```
---
## `test.properties`
```properties
# src/test/resources/test.properties
baseUrl=https://your-app.com
apiBaseUrl=https://api.your-app.com
browser=chromium
headless=true
slowMo=0
defaultTimeout=30000
navigationTimeout=60000
```
---
## `ConfigReader.java`
```java
package com.company.tests.config;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
public final class ConfigReader {
private static final Properties props = new Properties();
static {
try (InputStream in = ConfigReader.class
.getClassLoader()
.getResourceAsStream("test.properties")) {
if (in != null) props.load(in);
} catch (IOException e) {
throw new RuntimeException("Cannot load test.properties", e);
}
}
public static String getBaseUrl() {
return System.getProperty("baseUrl", props.getProperty("baseUrl", "http://localhost:3000"));
}
public static int getDefaultTimeout() {
String val = System.getProperty("defaultTimeout", props.getProperty("defaultTimeout", "30000"));
return Integer.parseInt(val);
}
public static boolean isHeadless() {
return Boolean.parseBoolean(System.getProperty("headless", props.getProperty("headless", "true")));
}
}
```
---
## Browser Installation (First-Time Setup)
```bash
# Install browsers for current OS
mvn exec:java -e -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install"
# Install with system dependencies (needed in CI/Docker)
mvn exec:java -e -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install --with-deps"
# Install specific browser only
mvn exec:java -e -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install chromium"
```
---
## Running Tests
```bash
# All tests, headless Chromium
mvn test
# Specific browser
mvn test -Dbrowser=firefox
# Headed mode (debug)
mvn test -Dheadless=false -DslowMo=500
# Single test class
mvn test -Dtest=LoginTest
# Generate Allure report
mvn allure:serve
# Export report to directory
mvn allure:report
```
---
## Docker / CI Environment
```dockerfile
FROM mcr.microsoft.com/playwright/java:v1.44.0-jammy
WORKDIR /app
COPY . .
RUN mvn dependency:resolve
CMD ["mvn", "test", "-Dheadless=true"]
```

View File

@@ -0,0 +1,263 @@
# Playwright Java Fixtures, Hooks & Test Data
## JUnit 5 Extension for Playwright (Custom Fixture)
Encapsulate browser lifecycle in a reusable JUnit 5 extension:
```java
package com.company.tests.base;
import com.microsoft.playwright.*;
import org.junit.jupiter.api.extension.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class PlaywrightExtension
implements BeforeEachCallback, AfterEachCallback, ParameterResolver {
private static final Map<String, Page> pageMap = new ConcurrentHashMap<>();
@Override
public void beforeEach(ExtensionContext ctx) {
Playwright pw = Playwright.create();
Browser browser = pw.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true));
BrowserContext context = browser.newContext();
Page page = context.newPage();
pageMap.put(ctx.getUniqueId(), page);
ctx.getStore(ExtensionContext.Namespace.GLOBAL).put("playwright", pw);
ctx.getStore(ExtensionContext.Namespace.GLOBAL).put("browser", browser);
ctx.getStore(ExtensionContext.Namespace.GLOBAL).put("context", context);
}
@Override
public void afterEach(ExtensionContext ctx) {
pageMap.remove(ctx.getUniqueId());
closeIfNotNull(ctx.getStore(ExtensionContext.Namespace.GLOBAL).remove("context", BrowserContext.class));
closeIfNotNull(ctx.getStore(ExtensionContext.Namespace.GLOBAL).remove("browser", Browser.class));
closeIfNotNull(ctx.getStore(ExtensionContext.Namespace.GLOBAL).remove("playwright", Playwright.class));
}
@Override
public boolean supportsParameter(ParameterContext param, ExtensionContext ext) {
return param.getParameter().getType() == Page.class;
}
@Override
public Object resolveParameter(ParameterContext param, ExtensionContext ext) {
return pageMap.get(ext.getUniqueId());
}
private void closeIfNotNull(AutoCloseable obj) {
if (obj != null) try { obj.close(); } catch (Exception ignored) {}
}
}
// Usage:
@ExtendWith(PlaywrightExtension.class)
class CheckoutTest {
@Test
void shouldCompleteCheckout(Page page) {
// Page is injected automatically
new LoginPage(page).navigate().loginAs("user@test.com", "pass");
}
}
```
---
## Test Data Factory
```java
package com.company.tests.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.datafaker.Faker;
import java.io.InputStream;
import java.util.List;
public final class TestDataFactory {
private static final Faker faker = new Faker();
private static final ObjectMapper mapper = new ObjectMapper();
// --- Static test data from JSON ---
public static User getDefaultUser() {
return loadUsers().stream()
.filter(u -> u.role().equals("default"))
.findFirst()
.orElseThrow(() -> new RuntimeException("No default user in testdata/users.json"));
}
public static User getAdminUser() {
return loadUsers().stream()
.filter(u -> u.role().equals("admin"))
.findFirst()
.orElseThrow();
}
@SuppressWarnings("unchecked")
private static List<User> loadUsers() {
try (InputStream in = TestDataFactory.class
.getClassLoader().getResourceAsStream("testdata/users.json")) {
return mapper.readValue(in, mapper.getTypeFactory()
.constructCollectionType(List.class, User.class));
} catch (Exception e) {
throw new RuntimeException("Failed to load users.json", e);
}
}
// --- Dynamic data generation ---
public static User generateRandomUser() {
return new User(
faker.internet().emailAddress(),
faker.internet().password(12, 20, true, true, true),
faker.name().firstName(),
faker.name().lastName(),
"default"
);
}
public static String randomPhone() {
return faker.phoneNumber().cellPhone();
}
public static String randomPostalCode() {
return faker.address().zipCode();
}
}
```
---
## `testdata/users.json`
```json
[
{
"email": "admin@company.com",
"password": "Admin@1234",
"firstName": "Admin",
"lastName": "User",
"role": "admin"
},
{
"email": "user@company.com",
"password": "User@1234",
"firstName": "Test",
"lastName": "User",
"role": "default"
}
]
```
---
## Pre-Authenticated Context (Reuse Auth State)
Save login state once, reuse across tests — massive speed improvement:
```java
// Run once before test suite (e.g., in @BeforeAll or a setup class)
public class AuthSetup {
public static void saveAuthState() {
try (Playwright pw = Playwright.create()) {
Browser browser = pw.chromium().launch();
BrowserContext context = browser.newContext();
Page page = context.newPage();
page.navigate(ConfigReader.getBaseUrl() + "/login");
page.getByLabel("Email").fill(TestDataFactory.getDefaultUser().email());
page.getByLabel("Password").fill(TestDataFactory.getDefaultUser().password());
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Sign in")).click();
page.waitForURL("**/dashboard");
// Save storage state (cookies + localStorage)
context.storageState(new BrowserContext.StorageStateOptions()
.setPath(Paths.get("target/auth/user-state.json")));
}
}
}
// In BaseTest, load saved state:
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setStorageStatePath(Paths.get("target/auth/user-state.json")));
```
---
## Screenshot on Test Failure (Allure Attachment)
```java
// Add this to @AfterEach in BaseTest
@AfterEach
void captureOnFailure(TestInfo info) {
// The test outcome is available via TestInfo with JUnit5 + Allure
byte[] screenshot = page().screenshot(new Page.ScreenshotOptions().setFullPage(true));
Allure.addAttachment("Screenshot on failure", "image/png",
new ByteArrayInputStream(screenshot), "png");
}
```
---
## WaitUtils Helper
```java
package com.company.tests.utils;
import com.microsoft.playwright.Page;
public final class WaitUtils {
public static void waitForSpinnerToDisappear(Page page) {
page.locator(".loading-spinner").waitFor(
new Locator.WaitForOptions()
.setState(WaitForSelectorState.HIDDEN)
.setTimeout(15_000));
}
public static void waitForToastMessage(Page page, String message) {
page.getByRole(AriaRole.ALERT)
.filter(new Locator.FilterOptions().setHasText(message))
.waitFor(new Locator.WaitForOptions()
.setState(WaitForSelectorState.VISIBLE)
.setTimeout(5_000));
}
public static void waitForApiResponse(Page page, String urlPattern) {
page.waitForResponse(resp ->
resp.url().contains(urlPattern) && resp.status() < 400,
() -> {}
);
}
}
```
---
## Retry Logic for Flaky Tests
```java
// JUnit 5 built-in retry extension
// Add to pom.xml:
// <dependency>
// <groupId>org.junit.jupiter</groupId>
// <artifactId>junit-jupiter-engine</artifactId>
// <version>5.10.2</version>
// </dependency>
@RepeatedTest(value = 3, failureThreshold = 1) // retry up to 3 times
void flakySmokeTest() {
// test body
}
// Custom @RetryTest annotation backed by a JUnit Extension:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(RetryExtension.class)
public @interface RetryTest {
int times() default 3;
}
```

View File

@@ -0,0 +1,224 @@
# Playwright Java Page Object Patterns
## Component Pattern (Reusable Sub-Page-Objects)
For repeated UI components (navbars, modals, tables), create Component classes:
```java
// Reusable table component — pass the locator root
public class DataTable extends BasePage {
private final Locator tableRoot;
public DataTable(Page page, Locator tableRoot) {
super(page);
this.tableRoot = tableRoot;
}
public int getRowCount() {
return tableRoot.locator("tbody tr").count();
}
public String getCellValue(int row, int col) {
return tableRoot.locator("tbody tr")
.nth(row)
.locator("td")
.nth(col)
.innerText();
}
public void clickRowAction(int row, String actionLabel) {
tableRoot.locator("tbody tr")
.nth(row)
.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName(actionLabel))
.click();
}
public DataTable sortByColumn(String columnHeader) {
tableRoot.getByRole(AriaRole.COLUMNHEADER,
new Locator.GetByRoleOptions().setName(columnHeader)).click();
return this;
}
}
// Usage in a page object:
public class UsersPage extends BasePage {
public final DataTable usersTable;
private final Locator searchInput;
private final Locator addUserButton;
public UsersPage(Page page) {
super(page);
usersTable = new DataTable(page, page.locator("#users-table"));
searchInput = page.getByPlaceholder("Search users…");
addUserButton = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Add User"));
}
@Override protected String getUrl() { return "/admin/users"; }
public UsersPage searchFor(String query) {
searchInput.fill(query);
searchInput.press("Enter");
page.waitForResponse("**/api/users**", () -> {});
return this;
}
}
```
---
## Modal / Dialog Component
```java
public class ConfirmDialog extends BasePage {
private final Locator dialog;
private final Locator confirmButton;
private final Locator cancelButton;
private final Locator titleText;
public ConfirmDialog(Page page) {
super(page);
dialog = page.getByRole(AriaRole.DIALOG);
confirmButton = dialog.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Confirm"));
cancelButton = dialog.getByRole(AriaRole.BUTTON, new Locator.GetByRoleOptions().setName("Cancel"));
titleText = dialog.getByRole(AriaRole.HEADING);
}
public void waitForOpen() {
dialog.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE));
}
public void confirm() { confirmButton.click(); dialog.waitFor(
new Locator.WaitForOptions().setState(WaitForSelectorState.HIDDEN)); }
public void cancel() { cancelButton.click(); }
public String getTitle() { return titleText.innerText(); }
@Override protected String getUrl() { return ""; }
}
```
---
## Navigation Chain Pattern
Page methods should **return the next page object** — never return `void` for navigating actions:
```java
// CORRECT — enables fluent chaining
public DashboardPage loginAs(String email, String password) {
fill(emailInput, email);
fill(passwordInput, password);
clickAndWaitForNav(submitButton);
return new DashboardPage(page);
}
// USAGE
DashboardPage dash = new LoginPage(page)
.navigate()
.loginAs("user@test.com", "secret")
.navigateTo(OrdersPage::new) // helper to reduce boilerplate
.filterByStatus("PENDING");
```
---
## Dynamic Locators
For lists of items that share structure:
```java
// Locate a card by its title
public Locator getProductCard(String productName) {
return page.locator(".product-card")
.filter(new Locator.FilterOptions().setHasText(productName));
}
// Wait for a specific row to appear in a table
public void waitForOrderRow(String orderId) {
page.locator("tr[data-order-id='" + orderId + "']")
.waitFor(new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE)
.setTimeout(15_000));
}
```
---
## Dropdown / Select Handling
```java
// Native <select>
page.selectOption("#country-select", "India");
// Custom dropdown (non-native)
public void selectCountry(String countryName) {
page.getByLabel("Country").click();
page.getByRole(AriaRole.LISTBOX)
.getByText(countryName)
.click();
}
```
---
## File Upload
```java
// Standard file input
page.setInputFiles("#file-upload", Paths.get("src/test/resources/testdata/sample.pdf"));
// Drag-and-drop upload zone
page.locator(".upload-zone").setInputFiles(Paths.get("src/test/resources/testdata/sample.pdf"));
// Multiple files
page.setInputFiles("#file-upload", new Path[]{
Paths.get("file1.png"),
Paths.get("file2.png")
});
```
---
## Download Handling
```java
Download download = page.waitForDownload(() ->
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Export CSV")).click()
);
Path downloadedFile = download.path();
assertThat(downloadedFile.toFile()).exists();
```
---
## Hover & Tooltip Verification
```java
page.getByTestId("info-icon").hover();
Locator tooltip = page.getByRole(AriaRole.TOOLTIP);
tooltip.waitFor();
assertThat(tooltip).hasText("This field is required");
```
---
## Waiting Strategies (Anti-Flake)
```java
// Wait for API response after action
page.waitForResponse(resp -> resp.url().contains("/api/search") && resp.status() == 200,
() -> searchInput.fill("test"));
// Wait for network idle (after complex renders)
page.waitForLoadState(LoadState.NETWORKIDLE);
// Wait for element count to stabilize
Locator rows = page.locator("tbody tr");
rows.first().waitFor(); // wait for at least one
assertThat(rows).hasCount(10);
// Polling custom condition
Assertions.assertDoesNotThrow(() -> {
page.waitForCondition(() -> page.locator(".spinner").count() == 0);
});
```