This recipe is the first true end-to-end Playwright test against QA Shop — sign in, add a product, walk the 3-step checkout wizard, and assert the order is on file.
It is the authenticated counterpart to the Add to cart and reach checkout guest recipe. That recipe stops at the auth gate; this one walks past it because the spec inherits a pre-built storageState session, so the entire flow runs without ever touching the login form in the test body.
It is the canonical recipe to write second, after the basic login flow — once you have credentials that work end-to-end, you turn them into a reusable session fixture and start writing the real e-commerce specs on top of it.
§ 01 · PREREQUISITES
Prerequisites
You need a Playwright project that already runs the login-flow-test recipe, plus a setup project that produces the storageState file this spec depends on.
Bootstrap with npm init playwright@latest if you don't already have a project. Make sure your version is 1.39+ — that is the cutoff for the dependencies: ['setup'] project pattern this recipe uses.
QA Shop ships one at tests/recipes/playwright/setup/auth.setup.ts. It signs in once and writes the session to tests/recipes/playwright/.auth/customer.json. The recipes-authenticated project block in playwright.config.ts declares dependencies: ['setup'] so the setup runs before this spec — and exactly once per worker, not per test.
Set to http://localhost:3002 for a local checkout, or https://automationtestingplatform.com for the hosted demo.
The seeded recipe-verifier@automationtestingplatform.com account ships with the QA Shop database. Locally it is provisioned by supabase/seed.sql; in CI both values live in GitHub repo secrets. The setup project — not this spec — is where they are read.
§ 02 · THE SPEC
The full spec
Save this file as tests/recipes/playwright/authenticated-checkout.spec.ts and run it with npx playwright test --project=recipes-authenticated.
// tests/recipes/playwright/authenticated-checkout.spec.ts
import { test, expect } from "@playwright/test";
const PRODUCT_SLUG = "wooden-train-set-100-piece";
const TEST_CARD_SUCCESS = "4242 4242 4242 4242";
test("authenticated customer can place an order end-to-end", async ({
page,
}) => {
// 1. Land on a PDP. The `recipes-authenticated` Playwright project loads
// storageState from tests/recipes/playwright/.auth/customer.json, so we
// arrive already logged in. Sanity-check the session by asserting the
// header user-menu — it only renders for authenticated visitors.
await page.goto(`/products/${PRODUCT_SLUG}`);
await expect(page.getByTestId("page-pdp")).toBeVisible();
await expect(page.getByTestId("header-user-menu")).toBeVisible();
// 2. Add to cart and head to checkout via the cart-summary CTA.
await page.getByTestId("pdp-add-to-cart").click();
await expect(page.getByTestId("cart-count")).toHaveText("1");
await page.goto("/cart");
await page.getByTestId("cart-summary-checkout").click();
// 3. Confirm we crossed the auth gate (storageState bypassed /auth/login)
// and landed on the checkout wizard.
await page.waitForURL(/\/checkout/);
await expect(page).toHaveURL(/\/checkout/);
await expect(page.getByTestId("page-checkout")).toBeVisible();
// 4. Step 1: shipping form. The saved-address dropdown may auto-fill if the
// seeded customer already has an address book entry; we fill defensively
// so the spec works against a fresh customer with zero saved addresses.
await expect(page.getByTestId("shipping-form")).toBeVisible();
await page.getByTestId("shipping-field-fullName").fill("Recipe Verifier");
await page.getByTestId("shipping-field-street").fill("123 Test Lane");
await page.getByTestId("shipping-field-city").fill("Testville");
await page.getByTestId("shipping-field-state").fill("NY");
await page.getByTestId("shipping-field-zip").fill("10001");
await page.getByTestId("shipping-field-phone").fill("555-0100");
// Country defaults to "US" in the form's initial state — no need to set it.
await page.getByTestId("shipping-continue-button").click();
// 5. Step 2: order review — single click forward to payment.
await expect(page.getByTestId("review-continue-button")).toBeVisible();
await page.getByTestId("review-continue-button").click();
// 6. Step 3: payment. Test card 4242 is the canonical success-path card.
// The decline card (4000 0000 0000 0002) would surface an inline error
// at data-testid="checkout-error" instead of redirecting (see Extend It).
await page
.getByTestId("payment-field-cardName")
.fill("Recipe Verifier");
await page
.getByTestId("payment-field-cardNumber")
.fill(TEST_CARD_SUCCESS);
await page.getByTestId("payment-field-expiry").fill("12/30");
await page.getByTestId("payment-field-cvv").fill("123");
await page.getByTestId("place-order-button").click();
// 7. The wizard pushes the browser to /checkout/confirmation/{orderNumber}
// on success. The order_number is an opaque public ID (NOT a UUID).
await page.waitForURL(/\/checkout\/confirmation\/[A-Z0-9-]+/);
await expect(page.getByTestId("page-order-confirmation")).toBeVisible();
await expect(page.getByTestId("confirmation-order-number")).toBeVisible();
});§ 03 · WALKTHROUGH
Walkthrough
The spec body is short for what it accomplishes — most of the complexity lives in the project configuration, not the test.
The first navigation to /products/wooden-train-set-100-piece looks ordinary, but the assertion on data-testid="header-user-menu" is the load-bearing line. The header user-menu only renders for authenticated sessions — if the storageState file failed to load or the session expired, this assertion fails fast with a clear diagnostic, and you do not waste time chasing a stale-cart or wizard-state red herring three steps later.
The add-to-cart click and cart navigation mirror the add-to-cart-checkout guest recipe exactly — that is intentional. The contract on data-testid="pdp-add-to-cart", data-testid="cart-count", and data-testid="cart-summary-checkout" is identical for guest and authenticated traffic; the only difference is what the checkout button click resolves to. For a guest, it is /auth/login?redirect=%2Fcheckout; for the authenticated session loaded from storageState, it is /checkout directly. The page.waitForURL(/\/checkout/) line is what proves the session crossed the gate.
The shipping form fills are defensive on purpose. The seeded recipe-verifier customer ships with no addresses on file by default, so the saved-address dropdown does not appear and the form starts empty. If you reseed and add a row to the addresses table for the verifier, the dropdown appears and the form auto-fills — at which point the explicit .fill(...) calls overwrite the auto-fill, which is also fine. Writing the spec to work both ways means the test does not flake when the seed evolves.
The wizard is a 3-step state machine: step 1 is shipping (ShippingForm), step 2 is review (OrderReview), step 3 is payment (PaymentForm)1. The visible step header pills include "delivery" between shipping and review, but that is a visual sub-view of step 1 and not a separate state — there is no separate testid contract to satisfy for it. The three submit testids — shipping-continue-button, review-continue-button, place-order-button — drive the entire flow.
The payment fill uses the test card 4242 4242 4242 4242 because the Stripe-compatible processor wired into the local stack accepts it as an immediate success authorization. The card is space-separated in the spec for readability; the payment-field-cardNumber input strips non-digits via its onChange handler, so the formatting is cosmetic. The expiry 12/30 and CVV 123 are equally arbitrary — the schema validates format, not real-world validity.
page.getByTestId("place-order-button").click() is the action that submits the createOrder server action. On success it pushes the browser to /checkout/confirmation/{orderNumber} and the wait/assert pair on /checkout\/confirmation\/[A-Z0-9-]+/ plus data-testid="page-order-confirmation" proves the redirect landed2. The order number itself is opaque — it is the public-facing order_number (e.g. QS-2026-XXXX), never the underlying UUID, in line with QA Shop's OWASP-A01 contract on every public order surface.
§ 04 · PROJECT CONFIG
Project config
The playwright.config.ts block this spec runs under is already wired in the QA Shop repo. If you are reproducing the recipe in a fresh project, the minimum config looks like this.
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
projects: [
{
name: "setup",
testDir: "./tests/recipes/playwright",
testMatch: /setup\/auth\.setup\.ts$/,
use: {
baseURL: process.env.RECIPE_BASE_URL ?? "http://localhost:3002",
},
},
{
name: "recipes-authenticated",
testDir: "./tests/recipes/playwright",
testMatch: /authenticated-checkout\.spec\.ts$/,
dependencies: ["setup"],
use: {
baseURL: process.env.RECIPE_BASE_URL ?? "http://localhost:3002",
storageState: "tests/recipes/playwright/.auth/customer.json",
},
},
],
});Three lines do the work: dependencies: ["setup"] runs the setup project once before any test in recipes-authenticated starts; storageState: "..." tells every test in the project to start its browser context from the file the setup wrote; and the matching baseURL between the two projects keeps the cookies' domain valid across the handoff. The testMatch keeps the projects from picking up each other's specs.
auth.setup.ts itself is intentionally tiny — it is just the login flow recipe with a final page.context().storageState({ path: AUTH_FILE }) call. Reuse the same shape for any other authenticated session you need (admin, principal, second customer): one setup file per role, one project per role, both pointing at distinct .auth/*.json files.
§ 05 · EXTEND IT
Things to extend
Three obvious variants build on this skeleton.
Swap TEST_CARD_SUCCESS for 4000 0000 0000 0002 and replace the final waitForURL + getByTestId("page-order-confirmation") block with await expect(page.getByTestId("checkout-error")).toBeVisible(). The wizard does not redirect on decline — the inline error renders in place. This variant doubles as the regression net for the payment-error rendering layer.
After the confirmation assertion, click data-testid="confirmation-view-order" to land on /orders/{orderNumber}. Assert data-testid="page-order-detail" and data-testid="order-detail-number" to verify the account-section order page renders the same order. This is the right place to add row-level assertions on order_total or shipping_method without bloating the recipe body.
Click data-testid="pdp-qty-inc" before adding to cart (or call add-to-cart twice) so the wizard receives a quantity > 1. The shipping and payment steps are unchanged; the only difference is the totals on the confirmation card. Combined with the decline-card variant, this becomes a four-test grid covering the cartesian product of cart size × payment outcome.
A fourth variant worth scripting eventually is the saved-address path — seed an address row for the recipe-verifier in supabase/seed.sql, then assert the saved-address dropdown appears, select it, and skip the explicit .fill calls. That spec doubles as the regression net for the saved-address auto-fill behaviour, which is the single most common source of customer-reported checkout flake in practice.
Related
For the underlying login mechanics this recipe depends on, see Log in to QA Shop with Playwright. For the guest cart flow that stops at the auth gate this recipe walks past, see Add to cart and reach checkout. For the full testid catalog and the security model behind the honeypot naming inversion in the login form, see the QA Shop testing guide.
The Selenium, Cypress, and WebdriverIO authenticated-checkout counterparts are coming soon — placeholder slugs are /recipes/selenium/authenticated-checkout, /recipes/cypress/authenticated-checkout, and /recipes/webdriverio/authenticated-checkout. The /recipes index lists them as they ship.
Footnotes
-
Playwright Auto-waiting and actionability — https://playwright.dev/docs/actionability (verified 2026-05-03, Playwright 1.58) ↩
-
Playwright page.waitForURL — https://playwright.dev/docs/api/class-page#page-wait-for-url (verified 2026-05-03, Playwright 1.58) ↩
Frequently asked questions
Last verified: