# End-to-end testing with Playwright
End-to-end (E2E) tests exercise your Gadget app through a real browser, covering the full stack from the frontend UI through your React Router loaders and actions and into the Gadget database. They complement unit tests by catching integration bugs that only appear when all the layers of your app are running together.
This guide covers Playwright setup for two app types:
* : React Router apps using email/password authentication
* : Embedded Shopify admin apps authenticated via Shopify App Bridge
For testing individual functions, actions, and components in isolation, see [unit and integration testing](https://docs.gadget.dev/guides/development-tools/unit-and-integration-testing).
## Set up Playwright
These steps apply to both web apps and Shopify apps.
### Install dependencies
1. Install Playwright and the dotenv helper in your project directory:
```bash
yarn add --dev @playwright/test dotenv
```
2. Install the Chromium browser binary that Playwright drives:
```bash
yarn playwright install chromium
```
### Add test files to your ignore list
3. Create or update your `.ignore` file at the root of your project to prevent local test artifacts from syncing to Gadget:
```markdown
// in .ignore
test-results/
playwright-report/
```
Add these same entries to your `.gitignore` file if you are using source control for your Gadget project.
### Add a test script
4. Add a `test:e2e` script to `package.json`:
```json
// in package.json
{
"scripts": {
"test:e2e": "playwright test"
}
}
```
## Web apps
This section covers E2E testing for Gadget web apps that use email/password authentication and React Router.
### Configure Playwright
5. Create a `playwright.config.ts` file at the root of your project:
```typescript
import { defineConfig, devices } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
// Load credentials from .env.local so they're available in test files via process.env
dotenv.config({ path: ".env.local" });
// Absolute path to the saved auth state -- shared between globalSetup and test files
export const AUTH_STATE_PATH = path.join(process.cwd(), "e2e/.auth-state.json");
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
timeout: 120_000,
reporter: process.env.CI ? "github" : "list",
globalSetup: "./e2e/globalSetup.ts",
globalTeardown: "./e2e/globalTeardown.ts",
use: {
baseURL: process.env.GADGET_APP_URL,
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
```
The config options above are chosen specifically for Gadget web apps:
* `timeout: 120_000`: Gadget's Vite dev server compiles assets lazily, so first navigation to a page can take 10-20 seconds. The higher timeout prevents false failures while the dev server warms up.
* `fullyParallel: false`: Tests run sequentially by default, which avoids race conditions when multiple tests create and delete the same data.
* `baseURL`: All `page.goto("/path")` calls resolve relative to the development environment URL.
Also add the auth state file to your `.ignore` and `.gitignore` files:
```markdown
// in .ignore and .gitignore
e2e/.auth-state.json
```
### Environment variables
Playwright connects to your live development environment, so it needs a few secrets.
Create a `.env.local` file at the root of your project and add it to both `.ignore` and `.gitignore`:
```bash
# The URL for your environment
GADGET_APP_URL=https://your-app--development.gadget.app
# The environment name used when constructing the API client
GADGET_ENVIRONMENT=development
# A Gadget API key with sufficient permissions to read/write test data
# Create one in the Gadget editor under Settings → API Keys
GADGET_API_KEY=gsk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Email and password of the dedicated E2E test user (see next section)
TEST_USER_EMAIL=e2e-test@example.com
TEST_USER_PASSWORD=a-strong-test-password
```
The `GADGET_API_KEY` is used **only** in `globalSetup` and `globalTeardown` to seed and clean up data via the Internal API. Browser tests themselves authenticate as the test user, not as the API key.
### Create a dedicated test user
E2E tests need a user account to sign in with. Rather than creating a new user on every run, seed a persistent test user directly in your Gadget development environment.
#### Create the user
In the Gadget editor, open the API playground and create a `user` model using the internal API:
```typescript
await api.internal.user.create({
email: "e2e-test@example.com",
password: "a-strong-test-password",
emailVerified: true,
roles: ["signed-in"],
});
```
This can also be done as part of the below.
The following fields are required:
* **email**: `e2e-test@example.com`, or whatever you put in `TEST_USER_EMAIL`
* **password**: set a known password via the Gadget editor's `changePassword` action
* **emailVerified**: `true`, required for auth to succeed
* **roles**: `["signed-in"]`, or any custom roles your app requires for access
#### Ensure `emailVerified` is `true`
Gadget's email/password auth requires `emailVerified: true` before sign-in succeeds. Your should verify this on every run, in case the flag was reset:
```typescript
// ... after creating the API client with the API key
const users = await api.user.findMany({
filter: { email: { equals: TEST_USER_EMAIL } },
first: 1,
});
if (users[0] && !users[0].emailVerified) {
await api.internal.user.update(users[0].id, { emailVerified: true });
}
// ... rest of globalSetup
```
This guard is idempotent: it does nothing if the flag is already set.
### Global setup
The `globalSetup` script runs once before the entire test suite. It:
1. Deletes any leftover test data from previous runs.
2. Ensures the test user can sign in.
3. Signs in via a real browser and saves the session cookies and localStorage to disk.
Create `e2e/globalSetup.js`:
```typescript
import { chromium } from "@playwright/test";
import { Client } from "@gadget-client/your-app-slug";
import dotenv from "dotenv";
import path from "path";
dotenv.config({ path: path.resolve(process.cwd(), ".env.local") });
// Keep in sync with AUTH_STATE_PATH in playwright.config.ts
const AUTH_STATE_PATH = path.join(process.cwd(), "e2e/.auth-state.json");
const TEST_USER_EMAIL = process.env.TEST_USER_EMAIL!;
const TEST_USER_PASSWORD = process.env.TEST_USER_PASSWORD!;
// Authenticated API client used to inspect and clean up the database
const api = new Client({
environment: process.env.GADGET_ENVIRONMENT,
authenticationMode: {
apiKey: process.env.GADGET_API_KEY,
},
});
export default async function globalSetup() {
// 1. Clean up any test data from previous runs
await api.internal.post.deleteMany({
filter: { title: { startsWith: "E2E Test" } },
});
// 2. Ensure the seeded test user has emailVerified: true so sign-in can succeed
// this could be modified to create a test user
const users = await api.user.findMany({
filter: { email: { equals: TEST_USER_EMAIL } },
first: 1,
});
if (users[0] && !users[0].emailVerified) {
await api.internal.user.update(users[0].id, { emailVerified: true });
}
// 3. Sign in via the browser and save the session cookie for authenticated tests.
// The test user is a pre-seeded user in the Gadget database with a known password.
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`${process.env.GADGET_APP_URL}/sign-in`);
// Wait for the React app to fully hydrate before interacting with the form.
// The sign-in form is handled by useActionForm (a client-side React hook), so
// submitting before hydration causes the default HTML GET submission to fire.
await page.waitForLoadState("networkidle");
await page.fill("#email", TEST_USER_EMAIL);
await page.fill("#password", TEST_USER_PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL(`${process.env.GADGET_APP_URL}/signed-in`, {
timeout: 60_000,
});
// Wait for all Vite assets to finish loading. The __Host-app-session cookie is set
// and rotated with every server response, so capturing state before assets load
// can yield a stale or missing cookie that causes authenticated tests to fail.
await page.waitForLoadState("networkidle");
// Save cookies and localStorage so authenticated tests can reuse this session
await page.context().storageState({ path: AUTH_STATE_PATH });
await browser.close();
}
```
Gadget's session cookie (`__Host-app-session`) is rotated on every server response. Capturing storage state immediately after `waitForURL` can yield a stale or incomplete cookie, which causes authenticated test pages to silently redirect back to sign-in. Waiting for `networkidle` ensures all Vite asset requests have settled and the final rotated cookie is in place.
### Global teardown
The `globalTeardown` script runs once after the entire test suite completes. Use it to remove test data and the saved auth state file.
Create `e2e/globalTeardown.js`:
```typescript
import { Client } from "@gadget-client/your-app-slug";
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
dotenv.config({ path: path.resolve(process.cwd(), ".env.local") });
const AUTH_STATE_PATH = path.join(process.cwd(), "e2e/.auth-state.json");
// the api client definition could be shared between globalSetup
// and globalTeardown by exporting it from a separate module
const api = new Client({
environment: process.env.GADGET_ENVIRONMENT,
authenticationMode: {
apiKey: process.env.GADGET_API_KEY,
},
});
export default async function globalTeardown() {
// Remove posts created during the test run
await api.internal.post.deleteMany({
filter: { title: { startsWith: "E2E Test" } },
});
// Remove the saved auth state file
if (fs.existsSync(AUTH_STATE_PATH)) {
fs.unlinkSync(AUTH_STATE_PATH);
}
}
```
If you are creating a fresh user on every test run instead of using a persistent seeded user, also add `api.internal.user.deleteMany(...)` here to clean up after the run. If you use a persistent seeded user as shown in this guide, do not delete the user here. The user will be missing on the next run.
### Writing tests
Place all test files inside `e2e/`. Playwright discovers them automatically.
#### Authentication tests
Auth tests exercise sign-in, sign-out, and error flows through the real browser UI. They do not use the saved auth state. They sign in fresh each time so the sign-in journey itself is tested.
```typescript
import { expect, test } from "@playwright/test";
const EMAIL = process.env.TEST_USER_EMAIL!;
const PASSWORD = process.env.TEST_USER_PASSWORD!;
test.describe("authentication", () => {
test("signs in with email and password", async ({ page }) => {
await page.goto("/sign-in");
await page.waitForLoadState("networkidle");
await page.fill("#email", EMAIL);
await page.fill("#password", PASSWORD);
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/signed-in/);
// Wait for React to hydrate and render the heading
await page.waitForLoadState("networkidle");
await expect(
page.getByRole("heading", { name: /you are now signed in/i })
).toBeVisible();
});
test("shows an error for invalid credentials", async ({ page }) => {
await page.goto("/sign-in");
await page.waitForLoadState("networkidle");
await page.fill("#email", EMAIL);
await page.fill("#password", "wrong-password");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/sign-in/);
await expect(page.locator(".text-destructive")).toBeVisible();
});
test("signs out and redirects to the homepage", async ({ page }) => {
await page.goto("/sign-in");
await page.waitForLoadState("networkidle");
await page.fill("#email", EMAIL);
await page.fill("#password", PASSWORD);
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/signed-in/);
await page.waitForLoadState("networkidle");
await page.locator("header").getByRole("button").click();
await page.getByText("Sign out").click();
await expect(page).toHaveURL("/");
});
});
```
#### Authenticated page tests
Tests for authenticated pages reuse the session saved by `globalSetup` via `test.use({ storageState: AUTH_STATE_PATH })`. This avoids repeating the sign-in flow in every test and keeps the suite fast.
```typescript
import { expect, test } from "@playwright/test";
import { AUTH_STATE_PATH } from "../playwright.config";
// All tests in this file start already signed in
test.use({ storageState: AUTH_STATE_PATH });
test.describe("posts page", () => {
test("shows the posts heading and create form", async ({ page }) => {
await page.goto("/posts");
await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: "Posts" })).toBeVisible();
await expect(page.getByRole("button", { name: "Create" })).toBeVisible();
});
test("creates a new post and it appears in the list as a draft", async ({
page,
}) => {
await page.goto("/posts");
await page.waitForLoadState("networkidle");
await page.fill("#title", "E2E Test Post");
await page.getByRole("button", { name: "Create" }).click();
const postItem = page.locator('[data-testid="post-item"]').filter({
has: page.locator('[data-testid="post-title"]', {
hasText: "E2E Test Post",
}),
});
await expect(postItem).toBeVisible();
await expect(postItem.locator('[data-testid="post-status"]')).toHaveText(
"Draft"
);
});
test("publishes a post and the status badge updates", async ({ page }) => {
await page.goto("/posts");
await page.waitForLoadState("networkidle");
const postItem = page.locator('[data-testid="post-item"]').filter({
has: page.locator('[data-testid="post-title"]', {
hasText: "E2E Test Post",
}),
});
await expect(postItem).toBeVisible();
await postItem.getByRole("button", { name: "Publish" }).click();
const updatedItem = page.locator('[data-testid="post-item"]').filter({
has: page.locator('[data-testid="post-title"]', {
hasText: "E2E Test Post",
}),
});
await expect(updatedItem.locator('[data-testid="post-status"]')).toHaveText(
"Published"
);
await expect(
updatedItem.getByRole("button", { name: "Publish" })
).not.toBeVisible();
});
test("deletes a post and it disappears from the list", async ({ page }) => {
await page.goto("/posts");
await page.waitForLoadState("networkidle");
const postItem = page.locator('[data-testid="post-item"]').filter({
has: page.locator('[data-testid="post-title"]', {
hasText: "E2E Test Post",
}),
});
await expect(postItem).toBeVisible();
await postItem.getByRole("button", { name: "Delete" }).click();
await expect(
page.locator('[data-testid="post-title"]', { hasText: "E2E Test Post" })
).not.toBeVisible();
});
});
```
#### Using `data-testid` attributes
Playwright can query elements by text, role, or CSS selector, but adding `data-testid` attributes makes selectors stable even when you rename labels or restructure markup:
```tsx