A visual assertion is often added in five lines and becomes flaky a week later. The problem is rarely the comparison method. The page was captured with a different font, live price, animation frame, cookie state or browser build. Visual testing makes those hidden variables visible, which is useful only if the test owns them deliberately.
This workflow starts with one pricing page and expands only after its failures are understandable. It also shows when to keep review inside the repository and when to send a public or scheduled page into RenderLog so a non-engineering owner can review the same kind of change without opening CI artifacts.
Pin the browser state before creating the first baseline
Use named Playwright projects instead of relying on the developer's current browser. Set viewport through a device profile or explicit dimensions. Pin locale, timezone and color scheme. Install the browser and fonts in CI from the same lockfile and image used to approve the baseline. A baseline created on macOS should not quietly become the expected image for a Linux runner with different font rasterization.
Decide where baseline files live. Repository storage makes changes reviewable in a pull request and works well for a modest suite. A separate artifact store may fit a large or generated matrix, but it needs versioning and access rules. In either case, the test must retrieve the exact baseline associated with its browser project rather than whichever file was uploaded most recently.
- Use one named browser project per meaningful rendering target.
- Keep browser and font installation reproducible in CI.
- Store baselines by project, viewport and expected state.
- Do not share one baseline across browsers that render differently.
Make readiness visible in the page
`networkidle` is not a guarantee that the page is ready. A client timer can update the DOM after the network settles and a font can swap without a new request that matters to the test. Wait for the heading, plan cards or a dedicated application state that a user could recognize. If the product exposes a test-only ready marker, keep it narrow and make sure it represents completed rendering rather than merely mounted JavaScript.
Avoid fixed sleeps. A two-second delay is slow when the page is ready in 200 milliseconds and unreliable when CI needs 2.2 seconds. Web-first assertions retry until the state is visible and fail with a reason connected to the page. The visual assertion should happen after those checks, so a missing card reports as a missing card before it becomes a mysterious full-page diff.
- Wait for a heading, stable card set or explicit ready marker.
- Assert critical text or state before taking the screenshot.
- Disable animations through Playwright and product test settings.
- Treat a readiness timeout as a run failure, not a visual change.
Control data without removing the behavior under test
Mock a pricing response when the test is about layout and the live price changes independently. Do not mock it when the purpose is to detect a wrong production price. Seed an account when the authenticated state matters. Freeze the clock when a relative date is incidental, but leave the real date visible when a release banner is the thing being checked.
The rule is simple: stabilize inputs that are outside the decision and preserve inputs that are part of the decision. Document the boundary in the test name and comments. A reviewer should know whether `pricing.png` proves the live production price, a component layout using fixture data or only the browser rendering of a static route.
- Mock third-party or live data only when its value is not under review.
- Use fixture names that reveal what the screenshot does and does not prove.
- Mask unrelated widgets narrowly instead of hiding large regions.
- Keep authentication and consent state explicit.
Set a threshold after looking at real failures
Start with Playwright's strict comparison on the pinned runner. Run the same test several times without changing the page. If it fails, inspect the pixels before changing `maxDiffPixels` or `maxDiffPixelRatio`. Font edges point to an environment problem. A blinking caret or transition points to a page-state problem. A large moving block points to unstable data or an element that needs its own check.
Use the smallest tolerance that absorbs known rendering variation. Keep threshold changes in code review and explain the example that required them. A ratio that is harmless for a full-page capture may erase a button-sized regression. Consider an element screenshot for the critical component while keeping a broader page capture for layout context.
- Repeat unchanged tests before choosing a tolerance.
- Fix deterministic causes before accepting pixel noise.
- Review threshold changes like production-code changes.
- Use focused screenshots for small high-risk components.
Keep every failure artifact, even when CI stops the merge
A CI failure without `actual`, `expected` and `diff` images forces the reviewer to reproduce the run locally. Upload the Playwright report and `test-results` directory on both success and failure, with a retention period long enough for the pull request to be reviewed. Include the project name and test title in the artifact path so mobile and desktop results are not confused.
The report should remain evidence, not the only record of an accepted change. When a new baseline is approved, update it in a dedicated commit that contains no unrelated product changes. The diff image explains why the test failed and the baseline change records what the team accepted. Keeping those decisions separate makes later regressions much easier to trace.
- Upload the HTML report and raw image artifacts on failure.
- Name artifacts by test and browser project.
- Approve baseline updates in a focused commit.
- Never run a blanket update command without reviewing the changed pages.
Move review out of CI only when ownership actually leaves engineering
Playwright is the right place when the test uses repository fixtures, blocks a merge and is maintained by the engineers who own the page. Keep it there. Adding another service to the same state creates duplicate baselines and two places to approve the same change.
A shared page check becomes useful when marketing owns the pricing page, an agency reviews client sites, operations watches a portal or a scheduled production check must continue between releases. RenderLog can keep the approved baseline, repeat the page run and show current, baseline and diff to those reviewers. Link the CI test and shared check only when they cover different ownership or timing.
- Repository check: merge gate, fixtures and engineering ownership.
- Shared check: scheduled production page and cross-team review.
- Capture API: one-off output with review handled by the caller.
- One page state should have one named approval path.
A practical Playwright failure triage
Do not update the baseline until the failure category is clear. The same red test can represent a broken page, a different environment or an intended design change.
| What you see | Likely cause | Check next | Action |
|---|---|---|---|
| Text edges differ everywhere | Browser or font mismatch | CI image, browser version and installed fonts | Fix the runner; keep the baseline |
| One live widget moves | Unstable unrelated region | Data, animation and widget timing | Stabilize or narrowly mask it |
| A card or control disappears | Product regression or incomplete state | Readiness assertion and browser logs | Investigate; do not update the baseline |
| Reviewed redesign changes the layout | Expected product change | Design and product approval | Promote the reviewed result |
A stable starting configuration
The example pins the main rendering inputs, waits for a meaningful page state and masks one unrelated widget. Replace the fixture and readiness condition with facts from your product rather than copying the names literally.
playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/visual",
expect: {
toHaveScreenshot: {
animations: "disabled",
caret: "hide",
maxDiffPixelRatio: 0.001
}
},
use: {
locale: "en-US",
timezoneId: "UTC",
colorScheme: "light"
},
projects: [
{ name: "desktop-chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "mobile-chromium", use: { ...devices["Pixel 7"] } }
]
});tests/visual/pricing.spec.ts
import { test, expect } from "@playwright/test";
test("pricing page", async ({ page }) => {
await page.route("**/api/prices", async route => {
await route.fulfill({ json: { plan: "Pro", price: 49 } });
});
await page.goto("https://example.com/pricing", { waitUntil: "networkidle" });
await page.getByRole("heading", { name: "Pricing" }).waitFor();
await expect(page).toHaveScreenshot("pricing.png", {
fullPage: true,
mask: [page.getByTestId("live-chat-launcher")]
});
});CI artifact handoff
npx playwright test --project=desktop-chromium
# Upload test-results/ and playwright-report/ even when the job fails.
# Send the public page to RenderLog when review must continue outside the repository.Primary implementation references
Use Playwright's current documentation for option details. The RenderLog links cover the separate shared-review path described above.
Related RenderLog guidance
Visual regression testing guide
Choose baselines, thresholds and review ownership before scaling the suite.
Visual regression tools
Compare repository, component and shared page-review approaches.
API documentation
Start a run, poll its state and retrieve the result artifact.
Baseline review in RenderLog
See how an approved result becomes the next baseline without losing history.
Keep the merge gate in Playwright and share the production review
Use RenderLog for the public or scheduled page state that product, marketing or a client must review. Keep deep fixture-driven tests in the repository instead of duplicating them.
Create a RenderLog workspace