Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Scaffold, write, debug, and enhance enterprise-grade Playwright E2E tests in Java using Page Object Model, JUnit 5, Allure reporting, and parallel execution.
.claude/skills/sickn33-playwright-java/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-06 | ✗→✓ | ▲ Improved | 120% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 148% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 139% | 0% |
| case-05 | ✓→✓ | = Same ✓ | 151% | 0% |
| case-07 | ✓→✓ | = Same ✓ | 126% | 0% |
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 |
Thread.sleep() with proper waitsUse 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 |
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.xmljavapublic 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(); }; } }
javapublic 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(); } }
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") ); } }
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(); }
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."); }
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"); }
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
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/
ThreadLocal<Page> for every parallel-safe test suiteLocator fields at the top of the Page Object classassertThat(locator) — it auto-retries until timeoutgetByRole, getByLabel, getByTestId as first-choice locators@BeforeEach and stop with a file path in @AfterEachSoftAssertions when validating multiple fields on a single pagestorageState) to skip login across test classesThread.sleep() — replace with waitFor() or waitForResponse()ConfigReader.getBaseUrl()Playwright instance inside a Page ObjectSolution: Ensure every test creates its own Playwright → Browser → BrowserContext → Page chain via ThreadLocal. Never share a Page across threads.
assertThat(locator).isVisible() times out even when the element appearsSolution: Increase timeout with .setTimeout(10_000) or raise context.setDefaultTimeout() in BaseTest.
Thread.sleep(2000) was added but tests are still flakySolution: Replace with page.waitForResponse("**/api/endpoint", () -> action()) or assertThat(locator).hasText("Done") which polls automatically.
Solution: Ensure tracing().start() is called before test actions and tracing().stop() is in @AfterEach — not @AfterAll.
Solution: Add the AspectJ agent to maven-surefire-plugin <argLine> in pom.xml — see references/config.md for the exact snippet.
storageState auth file is stale and tests redirect to loginSolution: Re-run AuthSetup to regenerate target/auth/user-state.json before the suite, or add a @BeforeAll that conditionally refreshes it.
@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| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 15,916 | 14,688 | -8% | 1 | 1 | 0% | 3,364 | 6,710 | +99% | 0 | 0 | — |
case-02 | fail→fail | 9,758 | 8,943 | -8% | 1 | 1 | 0% | 2,038 | 5,168 | +154% | 0 | 0 | — |
case-03 | pass→pass | 10,803 | 10,439 | -3% | 1 | 1 | 0% | 2,263 | 5,616 | +148% | 0 | 0 | — |
case-04 | pass→pass | 12,504 | 10,568 | -15% | 1 | 1 | 0% | 2,262 | 5,398 | +139% | 0 | 0 | — |
case-05 | pass→pass | 9,398 | 6,811 | -28% | 1 | 1 | 0% | 1,866 | 4,684 | +151% | 0 | 0 | — |
case-06 | fail→pass | 11,949 | 9,658 | -19% | 1 | 1 | 0% | 2,393 | 5,254 | +120% | 0 | 0 | — |
case-07 | pass→pass | 13,054 | 10,354 | -21% | 1 | 1 | 0% | 2,464 | 5,576 | +126% | 0 | 0 | — |
case-08 | pass→pass | 15,294 | 11,455 | -25% | 1 | 1 | 0% | 3,010 | 5,745 | +91% | 0 | 0 | — |
case-09 | pass→pass | 8,240 | 6,677 | -19% | 1 | 1 | 0% | 1,566 | 4,706 | +201% | 0 | 0 | — |
case-10 | fail→fail | 10,924 | 9,153 | -16% | 1 | 1 | 0% | 2,304 | 5,312 | +131% | 0 | 0 | — |
case-11 | pass→pass | 4,983 | 3,793 | -24% | 1 | 1 | 0% | 926 | 4,039 | +336% | 0 | 0 | — |
case-12 | pass→pass | 12,125 | 10,657 | -12% | 1 | 1 | 0% | 2,123 | 5,592 | +163% | 0 | 0 | — |
case-13 | pass→pass | 11,465 | 11,561 | +1% | 1 | 1 | 0% | 2,311 | 5,592 | +142% | 0 | 0 | — |
case-14 | pass→pass | 8,060 | 7,645 | -5% | 1 | 1 | 0% | 1,515 | 4,651 | +207% | 0 | 0 | — |
case-15 | pass→pass | 13,075 | 10,090 | -23% | 1 | 1 | 0% | 2,606 | 5,158 | +98% | 0 | 0 | — |
case-16 | pass→pass | 12,729 | 11,221 | -12% | 1 | 1 | 0% | 2,716 | 5,952 | +119% | 0 | 0 | — |
case-17 | pass→pass | 10,071 | 9,256 | -8% | 1 | 1 | 0% | 2,259 | 5,290 | +134% | 0 | 0 | — |
case-18 | pass→pass | 9,940 | 6,471 | -35% | 1 | 1 | 0% | 1,921 | 4,612 | +140% | 0 | 0 | — |
case-19 | pass→pass | 13,832 | 8,188 | -41% | 1 | 1 | 0% | 2,555 | 4,848 | +90% | 0 | 0 | — |
case-20 | pass→pass | 9,494 | 9,478 | -0% | 1 | 1 | 0% | 1,979 | 5,294 | +168% | 0 | 0 | — |
case-21 | pass→pass | 8,290 | 6,134 | -26% | 1 | 1 | 0% | 1,793 | 4,723 | +163% | 0 | 0 | — |
case-22 | pass→pass | 14,046 | 11,192 | -20% | 1 | 1 | 0% | 3,035 | 5,846 | +93% | 0 | 0 | — |
case-23 | pass→pass | 16,218 | 13,513 | -17% | 1 | 1 | 0% | 3,712 | 6,298 | +70% | 0 | 0 | — |
case-24 | pass→pass | 10,837 | 8,771 | -19% | 1 | 1 | 0% | 2,395 | 5,390 | +125% | 0 | 0 | — |
case-25 | pass→pass | 12,109 | 11,888 | -2% | 1 | 1 | 0% | 2,582 | 6,140 | +138% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 25 cases were attempted. The headline lift of +4 percentage points is the difference between those two pass rates over the 25 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.