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:

  • Web apps: React Router apps using email/password authentication
  • Shopify apps: Embedded Shopify admin apps authenticated via Shopify App Bridge

For testing individual functions, actions, and components in isolation, see 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:
terminal
yarn add --dev @playwright/test dotenv
  1. Install the Chromium browser binary that Playwright drives:
terminal
yarn playwright install chromium

Add test files to your ignore list 

  1. Create or update your .ignore file at the root of your project to prevent local test artifacts from syncing to Gadget:
.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 

  1. Add a test:e2e script to package.json:
package.json
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 

  1. Create a playwright.config.ts file at the root of your project:
playwright.config.js
JavaScript
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"] }, }, ], });
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:

.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:

terminal
# 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) [email protected] 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:

create a test user in the API playground
JavaScript
await api.internal.user.create({ email: "[email protected]", password: "a-strong-test-password", emailVerified: true, roles: ["signed-in"], });
await api.internal.user.create({ email: "[email protected]", password: "a-strong-test-password", emailVerified: true, roles: ["signed-in"], });

This can also be done as part of the globalSetup script below.

The following fields are required:

  • email: [email protected], 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 globalSetup script should verify this on every run, in case the flag was reset:

e2e/globalSetup.js
JavaScript
// ... 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
// ... 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:

e2e/globalSetup.js
JavaScript
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(); }
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(); }
Why waitForLoadState before saving auth state

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:

e2e/globalTeardown.js
JavaScript
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); } }
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.

e2e/auth.spec.js
JavaScript
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("/"); }); });
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.

e2e/posts.spec.js
JavaScript
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(); }); });
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:

React
<Card key={post.id} data-testid="post-item"> <span data-testid="post-title">{post.title}</span> <Badge data-testid="post-status">{post.published ? "Published" : "Draft"}</Badge> </Card>
<Card key={post.id} data-testid="post-item"> <span data-testid="post-title">{post.title}</span> <Badge data-testid="post-status">{post.published ? "Published" : "Draft"}</Badge> </Card>

Then in your test:

JavaScript
const postItem = page.locator('[data-testid="post-item"]').filter({ has: page.locator('[data-testid="post-title"]', { hasText: "E2E Test Post" }), });
const postItem = page.locator('[data-testid="post-item"]').filter({ has: page.locator('[data-testid="post-title"]', { hasText: "E2E Test Post" }), });

Interacting with forms 

Always call await page.waitForLoadState("networkidle") before filling in or submitting any form. The reason differs depending on the form type, but the rule is the same in both cases.

  • Client-side forms using useActionForm or other React hooks only become active after the React app hydrates. Submitting before hydration causes the browser to fall back to a plain HTML GET request instead of invoking the hook. The networkidle wait ensures React has attached its event handlers before you interact with the form.
  • Server-side forms using native <form method="post"> tied to a React Router action work correctly before hydration. Gadget's Vite dev server compiles assets lazily on first navigation, so assertions made before compilation completes can fail to find rendered content.
testing forms
JavaScript
// Client-side form (useActionForm): wait ensures hydration // in e2e/globalSetup.ts await page.goto(`${process.env.GADGET_APP_URL}/sign-in`); await page.waitForLoadState("networkidle"); await page.fill("#email", TEST_USER_EMAIL); await page.fill("#password", TEST_USER_PASSWORD); await page.click('button[type="submit"]'); // Server-side form (React Router action): wait ensures Vite has compiled assets // in e2e/posts.spec.ts await page.goto("/posts/new"); await page.waitForLoadState("networkidle"); await page.fill("#title", "E2E Test Post"); await page.getByRole("button", { name: "Create" }).click();
// Client-side form (useActionForm): wait ensures hydration // in e2e/globalSetup.ts await page.goto(`${process.env.GADGET_APP_URL}/sign-in`); await page.waitForLoadState("networkidle"); await page.fill("#email", TEST_USER_EMAIL); await page.fill("#password", TEST_USER_PASSWORD); await page.click('button[type="submit"]'); // Server-side form (React Router action): wait ensures Vite has compiled assets // in e2e/posts.spec.ts await page.goto("/posts/new"); await page.waitForLoadState("networkidle"); await page.fill("#title", "E2E Test Post"); await page.getByRole("button", { name: "Create" }).click();

Testing validation errors 

