Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ buildNumber.properties
# JDT-specific (Eclipse Java Development Tools)
.classpath
.idea
.allure
.allure
allure-results/
.playwright-mcp/
/com/
111 changes: 111 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# PlaywrightJava Test Project

## Overview
- **AUT**: https://practicesoftwaretesting.com
- **Stack**: Playwright 1.57, JUnit Jupiter 6, AssertJ, Allure, Maven · Java 21
- **Tests**: integration tests (`*IT` suffix) via `maven-failsafe-plugin`

## Commands
```bash
mvn verify # compile + run IT tests + collect Allure results
mvn allure:serve # generate and open Allure report in browser
```

## Package Layoutrest
```
com.serenitydojo.playwright
├── tests/ # JUnit test classes only
├── workflows/ # business-level orchestration
├── pages/ # full-page objects
├── components/ # reusable UI component objects (NavBar, Modal, ProductCard…)
├── fixtures/ # API-based test data setup
└── domain/ # Java 21 records for domain data
```

## Test Lifecycle — Composition Over Inheritance
- Every test class is annotated `@PlaywrightTest` (= `@UsePlaywright` + `@ExtendWith(CrossCuttingExtension.class)`) — **no BaseTest, no inheritance**
- `Page`, `BrowserContext`, `Browser`, `Playwright` are injected as **method parameters**
- Cross-cutting behaviour via marker interfaces; test classes opt-in by implementing them:
- `TakeFinalScreenshot` — attaches a PNG to Allure on test failure
- `WithTracing` — records a Playwright trace per test; attaches the zip on failure

→ [docs/examples/test-lifecycle.md](docs/examples/test-lifecycle.md)

## Three-Layer Architecture
```
Test → Workflow → Page / Component
```
| Layer | Responsibility | Returns |
|---|---|---|
| Test | Calls workflow methods only; zero `Page` or locator usage | — |
| Workflow | Orchestrates pages/components; one method = one business action | domain record or `this` |
| Page / Component | Wraps Playwright locators; fluent API | `this` or domain record |

**Component objects** are page objects scoped to one reusable UI element — `NavBar`, `ProductCard`, `Modal`, `CartWidget`. Never model an entire page as a component.

## Test Data

**Randomized values**: use `net.datafaker:datafaker`; generate values inside domain record factory methods, not inline in tests.

```java
record Customer(String email, String password) {
static Customer random() {
var f = new Faker();
return new Customer(f.internet().emailAddress(), "Pw-" + f.number().digits(8) + "!");
}
}
```

**API seeding**: create preconditions through the app's REST API — never through the UI.
- Fixture classes live in `fixtures/`; call them from `@BeforeEach` or directly in the test before the workflow
- `APIRequestContext` is obtained from `page.context().request()`
- Every fixture method carries `@Step` so it appears in the Allure report

```java
class UserFixture {
@Step("Register user via API")
static RegisteredUser register(APIRequestContext api) {
var email = new Faker().internet().emailAddress();
var password = "Pw-" + new Faker().number().digits(8) + "!";
var r = api.post("/users/register",
RequestOptions.create().setData(Map.of("email", email, "password", password)));
assertThat(r.status()).isEqualTo(201);
return new RegisteredUser(email, password);
}
}
```

- Never share a fixed test account between tests — seed fresh data per test run

## Allure
- `@Step` on **all public methods** in workflow, page, and component classes
- `@Description`, `@Severity`, `@Story` on test methods where meaningful
- Screenshots and traces attached **only on failure**, via marker interfaces

## Locator Priority
`getByRole` › `getByLabel` › `getByText` › `getByTestId` › CSS selector › XPath (last resort only)

## Coding Rules
- Java 21: `record` for all domain models; `var` for all local variables
- **No `Thread.sleep()`** — use `page.waitForResponse()`, `locator.waitFor()`, or Playwright's built-in auto-waiting
- **AssertJ only** for assertions — never `org.junit.jupiter.api.Assertions`
- Page/component methods return `this` for fluent chaining; terminal methods return a domain record or `void`
- `@Nested` + `@DisplayName` for readable test hierarchy; every test method has a `@DisplayName`
- Keep test data in domain records; avoid hard-coding raw strings in test assertions

## Naming
| Artefact | Pattern | Example |
|---|---|---|
| Test class | `<Feature>IT` | `CheckoutIT` |
| Workflow | `<Feature>Workflow` | `CheckoutWorkflow` |
| Page object | `<Page>Page` | `ProductDetailPage` |
| Component | bare noun | `NavBar`, `Modal` |
| Domain record | singular noun | `Product`, `CartItem`, `Address` |

## Never
- Inherit from a base class
- Call `Page` or a locator directly from a test method
- Use `Thread.sleep()` for any reason
- Use XPath when a role, label, or text selector exists
- Write assertions inside page or workflow classes
- Use `org.junit.jupiter.api.Assertions`
94 changes: 94 additions & 0 deletions docs/examples/test-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Test Lifecycle Pattern

