|

Database Seeding for E2E Tests via API in Playwright

If your end-to-end tests log in through the UI, click through three forms to create an order, then assert on it, you already know the pain: the suite is slow, brittle, and falls over the moment the signup page changes. The fix is Playwright database seeding E2E API setup — pushing test data straight into your backend over HTTP before the browser ever opens. In this guide you will learn how to seed data through real API calls inside fixtures, keep tests isolated, clean up afterward, and combine seeded state with a pre-authenticated browser context.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

Why Seed Through the API Instead of the UI

Every E2E test needs a known starting state. The naive approach builds that state by driving the browser: register a user, navigate to the products page, add items to a cart, and so on. That works, but it couples every test to the slowest, most fragile path in your app. A single tooltip change on the registration screen can break fifty tests that only cared about checkout.

Seeding through the API skips the theatre. You call the same REST or GraphQL endpoints your frontend uses (or a dedicated test-only endpoint) to create exactly the records a test needs, then point the browser at the result. The browser still validates the real user journey under test — you are only short-circuiting the arrange phase, not the act or assert.

ApproachSetup speedIsolationBrittlenessBest for
UI-driven setupSlow (full page loads)LowHighTesting the setup flow itself
API seedingFast (one HTTP call)HighLowMost E2E arrange steps
Direct DB writesFastestHighMedium (schema coupling)Data the API cannot create
Static fixtures/dumpsFastLow (shared state)MediumRead-only reference data

API seeding hits the sweet spot for the vast majority of tests: it is fast, it respects your business rules (validation, hashing, defaults) because it travels through the real application layer, and it does not chain you to the exact column names in your database. Reserve direct database writes for the rare data your API genuinely cannot produce.

The Core Tool: APIRequestContext

Playwright ships a first-class HTTP client called APIRequestContext. You get one from the request fixture in a test, or you create a standalone one with request.newContext(). It speaks the same cookie jar and TLS stack as the browser, supports get, post, put, patch, delete, and returns an APIResponse you can assert on. This is the engine behind all of our seeding.

import { test, expect, request } from '@playwright/test';

test('seed a product, then verify it renders', async ({ page, baseURL }) => {
  // Create a standalone API context bound to the app's base URL.
  const api = await request.newContext({
    baseURL,
    extraHTTPHeaders: { Authorization: `Bearer ${process.env.ADMIN_TOKEN}` },
  });

  // Seed the record we want the UI to display.
  const res = await api.post('/api/products', {
    data: { name: 'Seeded Widget', price: 1999, stock: 5 },
  });
  expect(res.ok()).toBeTruthy();
  const product = await res.json();

  // Now exercise the real UI against the seeded state.
  await page.goto(`/products/${product.id}`);
  await expect(page.getByRole('heading', { name: 'Seeded Widget' })).toBeVisible();
  await expect(page.getByTestId('price')).toHaveText('$19.99');

  await api.dispose();
});

Notice api.dispose() at the end — a standalone context holds a connection pool, so dispose it when finished. The baseURL from your config is reused, so you write relative paths. The pattern is already clean, but inlining seeding in every test gets repetitive fast. Fixtures solve that.

Building a Reusable Seeding Fixture

A Playwright fixture lets you run setup before a test and teardown after it, automatically, with full TypeScript types. The ideal seeding fixture creates data, yields a typed handle to the test, and deletes that data afterward — so every test starts and ends clean without you remembering to call cleanup.

// fixtures/seed.ts
import { test as base, APIRequestContext, request } from '@playwright/test';

type Product = { id: string; name: string; price: number };

type SeedApi = {
  createProduct: (overrides?: Partial<Product>) => Promise<Product>;
};

type Fixtures = { seed: SeedApi };