React Router server actions can return validation errors as actionData without redirecting. The page stays at the same URL and an error message becomes visible. Tests for this path should assert that the URL has not changed and that the error text is in the DOM.

e2e/posts.spec.js
JavaScript
test("shows a validation error when the title is blank", async ({ page }) => { await page.goto("/posts/new"); await page.waitForLoadState("networkidle"); // Submit without filling in the required title field await page.getByRole("button", { name: "Create" }).click(); // The server action returns actionData with an error; the page does not redirect await expect(page).toHaveURL(/\/posts\/new/); await expect(page.getByText("Title is required")).toBeVisible(); });
test("shows a validation error when the title is blank", async ({ page }) => { await page.goto("/posts/new"); await page.waitForLoadState("networkidle"); // Submit without filling in the required title field await page.getByRole("button", { name: "Create" }).click(); // The server action returns actionData with an error; the page does not redirect await expect(page).toHaveURL(/\/posts\/new/); await expect(page.getByText("Title is required")).toBeVisible(); });

Verifying database state 

UI assertions alone cannot confirm that a record was actually written to the database. When a form redirects away on success, such as a create form that navigates to the new record's detail page, there is nothing left in the UI to assert against the data itself.

Use the Gadget internal API to query the database directly from within the test. Import and instantiate the API client at the top of the file or import an exiting client, and call api.internal.* after the redirect:

e2e/posts.spec.js
JavaScript
import { expect, test } from "@playwright/test"; import { Client } from "@gadget-client/your-app-slug"; import dotenv from "dotenv"; import { AUTH_STATE_PATH } from "../playwright.config"; dotenv.config({ path: ".env.local" }); // Authenticated API client used to inspect database state from within tests // can also be imported from a shared module to avoid duplication with globalSetup/globalTeardown const api = new Client({ environment: process.env.GADGET_ENVIRONMENT, authenticationMode: { apiKey: process.env.GADGET_API_KEY }, }); test.use({ storageState: AUTH_STATE_PATH }); test("creates a post and persists it to the database", async ({ page }) => { await page.goto("/posts/new"); await page.waitForLoadState("networkidle"); await page.fill("#title", "E2E Test Database Post"); await page.getByRole("button", { name: "Create" }).click(); // The create form redirects to the new post's detail page on success await expect(page).toHaveURL(/\/posts\/\d+/); // Confirm the record is in the database, not just in the browser const posts = await api.internal.post.findMany({ filter: { title: { equals: "E2E Test Database Post" } }, first: 1, }); expect(posts).toHaveLength(1); expect(posts[0].title).toBe("E2E Test Database Post"); });
import { expect, test } from "@playwright/test"; import { Client } from "@gadget-client/your-app-slug"; import dotenv from "dotenv"; import { AUTH_STATE_PATH } from "../playwright.config"; dotenv.config({ path: ".env.local" }); // Authenticated API client used to inspect database state from within tests // can also be imported from a shared module to avoid duplication with globalSetup/globalTeardown const api = new Client({ environment: process.env.GADGET_ENVIRONMENT, authenticationMode: { apiKey: process.env.GADGET_API_KEY }, }); test.use({ storageState: AUTH_STATE_PATH }); test("creates a post and persists it to the database", async ({ page }) => { await page.goto("/posts/new"); await page.waitForLoadState("networkidle"); await page.fill("#title", "E2E Test Database Post"); await page.getByRole("button", { name: "Create" }).click(); // The create form redirects to the new post's detail page on success await expect(page).toHaveURL(/\/posts\/\d+/); // Confirm the record is in the database, not just in the browser const posts = await api.internal.post.findMany({ filter: { title: { equals: "E2E Test Database Post" } }, first: 1, }); expect(posts).toHaveLength(1); expect(posts[0].title).toBe("E2E Test Database Post"); });

api.internal.* methods bypass Gadget action logic and run directly against the database. Use them for asserting state in tests, not for setting up fixtures that your app's actions would normally validate. For seed data, use the same internal API calls in globalSetup so that data is created once before the test suite runs.

Troubleshooting 

Sign-in succeeds but authenticated pages redirect to sign-in 

The auth state was captured before all Vite assets finished loading. Gadget rotates the session cookie on every server response, so if you capture storageState before networkidle, you may get a stale or incomplete cookie.

Fix: always call await page.waitForLoadState("networkidle") after waitForURL("/signed-in") and before context.storageState(...).

Headings or components are not visible even after sign-in 

Gadget's development server compiles Vite assets on demand. The first navigation to a new route can take more time while the server builds the bundle. The default Playwright assertion timeout of 5 seconds is occasionally too short for development.

Fix: increase timeout in playwright.config.ts. 120 seconds is recommended for dev. Call await page.waitForLoadState("networkidle") before making assertions on page content.

Tests interfere with each other 

If tests are run against a shared development environment and database, test data created by one test can affect the next.

Fix:

  • Run tests sequentially with fullyParallel: false in your Playwright config
  • Use a distinctive prefix such as "E2E Test" for all test-created records
  • Delete records matching that prefix in globalSetup before each run and in globalTeardown after each run
  • Run tests on a fresh environment, created just for running e2e tests

Shopify apps 

Shopify embedded admin apps authenticate via Shopify App Bridge rather than email and password. The real OAuth flow requires Shopify's servers and a real store session, making it impossible to automate with Playwright. Gadget's development server provides a built-in escape hatch that allows tests to authenticate without Shopify's OAuth flow. This is the same mechanism used by the in-editor preview.

How mock Shopify auth works 

When your app receives a request with the query parameter gadgetmockappbridge=1, Gadget's server middleware replaces the real Shopify App Bridge CDN script with a mock implementation. The mock script handles the App Bridge handshake that useGadget() and @gadgetinc/react-shopify-app-bridge depend on, returning isAuthenticated: true without contacting Shopify.

A complete mock embed URL looks like:

mock embed URL
https://your-app--development.gadget.app/? shop=your-dev-store.myshopify.com &embedded=1 &clientid=your_shopify_api_key &clientsecret=your_shopify_api_secret &id_token=<HS256_JWT> &gadgetmockappbridge=1 &hmac=<SHA256_HMAC>

The id_token is a short-lived JWT signed with the Shopify API secret using HS256. The hmac is an HMAC-SHA256 computed over the alphabetically-sorted query parameters, also signed with the API secret. Both are generated programmatically in a Playwright fixture before each test, so the 2-minute JWT expiration never causes issues in practice.

The mock URL contains the Shopify API secret in plaintext as the clientsecret query parameter. Never log this URL, commit it to source control, or expose it in test output. Store the API secret only in .env.test, which must be gitignored.

This mechanism works without logging into the Gadget editor. The gadgetmockappbridge=1 check is a plain query-parameter condition in the Gadget server middleware. No developer session is required.

Install additional dependencies 

Shopify app tests require jose to sign the mock JWT, in addition to the Playwright dependencies:

terminal
yarn add --dev jose

Playwright configuration 

Create a playwright.config.ts file at the root of your project:

playwright.config.js
JavaScript
import { defineConfig, devices } from "@playwright/test"; import dotenv from "dotenv"; import path from "path"; dotenv.config({ path: path.resolve(__dirname, ".env.test") }); export default defineConfig({ testDir: "./e2e", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: "html", use: { trace: "on-first-retry", }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] }, }, ], });
import { defineConfig, devices } from "@playwright/test"; import dotenv from "dotenv"; import path from "path"; dotenv.config({ path: path.resolve(__dirname, ".env.test") }); export default defineConfig({ testDir: "./e2e", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: "html", use: { trace: "on-first-retry", }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] }, }, ], });

