feat: add playwright-java skill for enterprise E2E test automation (#243)
This commit is contained in:
137
skills/playwright-java/BasePage.java
Normal file
137
skills/playwright-java/BasePage.java
Normal 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();
|
||||
}
|
||||
}
|
||||
126
skills/playwright-java/BaseTest.java
Normal file
126
skills/playwright-java/BaseTest.java
Normal 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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
385
skills/playwright-java/SKILL.md
Normal file
385
skills/playwright-java/SKILL.md
Normal 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
|
||||
167
skills/playwright-java/assertions.md
Normal file
167
skills/playwright-java/assertions.md
Normal 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);
|
||||
```
|
||||
240
skills/playwright-java/config.md
Normal file
240
skills/playwright-java/config.md
Normal 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"]
|
||||
```
|
||||
263
skills/playwright-java/fixtures.md
Normal file
263
skills/playwright-java/fixtures.md
Normal 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;
|
||||
}
|
||||
```
|
||||
224
skills/playwright-java/page-objects.md
Normal file
224
skills/playwright-java/page-objects.md
Normal 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);
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user