export const test = base.extend<Fixtures>({
  seed: async ({ baseURL }, use) => {
    const api: APIRequestContext = await request.newContext({
      baseURL,
      extraHTTPHeaders: { Authorization: `Bearer ${process.env.ADMIN_TOKEN}` },
    });
    const createdIds: string[] = [];

    const seed: SeedApi = {
      async createProduct(overrides = {}) {
        const res = await api.post('/api/products', {
          data: { name: 'Default', price: 999, stock: 1, ...overrides },
        });
        if (!res.ok()) throw new Error(`Seed failed: ${res.status()} ${await res.text()}`);
        const product = await res.json();
        createdIds.push(product.id);
        return product;
      },
    };

    await use(seed);

    // Teardown: runs even if the test fails.
    for (const id of createdIds.reverse()) {
      await api.delete(`/api/products/${id}`).catch(() => { /* already gone */ });
    }
    await api.dispose();
  },
});

export { expect } from '@playwright/test';

The fixture tracks every ID it creates and deletes them in reverse order during teardown — the reverse matters when records have foreign-key dependencies (delete the order before the user it belongs to). The teardown code after await use(seed) runs whether the test passes or throws, so leaked rows never pile up.

// product.spec.ts
import { test, expect } from './fixtures/seed';

test('discount banner shows for cheap products', async ({ page, seed }) => {
  const product = await seed.createProduct({ name: 'Bargain Mug', price: 199 });

  await page.goto(`/products/${product.id}`);
  await expect(page.getByText('Bargain Mug')).toBeVisible();
  await expect(page.getByRole('status', { name: 'On sale' })).toBeVisible();
  // No manual cleanup — the fixture deletes Bargain Mug automatically.
});

Combining Seeded Data With Authentication

Most seeded scenarios need a logged-in user looking at the data. Driving the login form in every test is exactly the waste we are trying to avoid. The fastest pattern is to authenticate once via the API, capture the session with storageState, and reuse it for the browser context. You can seed the user and grab their token in the same step.

// fixtures/auth-seed.ts
import { test as base, request, APIRequestContext } from '@playwright/test';

type User = { id: string; email: string; token: string };
type Fixtures = { user: User };

export const test = base.extend<Fixtures>({
  user: async ({ baseURL }, use) => {
    const api: APIRequestContext = await request.newContext({ baseURL });

    // Seed a fresh user through the signup API.
    const email = `user-${Date.now()}@example.test`;
    const signup = await api.post('/api/users', {
      data: { email, password: 'Pw!12345', name: 'Seed User' },
    });
    const user: User = await signup.json();

    await use(user);

    await api.delete(`/api/users/${user.id}`).catch(() => {});
    await api.dispose();
  },
});

export { expect } from '@playwright/test';

To turn that token into a real browser session, write a storageState object and pass it to a new context. Because you set the cookie or local-storage entry yourself, the page loads already authenticated — no login screen, no flake.

import { test, expect } from './fixtures/auth-seed';

test('seeded user sees their dashboard', async ({ browser, user, baseURL }) => {
  const context = await browser.newContext({
    baseURL,
    storageState: {
      cookies: [],
      origins: [
        {
          origin: baseURL!,
          localStorage: [{ name: 'auth_token', value: user.token }],
        },
      ],
    },
  });
  const page = await context.newPage();

  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'Seed User' })).toBeVisible();

  await context.close();
});

If your app stores the session in a cookie instead of local storage, populate the cookies array of storageState with the right name, value, domain, and path. The principle is identical: seed the state, inject the session, skip the UI login.

Seeding Once Per Suite With a Project Dependency

Per-test seeding is correct for data a test mutates. But reference data — a catalog of categories, a set of feature flags, an admin account — only needs to exist once for the whole run. Playwright’s project dependencies let a dedicated “setup” project seed shared state and produce a storageState file that real test projects depend on.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: { baseURL: 'http://localhost:3000' },
  projects: [
    {
      name: 'setup',
      testMatch: /global\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: { storageState: 'playwright/.auth/admin.json' },
      dependencies: ['setup'], // runs after seeding completes
    },
  ],
});
// global.setup.ts
import { test as setup, expect } from '@playwright/test';