Key differences from the web app config:

  • No baseURL: tests navigate to fully-qualified mock URLs that include authentication parameters
  • No globalSetup or globalTeardown: there is no browser-based login to perform
  • fullyParallel: true: each test generates its own independent mock URL with a fresh JWT, so tests share no session state and are naturally isolated from each other

Setting up .env.test 

Create a .env.test file at the root of your project and add it to both .ignore and .gitignore:

.env.test
# Copy this file to .env.test and fill in your values. # .env.test is gitignored. Never commit real secrets. # Your Gadget app's test environment URL GADGET_APP_URL=https://your-app--development.gadget.app # From shopify.app.<environment>.toml (client_id) # or Shopify Dev Dashboard → Client credentials → Client ID SHOPIFY_CLIENT_ID=your_client_id_here # From Shopify Dev Dashboard → Your app → Client credentials → Client secret SHOPIFY_API_SECRET=your_api_secret_here # A dev store that has your app installed SHOPIFY_SHOP_DOMAIN=your-dev-store.myshopify.com # A Gadget API key for seeding and cleaning up test data via the internal API # Create one in the Gadget editor under Settings → API Keys GADGET_API_KEY=gsk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

To find these values:

  • GADGET_APP_URL: your app's development URL, visible at the top of the Gadget editor
  • SHOPIFY_CLIENT_ID: Shopify Dev Dashboard → your app → Client credentials → Client ID. Also visible in shopify.app.development.toml as client_id
  • SHOPIFY_API_SECRET: Shopify Dev Dashboard → your app → Client credentials → Client secret
  • SHOPIFY_SHOP_DOMAIN: the domain of a development store with your app installed, for example your-dev-store.myshopify.com. Do not include https://.
  • GADGET_API_KEY: Gadget editor → Settings → API Keys. Create a key with sufficient permissions to delete records for your test models.

