Skip to content
Astro as testing 4 min read

End-to-End Testing with Playwright

Unit tests verify your logic in isolation, but they can’t tell you whether a real visitor can actually navigate your site, hydrate an island, and submit a form. Playwright fills that gap: it drives Chromium, Firefox, and WebKit against a running Astro server and asserts on what the user genuinely sees. Because Astro ships zero JavaScript by default, end-to-end tests are especially valuable for confirming that the small amount of JS you do ship — your client:* islands — behaves correctly once it hydrates in a real browser.

Installing and configuring Playwright

Install Playwright and download the browser binaries, then create a playwright.config.ts. The most important setting is webServer: it lets Playwright build and serve your Astro site automatically before the tests run, so you never test against a stale build.

npm init playwright@latest
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  // Build the site and run the production preview server for tests.
  webServer: {
    command: "npm run build && npm run preview",
    url: "http://localhost:4321",
    timeout: 120_000,
    reuseExistingServer: !process.env.CI,
  },
  use: {
    baseURL: "http://localhost:4321",
    trace: "on-first-retry",
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
  ],
});

Testing the production build (build + preview) rather than the dev server is the right default: it exercises the same statically-rendered HTML, bundled islands, and adapter output your users receive. Add scripts so the suite is one command away:

// package.json (scripts)
{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui"
  }
}

Astro’s preview server listens on port 4321 by default. Keep webServer.url and use.baseURL pointed at the same origin, or Playwright will start a server it never connects to and time out.

Asserting on zero-JS pages

A pure static Astro page ships no client JavaScript, so the first thing to verify is that the content is present in the server-rendered HTML — no hydration required. Use web-first assertions like toHaveText and toBeVisible; they auto-wait and retry, which keeps tests stable.

// e2e/home.spec.ts
import { test, expect } from "@playwright/test";

test("renders the marketing heading and nav", async ({ page }) => {
  await page.goto("/");

  await expect(page).toHaveTitle(/DevCraftly/);
  await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
  await expect(page.getByRole("link", { name: "Docs" })).toHaveAttribute(
    "href",
    "/docs"
  );
});

Output:

Running 3 tests using 3 workers

  ✓  [chromium] › home.spec.ts:4:1 › renders the marketing heading and nav (612ms)
  ✓  [firefox]  › home.spec.ts:4:1 › renders the marketing heading and nav (701ms)
  ✓  [webkit]   › home.spec.ts:4:1 › renders the marketing heading and nav (688ms)

  3 passed (4.1s)

Testing an interactive island

This is where Playwright earns its place. An island only becomes interactive after its client:* directive hydrates, so you test it the way a user would: click, type, and assert on the resulting DOM. Consider a counter island:

---
// src/components/Counter.tsx is hydrated here
import Counter from "../components/Counter.tsx";
---
<h1>Demo</h1>
<Counter client:load />
// e2e/counter.spec.ts
import { test, expect } from "@playwright/test";

test("counter island increments after hydration", async ({ page }) => {
  await page.goto("/demo");

  const button = page.getByRole("button", { name: "Increment" });
  const value = page.getByTestId("count");

  await expect(value).toHaveText("0");
  await button.click();
  await button.click();
  await expect(value).toHaveText("2");
});

Because Playwright’s click() auto-waits for the element to be actionable, you don’t need an explicit “wait for hydration” step — the assertion simply retries until the island responds. To prove a client:visible island only hydrates when scrolled into view, assert it is inert first, then page.locator(...).scrollIntoViewIfNeeded() and re-check.

Locators and assertions reference

Prefer role- and label-based locators; they mirror how assistive tech and users find elements, and they survive markup refactors.

APIUse
page.getByRole("button", { name })Most robust locator; queries the accessibility tree.
page.getByText(...) / getByLabel(...)Find by visible text or form label.
page.getByTestId(...)Escape hatch via data-testid when no semantic handle exists.
expect(locator).toBeVisible()Auto-waiting visibility assertion.
expect(locator).toHaveText(...)Auto-waiting text-content assertion.
expect(page).toHaveURL(...)Assert client- or server-side navigation landed.

Avoid page.waitForTimeout() — fixed sleeps make suites flaky and slow. Web-first assertions already retry until the condition holds or the timeout expires.

Best Practices

  • Test the production build (build + preview) via webServer, not the dev server, so you exercise real bundled output.
  • Set reuseExistingServer: !process.env.CI so local runs are fast while CI always starts a clean server.
  • Use semantic locators (getByRole, getByLabel) and reserve getByTestId for elements with no accessible handle.
  • Lean on auto-waiting web-first assertions instead of waitForTimeout; they eliminate the most common source of flakiness.
  • Focus e2e coverage on hydrated islands, navigation, and forms — leave pure-logic units to Vitest and isolated rendering to the Container API.
  • Run the suite across chromium, firefox, and webkit in CI, and enable trace: "on-first-retry" to debug failures with full timelines.
Last updated June 14, 2026
Was this helpful?