setup('seed shared reference data and admin session', async ({ request, baseURL }) => {
  // Idempotent seed: create categories only if they are missing.
  const categories = ['Books', 'Electronics', 'Toys'];
  for (const name of categories) {
    const res = await request.post('/api/categories', { data: { name } });
    expect([200, 201, 409]).toContain(res.status()); // 409 = already exists
  }

  // Log in once and persist the admin session for every test project.
  const login = await request.post('/api/login', {
    data: { email: 'admin@example.test', password: process.env.ADMIN_PW },
  });
  expect(login.ok()).toBeTruthy();
  await request.storageState({ path: 'playwright/.auth/admin.json' });
});

Here the request fixture inside the setup project shares its cookie jar with the login call, and request.storageState({ path }) serializes the resulting session to disk. Every test in the chromium project then boots with that admin session already loaded. Make the seed idempotent — accept a 409 conflict — so re-running the suite locally never explodes on duplicate data.

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Keeping Seeded Tests Isolated and Stable

Speed is only half the win; isolation is the other half. A seeding strategy that leaks state across tests trades UI flake for data flake, which is worse because it is non-deterministic. A few rules keep things honest with the Playwright database seeding E2E API pattern.

  • Unique identifiers per test. Suffix emails, SKUs, and slugs with Date.now() or a UUID so parallel workers never collide on the same record.
  • Create what you assert, assert what you create. Never depend on a record some other test made; that creates an ordering dependency the test runner will eventually violate.
  • Clean up in teardown, not at the top. Deleting in fixture teardown runs even when the test fails, so the next run starts clean.
  • Use a test-only seeding endpoint when you can. A guarded /api/test/seed route that bulk-creates a scenario in one call is faster and atomic compared to chaining many public calls.
  • Fail loudly on seed errors. Throw on a non-OK seed response so a broken setup surfaces as a clear arrange failure, not a confusing assertion failure later.

For environments where you want a guaranteed-clean database, pair API seeding with a database reset between runs — truncate-and-reseed in CI, or wrap each test in a transaction that rolls back. API seeding and a clean baseline are complementary: the reset gives you a blank slate, the API gives you the precise rows each test needs.

Conclusion

Moving setup out of the browser is the single biggest reliability and speed upgrade most E2E suites can make. With APIRequestContext, typed fixtures, storageState injection, and project dependencies, the Playwright database seeding E2E API approach gives you fast, isolated, self-cleaning tests that still validate the real user journey. Seed exactly what each test needs, inject the session to skip login, clean up in teardown, and reserve direct database writes for the few cases the API cannot cover. Adopt these patterns and your suite will spend its time testing behavior, not clicking through forms.

FAQ

Should I seed through the API or write directly to the database?

Prefer the API for almost everything. Going through your application layer enforces validation, password hashing, default values, and business rules, so seeded data is always valid the way real data is. It also avoids coupling tests to exact column names and schema details. Reserve direct database writes for data your API genuinely cannot create — like backdated timestamps, corrupted-state edge cases, or records behind a permission you do not want to expose over HTTP.

How do I clean up data created during a Playwright test?

Track the IDs you create inside a fixture and delete them in the teardown phase — the code after await use(...). Because Playwright runs fixture teardown even when the test fails, your cleanup always executes, preventing leaked rows. Delete dependent records first (reverse creation order) to respect foreign keys, and wrap each delete in a catch so an already-removed record does not fail teardown. For full isolation, combine this with a database reset between CI runs.

Can I reuse a seeded login session across many tests?

Yes. Authenticate once via the API in a setup project, then call request.storageState({ path: 'playwright/.auth/user.json' }) to save the session to disk. Reference that file with the storageState option in your test project and add a dependencies: ['setup'] entry so seeding runs first. Every test then starts already logged in, with no UI login step. For per-test users, build the storageState object in code and pass it to browser.newContext().

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.