The API secret signs both the mock JWT and the request HMAC. Keep it out of source control and never include it in logs or test output.

The shopPage fixture 

Instead of saving a browser session with storageState, Shopify app tests use a custom Playwright fixture called shopPage. The fixture constructs a fresh mock embed URL before each test and navigates to it, establishing authentication via the mock App Bridge.

Build the mock URL 

Create e2e/helpers/mockShopifyAuth.js:

e2e/helpers/mockShopifyAuth.js
JavaScript
import { createHmac, randomBytes } from "crypto"; import { SignJWT } from "jose"; const APP_URL = process.env.GADGET_APP_URL; const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID; /** * Builds a mock Shopify embed URL that bypasses OAuth. * * Replicates what the Gadget editor's embed preview does: * - Signs a mock Shopify session token (id_token) with the API secret * - Computes an HMAC over the sorted params * - Adds `gadgetmockappbridge=1` which tells the Gadget server to inject * the mock App Bridge CDN script instead of Shopify's real one * * The resulting URL can be navigated to directly, no Gadget login needed. * The app will authenticate via the mock App Bridge and render normally. * * ⚠️ The URL contains the API secret in plaintext. Never log or expose it. */ export async function buildMockEmbedUrl( shopDomain: string, clientSecret: string, path = "/" ): Promise<string> { if (!APP_URL || !CLIENT_ID) { throw new Error("GADGET_APP_URL and SHOPIFY_CLIENT_ID must be set in .env.test"); } const idToken = await new SignJWT({ sid: randomBytes(8).toString("hex"), dest: `https://${shopDomain}`, }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setIssuer(`https://${shopDomain}/admin`) .setAudience(CLIENT_ID) .setSubject("123") .setExpirationTime("2m") .sign(new TextEncoder().encode(clientSecret)); const params = new URLSearchParams({ shop: shopDomain, embedded: "1", clientid: CLIENT_ID, clientsecret: clientSecret, id_token: idToken, gadgetmockappbridge: "1", }); // HMAC is computed over decoded, alphabetically sorted params (excluding hmac itself) const paramString = [...params.entries()] .sort((a, b) => a[0].localeCompare(b[0])) .map(([k, v]) => `${k}=${v}`) .join("&"); const hmac = createHmac("sha256", clientSecret).update(paramString).digest("hex"); params.set("hmac", hmac); return `${APP_URL}${path}?${params.toString()}`; }
import { createHmac, randomBytes } from "crypto"; import { SignJWT } from "jose"; const APP_URL = process.env.GADGET_APP_URL; const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID; /** * Builds a mock Shopify embed URL that bypasses OAuth. * * Replicates what the Gadget editor's embed preview does: * - Signs a mock Shopify session token (id_token) with the API secret * - Computes an HMAC over the sorted params * - Adds `gadgetmockappbridge=1` which tells the Gadget server to inject * the mock App Bridge CDN script instead of Shopify's real one * * The resulting URL can be navigated to directly, no Gadget login needed. * The app will authenticate via the mock App Bridge and render normally. * * ⚠️ The URL contains the API secret in plaintext. Never log or expose it. */ export async function buildMockEmbedUrl( shopDomain: string, clientSecret: string, path = "/" ): Promise<string> { if (!APP_URL || !CLIENT_ID) { throw new Error("GADGET_APP_URL and SHOPIFY_CLIENT_ID must be set in .env.test"); } const idToken = await new SignJWT({ sid: randomBytes(8).toString("hex"), dest: `https://${shopDomain}`, }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setIssuer(`https://${shopDomain}/admin`) .setAudience(CLIENT_ID) .setSubject("123") .setExpirationTime("2m") .sign(new TextEncoder().encode(clientSecret)); const params = new URLSearchParams({ shop: shopDomain, embedded: "1", clientid: CLIENT_ID, clientsecret: clientSecret, id_token: idToken, gadgetmockappbridge: "1", }); // HMAC is computed over decoded, alphabetically sorted params (excluding hmac itself) const paramString = [...params.entries()] .sort((a, b) => a[0].localeCompare(b[0])) .map(([k, v]) => `${k}=${v}`) .join("&"); const hmac = createHmac("sha256", clientSecret).update(paramString).digest("hex"); params.set("hmac", hmac); return `${APP_URL}${path}?${params.toString()}`; }

Both APP_URL and CLIENT_ID are read from .env.test at runtime. The Shopify API key is not a secret. It is the same value as the client_id field in shopify.app.development.toml.

Define the fixture 

Create e2e/fixtures.js:

e2e/fixtures.js
JavaScript
import { test as base, expect, type Page } from "@playwright/test"; import { buildMockEmbedUrl } from "./helpers/mockShopifyAuth"; type Fixtures = { /** A page that has navigated to the app with mock Shopify auth. */ shopPage: Page; }; export const test = base.extend<Fixtures>({ shopPage: async ({ page }, use) => { const shopDomain = process.env.SHOPIFY_SHOP_DOMAIN; const clientSecret = process.env.SHOPIFY_API_SECRET; if (!shopDomain || !clientSecret) { throw new Error( "SHOPIFY_SHOP_DOMAIN and SHOPIFY_API_SECRET must be set in .env.test" ); } const url = await buildMockEmbedUrl(shopDomain, clientSecret); await page.goto(url); // Fail fast if mock auth redirected away from the app (like accounts.shopify.com). // Without this check, a heading on the Shopify login page would satisfy the wait below // and silently pass the wrong page to the test. await expect(page).toHaveURL(new RegExp(`^${process.env.GADGET_APP_URL}`), { timeout: 15_000, }); // Wait until the authenticated route has rendered. // Replace this selector with one that reliably appears on your app's index route. await page.locator("h1, h2").first().waitFor({ timeout: 15_000 }); await use(page); }, }); export { expect } from "@playwright/test";
import { test as base, expect, type Page } from "@playwright/test"; import { buildMockEmbedUrl } from "./helpers/mockShopifyAuth"; type Fixtures = { /** A page that has navigated to the app with mock Shopify auth. */ shopPage: Page; }; export const test = base.extend<Fixtures>({ shopPage: async ({ page }, use) => { const shopDomain = process.env.SHOPIFY_SHOP_DOMAIN; const clientSecret = process.env.SHOPIFY_API_SECRET; if (!shopDomain || !clientSecret) { throw new Error( "SHOPIFY_SHOP_DOMAIN and SHOPIFY_API_SECRET must be set in .env.test" ); } const url = await buildMockEmbedUrl(shopDomain, clientSecret); await page.goto(url); // Fail fast if mock auth redirected away from the app (like accounts.shopify.com). // Without this check, a heading on the Shopify login page would satisfy the wait below // and silently pass the wrong page to the test. await expect(page).toHaveURL(new RegExp(`^${process.env.GADGET_APP_URL}`), { timeout: 15_000, }); // Wait until the authenticated route has rendered. // Replace this selector with one that reliably appears on your app's index route. await page.locator("h1, h2").first().waitFor({ timeout: 15_000 }); await use(page); }, }); export { expect } from "@playwright/test";

The two waits serve different purposes. The toHaveURL assertion confirms mock auth succeeded and the browser stayed on your app. The heading wait confirms the route has finished rendering. Replace h1, h2 with a selector specific to your index route, for example, a named heading or a data-testid element, so a wrong-page render produces a clear failure rather than a silent pass.

Import test and expect from e2e/fixtures.ts (using a relative path) in every test file instead of from @playwright/test. This gives every test the shopPage fixture automatically.

Pass a path argument to buildMockEmbedUrl to navigate somewhere other than the index route:

navigate to /some-route with mock auth
JavaScript
const url = await buildMockEmbedUrl(shopDomain, clientSecret, "/some-route"); await page.goto(url);
const url = await buildMockEmbedUrl(shopDomain, clientSecret, "/some-route"); await page.goto(url);

Example tests 

Place all test files inside e2e/. Playwright discovers them automatically.

Import test and expect from your fixtures file rather than from @playwright/test:

e2e/index.spec.js
JavaScript
import { test, expect } from "./fixtures";
import { test, expect } from "./fixtures";

Cleaning up test data 

Tests that create records must clean up before and after each run to keep the database in a known state. Use the Gadget internal API, api.internal.*, for cleanup. It bypasses access control and works directly with an API key.

Use a distinctive string prefix for all test-created records. Define a cleanup function and call it in both beforeEach, which removes leftovers from an interrupted run, and afterEach, which removes records after a passing or failing run:

e2e/index.spec.js
JavaScript
import { Client } from "@gadget-client/your-app-slug"; import { test, expect } from "./fixtures"; const TEST_PREFIX = "e2e-test-"; const api = new Client({ environment: "development", authenticationMode: { apiKey: process.env.GADGET_API_KEY! }, }); async function cleanupTestRecords() { await api.internal.allowedTag.deleteMany({ filter: { keyword: { startsWith: TEST_PREFIX } }, }); } test.beforeEach(cleanupTestRecords); test.afterEach(cleanupTestRecords);
import { Client } from "@gadget-client/your-app-slug"; import { test, expect } from "./fixtures"; const TEST_PREFIX = "e2e-test-"; const api = new Client({ environment: "development", authenticationMode: { apiKey: process.env.GADGET_API_KEY! }, }); async function cleanupTestRecords() { await api.internal.allowedTag.deleteMany({ filter: { keyword: { startsWith: TEST_PREFIX } }, }); } test.beforeEach(cleanupTestRecords); test.afterEach(cleanupTestRecords);

Use api.internal.<model> for cleanup rather than api.<model>. The internal API bypasses Gadget's access control rules and runs directly against the database. Cleanup using the public API can fail with GGT_PERMISSION_DENIED if the unauthenticated role does not have delete access on the model.

Testing AutoForm 

AutoForm from @gadgetinc/react/auto/polaris generates a Polaris form from your model's fields. Key selectors to know:

  • getByLabel("Field name"): targets the input by its label. AutoForm derives the label from the field's API identifier, for example keyword becomes "Keyword".
  • getByRole("button", { name: "Submit" }): AutoForm's default submit button text is "Submit".
  • Validation errors appear as inline text in the format "<Field> is required" for required string fields.
e2e/index.spec.js
JavaScript
test.describe("AutoForm: Add keywords", () => { test("renders with title and keyword input", async ({ shopPage }) => { await expect( shopPage.getByRole("heading", { name: "Add keywords" }) ).toBeVisible(); await expect(shopPage.getByLabel("Keyword")).toBeVisible(); }); test("shows a validation error when submitted empty", async ({ shopPage }) => { await shopPage.getByRole("button", { name: "Submit" }).click(); await expect(shopPage.getByText("Keyword is required")).toBeVisible(); }); test("creates a record and it appears in the table", async ({ shopPage }) => { const keyword = `${TEST_PREFIX}playwright`; await shopPage.getByLabel("Keyword").fill(keyword); await shopPage.getByRole("button", { name: "Submit" }).click(); // New record should appear in the AutoTable below await expect(shopPage.getByRole("cell", { name: keyword })).toBeVisible({ timeout: 15_000, }); }); });
test.describe("AutoForm: Add keywords", () => { test("renders with title and keyword input", async ({ shopPage }) => { await expect( shopPage.getByRole("heading", { name: "Add keywords" }) ).toBeVisible(); await expect(shopPage.getByLabel("Keyword")).toBeVisible(); }); test("shows a validation error when submitted empty", async ({ shopPage }) => { await shopPage.getByRole("button", { name: "Submit" }).click(); await expect(shopPage.getByText("Keyword is required")).toBeVisible(); }); test("creates a record and it appears in the table", async ({ shopPage }) => { const keyword = `${TEST_PREFIX}playwright`; await shopPage.getByLabel("Keyword").fill(keyword); await shopPage.getByRole("button", { name: "Submit" }).click(); // New record should appear in the AutoTable below await expect(shopPage.getByRole("cell", { name: keyword })).toBeVisible({ timeout: 15_000, }); }); });

Testing AutoTable 

AutoTable from @gadgetinc/react/auto/polaris fetches data after the initial render. The table skeleton appears immediately, but column headers and rows appear only after the data fetch completes. Key selectors to know:

  • Column headers use role="columnheader". Pass exact: true when the column name is a substring of another column name, for example "Name" would also match "Country name" without exact: true.
  • Always give the first column header assertion a generous timeout. The default 5-second timeout can expire before the data fetch completes.
  • Row cells use role="cell". Use getByRole("cell", { name: value }) to assert a specific record is visible.
e2e/index.spec.js
JavaScript
test.describe("AutoTable: Keywords", () => { test("renders the Keywords heading and column header", async ({ shopPage }) => { await expect(shopPage.getByRole("heading", { name: "Keywords" })).toBeVisible(); await expect( shopPage.getByRole("columnheader", { name: "Keyword", exact: true }) ).toBeVisible({ timeout: 15_000 }); }); test("shows a newly created record as a row", async ({ shopPage }) => { const keyword = `${TEST_PREFIX}row-check`; // Create via the form await shopPage.getByLabel("Keyword").fill(keyword); await shopPage.getByRole("button", { name: "Submit" }).click(); await expect(shopPage.getByRole("cell", { name: keyword })).toBeVisible({ timeout: 15_000, }); }); });
test.describe("AutoTable: Keywords", () => { test("renders the Keywords heading and column header", async ({ shopPage }) => { await expect(shopPage.getByRole("heading", { name: "Keywords" })).toBeVisible(); await expect( shopPage.getByRole("columnheader", { name: "Keyword", exact: true }) ).toBeVisible({ timeout: 15_000 }); }); test("shows a newly created record as a row", async ({ shopPage }) => { const keyword = `${TEST_PREFIX}row-check`; // Create via the form await shopPage.getByLabel("Keyword").fill(keyword); await shopPage.getByRole("button", { name: "Submit" }).click(); await expect(shopPage.getByRole("cell", { name: keyword })).toBeVisible({ timeout: 15_000, }); }); });

The "creates a record and it appears in the table" and "shows a newly created record as a row" tests submit the AutoForm and then assert the result in the AutoTable. This covers the full create path: form submission, Gadget action, AutoTable re-fetch, and DOM update.

Stable selectors with data-testid 

Adding data-testid attributes makes selectors stable even when you rename labels or restructure markup:

React
<Card data-testid="shop-card"> <span data-testid="shop-name">{shop.name}</span> </Card>
<Card data-testid="shop-card"> <span data-testid="shop-name">{shop.name}</span> </Card>
JavaScript
await expect(shopPage.locator('[data-testid="shop-name"]')).toBeVisible();
await expect(shopPage.locator('[data-testid="shop-name"]')).toBeVisible();

Querying the database in tests 

Use the Gadget API client to query the database directly from within a test. Instantiate the client at the top of your test file using the GADGET_API_KEY from .env.test:

e2e/shops.spec.js
JavaScript
import { Client } from "@gadget-client/your-app-slug"; import { test, expect } from "./fixtures"; const api = new Client({ environment: "development", authenticationMode: { apiKey: process.env.GADGET_API_KEY! }, }); test("shop record exists in the database", async ({ shopPage }) => { const shops = await api.internal.shopifyShop.findMany({ first: 1 }); expect(shops).toHaveLength(1); });
import { Client } from "@gadget-client/your-app-slug"; import { test, expect } from "./fixtures"; const api = new Client({ environment: "development", authenticationMode: { apiKey: process.env.GADGET_API_KEY! }, }); test("shop record exists in the database", async ({ shopPage }) => { const shops = await api.internal.shopifyShop.findMany({ first: 1 }); expect(shops).toHaveLength(1); });

api.internal.* methods bypass Gadget action logic and run directly against the database. Use them for asserting state and cleaning up test records. Shopify data is scoped per shop, so your test dev store is naturally isolated from production data.

Common issues 

App shows "App must be viewed in the Shopify Admin" 

The mock auth URL did not authenticate correctly. Check the following:

  • SHOPIFY_API_SECRET in .env.test matches the Client secret in your Shopify Dev Dashboard. Any mismatch causes HMAC or JWT verification to fail silently, and the app falls back to the unauthenticated state.
  • SHOPIFY_SHOP_DOMAIN is the full domain of a dev store with your app installed. Do not include https://.
  • SHOPIFY_CLIENT_ID in .env.test matches the client_id field in shopify.app.development.toml.

Data does not appear in AutoTable 

The default Playwright assertion timeout is 5 seconds. AutoTable makes a network request after the initial render, so assertions on column headers or rows can time out before the data arrives.

Fix: pass an explicit timeout to the first column header or row assertion:

pass explicit timeout to AutoTable assertions
JavaScript
await expect(shopPage.getByRole("columnheader", { name: "Name" })).toBeVisible({ timeout: 15_000, });
await expect(shopPage.getByRole("columnheader", { name: "Name" })).toBeVisible({ timeout: 15_000, });

HMAC mismatch or JWT signature errors 

These errors indicate that SHOPIFY_API_SECRET in .env.test does not match the secret used to register your app. Open your Shopify Dev Dashboard, navigate to the app's Client credentials page, and copy the Client secret again to make sure it is current.

Running tests 

Running tests locally 

To run all tests, use the test script defined in package.json:

terminal
yarn test:e2e

To run a specific file:

terminal
yarn playwright test e2e/your-test.spec.ts

To run in headed mode with a visible browser window, which is useful for debugging:

terminal
yarn playwright test --headed

To open the interactive Playwright UI:

terminal
yarn playwright test --ui

After a failure, Playwright saves a trace to test-results/. Open it with:

terminal
yarn playwright show-report

Running tests with CI/CD 

You can run Playwright tests as part of a CI/CD pipeline. The steps below use GitHub Actions. The workflow structure is identical for both app types. The differences are the secrets passed and the test command used.

GitHub Actions example 

  1. Add your secrets to your GitHub repository under Settings > Secrets and variables > Actions.

    Web apps: GADGET_APP_URL, GADGET_ENVIRONMENT, GADGET_API_KEY, TEST_USER_EMAIL, TEST_USER_PASSWORD

    Shopify apps: GADGET_APP_URL, SHOPIFY_CLIENT_ID, SHOPIFY_API_SECRET, SHOPIFY_SHOP_DOMAIN, GADGET_API_KEY

  2. Confirm your .gitignore includes test artifacts. Web apps also need e2e/.auth-state.json. Shopify apps need .env.test.

  3. Create .github/workflows/e2e.yml:

.github/workflows/e2e.yml
name: E2E tests on: push: branches: [main] pull_request: branches: [main] jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v4 with: node-version: "20.x" - name: Install dependencies run: yarn --frozen-lockfile - name: Install Playwright browsers run: yarn playwright install chromium --with-deps - name: Run Playwright tests run: yarn test:e2e env: GADGET_APP_URL: ${{ secrets.GADGET_APP_URL }} GADGET_ENVIRONMENT: ${{ secrets.GADGET_ENVIRONMENT }} GADGET_API_KEY: ${{ secrets.GADGET_API_KEY }} TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }} TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }} - name: Upload test report uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/ retention-days: 7
name: E2E tests on: push: branches: [main] pull_request: branches: [main] jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v4 with: node-version: "20.x" - name: Install dependencies run: yarn --frozen-lockfile - name: Install Playwright browsers run: yarn playwright install chromium --with-deps - name: Run Playwright tests run: yarn test:e2e env: GADGET_APP_URL: ${{ secrets.GADGET_APP_URL }} SHOPIFY_CLIENT_ID: ${{ secrets.SHOPIFY_CLIENT_ID }} SHOPIFY_API_SECRET: ${{ secrets.SHOPIFY_API_SECRET }} SHOPIFY_SHOP_DOMAIN: ${{ secrets.SHOPIFY_SHOP_DOMAIN }} GADGET_API_KEY: ${{ secrets.GADGET_API_KEY }} - name: Upload test report uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/ retention-days: 7

The upload-artifact step captures the HTML test report on every run, including failures, so you can download and review it from the Actions tab.

Was this page helpful?