The cross-cutting extension pattern is the only genuinely non-obvious design decision.
Everything else (three-layer architecture, locator priority) is self-evident from CLAUDE.md.

## `@PlaywrightTest` — composed annotation

Keeps test classes clean; one annotation covers lifecycle + cross-cutting.

```java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@UsePlaywright
@ExtendWith(CrossCuttingExtension.class)
public @interface PlaywrightTest {}
```

## Marker interfaces

```java
public interface TakeFinalScreenshot {}
public interface WithTracing {}
```

## CrossCuttingExtension

`PlaywrightExtension` (the extension behind `@UsePlaywright`) stores `Page` and friends
in the JUnit extension store under its own namespace. Retrieve them with the helper below.

```java
public class CrossCuttingExtension implements BeforeEachCallback, AfterEachCallback {

private static final ExtensionContext.Namespace PW_NS =
ExtensionContext.Namespace.create(PlaywrightExtension.class);

@Override
public void beforeEach(ExtensionContext ctx) throws Exception {
if (ctx.getRequiredTestInstance() instanceof WithTracing) {
page(ctx).context().tracing().start(
new Tracing.StartOptions().setScreenshots(true).setSnapshots(true));
}
}

@Override
public void afterEach(ExtensionContext ctx) throws Exception {
var failed = ctx.getExecutionException().isPresent();
var page = page(ctx);

if (ctx.getRequiredTestInstance() instanceof WithTracing) {
var dest = Paths.get("target/traces", ctx.getDisplayName() + ".zip");
Files.createDirectories(dest.getParent());
page.context().tracing().stop(new Tracing.StopOptions().setPath(dest));
if (failed)
Allure.addAttachment("Trace", new FileInputStream(dest.toFile()));
}

if (ctx.getRequiredTestInstance() instanceof TakeFinalScreenshot && failed) {
Allure.addAttachment("Screenshot", "image/png",
new ByteArrayInputStream(page.screenshot()), "png");
}
}

private Page page(ExtensionContext ctx) {
return ctx.getStore(PW_NS).get(Page.class, Page.class);
}
}
```

> **Upgrade note**: `PlaywrightExtension.class` is the public class behind `@UsePlaywright`.
> Verify the namespace key resolves correctly after any Playwright major version bump.

## Typical test class

```java
@PlaywrightTest
@DisplayName("Checkout")
class CheckoutIT implements TakeFinalScreenshot, WithTracing {

@Nested
@DisplayName("As a guest")
class AsAGuest {

@Test
@DisplayName("I can add a product and complete checkout")
void completeCheckout(Page page) {
var order = new CheckoutWorkflow(page)
.addToCart("Bolt Cutters")
.checkout(new Address("John", "Doe", "123 Main St", "London", "SW1A 1AA"));

assertThat(order.confirmationNumber()).isNotBlank();
}
}
}
```
73 changes: 71 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,31 @@
<version>1.0-SNAPSHOT</version>

<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<version>6.0.1</version>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-commons</artifactId>
<version>6.0.1</version>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-engine</artifactId>
<version>6.0.1</version>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>com.microsoft.playwright</groupId>
Expand All @@ -33,6 +53,55 @@
<version>3.27.6</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.datafaker</groupId>
<artifactId>datafaker</artifactId>
<version>2.4.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-junit5</artifactId>
<version>2.29.0</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>io.qameta.allure</groupId>
<artifactId>allure-maven</artifactId>
<version>2.14.0</version>
</plugin>
</plugins>
</build>

</project>
53 changes: 53 additions & 0 deletions src/test/java/com/serenitydojo/playwright/domain/Customer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.serenitydojo.playwright.domain;

import net.datafaker.Faker;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

public record Customer(
String firstName,
String lastName,
String dob,
String country,
String postalCode,
String houseNumber,
String street,
String city,
String state,
String phone,
String email,
String password
) {
public static String invalidEmailAddress() {
return new Faker(Locale.US).internet().username();
}

public static String shortPassword() {
return new Faker(Locale.US).internet().password(2, 5, true, true);
}

public static Customer random() {
var faker = new Faker(Locale.US);
var dob = LocalDate.of(
faker.number().numberBetween(1950, 2000),
faker.number().numberBetween(1, 12),
faker.number().numberBetween(1, 28)
).format(DateTimeFormatter.ISO_LOCAL_DATE);
return new Customer(
faker.name().firstName(),
faker.name().lastName(),
dob,
"United States of America (the)",
faker.number().digits(5),
String.valueOf(faker.number().numberBetween(1, 999)),
faker.address().streetName(),
faker.address().city(),
faker.address().stateAbbr(),
faker.phoneNumber().subscriberNumber(10),
faker.internet().emailAddress(),
faker.internet().password(8, 12, true, true)
);
}
}
Loading