From 118f8d2f4e93e11ecade30f41694ba4b5642fa79 Mon Sep 17 00:00:00 2001 From: Rahul Mishra Date: Sun, 12 Jul 2026 16:49:13 +0530 Subject: [PATCH] feat: add visual regression, accessibility, Pact contract testing & data factories Syncs the framework with the maturity work developed in the sdet-prep mega-repo, keeping this standalone repo as the source of truth: - Visual regression: homegrown pixel-diff VisualRegressionUtils vs committed baselines against a deterministic local page; diff attached to Allure (-Pvisual) - Accessibility: axe-core-selenium AccessibilityUtils, WCAG-tag filtering (-Pa11y) - Contract testing: real Pact JVM consumer + embedded-provider verification (-Pcontract) - Test data: datafaker-backed factories (User/PostPayload/Credentials), seeded; refactored PostRequestTest + a factory-fed DataProvider Deps: datafaker 2.4.3, axe-core-selenium 4.10.1, pact consumer/provider junit5 4.6.17. Pins surefire-testng so the JUnit5-on-classpath (from Pact) doesn't hijack the runner. aspectjweaver argLine preserved. Adds visual/a11y/contract profiles. --- CLAUDE.md | 125 ++++++++ README.md | 37 +++ pom.xml | 63 ++++ .../hul/framework/data/CredentialFactory.java | 64 ++++ .../ra/hul/framework/data/Credentials.java | 20 ++ .../ra/hul/framework/data/FakerProvider.java | 45 +++ .../framework/data/PostPayloadFactory.java | 62 ++++ .../ra/hul/framework/data/UserFactory.java | 76 +++++ .../web/utils/AccessibilityUtils.java | 135 +++++++++ .../web/utils/VisualRegressionUtils.java | 276 ++++++++++++++++++ .../ra/hul/tests/a11y/AccessibilityTest.java | 65 +++++ .../ra/hul/tests/api/PostRequestTest.java | 37 ++- .../tests/contract/ConsumerContractTest.java | 101 +++++++ .../ProviderContractVerificationTest.java | 143 +++++++++ .../ra/hul/tests/data/DataFactoryTest.java | 114 ++++++++ .../tests/visual/VisualRegressionTest.java | 76 +++++ src/test/resources/a11y-tests.xml | 13 + src/test/resources/all-tests.xml | 3 + src/test/resources/config.properties | 25 ++ src/test/resources/contract-tests.xml | 16 + src/test/resources/pages/a11y-sample.html | 35 +++ .../pages/visual-sample-modified.html | 48 +++ src/test/resources/pages/visual-sample.html | 48 +++ src/test/resources/visual-tests.xml | 13 + .../visual/baseline/visual-sample.png | Bin 0 -> 14505 bytes 25 files changed, 1634 insertions(+), 6 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/main/java/ra/hul/framework/data/CredentialFactory.java create mode 100644 src/main/java/ra/hul/framework/data/Credentials.java create mode 100644 src/main/java/ra/hul/framework/data/FakerProvider.java create mode 100644 src/main/java/ra/hul/framework/data/PostPayloadFactory.java create mode 100644 src/main/java/ra/hul/framework/data/UserFactory.java create mode 100644 src/main/java/ra/hul/framework/web/utils/AccessibilityUtils.java create mode 100644 src/main/java/ra/hul/framework/web/utils/VisualRegressionUtils.java create mode 100644 src/test/java/ra/hul/tests/a11y/AccessibilityTest.java create mode 100644 src/test/java/ra/hul/tests/contract/ConsumerContractTest.java create mode 100644 src/test/java/ra/hul/tests/contract/ProviderContractVerificationTest.java create mode 100644 src/test/java/ra/hul/tests/data/DataFactoryTest.java create mode 100644 src/test/java/ra/hul/tests/visual/VisualRegressionTest.java create mode 100644 src/test/resources/a11y-tests.xml create mode 100644 src/test/resources/contract-tests.xml create mode 100644 src/test/resources/pages/a11y-sample.html create mode 100644 src/test/resources/pages/visual-sample-modified.html create mode 100644 src/test/resources/pages/visual-sample.html create mode 100644 src/test/resources/visual-tests.xml create mode 100644 src/test/resources/visual/baseline/visual-sample.png diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..44b6cd0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,125 @@ +# CLAUDE.md + +This file provides guidance to AI assistants working in the **`framework/` module**. For the +whole mega-repo (all seven pillars + toolchains), see the root [`../CLAUDE.md`](../CLAUDE.md). + +## Project + +Java 21 / Maven test automation framework covering Web (Selenium 4), API (Rest Assured), Mobile (Appium 2.x), and Performance (Gatling). TestNG is the runner; Allure is the reporter. + +Package root: `ra.hul.framework` (production code in `src/main/java`) and `ra.hul.tests` (test classes in `src/test/java`). This split is enforced — framework infrastructure must never live under `src/test`. + +## Common commands + +```bash +# Build & verify +mvn clean compile +mvn test-compile + +# Run by module (each profile points Surefire at a different TestNG suite XML) +mvn test -Pweb +mvn test -Papi +mvn test -Pmobile +mvn test -Psmoke # cross-module, group="smoke" +mvn test # all-tests.xml + +# Maturity capabilities (each profile swaps the suite XML) +mvn test -Pvisual -Dheadless=true -Dbrowser=chrome # visual regression (needs a browser) +mvn test -Pa11y -Dheadless=true -Dbrowser=chrome # accessibility scan (needs a browser) +mvn test -Pcontract # Pact contract + datafaker tests (no browser) + +# Single test class / method (Surefire still uses the suite XML, so -Dtest is filtering) +mvn test -Pweb -Dtest=LoginTest +mvn test -Pweb -Dtest=LoginTest#login_validCredentials_shouldShowSecurePage + +# Environment + browser overrides +mvn test -Pweb -Denv=stage -Dbrowser=firefox -Dheadless=false +mvn test -Pweb -Dgrid.url=http://localhost:4444 # switches to RemoteWebDriver + +# Performance +mvn gatling:test +mvn gatling:test -Dgatling.simulationClass=ra.hul.framework.performance.simulations.HttpBinGetSimulation + +# Reporting +mvn allure:serve # generates + opens report +mvn allure:report # writes to target/site/allure-maven-plugin +``` + +Mobile tests require an emulator + running Appium server before `mvn test -Pmobile`. Full setup steps are in `MOBILE_SETUP.md` — `ANDROID_HOME` must be exported in the same shell that runs Maven. + +## Architecture — what's non-obvious + +**4-level config resolution (`ConfigManager`).** Precedence, highest first: OS env var (dot.key → `DOT_KEY`) → `-D` system property → `config-.properties` (loaded when `-Denv=` is set) → `config.properties`. Missing keys throw `IllegalStateException` — use `getOrDefault` if absence is legal. Any new tunable belongs in `config.properties` and read through this manager; never hardcode timeouts, URLs, or paths in tests. + +**ThreadLocal driver isolation.** `DriverManager` (web) and `AppiumDriverManager` (mobile) each hold a `ThreadLocal<...Driver>`. Base test classes (`BaseWebTest`, `BaseMobileTest`) initialize per `@BeforeMethod` and quit per `@AfterMethod`. Parallel execution is safe because of this, *not* because of any locking. **Do not** introduce static driver fields or share drivers across threads. + +**Parallelism is owned by TestNG suite XMLs, not Maven.** `web-tests.xml` runs methods in 10 threads; `api-tests.xml` runs methods in 20; `mobile-tests.xml` runs classes in 1. To change parallelism, edit the suite XML or override `parallel.count` via `-D`. Surefire's fork settings are not used for this. + +**Sealed `BrowserStrategy`.** Java 21 sealed interface restricts implementations to `ChromeStrategy`, `FirefoxStrategy`, `EdgeStrategy` at compile time. `WebDriverFactory` selects a strategy based on the `browser` config and decides local vs. `RemoteWebDriver` based on whether `grid.url` is set. Adding a new browser means a new sealed permits entry plus a strategy class — there is no runtime registry. + +**Allure `@Step` requires AspectJ weaver.** The Surefire `argLine` in `pom.xml` injects `-javaagent:.../aspectjweaver.jar`. If `@Step` annotations stop appearing in reports, that javaagent is the first thing to check. Don't remove the `argLine` block when editing other Surefire config. + +**Surefire is pinned to the TestNG provider.** The Pact JVM `junit5` artifacts pull JUnit 5 onto the test classpath, which makes Surefire auto-select the JUnit Platform provider and silently ignore our TestNG suite XMLs (`Tests run: 0`). The Surefire plugin therefore declares a `surefire-testng` plugin-level dependency to force the TestNG provider. Do not remove it while the Pact deps are present. + +**Auto-applied retry.** `RetryTransformer` is a TestNG `IAnnotationTransformer` registered in every suite XML — it attaches `RetryAnalyzer` (count from `retry.count`) to every `@Test` automatically. Individual tests do not declare retry. To disable for a specific test, the right move is to make `RetryAnalyzer` honor an opt-out attribute, not to special-case the transformer. + +**Page/Screen Object Model is enforced, not optional.** +- Tests must not reference Selenium `By` or Appium locators directly. +- Page objects (`web/pages/`) and screen objects (`mobile/screens/`) own all locators as `private final` fields. +- Public methods on page/screen objects are annotated `@Step` for Allure traceability. +- Assertions live in test classes, never inside page/screen objects. +- `BasePage.isLoaded()` and `BaseScreen.isLoaded()` are template-method hooks — every concrete page/screen implements one. + +**No `Thread.sleep`.** Use `WaitUtils` (web) or `MobileWaitUtils` (mobile). Both wrap explicit/fluent waits with config-driven timeouts from `TimeoutConstants`. + +## Test naming convention + +`methodUnderTest_condition_expectedBehavior` — e.g. `login_validCredentials_shouldShowSecurePage`. Enforced by convention; new tests should match. + +Every test method declares `@Epic`, `@Feature`, `@Story`, `@Severity` (Allure metadata) and `groups = {"regression"}` at minimum. Critical happy paths additionally tag `"smoke"` to be picked up by `smoke-tests.xml`. + +## Adding new tests + +- New web test: add a page object under `ra.hul.framework.web.pages` extending `BasePage`, then a test class under `ra.hul.tests.web` extending `BaseWebTest`. Register the test class in `web-tests.xml` (and `smoke-tests.xml` if applicable). +- New API test: extend `BaseApiTest`. POJOs go under `ra.hul.framework.api.models` with Lombok `@Data @Builder @NoArgsConstructor @AllArgsConstructor`. JSON schemas for contract tests go in `src/test/resources/schemas/`. +- New mobile test: add a screen object under `ra.hul.framework.mobile.screens` extending `BaseScreen`. Prefer `AppiumBy.accessibilityId()`. Test class extends `BaseMobileTest` and is registered in `mobile-tests.xml`. + +A test class that is not added to its suite XML will silently not run. + +## Maturity capabilities + +Four self-contained, fully offline capabilities (no cloud/SaaS). All tunables are read via +`ConfigManager.getOrDefault`/`getIntOrDefault`/`getLongOrDefault` so absence never crashes; defaults +and keys live in `src/test/resources/config.properties` (this module has no `src/main/resources`, so +config is loaded from the test classpath). + +- **Visual regression** — `web/utils/VisualRegressionUtils` (`src/main`). Homegrown per-pixel + `BufferedImage` diff, no external visual SaaS. Captures via `DriverManager.getDriver()` + + `TakesScreenshot` (reuses the AllureTestListener screenshot idiom), compares against a committed + baseline under `visual.baseline.dir` (default `src/test/resources/visual/baseline/`), writes + actual+diff to `visual.output.dir` (default `target/visual/`), attaches baseline/actual/diff to + Allure. Never asserts — returns `VisualComparisonResult`; the test asserts. Keys: + `visual.baseline.dir`, `visual.output.dir`, `visual.pixel.tolerance`, `visual.diff.threshold`, + `visual.update.baselines` (set `true` to (re)write baselines instead of failing). Tests: + `tests/visual/VisualRegressionTest`, suite `visual-tests.xml`, profile `visual`. +- **Accessibility** — `web/utils/AccessibilityUtils` (`src/main`) wraps axe-core's `AxeBuilder`, + filters by `a11y.tags` (default `wcag2a,wcag2aa`), attaches violations (JSON via `AxeReporter` + + readable summary) to Allure, returns `List` for the test to assert. Because a `src/main` + util references axe, the `com.deque.html.axe-core:selenium` dep is **compile** scope (not test). + Tests: `tests/a11y/AccessibilityTest`, suite `a11y-tests.xml`, profile `a11y`. +- **Contract testing** — `tests/contract/` (test-only). Real Pact JVM consumer test using the DSL + **programmatically** (`ConsumerPactBuilder` + `runConsumerTest`), NOT the JUnit5 extension. Uses + the V3 model (`RequestResponsePact`) end-to-end so the embedded-`HttpServer` provider verification + can replay `RequestResponseInteraction`s. Pact files land in `pact.output.dir` (default + `target/pacts/`). Suite `contract-tests.xml` (also runs `DataFactoryTest`), profile `contract`. +- **Test-data management** — `data/` (`src/main`): `UserFactory`, `PostPayloadFactory`, + `CredentialFactory` + `Credentials` value object, backed by datafaker via `FakerProvider` + (seeded from `data.faker.seed` / `data.faker.locale` → deterministic). Fluent `withX(...)` + overrides win over generated values; `build()` generates all fields in a fixed order so faker + consumption stays deterministic regardless of overrides. `datafaker` is **compile** scope (used + from `src/main`). Do NOT route the web login creds through a factory — `tomsmith`/ + `SuperSecretPassword!` must match the live demo site. Tests: `tests/data/DataFactoryTest`. + +## CI + +`.github/workflows/test-automation.yml` runs web + api jobs in parallel on push to main/develop and on PRs, then merges Allure results and deploys the report to `gh-pages` with 20-run history. Mobile + performance are `workflow_dispatch` only. diff --git a/README.md b/README.md index 9ca8a44..aaf51f7 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,11 @@ A production-grade test automation framework built with Java 21, covering Web, API, Mobile, and Performance testing. Designed for real-world adoption and structured around industry-standard design patterns. +> **Part of the [SDET Interview Prep mega-repo](../README.md).** This is the Java automation pillar; its +> TypeScript counterpart is [`../playwright/`](../playwright/). See also [`../dsa/`](../dsa/) (coding), +> [`../sdet/`](../sdet/) (practical problems + company bank), [`../sd/`](../sd/) (system design), and +> [`../study-tracker/`](../study-tracker/) (spaced-repetition tracker). + --- ## Table of Contents @@ -177,6 +182,38 @@ mvn test -Psmoke mvn test ``` +### Maturity Capabilities + +Four higher-maturity capabilities are wired in, each fully self-contained and offline (no cloud +accounts or SaaS). Each has its own profile that swaps the TestNG suite XML. + +```bash +# Visual regression (homegrown pixel-diff) -- needs a browser +mvn test -Pvisual -Dheadless=true -Dbrowser=chrome + +# Accessibility scan (axe-core) -- needs a browser +mvn test -Pa11y -Dheadless=true -Dbrowser=chrome + +# Contract testing (Pact JVM) + test-data factories -- no browser needed +mvn test -Pcontract +``` + +| Capability | What it does | Key classes | Config keys | +|-----------|--------------|-------------|-------------| +| **Visual regression** | Captures a page/element screenshot, pixel-diffs it against a committed baseline PNG with a configurable tolerance/threshold, writes a highlighted diff, attaches baseline/actual/diff to Allure. Set `-Dvisual.update.baselines=true` to refresh baselines instead of failing. | `web/utils/VisualRegressionUtils` | `visual.baseline.dir`, `visual.output.dir`, `visual.pixel.tolerance`, `visual.diff.threshold`, `visual.update.baselines` | +| **Accessibility** | Runs an [axe-core](https://github.com/dequelabs/axe-core) WCAG scan of the current page/subtree, filters by WCAG tags, attaches violations (JSON + readable summary) to Allure. | `web/utils/AccessibilityUtils` | `a11y.tags`, `a11y.fail.on.violation` | +| **Contract testing** | Real Pact JVM **consumer** test built with the DSL programmatically (TestNG-friendly, no JUnit5 runner): spins up the Pact mock server, drives `ApiClient` at it, writes the pact to `target/pacts/`. Plus a lightweight embedded-`HttpServer` **provider** verification that replays the pact. | `tests/contract/ConsumerContractTest`, `tests/contract/ProviderContractVerificationTest` | `pact.output.dir` | +| **Test-data management** | Deterministic [datafaker](https://www.datafaker.net/)-backed factories (seeded from config) with per-field overrides. | `data/UserFactory`, `data/PostPayloadFactory`, `data/CredentialFactory`, `data/Credentials`, `data/FakerProvider` | `data.faker.seed`, `data.faker.locale` | + +**Baselines** live under `src/test/resources/visual/baseline/` (committed). On a fresh checkout the +first visual run generates the baseline and passes; subsequent runs compare against it. Actual/diff +artifacts are written to `target/visual/`. Bundled deterministic sample pages live under +`src/test/resources/pages/` (`visual-sample.html`, `visual-sample-modified.html`, `a11y-sample.html`). + +> **Note:** because the Pact JVM `junit5` artifacts drag JUnit 5 onto the test classpath, the +> Surefire plugin pins the **TestNG** provider (`surefire-testng` plugin dependency) so our TestNG +> suite XMLs are still honoured. Do not remove that pin. + ### Environment Selection ```bash diff --git a/pom.xml b/pom.xml index 1675f90..ef3c424 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,11 @@ 3.1.0 1.18.44 + + 4.10.1 + 2.4.3 + 4.6.17 + 3.15.0 3.5.5 @@ -129,6 +134,34 @@ ${lombok.version} provided + + + + net.datafaker + datafaker + ${datafaker.version} + + + + + com.deque.html.axe-core + selenium + ${axe-selenium.version} + + + + + au.com.dius.pact.consumer + junit5 + ${pact.version} + test + + + au.com.dius.pact.provider + junit5 + ${pact.version} + test + @@ -166,6 +199,18 @@ ${project.build.directory}/allure-results + + + + org.apache.maven.surefire + surefire-testng + ${maven-surefire-plugin.version} + + @@ -217,5 +262,23 @@ src/test/resources/smoke-tests.xml + + visual + + src/test/resources/visual-tests.xml + + + + a11y + + src/test/resources/a11y-tests.xml + + + + contract + + src/test/resources/contract-tests.xml + + diff --git a/src/main/java/ra/hul/framework/data/CredentialFactory.java b/src/main/java/ra/hul/framework/data/CredentialFactory.java new file mode 100644 index 0000000..fdd4ec2 --- /dev/null +++ b/src/main/java/ra/hul/framework/data/CredentialFactory.java @@ -0,0 +1,64 @@ +package ra.hul.framework.data; + +import net.datafaker.Faker; + +/** + * Fluent factory that builds {@link Credentials} test data backed by a seeded {@link Faker}. + * Deterministic under a fixed {@code data.faker.seed}; overrides win over generated values. + * + *

