{"title":"Playwright Test Architecture: Start with Risk, Not Pages","excerpt":"Page objects make every screen equally easy to test, and equal ease produces equal coverage of things with wildly unequal consequences. This shows how to score capabilities by impact multiplied by likelihood, then encode that ranking in Playwright projects, tags, boxed steps and per-tier retries so the risk model actually changes what runs.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/playwright-test-architecture-risk-not-page-objects","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["playwright","test-architecture","risk-based-testing","page-object-model","test-strategy"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-08-30T15:59:03.602Z","dateModified":"2026-09-04T04:18:37.074Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v2/playwright-test-architecture-risk-not-page-objects.webp","alt":"Abstract diagram of a risk grid in which a few cells are highlighted and a single test spine branches only to those high-risk cells, rather than spreading evenly across every page."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> The Page Object Model is a code-organisation tactic for deduplicating locators. It is not a test architecture, and Playwright's docs never claim it is. Architecture is the decision about <em>which failures you are willing to ship</em>. Structure the suite around business capabilities, score each by impact multiplied by likelihood, then let Playwright's projects, tags and fixtures make that score executable at the command line.</div>\n\n<p>A drifted suite looks healthy from the outside. Four hundred tests, a class per page, ninety-four percent pass rate, a green badge in the README. Then production breaks: a coupon applies twice, the order total is wrong, and nobody can say whether a test covered it. Somebody checks. Eleven tests for the settings page, six for the avatar upload, two for checkout — and both of those stop at the order summary because the payment provider was \"hard to automate\".</p>\n\n<p>Nothing in that suite is badly written. Every page object is tidy. The problem is that page objects made every screen equally easy to test, and equal ease produced equal coverage of things carrying wildly unequal consequences.</p>\n\n<h2 id=\"what-the-docs-actually-claim-for-page-objects\">What the docs actually claim for page objects</h2>\n\n<p>Read the Playwright page on the pattern carefully. It says large test suites \"can be structured to optimize ease of authoring and maintenance\" and that page object models \"are one such approach\". The stated benefits are a higher-level API for authoring and locators captured in one place for maintenance. That is a claim about duplication, not about coverage.</p>\n\n<p>The pattern is also not privileged in the tooling. There is no <code>PageObject</code> base class in the library and nothing in the runner knows what a page object is; the fixtures guide simply shows them supplied as fixtures so the runner constructs them for you. It is your code, so it can be shaped by anything you like — including something other than the site map.</p>\n\n<p>Keep page objects where they earn their place: holding a locator that appears in nine tests. Stop using the file tree of page classes as the answer to \"what does this suite protect?\"</p>\n\n<h2 id=\"score-risk-before-you-write-a-locator\">Score risk before you write a locator</h2>\n\n<p>Risk is impact multiplied by likelihood, and both halves need evidence rather than a vote.</p>\n\n<p><strong>Impact</strong> answers: if this fails silently for a day, what does it cost? Use categories your business already tracks — money moved, data corrupted, users locked out, regulatory exposure. \"Users cannot pay\" and \"the avatar crops badly\" are not two points apart on a five-point scale; they are different kinds of event.</p>\n\n<p><strong>Likelihood</strong> is not a guess. Three sources you already have: change frequency (<code>git log --since=90.days --name-only</code> over the app source, not the test source), defect history in your tracker, and integration count — every third party, queue or feature flag in a path multiplies the ways it can break.</p>\n\n<table>\n<thead><tr><th scope=\"col\">Capability</th><th scope=\"col\">Impact</th><th scope=\"col\">Likelihood signal</th><th scope=\"col\">Decision</th></tr></thead>\n<tbody>\n<tr><td>Checkout with a discount code</td><td>Money moved incorrectly</td><td>18 commits/90d, 2 payment integrations</td><td>End-to-end, every commit</td></tr>\n<tr><td>Password reset by email</td><td>Users locked out</td><td>3 commits/90d, mail provider</td><td>End-to-end, nightly</td></tr>\n<tr><td>Settings page field validation</td><td>Cosmetic, self-correcting</td><td>1 commit/90d</td><td>Component/unit tests only</td></tr>\n<tr><td>Marketing footer links</td><td>Negligible</td><td>CMS-driven, changes weekly</td><td>Do not automate</td></tr>\n</tbody>\n</table>\n\n<p>The output is not a document. It is a list of named capabilities with a tier, which becomes the folder structure, the tags and the CI schedule. If the register never changes how tests run, it was theatre.</p>\n\n<h2 id=\"why-page-shaped-suites-drift\">Why page-shaped suites drift to low-value coverage</h2>\n\n<p>The drift has a mechanism, worth naming precisely because it is not laziness.</p>\n\n<p>A page object exposes a surface — every field, toggle and button on that screen becomes a method. A visible surface invites coverage; an untested method reads like a gap. So the settings page, thirty controls and no consequences, accumulates thirty tests. Checkout, three controls and all the consequences, accumulates three.</p>\n\n<p>Second, page objects are cheapest to write for self-contained screens. Checkout needs a seeded cart, a funded test account, a payment sandbox and an inventory hold; the settings page needs a login. Setup cost is inversely correlated with business risk in almost every product, so a suite following the path of least resistance ends up systematically inverted.</p>\n\n<p>Third, page-shaped assertions test the page, not the outcome. Contrast these two tests of the same flow:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Page-shaped: asserts that the checkout screen rendered.\ntest('checkout page shows totals', async ({ page }) =&gt; {\n  const checkout = new CheckoutPage(page);\n  await checkout.goto();\n  await expect(checkout.subtotal).toHaveText('$120.00');\n  await expect(checkout.taxRow).toBeVisible();\n  await expect(checkout.payButton).toBeEnabled();\n});</code></pre>\n\n<p>Every assertion above passes if the payment capture silently fails, because the page renders identically either way. The risk-shaped version asserts what the business would notice:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Risk-shaped: asserts that money moved and the order exists server-side.\ntest('discounted order is captured at the discounted amount', {\n  tag: ['@critical', '@payments'],\n  annotation: { type: 'risk', description: 'impact=revenue likelihood=high' },\n}, async ({ page, request, seededCart }) =&gt; {\n  await page.goto(`/checkout/${seededCart.id}`);\n  await page.getByLabel('Discount code').fill('SAVE25');\n  await page.getByRole('button', { name: 'Apply' }).click();\n  await expect(page.getByTestId('order-total')).toHaveText('$90.00');\n  await page.getByRole('button', { name: 'Pay now' }).click();\n  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();\n\n  // The assertion that actually protects revenue.\n  const order = await request.get(`/api/orders/${seededCart.id}`);\n  expect(order.ok()).toBeTruthy();\n  expect(await order.json()).toMatchObject({ status: 'captured', amountCents: 9000 });\n});</code></pre>\n\n<p>The <code>request</code> fixture there is the built-in <code>APIRequestContext</code>, which inherits <code>baseURL</code> and <code>extraHTTPHeaders</code> from your config — a server-side post-condition costs one line, not a second HTTP client.</p>\n\n<h2 id=\"structure-around-journeys\">Structure around journeys, and box the helpers</h2>\n\n<p>Replace \"one file per page\" with \"one file per capability, one test per risk\". A journey helper spans whatever pages the journey touches, and it is allowed to call three page objects — that is the correct dependency direction.</p>\n\n<p>Playwright's tool for making journeys readable in the report is <code>test.step()</code>. The non-obvious part is the <code>box</code> option, added in v1.39. Without it, a failure inside a shared helper points the error at a line inside the helper — the same line for every one of the forty tests that call it. With <code>{ box: true }</code>, the error points at the <em>call site</em> in the failing test.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// journeys/purchase.ts — a capability-shaped helper, not a page-shaped one.\nimport { expect, type Page } from '@playwright/test';\nimport { test } from '@playwright/test';\n\nexport async function completePurchase(\n  page: Page,\n  opts: { cartId: string; discount?: string },\n) {\n  await test.step(`Purchase cart ${opts.cartId}`, async () =&gt; {\n    await page.goto(`/checkout/${opts.cartId}`);\n    if (opts.discount) {\n      await page.getByLabel('Discount code').fill(opts.discount);\n      await page.getByRole('button', { name: 'Apply' }).click();\n    }\n    await page.getByRole('button', { name: 'Pay now' }).click();\n    await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();\n  }, { box: true, timeout: 60_000 });\n}</code></pre>\n\n<p>The step-level <code>timeout</code> option (v1.50) is the other half: a journey step that must finish in sixty seconds says so without raising the whole test timeout and hiding a slow regression elsewhere.</p>\n\n<p>Push the setup for these journeys below the UI. A fixture that seeds through the API stops a checkout test from failing because the sign-up form changed:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">import { test as base } from '@playwright/test';\n\ntype Fixtures = { seededCart: { id: string } };\n\nexport const test = base.extend&lt;Fixtures&gt;({\n  seededCart: async ({ request }, use) =&gt; {\n    const created = await request.post('/api/test/carts', {\n      data: { items: [{ sku: 'DESK-01', qty: 1, priceCents: 12000 }] },\n    });\n    const cart = await created.json();\n    await use(cart);\n    await request.delete(`/api/test/carts/${cart.id}`);\n  },\n});</code></pre>\n\n<h2 id=\"make-the-risk-model-executable\">Make the risk model executable</h2>\n\n<p>A tier that only exists in a spreadsheet decays. Encode it where the runner can act on it.</p>\n\n<p>Tags go in the test declaration or in the title with an <code>@</code> prefix, filtered with <code>--grep</code>. Two facts worth memorising: <code>--grep \"@critical|@payments\"</code> is a logical OR, and AND requires regex lookaheads — <code>--grep \"(?=.*@critical)(?=.*@payments)\"</code>. There is no dedicated AND syntax.</p>\n\n<p>Projects bind a tier to an execution policy, and <code>dependencies</code> guarantees ordering:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// playwright.config.ts — tiers as projects, not as folders alone.\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n  fullyParallel: true,\n  projects: [\n    { name: 'setup', testMatch: /.*\\.setup\\.ts/, teardown: 'cleanup' },\n    { name: 'cleanup', testMatch: /global\\.teardown\\.ts/ },\n    {\n      name: 'critical',            // runs on every push\n      grep: /@critical/,\n      retries: 0,                  // a retry here hides a real defect\n      use: { trace: 'on' },\n      dependencies: ['setup'],\n    },\n    {\n      name: 'extended',            // runs nightly\n      grepInvert: /@critical/,\n      retries: 2,\n      use: { trace: 'on-first-retry' },\n      dependencies: ['setup'],\n    },\n  ],\n});</code></pre>\n\n<p>Three details there are load-bearing. <code>teardown</code> on the setup project runs after all dependent projects finish, so cleanup needs no separate CI step. Filtering with <code>--grep</code> or <code>--shard</code> selects only the primary tests, but dependency projects still run — pass <code>--no-deps</code> to skip them. And <code>retries: 0</code> on the critical project is the point of the tier: a flaky pass on a revenue path is indistinguishable from a real intermittent defect.</p>\n\n<p>Set <code>fullyParallel: true</code> when you shard. Without it Playwright shards at file granularity, so one fat capability file lands entirely in one shard and the tier is as slow as its largest file.</p>\n\n<h2 id=\"the-pyramid-trade-off\">The pyramid trade-off, stated honestly</h2>\n\n<p>The pyramid is not a rule about ratios. It is a statement that a test should run at the lowest level that can still observe the failure you care about.</p>\n\n<p>Field validation on a form is observable in a component test. \"The discount is applied before tax, and the captured amount matches\" is not — it spans the browser, the pricing service and the payment provider, and only an end-to-end test sees the composition. Push the case down when the failure is local; keep it up when the failure lives in a seam between systems.</p>\n\n<p>The costliest seam is the third party you do not control. Playwright's best-practices guide is explicit: do not test third-party dependencies, and use routing to guarantee the response. That is a risk decision, not just a speed one — a test failing because of someone else's cookie banner trains the team to ignore red.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">test('order survives a slow fraud-check response', async ({ page }) =&gt; {\n  await page.route('**/fraud-check/v2/score', async route =&gt; {\n    await new Promise(r =&gt; setTimeout(r, 3_000));\n    await route.fulfill({\n      status: 200,\n      contentType: 'application/json',\n      body: JSON.stringify({ decision: 'review', score: 71 }),\n    });\n  });\n  await page.goto('/checkout/seed-1');\n  await page.getByRole('button', { name: 'Pay now' }).click();\n  await expect(page.getByText('We are reviewing your order')).toBeVisible();\n});</code></pre>\n\n<p>That buys a deterministic test of <em>your</em> behaviour in a state the real provider produces rarely and on its own schedule — a state previously untested precisely because it was unreachable through the UI.</p>\n\n<h2 id=\"deciding-what-not-to-automate\">Deciding what not to automate</h2>\n\n<p>The hardest architectural decision is subtraction. Four rules that hold up:</p>\n\n<ul>\n<li><strong>The outcome is not machine-observable.</strong> \"The page looks right\" belongs to a human or a dedicated visual tool, not to an assertion.</li>\n<li><strong>The failure costs less than the test.</strong> A footer link that changes weekly generates more maintenance hours per year than the outage it prevents.</li>\n<li><strong>You cannot control the data.</strong> A test against a shared environment whose rows change under you is a flake generator with a name.</li>\n<li><strong>The check is already made upstream.</strong> If a type, a database constraint or a contract test blocks the failure, the end-to-end test only reports on the guard.</li>\n</ul>\n\n<p>When you retire a test, delete it. If it must stay visible, use the annotation that describes reality: <code>test.fixme()</code> marks it failing <em>and does not run it</em>, which is what you want when the test is slow or crashes; <code>test.fail()</code> runs it and complains if it unexpectedly passes; <code>test.skip()</code> means \"not applicable in this configuration\". A commented-out block means nothing.</p>\n\n<div class=\"callout callout-warning\"><strong>A trap worth naming:</strong> <code>--only-changed [ref]</code> selects test files changed between <code>HEAD</code> and the ref. It does not compute which tests exercise changed application code — Playwright has no such dependency graph. Using it as your risk-based selection strategy means a pure application change runs nothing.</div>\n\n<h2 id=\"failure-modes\">Failure modes, with distinct causes</h2>\n\n<p><strong>The register that never re-scores.</strong> Tiers assigned once at kick-off go stale the first time a service is rewritten, because scoring lives outside the workflow. Re-derive likelihood from <code>git log</code> on the application source each quarter and diff it against the current tags; anything with commits and no <code>@critical</code> tag is a promotion candidate.</p>\n\n<p><strong>Tier inflation.</strong> Every team wants its area in the critical project, so within a year that tier is the whole suite and fast feedback is gone. The cause is a tier with no budget. Cap the critical project by wall-clock time — if it exceeds ten minutes, something is demoted before anything is added.</p>\n\n<p><strong>Retries laundering real defects.</strong> A critical capability configured with <code>retries: 2</code> reports intermittent revenue bugs as green. The cause is a global retry setting applied to a tier that should not have one. Use per-project retries, and turn on <code>--fail-on-flaky-tests</code> in the critical job so a test that only passes on retry fails the build.</p>\n\n<p><strong>Journeys collapsed into one giant test.</strong> Reorganising by journey tempts people to write a single test that signs up, browses, buys and refunds. When it fails at step nine you learn nothing about steps one to eight, and the whole thing reruns. The cause is confusing \"structure by journey\" with \"one test per journey\". One test per <em>risk</em>: capture amount and refund amount are two risks, so two tests, even though both traverse checkout.</p>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Take one hour today. Run <code>npx playwright test --list</code> and paste the output into a sheet next to a second column: the business consequence of that test failing to catch a defect. Any test whose consequence column reads \"none that I can name\" is a deletion candidate.</p>\n\n<p>Then run <code>git log --since=90.days --name-only --pretty=format:</code> over your application source, count changes per directory, and check whether the three most-changed areas have a test tagged <code>@critical</code>. Capture two numbers: tests you could not justify, and high-change areas with no critical-tier coverage. That is the argument for the restructure, and it is more persuasive than any diagram.</p>\n\n<h2 id=\"faq\">FAQ</h2>\n\n<p><strong>Does this mean I should delete my page objects?</strong> No. Keep them as locator repositories consumed by journey helpers. What changes is that no page object gets a test file named after it, and the count of methods on a class stops being a coverage target.</p>\n\n<p><strong>How do I score risk when I have no defect history?</strong> Use change frequency and integration count alone; both are available on day one. Add defect history as it accumulates. Do not delay the structure waiting for perfect data — the ranking is what matters, not the absolute score.</p>\n\n<p><strong>Where do exploratory findings fit?</strong> A bug found by hand is direct evidence that a capability's likelihood is higher than you scored it. Re-score first, then decide whether the specific regression test is worth writing; often the correct response is a new critical-tier journey rather than a narrow test for that one bug.</p>\n\n<p><strong>Should tiers be separate projects or separate config files?</strong> Projects. One config keeps the shared setup dependency, one report, and one <code>--project critical</code> invocation in CI. Separate config files duplicate the setup project and drift apart.</p>\n\n<h2 id=\"references\">References</h2>\n\n<ul>\n<li><a href=\"https://playwright.dev/docs/pom\">Page object models</a> — states the pattern's scope: ease of authoring and maintenance, \"one such approach\" to structuring a suite.</li>\n<li><a href=\"https://playwright.dev/docs/best-practices\">Best Practices</a> — test user-visible behaviour, avoid testing third-party dependencies, control your data.</li>\n<li><a href=\"https://playwright.dev/docs/test-projects\">Projects</a> — dependencies, <code>teardown</code>, per-project retries, and the rule that filtering still runs dependencies unless <code>--no-deps</code> is passed.</li>\n<li><a href=\"https://playwright.dev/docs/test-annotations\">Annotations</a> — tag syntax, <code>--grep</code> OR versus lookahead AND, and the semantics of <code>skip</code>, <code>fail</code> and <code>fixme</code>.</li>\n<li><a href=\"https://playwright.dev/docs/api/class-test\">Test API</a> — <code>test.step()</code> with the <code>box</code> (v1.39) and <code>timeout</code> (v1.50) options.</li>\n<li><a href=\"https://playwright.dev/docs/api-testing\">API testing</a> — the built-in <code>request</code> fixture and its inheritance of <code>baseURL</code> and <code>extraHTTPHeaders</code>.</li>\n<li><a href=\"https://playwright.dev/docs/test-sharding\">Sharding</a> — file-level versus test-level shard granularity under <code>fullyParallel</code>.</li>\n<li><a href=\"https://playwright.dev/docs/test-cli\">Command line</a> — <code>--only-changed</code>, <code>--fail-on-flaky-tests</code>, <code>--no-deps</code>, <code>--shard</code>.</li>\n</ul>\n"}