diff --git a/.gitignore b/.gitignore index 4ef14e9..619607b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ buildNumber.properties # JDT-specific (Eclipse Java Development Tools) .classpath .idea -.allure \ No newline at end of file +.allure +allure-results/ +.playwright-mcp/ +/com/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..980bbe8 --- /dev/null +++ b/CLAUDE.md @@ -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 | `IT` | `CheckoutIT` | +| Workflow | `Workflow` | `CheckoutWorkflow` | +| Page object | `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` diff --git a/docs/examples/test-lifecycle.md b/docs/examples/test-lifecycle.md new file mode 100644 index 0000000..1a38df3 --- /dev/null +++ b/docs/examples/test-lifecycle.md @@ -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(); + } + } +} +``` diff --git a/pom.xml b/pom.xml index 76b6e2e..a6f9119 100644 --- a/pom.xml +++ b/pom.xml @@ -9,11 +9,31 @@ 1.0-SNAPSHOT - 17 - 17 + 21 + 21 UTF-8 + + + + org.junit.platform + junit-platform-launcher + 6.0.1 + + + org.junit.platform + junit-platform-commons + 6.0.1 + + + org.junit.platform + junit-platform-engine + 6.0.1 + + + + com.microsoft.playwright @@ -33,6 +53,55 @@ 3.27.6 test + + net.datafaker + datafaker + 2.4.0 + test + + + io.qameta.allure + allure-junit5 + 2.29.0 + test + + + org.junit.jupiter + junit-jupiter-api + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + true + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.2 + + + + integration-test + verify + + + + + + io.qameta.allure + allure-maven + 2.14.0 + + + + \ No newline at end of file diff --git a/src/test/java/com/serenitydojo/playwright/domain/Customer.java b/src/test/java/com/serenitydojo/playwright/domain/Customer.java new file mode 100644 index 0000000..82ec0c1 --- /dev/null +++ b/src/test/java/com/serenitydojo/playwright/domain/Customer.java @@ -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) + ); + } +} diff --git a/src/test/java/com/serenitydojo/playwright/fixtures/CrossCuttingExtension.java b/src/test/java/com/serenitydojo/playwright/fixtures/CrossCuttingExtension.java new file mode 100644 index 0000000..53e536e --- /dev/null +++ b/src/test/java/com/serenitydojo/playwright/fixtures/CrossCuttingExtension.java @@ -0,0 +1,66 @@ +package com.serenitydojo.playwright.fixtures; + +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.Tracing; +import io.qameta.allure.Allure; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +import java.io.ByteArrayInputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Paths; + +public class CrossCuttingExtension implements BeforeEachCallback, AfterEachCallback { + + @Override + public void beforeEach(ExtensionContext ctx) { + if (!(ctx.getRequiredTestInstance() instanceof WithTracing)) return; + var context = getBrowserContext(ctx); + if (context == null) return; + context.tracing().start( + new Tracing.StartOptions().setScreenshots(true).setSnapshots(true)); + } + + @Override + public void afterEach(ExtensionContext ctx) { + var failed = ctx.getExecutionException().isPresent(); + var instance = ctx.getRequiredTestInstance(); + var context = getBrowserContext(ctx); + if (context == null) return; + + if (instance instanceof WithTracing) { + try { + var dest = Paths.get("target/traces", ctx.getDisplayName() + ".zip"); + Files.createDirectories(dest.getParent()); + context.tracing().stop(new Tracing.StopOptions().setPath(dest)); + if (failed) { + Allure.addAttachment("Trace", new FileInputStream(dest.toFile())); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + if (instance instanceof TakeFinalScreenshot && failed) { + var pages = context.pages(); + if (!pages.isEmpty()) { + Allure.addAttachment("Screenshot", "image/png", + new ByteArrayInputStream(pages.get(0).screenshot()), "png"); + } + } + } + + private static BrowserContext getBrowserContext(ExtensionContext ctx) { + try { + Class cls = Class.forName("com.microsoft.playwright.impl.junit.BrowserContextExtension"); + Method method = cls.getMethod("getOrCreateBrowserContext", ExtensionContext.class); + return (BrowserContext) method.invoke(null, ctx); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/test/java/com/serenitydojo/playwright/fixtures/PlaywrightOptions.java b/src/test/java/com/serenitydojo/playwright/fixtures/PlaywrightOptions.java new file mode 100644 index 0000000..f933e08 --- /dev/null +++ b/src/test/java/com/serenitydojo/playwright/fixtures/PlaywrightOptions.java @@ -0,0 +1,15 @@ +package com.serenitydojo.playwright.fixtures; + +import com.microsoft.playwright.junit.Options; +import com.microsoft.playwright.junit.OptionsFactory; + +public class PlaywrightOptions implements OptionsFactory { + + @Override + public Options getOptions() { + return new Options() + .setHeadless(false) + .setBaseUrl("https://practicesoftwaretesting.com") + .setTestIdAttribute("data-test"); + } +} diff --git a/src/test/java/com/serenitydojo/playwright/fixtures/PlaywrightTest.java b/src/test/java/com/serenitydojo/playwright/fixtures/PlaywrightTest.java new file mode 100644 index 0000000..a77a497 --- /dev/null +++ b/src/test/java/com/serenitydojo/playwright/fixtures/PlaywrightTest.java @@ -0,0 +1,16 @@ +package com.serenitydojo.playwright.fixtures; + +import com.microsoft.playwright.junit.UsePlaywright; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@UsePlaywright(PlaywrightOptions.class) +@ExtendWith(CrossCuttingExtension.class) +public @interface PlaywrightTest { +} diff --git a/src/test/java/com/serenitydojo/playwright/fixtures/TakeFinalScreenshot.java b/src/test/java/com/serenitydojo/playwright/fixtures/TakeFinalScreenshot.java new file mode 100644 index 0000000..9587c0b --- /dev/null +++ b/src/test/java/com/serenitydojo/playwright/fixtures/TakeFinalScreenshot.java @@ -0,0 +1,4 @@ +package com.serenitydojo.playwright.fixtures; + +public interface TakeFinalScreenshot { +} diff --git a/src/test/java/com/serenitydojo/playwright/fixtures/UserFixture.java b/src/test/java/com/serenitydojo/playwright/fixtures/UserFixture.java new file mode 100644 index 0000000..9b6f327 --- /dev/null +++ b/src/test/java/com/serenitydojo/playwright/fixtures/UserFixture.java @@ -0,0 +1,40 @@ +package com.serenitydojo.playwright.fixtures; + +import com.microsoft.playwright.APIRequestContext; +import com.microsoft.playwright.options.RequestOptions; +import com.serenitydojo.playwright.domain.Customer; +import io.qameta.allure.Step; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class UserFixture { + + @Step("Register customer via API") + public static Customer registerViaApi(APIRequestContext api) { + var customer = Customer.random(); + var response = api.post( + "https://api.practicesoftwaretesting.com/users/register", + RequestOptions.create().setData(Map.of( + "first_name", customer.firstName(), + "last_name", customer.lastName(), + "dob", customer.dob(), + "phone", customer.phone(), + "email", customer.email(), + "password", customer.password(), + "address", Map.of( + "street", customer.street(), + "city", customer.city(), + "state", customer.state(), + "country", "US", + "postal_code", customer.postalCode() + ) + )) + ); + assertThat(response.status()) + .as("API registration failed: " + response.text()) + .isEqualTo(201); + return customer; + } +} diff --git a/src/test/java/com/serenitydojo/playwright/fixtures/WithTracing.java b/src/test/java/com/serenitydojo/playwright/fixtures/WithTracing.java new file mode 100644 index 0000000..b7d3e90 --- /dev/null +++ b/src/test/java/com/serenitydojo/playwright/fixtures/WithTracing.java @@ -0,0 +1,4 @@ +package com.serenitydojo.playwright.fixtures; + +public interface WithTracing { +} diff --git a/src/test/java/com/serenitydojo/playwright/pages/RegistrationPage.java b/src/test/java/com/serenitydojo/playwright/pages/RegistrationPage.java new file mode 100644 index 0000000..532e399 --- /dev/null +++ b/src/test/java/com/serenitydojo/playwright/pages/RegistrationPage.java @@ -0,0 +1,121 @@ +package com.serenitydojo.playwright.pages; + +import com.microsoft.playwright.Page; +import io.qameta.allure.Step; + +public class RegistrationPage { + + private final Page page; + + public RegistrationPage(Page page) { + this.page = page; + } + + @Step("Open registration page") + public RegistrationPage open() { + page.navigate("/auth/register"); + return this; + } + + @Step("Enter first name") + public RegistrationPage enterFirstName(String value) { + page.getByLabel("First name").fill(value); + return this; + } + + @Step("Enter last name") + public RegistrationPage enterLastName(String value) { + page.getByLabel("Last name").fill(value); + return this; + } + + @Step("Enter date of birth") + public RegistrationPage enterDateOfBirth(String value) { + page.getByTestId("dob").fill(value); + return this; + } + + @Step("Select country") + public RegistrationPage selectCountry(String value) { + page.getByLabel("Country").selectOption(value); + return this; + } + + @Step("Enter postal code") + public RegistrationPage enterPostalCode(String value) { + page.getByLabel("Postal code").fill(value); + return this; + } + + @Step("Enter house number") + public RegistrationPage enterHouseNumber(String value) { + page.getByLabel("House number").fill(value); + return this; + } + + @Step("Enter street") + public RegistrationPage enterStreet(String value) { + page.getByLabel("Street").fill(value); + return this; + } + + @Step("Enter city") + public RegistrationPage enterCity(String value) { + page.getByLabel("City").fill(value); + return this; + } + + @Step("Enter state") + public RegistrationPage enterState(String value) { + page.getByLabel("State").fill(value); + return this; + } + + @Step("Enter phone") + public RegistrationPage enterPhone(String value) { + page.getByLabel("Phone").fill(value); + return this; + } + + @Step("Enter email") + public RegistrationPage enterEmail(String value) { + page.getByLabel("Email address").fill(value); + return this; + } + + @Step("Enter password") + public RegistrationPage enterPassword(String value) { + page.getByTestId("password").fill(value); + return this; + } + + @Step("Submit registration form") + public void submit() { + page.getByTestId("register-submit").click(); + } + + @Step("Wait for login redirect") + public void waitForLoginRedirect() { + page.waitForURL("**/auth/login"); + } + + public String fieldError(String fieldKey) { + return page.getByTestId(fieldKey + "-error").textContent().trim(); + } + + public String registrationError() { + return page.getByTestId("register-error").textContent().trim(); + } + + public boolean hasFieldError(String fieldKey) { + return page.getByTestId(fieldKey + "-error").isVisible(); + } + + public boolean hasRegistrationError() { + return page.getByTestId("register-error").isVisible(); + } + + public String currentUrl() { + return page.url(); + } +} diff --git a/src/test/java/com/serenitydojo/playwright/tests/CustomerRegistrationIT.java b/src/test/java/com/serenitydojo/playwright/tests/CustomerRegistrationIT.java new file mode 100644 index 0000000..579e186 --- /dev/null +++ b/src/test/java/com/serenitydojo/playwright/tests/CustomerRegistrationIT.java @@ -0,0 +1,143 @@ +package com.serenitydojo.playwright.tests; + +import com.microsoft.playwright.BrowserContext; +import com.serenitydojo.playwright.domain.Customer; +import com.serenitydojo.playwright.fixtures.PlaywrightTest; +import com.serenitydojo.playwright.fixtures.TakeFinalScreenshot; +import com.serenitydojo.playwright.fixtures.UserFixture; +import com.serenitydojo.playwright.workflows.RegistrationWorkflow; +import io.qameta.allure.Description; +import io.qameta.allure.Severity; +import io.qameta.allure.SeverityLevel; +import io.qameta.allure.Story; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@PlaywrightTest +@DisplayName("Customer Registration") +class CustomerRegistrationIT implements TakeFinalScreenshot { + + @Nested + @DisplayName("Happy Path") + class HappyPath { + + @Test + @Story("Customer Registration") + @Severity(SeverityLevel.BLOCKER) + @Description("AC-1: A user can register with valid details and is redirected to the login page") + @DisplayName("AC-1: Valid registration with all required details redirects to login page") + void validRegistrationRedirectsToLogin(BrowserContext context) { + var workflow = new RegistrationWorkflow(context); + workflow.register(Customer.random()); + assertThat(workflow.currentUrl()).contains("/auth/login"); + } + } + + @Nested + @DisplayName("Required Field Validation") + class RequiredFieldValidation { + + @Test + @Story("Customer Registration") + @Severity(SeverityLevel.CRITICAL) + @Description("AC-2: Submitting an empty form shows a validation error for every required field") + @DisplayName("AC-2: Empty form submission shows all required-field errors") + void emptyFormShowsAllRequiredFieldErrors(BrowserContext context) { + var workflow = new RegistrationWorkflow(context); + workflow.submitEmptyForm(); + + SoftAssertions.assertSoftly(soft -> { + soft.assertThat(workflow.fieldError("first-name")).contains("First name is required"); + soft.assertThat(workflow.fieldError("last-name")).contains("Last name is required"); + soft.assertThat(workflow.fieldError("dob")).contains("Date of Birth is required"); + soft.assertThat(workflow.fieldError("country")).contains("Country is required"); + soft.assertThat(workflow.fieldError("postal_code")).contains("Postcode is required"); + soft.assertThat(workflow.fieldError("house_number")).contains("House number is required"); + soft.assertThat(workflow.fieldError("street")).contains("Street is required"); + soft.assertThat(workflow.fieldError("city")).contains("City is required"); + soft.assertThat(workflow.fieldError("state")).contains("State is required"); + soft.assertThat(workflow.fieldError("phone")).contains("Phone is required"); + soft.assertThat(workflow.fieldError("email")).contains("Email is required"); + soft.assertThat(workflow.fieldError("password")).contains("Password is required"); + }); + } + + @Test + @Story("Customer Registration") + @Severity(SeverityLevel.NORMAL) + @Description("AC-2: A date of birth in the wrong format is rejected with a format hint") + @DisplayName("AC-2: Date of birth not in YYYY-MM-DD format shows format error") + void invalidDobFormatShowsError(BrowserContext context) { + var workflow = new RegistrationWorkflow(context); + workflow.registerWithDob("24-04-1990"); + assertThat(workflow.fieldError("dob")) + .contains("Please enter a valid date in YYYY-MM-DD format"); + } + } + + @Nested + @DisplayName("Email Validation") + class EmailValidation { + + @Test + @Story("Customer Registration") + @Severity(SeverityLevel.CRITICAL) + @Description("AC-3: A value with no @ symbol is rejected with an email format error") + @DisplayName("AC-3: Plain text with no @ shows email format error") + void plainTextEmailShowsFormatError(BrowserContext context) { + var workflow = new RegistrationWorkflow(context); + workflow.registerWithInvalidEmail(Customer.invalidEmailAddress()); + assertThat(workflow.fieldError("email")).contains("Email format is invalid"); + } + + @Test + @Story("Customer Registration") + @Severity(SeverityLevel.NORMAL) + @Description("AC-3: An email with no domain after @ is rejected with a format error") + @DisplayName("AC-3: Email missing domain part shows format error") + void emailMissingDomainShowsFormatError(BrowserContext context) { + var workflow = new RegistrationWorkflow(context); + workflow.registerWithInvalidEmail("user@"); + assertThat(workflow.fieldError("email")).contains("Email format is invalid"); + } + } + + @Nested + @DisplayName("Password Validation") + class PasswordValidation { + + @Test + @Story("Customer Registration") + @Severity(SeverityLevel.CRITICAL) + @Description("AC-4: A password shorter than the minimum length is rejected") + @DisplayName("AC-4: Password shorter than minimum length shows length error") + void shortPasswordShowsLengthError(BrowserContext context) { + var workflow = new RegistrationWorkflow(context); + workflow.registerWithPassword(Customer.shortPassword()); + assertThat(workflow.fieldError("password")) + .contains("Password must be minimal 6 characters long"); + } + } + + @Nested + @DisplayName("Duplicate Email") + class DuplicateEmailCheck { + + @Test + @Story("Customer Registration") + @Severity(SeverityLevel.CRITICAL) + @Description("AC-5: Attempting to register with an already-used email is rejected") + @DisplayName("AC-5: Registering with an already-registered email shows duplicate error") + void duplicateEmailShowsDuplicateError(BrowserContext context) { + var existingCustomer = UserFixture.registerViaApi(context.request()); + var workflow = new RegistrationWorkflow(context); + workflow.registerWithEmail(existingCustomer.email()); + assertThat(workflow.registrationError()) + .contains("A customer with this email address already exists"); + } + } +} diff --git a/src/test/java/com/serenitydojo/playwright/workflows/RegistrationWorkflow.java b/src/test/java/com/serenitydojo/playwright/workflows/RegistrationWorkflow.java new file mode 100644 index 0000000..da1a67b --- /dev/null +++ b/src/test/java/com/serenitydojo/playwright/workflows/RegistrationWorkflow.java @@ -0,0 +1,138 @@ +package com.serenitydojo.playwright.workflows; + +import com.microsoft.playwright.BrowserContext; +import com.serenitydojo.playwright.domain.Customer; +import com.serenitydojo.playwright.pages.RegistrationPage; +import io.qameta.allure.Step; + +public class RegistrationWorkflow { + + private final RegistrationPage registrationPage; + + public RegistrationWorkflow(BrowserContext context) { + this.registrationPage = new RegistrationPage(context.newPage()); + } + + @Step("Register customer with valid details") + public RegistrationWorkflow register(Customer customer) { + registrationPage + .open() + .enterFirstName(customer.firstName()) + .enterLastName(customer.lastName()) + .enterDateOfBirth(customer.dob()) + .selectCountry(customer.country()) + .enterPostalCode(customer.postalCode()) + .enterHouseNumber(customer.houseNumber()) + .enterStreet(customer.street()) + .enterCity(customer.city()) + .enterState(customer.state()) + .enterPhone(customer.phone()) + .enterEmail(customer.email()) + .enterPassword(customer.password()) + .submit(); + registrationPage.waitForLoginRedirect(); + return this; + } + + @Step("Submit empty registration form") + public RegistrationWorkflow submitEmptyForm() { + registrationPage.open().submit(); + return this; + } + + @Step("Attempt registration with invalid email format") + public RegistrationWorkflow registerWithInvalidEmail(String invalidEmail) { + var customer = Customer.random(); + registrationPage + .open() + .enterFirstName(customer.firstName()) + .enterLastName(customer.lastName()) + .enterDateOfBirth(customer.dob()) + .selectCountry(customer.country()) + .enterPostalCode(customer.postalCode()) + .enterHouseNumber(customer.houseNumber()) + .enterStreet(customer.street()) + .enterCity(customer.city()) + .enterState(customer.state()) + .enterPhone(customer.phone()) + .enterEmail(invalidEmail) + .enterPassword(customer.password()) + .submit(); + return this; + } + + @Step("Attempt registration with specific password") + public RegistrationWorkflow registerWithPassword(String password) { + var customer = Customer.random(); + registrationPage + .open() + .enterFirstName(customer.firstName()) + .enterLastName(customer.lastName()) + .enterDateOfBirth(customer.dob()) + .selectCountry(customer.country()) + .enterPostalCode(customer.postalCode()) + .enterHouseNumber(customer.houseNumber()) + .enterStreet(customer.street()) + .enterCity(customer.city()) + .enterState(customer.state()) + .enterPhone(customer.phone()) + .enterEmail(customer.email()) + .enterPassword(password) + .submit(); + return this; + } + + @Step("Attempt registration with an already-registered email") + public RegistrationWorkflow registerWithEmail(String email) { + var customer = Customer.random(); + registrationPage + .open() + .enterFirstName(customer.firstName()) + .enterLastName(customer.lastName()) + .enterDateOfBirth(customer.dob()) + .selectCountry(customer.country()) + .enterPostalCode(customer.postalCode()) + .enterHouseNumber(customer.houseNumber()) + .enterStreet(customer.street()) + .enterCity(customer.city()) + .enterState(customer.state()) + .enterPhone(customer.phone()) + .enterEmail(email) + .enterPassword(customer.password()) + .submit(); + return this; + } + + @Step("Attempt registration with specific date of birth") + public RegistrationWorkflow registerWithDob(String dob) { + var customer = Customer.random(); + registrationPage + .open() + .enterFirstName(customer.firstName()) + .enterLastName(customer.lastName()) + .enterDateOfBirth(dob) + .selectCountry(customer.country()) + .enterPostalCode(customer.postalCode()) + .enterHouseNumber(customer.houseNumber()) + .enterStreet(customer.street()) + .enterCity(customer.city()) + .enterState(customer.state()) + .enterPhone(customer.phone()) + .enterEmail(customer.email()) + .enterPassword(customer.password()) + .submit(); + return this; + } + + public String fieldError(String fieldKey) { + return registrationPage.fieldError(fieldKey); + } + + public String registrationError() { + return registrationPage.registrationError(); + } + + public String currentUrl() { + return registrationPage.currentUrl(); + } +} diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..88228a9 --- /dev/null +++ b/src/test/resources/junit-platform.properties @@ -0,0 +1,5 @@ +junit.jupiter.execution.parallel.enabled = true +junit.jupiter.execution.parallel.mode.default = concurrent +junit.jupiter.execution.parallel.mode.classes.default = concurrent +junit.jupiter.execution.parallel.config.strategy = dynamic +junit.jupiter.execution.parallel.config.dynamic.factor = 1