NOTE: these are synthetic credentials for API/data-driven tests. Do not use them for the + * live web login demo (that requires the real {@code tomsmith}/{@code SuperSecretPassword!}).

+ * + *
{@code
+ * Credentials c = CredentialFactory.newCredentials().withUsername("qa_bot").build();
+ * }
+ */ +public final class CredentialFactory { + + private final Faker faker; + + private String username; + private String password; + private String email; + + private CredentialFactory(Faker faker) { + this.faker = faker; + } + + /** Factory seeded from config ({@code data.faker.seed} / {@code data.faker.locale}). */ + public static CredentialFactory newCredentials() { + return new CredentialFactory(FakerProvider.seeded()); + } + + /** Factory backed by a caller-supplied Faker (e.g. a specific seed). */ + public static CredentialFactory newCredentials(Faker faker) { + return new CredentialFactory(faker); + } + + public CredentialFactory withUsername(String username) { + this.username = username; + return this; + } + + public CredentialFactory withPassword(String password) { + this.password = password; + return this; + } + + public CredentialFactory withEmail(String email) { + this.email = email; + return this; + } + + public Credentials build() { + String genUsername = faker.internet().username(); + String genPassword = faker.internet().password(10, 16, true); + String genEmail = faker.internet().emailAddress(); + + return Credentials.builder() + .username(username != null ? username : genUsername) + .password(password != null ? password : genPassword) + .email(email != null ? email : genEmail) + .build(); + } +} diff --git a/src/main/java/ra/hul/framework/data/Credentials.java b/src/main/java/ra/hul/framework/data/Credentials.java new file mode 100644 index 0000000..4842615 --- /dev/null +++ b/src/main/java/ra/hul/framework/data/Credentials.java @@ -0,0 +1,20 @@ +package ra.hul.framework.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Immutable-ish value object holding a generated set of login credentials. + * Not tied to any real account — produced by {@link CredentialFactory} for test data. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Credentials { + private String username; + private String password; + private String email; +} diff --git a/src/main/java/ra/hul/framework/data/FakerProvider.java b/src/main/java/ra/hul/framework/data/FakerProvider.java new file mode 100644 index 0000000..5d39ff3 --- /dev/null +++ b/src/main/java/ra/hul/framework/data/FakerProvider.java @@ -0,0 +1,45 @@ +package ra.hul.framework.data; + +import net.datafaker.Faker; +import ra.hul.framework.core.config.ConfigManager; + +import java.util.Locale; +import java.util.Random; + +/** + * Central factory for {@link Faker} instances. + * + *

Generation is made deterministic by seeding datafaker's random source from + * config: {@code data.faker.seed} (long) and {@code data.faker.locale} (BCP-47 language tag). + * Two Fakers built from the same seed/locale emit the identical sequence of values, which is + * what makes the factory tests reproducible.

+ * + *

Both keys are read via {@code getOrDefault}/{@code getLongOrDefault} so their absence + * never crashes — defaults are seed {@code 1337} and locale {@code en}.

+ */ +public final class FakerProvider { + + public static final long DEFAULT_SEED = 1337L; + public static final String DEFAULT_LOCALE = "en"; + + private FakerProvider() { + } + + /** Faker seeded from the configured {@code data.faker.seed} / {@code data.faker.locale}. */ + public static Faker seeded() { + long seed = ConfigManager.getLongOrDefault("data.faker.seed", DEFAULT_SEED); + String locale = ConfigManager.getOrDefault("data.faker.locale", DEFAULT_LOCALE); + return seeded(seed, locale); + } + + /** Faker seeded from an explicit seed, using the configured locale. */ + public static Faker seeded(long seed) { + String locale = ConfigManager.getOrDefault("data.faker.locale", DEFAULT_LOCALE); + return seeded(seed, locale); + } + + /** Faker seeded from an explicit seed and locale. */ + public static Faker seeded(long seed, String locale) { + return new Faker(Locale.forLanguageTag(locale), new Random(seed)); + } +} diff --git a/src/main/java/ra/hul/framework/data/PostPayloadFactory.java b/src/main/java/ra/hul/framework/data/PostPayloadFactory.java new file mode 100644 index 0000000..19baee7 --- /dev/null +++ b/src/main/java/ra/hul/framework/data/PostPayloadFactory.java @@ -0,0 +1,62 @@ +package ra.hul.framework.data; + +import net.datafaker.Faker; +import ra.hul.framework.api.models.PostPayload; + +/** + * Fluent factory that builds {@link PostPayload} test data backed by a seeded {@link Faker}. + * Deterministic under a fixed {@code data.faker.seed}; overrides win over generated values. + * + *
{@code
+ * PostPayload p = PostPayloadFactory.newPost().withUserId(7).build();
+ * }
+ */ +public final class PostPayloadFactory { + + private final Faker faker; + + private String title; + private String body; + private Integer userId; + + private PostPayloadFactory(Faker faker) { + this.faker = faker; + } + + /** Factory seeded from config ({@code data.faker.seed} / {@code data.faker.locale}). */ + public static PostPayloadFactory newPost() { + return new PostPayloadFactory(FakerProvider.seeded()); + } + + /** Factory backed by a caller-supplied Faker (e.g. a specific seed). */ + public static PostPayloadFactory newPost(Faker faker) { + return new PostPayloadFactory(faker); + } + + public PostPayloadFactory withTitle(String title) { + this.title = title; + return this; + } + + public PostPayloadFactory withBody(String body) { + this.body = body; + return this; + } + + public PostPayloadFactory withUserId(int userId) { + this.userId = userId; + return this; + } + + public PostPayload build() { + String genTitle = faker.lorem().sentence(4); + String genBody = faker.lorem().paragraph(2); + int genUserId = faker.number().numberBetween(1, 1_000); + + return PostPayload.builder() + .title(title != null ? title : genTitle) + .body(body != null ? body : genBody) + .userId(userId != null ? userId : genUserId) + .build(); + } +} diff --git a/src/main/java/ra/hul/framework/data/UserFactory.java b/src/main/java/ra/hul/framework/data/UserFactory.java new file mode 100644 index 0000000..2c84e3e --- /dev/null +++ b/src/main/java/ra/hul/framework/data/UserFactory.java @@ -0,0 +1,76 @@ +package ra.hul.framework.data; + +import net.datafaker.Faker; +import ra.hul.framework.api.models.User; + +/** + * Fluent factory that builds {@link User} test data backed by a seeded {@link Faker}. + * + *

Deterministic: two factories created via {@link #newUser()} with the same configured + * {@code data.faker.seed}/{@code data.faker.locale} produce identical users. To keep the + * faker consumption order stable regardless of which fields are overridden, {@link #build()} + * always generates every field in a fixed order and then applies any overrides on top.

+ * + *
{@code
+ * User u = UserFactory.newUser().withName("Rahul").build();
+ * }
+ */ +public final class UserFactory { + + private final Faker faker; + + private Integer id; + private String name; + private String email; + private String job; + + private UserFactory(Faker faker) { + this.faker = faker; + } + + /** Factory seeded from config ({@code data.faker.seed} / {@code data.faker.locale}). */ + public static UserFactory newUser() { + return new UserFactory(FakerProvider.seeded()); + } + + /** Factory backed by a caller-supplied Faker (e.g. a specific seed). */ + public static UserFactory newUser(Faker faker) { + return new UserFactory(faker); + } + + public UserFactory withId(int id) { + this.id = id; + return this; + } + + public UserFactory withName(String name) { + this.name = name; + return this; + } + + public UserFactory withEmail(String email) { + this.email = email; + return this; + } + + public UserFactory withJob(String job) { + this.job = job; + return this; + } + + public User build() { + // Generate all fields in a fixed order so faker consumption is deterministic, + // then let explicit overrides win. + int genId = faker.number().numberBetween(1, 100_000); + String genName = faker.name().fullName(); + String genEmail = faker.internet().emailAddress(); + String genJob = faker.job().position(); + + return User.builder() + .id(id != null ? id : genId) + .name(name != null ? name : genName) + .email(email != null ? email : genEmail) + .job(job != null ? job : genJob) + .build(); + } +} diff --git a/src/main/java/ra/hul/framework/web/utils/AccessibilityUtils.java b/src/main/java/ra/hul/framework/web/utils/AccessibilityUtils.java new file mode 100644 index 0000000..a8f4f87 --- /dev/null +++ b/src/main/java/ra/hul/framework/web/utils/AccessibilityUtils.java @@ -0,0 +1,135 @@ +package ra.hul.framework.web.utils; + +import com.deque.html.axecore.results.CheckedNode; +import com.deque.html.axecore.results.Results; +import com.deque.html.axecore.results.Rule; +import com.deque.html.axecore.selenium.AxeBuilder; +import com.deque.html.axecore.selenium.AxeReporter; +import io.qameta.allure.Attachment; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.openqa.selenium.WebDriver; +import ra.hul.framework.core.config.ConfigManager; +import ra.hul.framework.web.driver.DriverManager; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Thin wrapper around the axe-core Selenium binding ({@link AxeBuilder}) that runs a WCAG + * accessibility scan of the current page (or a subtree) entirely offline — axe-core is injected + * into the page by the binding, no network calls. + * + *

Config (read via {@code getOrDefault} so absence never crashes):

+ *
    + *
  • {@code a11y.tags} — comma-separated axe tag filter (default {@code wcag2a,wcag2aa})
  • + *
+ * + *

Violations (both raw JSON and a human-readable summary) are attached to the Allure report. + * This class never asserts — it returns the violations and the test decides pass/fail.

+ */ +public final class AccessibilityUtils { + + private static final Logger log = LogManager.getLogger(AccessibilityUtils.class); + private static final String DEFAULT_TAGS = "wcag2a,wcag2aa"; + + private AccessibilityUtils() { + } + + /** Scan the whole current page with the configured WCAG tag filter. */ + public static List analyze() { + return analyze(configuredTags()); + } + + /** Scan the whole current page with an explicit tag list. */ + public static List analyze(List tags) { + Results results = runScan(new AxeBuilder(), tags); + return report(results, "page"); + } + + /** + * Scan only the subtree matched by a CSS selector (used to demonstrate that a clean + * region passes even when the wider page has violations). + */ + public static List analyzeSelector(String cssSelector) { + return analyzeSelector(cssSelector, configuredTags()); + } + + public static List analyzeSelector(String cssSelector, List tags) { + Results results = runScan(new AxeBuilder().include(List.of(cssSelector)), tags); + return report(results, "selector '" + cssSelector + "'"); + } + + // --------------------------------------------------------------------------------------------- + + private static Results runScan(AxeBuilder builder, List tags) { + WebDriver driver = DriverManager.getDriver(); + Results results = builder.withTags(tags).analyze(driver); + if (results.isErrored()) { + throw new IllegalStateException("axe-core scan errored: " + results.getErrorMessage()); + } + return results; + } + + private static List report(Results results, String scope) { + List violations = results.getViolations(); + log.info("Accessibility scan of {} found {} violation rule(s)", scope, violations.size()); + attachViolationsJson(AxeReporter.serialize(violations)); + attachViolationsSummary(buildSummary(violations, scope)); + return violations; + } + + private static List configuredTags() { + String raw = ConfigManager.getOrDefault("a11y.tags", DEFAULT_TAGS); + return Arrays.stream(raw.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + + /** Build a readable, deterministic summary of the violations for the Allure report / logs. */ + public static String buildSummary(List violations, String scope) { + if (violations.isEmpty()) { + return "No accessibility violations found for " + scope + "."; + } + StringBuilder sb = new StringBuilder(); + sb.append(violations.size()).append(" accessibility violation rule(s) for ").append(scope).append(":\n"); + for (Rule rule : violations) { + List nodes = rule.getNodes(); + sb.append(" • [").append(rule.getImpact()).append("] ") + .append(rule.getId()).append(" — ").append(rule.getHelp()) + .append(" (").append(nodes == null ? 0 : nodes.size()).append(" node(s))\n"); + if (nodes != null) { + for (CheckedNode node : nodes) { + sb.append(" target=").append(node.getTarget()) + .append(" html=").append(compact(node.getHtml())).append('\n'); + } + } + } + return sb.toString(); + } + + /** Convenience: true if any returned violation matches the given rule id. */ + public static boolean containsRule(List violations, String ruleId) { + return violations.stream().anyMatch(r -> ruleId.equals(r.getId())); + } + + private static String compact(String html) { + if (html == null) { + return ""; + } + String collapsed = html.replaceAll("\\s+", " ").trim(); + return collapsed.length() > 120 ? collapsed.substring(0, 117) + "..." : collapsed; + } + + @Attachment(value = "Accessibility Violations (JSON)", type = "application/json") + private static String attachViolationsJson(String json) { + return json; + } + + @Attachment(value = "Accessibility Violations (Summary)", type = "text/plain") + private static String attachViolationsSummary(String summary) { + return summary; + } +} diff --git a/src/main/java/ra/hul/framework/web/utils/VisualRegressionUtils.java b/src/main/java/ra/hul/framework/web/utils/VisualRegressionUtils.java new file mode 100644 index 0000000..4555325 --- /dev/null +++ b/src/main/java/ra/hul/framework/web/utils/VisualRegressionUtils.java @@ -0,0 +1,276 @@ +package ra.hul.framework.web.utils; + +import io.qameta.allure.Attachment; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.openqa.selenium.OutputType; +import org.openqa.selenium.TakesScreenshot; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import ra.hul.framework.core.config.ConfigManager; +import ra.hul.framework.web.driver.DriverManager; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Homegrown, fully-offline visual regression helper. + * + *

Captures a screenshot of the current page (or a single element), compares it pixel-by-pixel + * against a committed baseline PNG, and produces a highlighted diff image — no cloud service or + * external visual-testing SaaS involved. All tuning comes from config (read via {@code getOrDefault} + * so absence never crashes):

+ * + *
    + *
  • {@code visual.baseline.dir} — where committed baselines live (default {@code src/test/resources/visual/baseline})
  • + *
  • {@code visual.output.dir} — where actual + diff artifacts are written (default {@code target/visual})
  • + *
  • {@code visual.pixel.tolerance}— per-channel colour delta treated as equal, 0-255 (default {@code 20})
  • + *
  • {@code visual.diff.threshold} — max fraction of mismatching pixels before failing, 0.0-1.0 (default {@code 0.01})
  • + *
  • {@code visual.update.baselines}— when {@code true}, (re)writes the baseline instead of comparing (default {@code false})
  • + *
+ * + *

This class never asserts — it returns a {@link VisualComparisonResult} and the calling test + * decides pass/fail (assertions live in tests, POM/enforcement rule).

+ */ +public final class VisualRegressionUtils { + + private static final Logger log = LogManager.getLogger(VisualRegressionUtils.class); + + private VisualRegressionUtils() { + } + + /** Immutable result of a single visual comparison. Highlight image bytes are attached to Allure. */ + public static final class VisualComparisonResult { + private final String name; + private final boolean match; + private final boolean baselineCreated; + private final long diffPixels; + private final long totalPixels; + private final double diffRatio; + private final double threshold; + private final boolean dimensionMismatch; + private final Path baselinePath; + private final Path actualPath; + private final Path diffPath; + + VisualComparisonResult(String name, boolean match, boolean baselineCreated, long diffPixels, + long totalPixels, double diffRatio, double threshold, + boolean dimensionMismatch, Path baselinePath, Path actualPath, Path diffPath) { + this.name = name; + this.match = match; + this.baselineCreated = baselineCreated; + this.diffPixels = diffPixels; + this.totalPixels = totalPixels; + this.diffRatio = diffRatio; + this.threshold = threshold; + this.dimensionMismatch = dimensionMismatch; + this.baselinePath = baselinePath; + this.actualPath = actualPath; + this.diffPath = diffPath; + } + + public String getName() { return name; } + public boolean isMatch() { return match; } + public boolean isBaselineCreated() { return baselineCreated; } + public long getDiffPixels() { return diffPixels; } + public long getTotalPixels() { return totalPixels; } + public double getDiffRatio() { return diffRatio; } + public double getThreshold() { return threshold; } + public boolean isDimensionMismatch() { return dimensionMismatch; } + public Path getBaselinePath() { return baselinePath; } + public Path getActualPath() { return actualPath; } + public Path getDiffPath() { return diffPath; } + + public String summary() { + if (baselineCreated) { + return "Baseline '" + name + "' created at " + baselinePath + " (first run — no comparison performed)"; + } + return String.format( + "Visual '%s': match=%b, diffPixels=%d/%d (ratio=%.5f, threshold=%.5f)%s", + name, match, diffPixels, totalPixels, diffRatio, threshold, + dimensionMismatch ? " [DIMENSION MISMATCH]" : ""); + } + + @Override + public String toString() { + return summary(); + } + } + + /** Capture the whole page and compare against baseline {@code .png}. */ + public static VisualComparisonResult compare(String name) { + return doCompare(name, captureImage(screenshotBytes())); + } + + /** Capture a single element and compare against baseline {@code .png}. */ + public static VisualComparisonResult compare(String name, WebElement element) { + return doCompare(name, captureImage(element.getScreenshotAs(OutputType.BYTES))); + } + + // --------------------------------------------------------------------------------------------- + + private static VisualComparisonResult doCompare(String name, BufferedImage actual) { + String baselineDir = ConfigManager.getOrDefault("visual.baseline.dir", "src/test/resources/visual/baseline"); + String outputDir = ConfigManager.getOrDefault("visual.output.dir", "target/visual"); + int tolerance = ConfigManager.getIntOrDefault("visual.pixel.tolerance", 20); + double threshold = parseDoubleOrDefault("visual.diff.threshold", 0.01); + boolean updateMode = Boolean.parseBoolean(ConfigManager.getOrDefault("visual.update.baselines", "false")); + + Path baselinePath = Path.of(baselineDir, name + ".png"); + Path outDir = Path.of(outputDir); + Path actualPath = outDir.resolve(name + "-actual.png"); + Path diffPath = outDir.resolve(name + "-diff.png"); + + try { + Files.createDirectories(outDir); + writePng(actual, actualPath); + + boolean baselineExists = Files.exists(baselinePath); + + if (updateMode || !baselineExists) { + Files.createDirectories(baselinePath.getParent()); + writePng(actual, baselinePath); + attachActual(toPng(actual)); + log.info("Visual baseline {} written to {} (updateMode={}, existed={})", + name, baselinePath, updateMode, baselineExists); + return new VisualComparisonResult(name, true, true, 0, + (long) actual.getWidth() * actual.getHeight(), 0.0, threshold, false, + baselinePath, actualPath, null); + } + + BufferedImage baseline = ImageIO.read(baselinePath.toFile()); + if (baseline == null) { + throw new IllegalStateException("Baseline could not be read as an image: " + baselinePath); + } + + int width = Math.max(baseline.getWidth(), actual.getWidth()); + int height = Math.max(baseline.getHeight(), actual.getHeight()); + boolean dimensionMismatch = baseline.getWidth() != actual.getWidth() + || baseline.getHeight() != actual.getHeight(); + + BufferedImage diff = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + long diffPixels = 0; + long totalPixels = (long) width * height; + int highlight = Color.RED.getRGB(); + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + boolean inBaseline = x < baseline.getWidth() && y < baseline.getHeight(); + boolean inActual = x < actual.getWidth() && y < actual.getHeight(); + + if (!inBaseline || !inActual) { + // Out-of-overlap area (different dimensions) counts as a difference. + diff.setRGB(x, y, highlight); + diffPixels++; + continue; + } + + int b = baseline.getRGB(x, y); + int a = actual.getRGB(x, y); + if (pixelsDiffer(b, a, tolerance)) { + diff.setRGB(x, y, highlight); + diffPixels++; + } else { + // Keep matching pixels but dim them so the red diff stands out. + diff.setRGB(x, y, dim(a)); + } + } + } + + writePng(diff, diffPath); + + double diffRatio = totalPixels == 0 ? 0.0 : (double) diffPixels / totalPixels; + boolean match = !dimensionMismatch && diffRatio <= threshold; + + attachBaseline(toPng(baseline)); + attachActual(toPng(actual)); + attachDiff(toPng(diff)); + + VisualComparisonResult result = new VisualComparisonResult(name, match, false, diffPixels, + totalPixels, diffRatio, threshold, dimensionMismatch, baselinePath, actualPath, diffPath); + log.info(result.summary()); + return result; + } catch (IOException e) { + throw new UncheckedIOException("Visual comparison failed for '" + name + "'", e); + } + } + + private static boolean pixelsDiffer(int rgb1, int rgb2, int tolerance) { + int r1 = (rgb1 >> 16) & 0xFF, g1 = (rgb1 >> 8) & 0xFF, b1 = rgb1 & 0xFF; + int r2 = (rgb2 >> 16) & 0xFF, g2 = (rgb2 >> 8) & 0xFF, b2 = rgb2 & 0xFF; + return Math.abs(r1 - r2) > tolerance + || Math.abs(g1 - g2) > tolerance + || Math.abs(b1 - b2) > tolerance; + } + + /** Lighten a matching pixel toward white so the red diff overlay stands out visually. */ + private static int dim(int rgb) { + int r = (rgb >> 16) & 0xFF, g = (rgb >> 8) & 0xFF, b = rgb & 0xFF; + r = r + (255 - r) * 3 / 5; + g = g + (255 - g) * 3 / 5; + b = b + (255 - b) * 3 / 5; + return (r << 16) | (g << 8) | b; + } + + private static byte[] screenshotBytes() { + WebDriver driver = DriverManager.getDriver(); + return ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES); + } + + private static BufferedImage captureImage(byte[] png) { + try { + BufferedImage img = ImageIO.read(new ByteArrayInputStream(png)); + if (img == null) { + throw new IllegalStateException("Captured screenshot could not be decoded as an image"); + } + return img; + } catch (IOException e) { + throw new UncheckedIOException("Failed to decode screenshot", e); + } + } + + private static void writePng(BufferedImage image, Path path) throws IOException { + Files.createDirectories(path.getParent()); + ImageIO.write(image, "png", path.toFile()); + } + + private static byte[] toPng(BufferedImage image) { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, "png", out); + return out.toByteArray(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to encode PNG for Allure attachment", e); + } + } + + private static double parseDoubleOrDefault(String key, double fallback) { + try { + return Double.parseDouble(ConfigManager.getOrDefault(key, Double.toString(fallback))); + } catch (NumberFormatException e) { + return fallback; + } + } + + @Attachment(value = "Visual - Baseline", type = "image/png") + private static byte[] attachBaseline(byte[] png) { + return png; + } + + @Attachment(value = "Visual - Actual", type = "image/png") + private static byte[] attachActual(byte[] png) { + return png; + } + + @Attachment(value = "Visual - Diff", type = "image/png") + private static byte[] attachDiff(byte[] png) { + return png; + } +} diff --git a/src/test/java/ra/hul/tests/a11y/AccessibilityTest.java b/src/test/java/ra/hul/tests/a11y/AccessibilityTest.java new file mode 100644 index 0000000..efea5a0 --- /dev/null +++ b/src/test/java/ra/hul/tests/a11y/AccessibilityTest.java @@ -0,0 +1,65 @@ +package ra.hul.tests.a11y; + +import com.deque.html.axecore.results.Rule; +import io.qameta.allure.*; +import org.openqa.selenium.WebDriver; +import org.testng.Assert; +import org.testng.annotations.Test; +import ra.hul.framework.web.driver.DriverManager; +import ra.hul.framework.web.utils.AccessibilityUtils; +import ra.hul.tests.base.BaseWebTest; + +import java.util.List; + +/** + * Demonstrates the axe-core accessibility capability against a bundled, fully-offline page that + * contains deterministic, known WCAG violations (missing image alt, unlabelled input, low contrast) + * plus a clean subtree that should pass. + */ +@Epic("Web Automation") +@Feature("Accessibility (axe-core)") +public class AccessibilityTest extends BaseWebTest { + + private static WebDriver driver() { + return DriverManager.getDriver(); + } + + private static String pageUrl() { + return AccessibilityTest.class.getClassLoader().getResource("pages/a11y-sample.html").toString(); + } + + @Test(groups = {"regression"}, + description = "axe-core detects the known WCAG violations on the sample page") + @Severity(SeverityLevel.CRITICAL) + @Story("Known violations detected") + public void a11y_samplePage_shouldDetectKnownViolations() { + driver().get(pageUrl()); + + List violations = AccessibilityUtils.analyze(); + + Assert.assertFalse(violations.isEmpty(), "Expected the sample page to have violations"); + Assert.assertTrue(AccessibilityUtils.containsRule(violations, "image-alt"), + "Expected an 'image-alt' violation (img without alt). Found: " + ruleIds(violations)); + Assert.assertTrue(AccessibilityUtils.containsRule(violations, "label"), + "Expected a 'label' violation (input without label). Found: " + ruleIds(violations)); + Assert.assertTrue(AccessibilityUtils.containsRule(violations, "color-contrast"), + "Expected a 'color-contrast' violation (low-contrast text). Found: " + ruleIds(violations)); + } + + @Test(groups = {"regression"}, + description = "The clean subtree passes the accessibility scan") + @Severity(SeverityLevel.NORMAL) + @Story("Clean subtree passes") + public void a11y_cleanSubtree_shouldHaveNoViolations() { + driver().get(pageUrl()); + + List violations = AccessibilityUtils.analyzeSelector("#clean"); + + Assert.assertTrue(violations.isEmpty(), + "Clean subtree should have no violations but found: " + ruleIds(violations)); + } + + private static String ruleIds(List violations) { + return violations.stream().map(Rule::getId).toList().toString(); + } +} diff --git a/src/test/java/ra/hul/tests/api/PostRequestTest.java b/src/test/java/ra/hul/tests/api/PostRequestTest.java index 24e37c2..850b054 100644 --- a/src/test/java/ra/hul/tests/api/PostRequestTest.java +++ b/src/test/java/ra/hul/tests/api/PostRequestTest.java @@ -3,9 +3,12 @@ import io.qameta.allure.*; import io.restassured.response.Response; import org.testng.Assert; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import ra.hul.framework.api.models.User; import ra.hul.framework.core.constants.Endpoints; +import ra.hul.framework.data.FakerProvider; +import ra.hul.framework.data.UserFactory; import ra.hul.tests.base.BaseApiTest; import java.util.Map; @@ -28,15 +31,14 @@ public void post_withJsonBody_shouldReturn200AndEchoData() { } @Test(groups = {"regression"}, - description = "POST with POJO serialization") + description = "POST with POJO serialization (built via the datafaker UserFactory)") @Severity(SeverityLevel.NORMAL) @Story("POJO Serialization") public void post_withPojo_shouldSerializeAndEcho() { - User user = User.builder() - .id(1) - .name("Rahul") - .email("rahul@test.com") - .job("SDET") + // Test data comes from the deterministic factory; overrides keep the assertion stable. + User user = UserFactory.newUser() + .withName("Rahul") + .withEmail("rahul@test.com") .build(); Response response = apiClient.post(Endpoints.API_POST, user); @@ -45,6 +47,29 @@ public void post_withPojo_shouldSerializeAndEcho() { Assert.assertEquals(response.jsonPath().getString("json.email"), "rahul@test.com"); } + /** Data-driven users produced by the seeded factory — deterministic across runs. */ + @DataProvider(name = "factoryUsers") + public Object[][] factoryUsers() { + return new Object[][]{ + {UserFactory.newUser(FakerProvider.seeded(1)).withJob("SDET").build()}, + {UserFactory.newUser(FakerProvider.seeded(2)).withJob("QA Lead").build()}, + {UserFactory.newUser(FakerProvider.seeded(3)).withJob("Automation Architect").build()}, + }; + } + + @Test(dataProvider = "factoryUsers", groups = {"regression"}, + description = "POST factory-generated users; httpbin echoes the exact fields back") + @Severity(SeverityLevel.NORMAL) + @Story("Data-driven POST via factory") + public void post_withFactoryUser_shouldEchoData(User user) { + Response response = apiClient.post(Endpoints.API_POST, user); + + Assert.assertEquals(response.statusCode(), 200); + Assert.assertEquals(response.jsonPath().getString("json.name"), user.getName()); + Assert.assertEquals(response.jsonPath().getString("json.email"), user.getEmail()); + Assert.assertEquals(response.jsonPath().getInt("json.id"), user.getId()); + } + @Test(groups = {"regression"}, description = "POST with empty body returns 200") @Severity(SeverityLevel.MINOR) diff --git a/src/test/java/ra/hul/tests/contract/ConsumerContractTest.java b/src/test/java/ra/hul/tests/contract/ConsumerContractTest.java new file mode 100644 index 0000000..d69b484 --- /dev/null +++ b/src/test/java/ra/hul/tests/contract/ConsumerContractTest.java @@ -0,0 +1,101 @@ +package ra.hul.tests.contract; + +import au.com.dius.pact.consumer.ConsumerPactBuilder; +import au.com.dius.pact.consumer.PactVerificationResult; +import au.com.dius.pact.consumer.dsl.PactDslJsonBody; +import au.com.dius.pact.consumer.model.MockProviderConfig; +import au.com.dius.pact.core.model.PactSpecVersion; +import au.com.dius.pact.core.model.RequestResponsePact; +import io.qameta.allure.*; +import io.restassured.response.Response; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.testng.Assert; +import org.testng.annotations.Test; +import ra.hul.framework.api.client.ApiClient; +import ra.hul.framework.api.models.User; +import ra.hul.framework.core.config.ConfigManager; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static au.com.dius.pact.consumer.ConsumerPactRunnerKt.runConsumerTest; + +/** + * Real Pact JVM consumer contract test driven programmatically (no JUnit5 extension — this + * project runs on TestNG). Builds a V4 pact for consumer {@code FrameworkClient} ↔ provider + * {@code UserService}, spins up the Pact mock server, points the framework's {@link ApiClient} at + * it, verifies the response deserializes to the {@link User} POJO, and confirms the pact file is + * written to the configured output dir ({@code pact.output.dir}, default {@code target/pacts}). + */ +@Epic("API Automation") +@Feature("Contract Testing (Pact JVM)") +public class ConsumerContractTest { + + private static final Logger log = LogManager.getLogger(ConsumerContractTest.class); + + private static final String CONSUMER = "FrameworkClient"; + private static final String PROVIDER = "UserService"; + + @Test(groups = {"regression"}, + description = "Pact consumer test: GET /users/1 -> 200 JSON maps to User, pact file written") + @Severity(SeverityLevel.CRITICAL) + @Story("Consumer contract for GET /users/1") + public void contract_getUser_shouldSatisfyPactAndWriteFile() throws Exception { + String outputDir = ConfigManager.getOrDefault("pact.output.dir", "target/pacts"); + Path pactFile = Path.of(outputDir, CONSUMER + "-" + PROVIDER + ".json"); + // Start from a clean slate so re-runs never hit a "cannot merge incompatible pacts" error. + Files.deleteIfExists(pactFile); + + RequestResponsePact pact = ConsumerPactBuilder + .consumer(CONSUMER) + .hasPactWith(PROVIDER) + .uponReceiving("a request for user 1") + .path("/users/1") + .method("GET") + .willRespondWith() + .status(200) + .headers(Map.of("Content-Type", "application/json")) + .body(new PactDslJsonBody() + .integerType("id", 1) + .stringType("name", "Rahul Mishra") + .stringType("email", "rahul@example.com") + .stringType("job", "SDET")) + .toPact(); + + MockProviderConfig config = MockProviderConfig.createDefault(PactSpecVersion.V3); + AtomicReference received = new AtomicReference<>(); + + PactVerificationResult verification = runConsumerTest(pact, config, (mockServer, context) -> { + ApiClient client = new ApiClient(mockServer.getUrl()); + Response response = client.get("/users/1"); + Assert.assertEquals(response.statusCode(), 200, "Mock server should honour the contract"); + User user = response.as(User.class); + received.set(user); + return null; + }); + + // The interaction was matched by the Pact mock server. + Assert.assertTrue(verification instanceof PactVerificationResult.Ok, + "Pact verification should be Ok but was: " + verification); + + // The response mapped cleanly onto the framework POJO. + User user = received.get(); + Assert.assertNotNull(user, "User should have been deserialized from the mock response"); + Assert.assertEquals(user.getId(), 1); + Assert.assertEquals(user.getName(), "Rahul Mishra"); + Assert.assertEquals(user.getEmail(), "rahul@example.com"); + Assert.assertEquals(user.getJob(), "SDET"); + + // Persist the contract for the provider side / broker (V3 model = RequestResponsePact). + // runConsumerTest already writes it on success; this makes the location explicit and + // is a no-op merge since both sides are the same V3 pact. + pact.write(outputDir, PactSpecVersion.V3); + + Assert.assertTrue(Files.exists(pactFile), + "Pact file should be written to " + pactFile.toAbsolutePath()); + log.info("Pact file written: {}", pactFile.toAbsolutePath()); + } +} diff --git a/src/test/java/ra/hul/tests/contract/ProviderContractVerificationTest.java b/src/test/java/ra/hul/tests/contract/ProviderContractVerificationTest.java new file mode 100644 index 0000000..fccd66e --- /dev/null +++ b/src/test/java/ra/hul/tests/contract/ProviderContractVerificationTest.java @@ -0,0 +1,143 @@ +package ra.hul.tests.contract; + +import au.com.dius.pact.consumer.ConsumerPactBuilder; +import au.com.dius.pact.consumer.dsl.PactDslJsonBody; +import au.com.dius.pact.core.model.DefaultPactReader; +import au.com.dius.pact.core.model.Interaction; +import au.com.dius.pact.core.model.Pact; +import au.com.dius.pact.core.model.PactSpecVersion; +import au.com.dius.pact.core.model.RequestResponseInteraction; +import au.com.dius.pact.core.model.RequestResponsePact; +import com.sun.net.httpserver.HttpServer; +import io.qameta.allure.*; +import io.restassured.response.Response; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; +import ra.hul.framework.api.client.ApiClient; +import ra.hul.framework.api.models.User; +import ra.hul.framework.core.config.ConfigManager; + +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * Lightweight, self-contained Pact provider verification. + * + *

Stands up a tiny embedded provider ({@link HttpServer} from the JDK) that serves the + * contracted {@code GET /users/1} response, then loads the pact written to {@code pact.output.dir} + * and replays every interaction against the embedded provider, asserting the real response + * satisfies the contract (status + body deserializes to {@link User}). Fully offline.

+ * + *

If the consumer pact has not been written yet (e.g. this class runs first), it is regenerated + * so the test is independent.

+ */ +@Epic("API Automation") +@Feature("Contract Testing (Pact JVM)") +public class ProviderContractVerificationTest { + + private static final Logger log = LogManager.getLogger(ProviderContractVerificationTest.class); + + private static final String CONSUMER = "FrameworkClient"; + private static final String PROVIDER = "UserService"; + private static final String PROVIDER_BODY = + "{\"id\":1,\"name\":\"Rahul Mishra\",\"email\":\"rahul@example.com\",\"job\":\"SDET\"}"; + + private HttpServer server; + private String baseUrl; + + @BeforeClass(alwaysRun = true) + public void startProvider() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/users/1", exchange -> { + byte[] payload = PROVIDER_BODY.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, payload.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(payload); + } + }); + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + log.info("Embedded provider started at {}", baseUrl); + } + + @AfterClass(alwaysRun = true) + public void stopProvider() { + if (server != null) { + server.stop(0); + } + } + + @Test(groups = {"regression"}, + description = "Embedded provider satisfies every interaction in the consumer pact") + @Severity(SeverityLevel.NORMAL) + @Story("Provider verification against target/pacts") + public void contract_embeddedProvider_shouldSatisfyPact() throws Exception { + Path pactFile = ensurePactFile(); + Pact pact = DefaultPactReader.INSTANCE.loadPact(pactFile.toFile()); + + List interactions = pact.getInteractions(); + Assert.assertFalse(interactions.isEmpty(), "Pact should contain at least one interaction"); + + ApiClient client = new ApiClient(baseUrl); + int verified = 0; + for (Interaction interaction : interactions) { + Assert.assertTrue(interaction instanceof RequestResponseInteraction, + "Expected a request/response interaction"); + RequestResponseInteraction rr = (RequestResponseInteraction) interaction; + + String method = rr.getRequest().getMethod(); + String path = rr.getRequest().getPath(); + int expectedStatus = rr.getResponse().getStatus(); + + Assert.assertEquals(method, "GET", "This demo only replays GET interactions"); + Response response = client.get(path); + + Assert.assertEquals(response.statusCode(), expectedStatus, + "Provider status for " + method + " " + path + " must match the contract"); + + User user = response.as(User.class); + Assert.assertEquals(user.getId(), 1); + Assert.assertNotNull(user.getName(), "Contract requires a name field"); + Assert.assertNotNull(user.getEmail(), "Contract requires an email field"); + Assert.assertNotNull(user.getJob(), "Contract requires a job field"); + verified++; + } + log.info("Provider verification passed for {} interaction(s) from {}", verified, pactFile); + } + + /** Load the consumer pact from the output dir, regenerating it if this class runs first. */ + private Path ensurePactFile() { + String outputDir = ConfigManager.getOrDefault("pact.output.dir", "target/pacts"); + Path pactFile = Path.of(outputDir, CONSUMER + "-" + PROVIDER + ".json"); + if (!Files.exists(pactFile)) { + log.info("Pact file {} not found — regenerating from the DSL", pactFile); + RequestResponsePact pact = ConsumerPactBuilder + .consumer(CONSUMER) + .hasPactWith(PROVIDER) + .uponReceiving("a request for user 1") + .path("/users/1") + .method("GET") + .willRespondWith() + .status(200) + .headers(Map.of("Content-Type", "application/json")) + .body(new PactDslJsonBody() + .integerType("id", 1) + .stringType("name", "Rahul Mishra") + .stringType("email", "rahul@example.com") + .stringType("job", "SDET")) + .toPact(); + pact.write(outputDir, PactSpecVersion.V3); + } + return pactFile; + } +} diff --git a/src/test/java/ra/hul/tests/data/DataFactoryTest.java b/src/test/java/ra/hul/tests/data/DataFactoryTest.java new file mode 100644 index 0000000..a2219fb --- /dev/null +++ b/src/test/java/ra/hul/tests/data/DataFactoryTest.java @@ -0,0 +1,114 @@ +package ra.hul.tests.data; + +import io.qameta.allure.*; +import net.datafaker.Faker; +import org.testng.Assert; +import org.testng.annotations.Test; +import ra.hul.framework.api.models.PostPayload; +import ra.hul.framework.api.models.User; +import ra.hul.framework.data.CredentialFactory; +import ra.hul.framework.data.Credentials; +import ra.hul.framework.data.FakerProvider; +import ra.hul.framework.data.PostPayloadFactory; +import ra.hul.framework.data.UserFactory; + +/** + * Verifies the datafaker-backed test-data factories: deterministic reproducibility under a fixed + * seed, independence across seeds, and that per-field overrides win over generated values. + * No browser or network required. + */ +@Epic("Test Data Management") +@Feature("Datafaker Factories") +public class DataFactoryTest { + + @Test(groups = {"regression"}, + description = "Same seed produces identical User data (reproducible)") + @Severity(SeverityLevel.CRITICAL) + @Story("Seeded reproducibility") + public void userFactory_sameSeed_shouldProduceIdenticalData() { + User first = UserFactory.newUser(FakerProvider.seeded(42)).build(); + User second = UserFactory.newUser(FakerProvider.seeded(42)).build(); + + Assert.assertEquals(second, first, "Same seed must yield identical User objects"); + } + + @Test(groups = {"regression"}, + description = "Config-seeded factory is reproducible across calls") + @Severity(SeverityLevel.NORMAL) + @Story("Seeded reproducibility") + public void userFactory_configSeed_shouldBeReproducible() { + User first = UserFactory.newUser().build(); + User second = UserFactory.newUser().build(); + + Assert.assertEquals(second, first, "Config-seeded factory must be reproducible"); + Assert.assertNotNull(first.getName()); + Assert.assertTrue(first.getEmail().contains("@"), "Generated email should look like an email"); + } + + @Test(groups = {"regression"}, + description = "Different seeds produce different User data") + @Severity(SeverityLevel.NORMAL) + @Story("Seed independence") + public void userFactory_differentSeeds_shouldProduceDifferentData() { + User a = UserFactory.newUser(FakerProvider.seeded(1)).build(); + User b = UserFactory.newUser(FakerProvider.seeded(999)).build(); + + Assert.assertNotEquals(b, a, "Different seeds should yield different User objects"); + } + + @Test(groups = {"regression"}, + description = "Field overrides win over generated values") + @Severity(SeverityLevel.CRITICAL) + @Story("Overrides") + public void userFactory_overrides_shouldWin() { + User user = UserFactory.newUser(FakerProvider.seeded(42)) + .withId(7) + .withName("Rahul Mishra") + .withEmail("rahul@example.com") + .withJob("Principal SDET") + .build(); + + Assert.assertEquals(user.getId(), 7); + Assert.assertEquals(user.getName(), "Rahul Mishra"); + Assert.assertEquals(user.getEmail(), "rahul@example.com"); + Assert.assertEquals(user.getJob(), "Principal SDET"); + } + + @Test(groups = {"regression"}, + description = "PostPayload factory is seed-reproducible and honours overrides") + @Severity(SeverityLevel.NORMAL) + @Story("PostPayload factory") + public void postPayloadFactory_seedAndOverride_shouldBehave() { + PostPayload first = PostPayloadFactory.newPost(FakerProvider.seeded(7)).build(); + PostPayload second = PostPayloadFactory.newPost(FakerProvider.seeded(7)).build(); + Assert.assertEquals(second, first, "Same seed must yield identical PostPayload"); + + PostPayload overridden = PostPayloadFactory.newPost(FakerProvider.seeded(7)) + .withUserId(123) + .withTitle("Fixed Title") + .build(); + Assert.assertEquals(overridden.getUserId(), 123); + Assert.assertEquals(overridden.getTitle(), "Fixed Title"); + Assert.assertNotNull(overridden.getBody(), "Non-overridden body should still be generated"); + } + + @Test(groups = {"regression"}, + description = "Credential factory is seed-reproducible and honours overrides") + @Severity(SeverityLevel.NORMAL) + @Story("Credential factory") + public void credentialFactory_seedAndOverride_shouldBehave() { + Faker faker = FakerProvider.seeded(100); + Credentials generated = CredentialFactory.newCredentials(FakerProvider.seeded(100)).build(); + Credentials again = CredentialFactory.newCredentials(FakerProvider.seeded(100)).build(); + Assert.assertEquals(again, generated, "Same seed must yield identical Credentials"); + Assert.assertNotNull(generated.getUsername()); + + Credentials custom = CredentialFactory.newCredentials(faker) + .withUsername("qa_bot") + .withPassword("Sup3rSecret!") + .build(); + Assert.assertEquals(custom.getUsername(), "qa_bot"); + Assert.assertEquals(custom.getPassword(), "Sup3rSecret!"); + Assert.assertNotNull(custom.getEmail(), "Non-overridden email should still be generated"); + } +} diff --git a/src/test/java/ra/hul/tests/visual/VisualRegressionTest.java b/src/test/java/ra/hul/tests/visual/VisualRegressionTest.java new file mode 100644 index 0000000..d92337d --- /dev/null +++ b/src/test/java/ra/hul/tests/visual/VisualRegressionTest.java @@ -0,0 +1,76 @@ +package ra.hul.tests.visual; + +import io.qameta.allure.*; +import org.openqa.selenium.WebDriver; +import org.testng.Assert; +import org.testng.annotations.Test; +import ra.hul.framework.web.driver.DriverManager; +import ra.hul.framework.web.utils.VisualRegressionUtils; +import ra.hul.framework.web.utils.VisualRegressionUtils.VisualComparisonResult; +import ra.hul.tests.base.BaseWebTest; + +/** + * Demonstrates the homegrown, offline visual regression capability. + * + *

On the very first run (no committed baseline yet), the util generates the baseline PNG under + * {@code src/test/resources/visual/baseline/} and reports {@code baselineCreated=true} — that run + * is treated as a pass. Subsequent runs pixel-diff the live render against the committed baseline.

+ * + *

Runs single-threaded with explicit priority so the baseline is guaranteed to exist before the + * negative demo compares a deliberately modified page against it.

+ */ +@Epic("Web Automation") +@Feature("Visual Regression") +public class VisualRegressionTest extends BaseWebTest { + + private static final String BASELINE_NAME = "visual-sample"; + + private static WebDriver driver() { + return DriverManager.getDriver(); + } + + private static String pageUrl(String resource) { + return VisualRegressionTest.class.getClassLoader().getResource("pages/" + resource).toString(); + } + + @Test(priority = 1, groups = {"regression"}, + description = "Deterministic page matches its committed visual baseline") + @Severity(SeverityLevel.NORMAL) + @Story("Baseline match") + public void visual_deterministicPage_shouldMatchBaseline() { + driver().get(pageUrl("visual-sample.html")); + + VisualComparisonResult result = VisualRegressionUtils.compare(BASELINE_NAME); + log.info(result.summary()); + + if (result.isBaselineCreated()) { + // First run in a fresh checkout: baseline was just generated — nothing to compare against. + Assert.assertTrue(result.isMatch(), + "Baseline creation run should pass: " + result.summary()); + } else { + Assert.assertTrue(result.isMatch(), + "Live render drifted from committed baseline beyond threshold: " + result.summary()); + } + } + + @Test(priority = 2, groups = {"regression"}, + description = "A deliberately modified page is detected as a visual mismatch") + @Severity(SeverityLevel.MINOR) + @Story("Negative demo — change detected") + public void visual_modifiedPage_shouldBeDetectedAsMismatch() { + // Ensure the baseline exists (generate from the good page if a fresh checkout). + driver().get(pageUrl("visual-sample.html")); + VisualComparisonResult ensured = VisualRegressionUtils.compare(BASELINE_NAME); + log.info("Baseline ensured: {}", ensured.summary()); + + // Now render the modified page and compare against the SAME baseline — expect a mismatch. + driver().get(pageUrl("visual-sample-modified.html")); + VisualComparisonResult result = VisualRegressionUtils.compare(BASELINE_NAME); + log.info("Modified comparison: {}", result.summary()); + + Assert.assertFalse(result.isMatch(), + "Modified page should NOT match the baseline: " + result.summary()); + Assert.assertTrue(result.getDiffRatio() > result.getThreshold(), + "Diff ratio should exceed the configured threshold: " + result.summary()); + } +} diff --git a/src/test/resources/a11y-tests.xml b/src/test/resources/a11y-tests.xml new file mode 100644 index 0000000..1d5d9a3 --- /dev/null +++ b/src/test/resources/a11y-tests.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/test/resources/all-tests.xml b/src/test/resources/all-tests.xml index 9e0fbc0..5858cac 100644 --- a/src/test/resources/all-tests.xml +++ b/src/test/resources/all-tests.xml @@ -8,6 +8,9 @@ + + + diff --git a/src/test/resources/config.properties b/src/test/resources/config.properties index 88d065e..814f5c1 100644 --- a/src/test/resources/config.properties +++ b/src/test/resources/config.properties @@ -47,3 +47,28 @@ screenshot.on.failure=true # Download directory (for file download tests) download.dir=target/downloads + +# ================================================== +# Maturity Capabilities +# ================================================== + +# Visual Regression (homegrown offline pixel-diff) +visual.baseline.dir=src/test/resources/visual/baseline +visual.output.dir=target/visual +# Per-pixel colour tolerance (0-255): channel deltas <= this are treated as equal +visual.pixel.tolerance=20 +# Max fraction of mismatching pixels allowed before a comparison fails (0.0 - 1.0) +visual.diff.threshold=0.01 +# When true, baselines are (re)written instead of compared/failing +visual.update.baselines=false + +# Accessibility (axe-core) +a11y.tags=wcag2a,wcag2aa +a11y.fail.on.violation=true + +# Test Data (datafaker) — deterministic via seed +data.faker.seed=1337 +data.faker.locale=en + +# Contract testing (Pact JVM) +pact.output.dir=target/pacts diff --git a/src/test/resources/contract-tests.xml b/src/test/resources/contract-tests.xml new file mode 100644 index 0000000..9bd312c --- /dev/null +++ b/src/test/resources/contract-tests.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/test/resources/pages/a11y-sample.html b/src/test/resources/pages/a11y-sample.html new file mode 100644 index 0000000..68117e3 --- /dev/null +++ b/src/test/resources/pages/a11y-sample.html @@ -0,0 +1,35 @@ + + + + + Accessibility Sample + + + + +
+

Accessibility Demo

+ + + + + + + + +

This grey text on a white background fails contrast requirements.

+
+ + +
+ + +

This black text on white passes contrast requirements.

+
+ + diff --git a/src/test/resources/pages/visual-sample-modified.html b/src/test/resources/pages/visual-sample-modified.html new file mode 100644 index 0000000..3423280 --- /dev/null +++ b/src/test/resources/pages/visual-sample-modified.html @@ -0,0 +1,48 @@ + + + + + Visual Regression Sample (Modified) + + + +
+ +
+
Card Component
+
+
+
+
+
+
+
+
+ +
+ + diff --git a/src/test/resources/pages/visual-sample.html b/src/test/resources/pages/visual-sample.html new file mode 100644 index 0000000..ed423d3 --- /dev/null +++ b/src/test/resources/pages/visual-sample.html @@ -0,0 +1,48 @@ + + + + + Visual Regression Sample + + + +
+ +
+
Card Component
+
+
+
+
+
+
+
+
+ +
+ + diff --git a/src/test/resources/visual-tests.xml b/src/test/resources/visual-tests.xml new file mode 100644 index 0000000..9d8d6fd --- /dev/null +++ b/src/test/resources/visual-tests.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/test/resources/visual/baseline/visual-sample.png b/src/test/resources/visual/baseline/visual-sample.png new file mode 100644 index 0000000000000000000000000000000000000000..4d9c08bc83b2560737a548de7ca5acd96f04d302 GIT binary patch literal 14505 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 N&1r{Ig(tkD{sa3<(wqPQ literal 0 HcmV?d00001