From e7501f1dced3c7babb92f097448a7fe744c1b96a Mon Sep 17 00:00:00 2001 From: amalsam <47856775+amalsam@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:20:42 +0530 Subject: [PATCH] feat: add playwright-java skill for enterprise E2E test automation (#243) --- skills/playwright-java/BasePage.java | 137 +++++++++ skills/playwright-java/BaseTest.java | 126 ++++++++ skills/playwright-java/SKILL.md | 385 +++++++++++++++++++++++++ skills/playwright-java/assertions.md | 167 +++++++++++ skills/playwright-java/config.md | 240 +++++++++++++++ skills/playwright-java/fixtures.md | 263 +++++++++++++++++ skills/playwright-java/page-objects.md | 224 ++++++++++++++ 7 files changed, 1542 insertions(+) create mode 100644 skills/playwright-java/BasePage.java create mode 100644 skills/playwright-java/BaseTest.java create mode 100644 skills/playwright-java/SKILL.md create mode 100644 skills/playwright-java/assertions.md create mode 100644 skills/playwright-java/config.md create mode 100644 skills/playwright-java/fixtures.md create mode 100644 skills/playwright-java/page-objects.md diff --git a/skills/playwright-java/BasePage.java b/skills/playwright-java/BasePage.java new file mode 100644 index 00000000..41fe5f78 --- /dev/null +++ b/skills/playwright-java/BasePage.java @@ -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 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(); + } +} diff --git a/skills/playwright-java/BaseTest.java b/skills/playwright-java/BaseTest.java new file mode 100644 index 00000000..6b9f6b13 --- /dev/null +++ b/skills/playwright-java/BaseTest.java @@ -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 playwrightTL = new ThreadLocal<>(); + private static final ThreadLocal browserTL = new ThreadLocal<>(); + private static final ThreadLocal contextTL = new ThreadLocal<>(); + private static final ThreadLocal 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) {} + } + } +} diff --git a/skills/playwright-java/SKILL.md b/skills/playwright-java/SKILL.md new file mode 100644 index 00000000..4f7376c8 --- /dev/null +++ b/skills/playwright-java/SKILL.md @@ -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 playwrightTL = new ThreadLocal<>(); + protected static ThreadLocal browserTL = new ThreadLocal<>(); + protected static ThreadLocal contextTL = new ThreadLocal<>(); + protected static ThreadLocal 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 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 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` 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` `` 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 diff --git a/skills/playwright-java/assertions.md b/skills/playwright-java/assertions.md new file mode 100644 index 00000000..0137841d --- /dev/null +++ b/skills/playwright-java/assertions.md @@ -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); +``` diff --git a/skills/playwright-java/config.md b/skills/playwright-java/config.md new file mode 100644 index 00000000..5eb56fa7 --- /dev/null +++ b/skills/playwright-java/config.md @@ -0,0 +1,240 @@ +# Playwright Java – Project Configuration + +## Maven POM (`pom.xml`) + +```xml + + + 4.0.0 + + com.company + playwright-tests + 1.0-SNAPSHOT + + + 17 + 17 + 17 + UTF-8 + 1.44.0 + 5.10.2 + 2.27.0 + 1.9.22 + + + + + + com.microsoft.playwright + playwright + ${playwright.version} + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + + + + + io.qameta.allure + allure-junit5 + ${allure.version} + test + + + + + org.assertj + assertj-core + 3.25.3 + test + + + + + com.fasterxml.jackson.core + jackson-databind + 2.17.1 + + + + + net.datafaker + datafaker + 2.2.2 + + + + + ch.qos.logback + logback-classic + 1.5.6 + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + -javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar" + + + ${browser} + ${headless} + ${baseUrl} + ${project.build.directory}/allure-results + + + + 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 + + + + + + org.aspectj + aspectjweaver + ${aspectj.version} + + + + + + + io.qameta.allure + allure-maven + 2.12.0 + + ${allure.version} + + + + + +``` + +--- + +## `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"] +``` diff --git a/skills/playwright-java/fixtures.md b/skills/playwright-java/fixtures.md new file mode 100644 index 00000000..6bd1a38c --- /dev/null +++ b/skills/playwright-java/fixtures.md @@ -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 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 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: +// +// org.junit.jupiter +// junit-jupiter-engine +// 5.10.2 +// + +@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; +} +``` diff --git a/skills/playwright-java/page-objects.md b/skills/playwright-java/page-objects.md new file mode 100644 index 00000000..649379ef --- /dev/null +++ b/skills/playwright-java/page-objects.md @@ -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