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
4321by default. KeepwebServer.urlanduse.baseURLpointed 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.
| API | Use |
|---|---|
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) viawebServer, not the dev server, so you exercise real bundled output. - Set
reuseExistingServer: !process.env.CIso local runs are fast while CI always starts a clean server. - Use semantic locators (
getByRole,getByLabel) and reservegetByTestIdfor 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, andwebkitin CI, and enabletrace: "on-first-retry"to debug failures with full timelines.