Guide

Test Data Management: Best Practices for Automated Testing

By Pavel Borji··Founder @ Assrt

Poorly managed test data is the silent killer of reliable test suites. Shared mutable state, stale fixtures, and compliance violations create flaky tests and security risks. This guide covers everything you need to build a robust test data strategy, from factory patterns to CI/CD integration.

Most

In our experience, the majority of test failures teams investigate are caused by data issues rather than actual application bugs.

1. Why Test Data Management Matters

Test data management is the practice of creating, maintaining, and cleaning up the data that your automated tests depend on. When done poorly, it becomes the single largest source of flaky tests, false positives, and wasted engineering time. When done well, it enables fast, deterministic, and parallelizable test execution.

Flaky tests from shared data

The most common data antipattern is shared mutable state. When multiple tests read from and write to the same database rows, they create implicit dependencies on execution order. Test A creates a user, test B reads that user, test C deletes that user. Run them in a different order and everything breaks. Run them in parallel and you get race conditions that produce intermittent failures nobody can reproduce locally.

Shared test data also makes it impossible to run tests in isolation. You cannot debug test B without first running test A to set up its preconditions. This coupling defeats the purpose of automated testing: fast, independent verification of individual behaviors.

GDPR and compliance risks

Using production data snapshots for testing is a compliance minefield. GDPR, CCPA, HIPAA, and similar regulations impose strict rules on how personally identifiable information (PII) can be stored, processed, and transferred. Copying production data into test environments without proper anonymization can result in regulatory fines, data breach notifications, and loss of customer trust. Many organizations have learned this lesson the hard way.

Environment parity problems

Tests that pass in staging but fail in CI (or vice versa) are often caused by environment-specific data assumptions. Hardcoded IDs that exist in one database but not another, timestamps that depend on timezone settings, and file paths that differ between operating systems all create brittle tests that work only under specific conditions.

2. Test Data Strategies

There is no single correct approach to test data. The right strategy depends on your application domain, data complexity, regulatory requirements, and team size. Most mature test suites combine multiple strategies for different testing layers.

Static fixtures

Static fixtures are JSON, CSV, or SQL files checked into your repository alongside your tests. They provide predictable, version-controlled test data that every team member and CI environment can access identically. Fixtures work well for read-only tests and for establishing baseline data that does not change between test runs.

The downside of static fixtures is maintenance burden. As your schema evolves, every fixture file must be updated in lockstep. With hundreds of fixture files, this becomes a significant tax on feature development.

Dynamic generation

Dynamic test data generation creates fresh data for each test run using builder patterns or factory functions. Each test gets its own isolated dataset, eliminating cross-test dependencies entirely. Libraries like Faker.js can generate realistic names, emails, addresses, and other domain-specific values.

Production snapshots

Sanitized production snapshots capture the complexity and edge cases of real-world data. They expose bugs that synthetic data misses: Unicode characters in names, extremely long text fields, null values in unexpected columns, and data relationships that no developer would think to create manually. The key word is sanitized. Raw production data must never enter test environments.

Synthetic data

Synthetic data generators create statistically realistic datasets that match the distribution patterns of production data without containing any real PII. Tools like Gretel, Mostly AI, and custom generators using Faker.js can produce datasets that maintain referential integrity, realistic value distributions, and edge case coverage while being completely safe for testing.

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started

3. Data Factories and Builders

The builder pattern is the gold standard for test data creation in TypeScript projects. A data factory provides sensible defaults for every field while allowing individual tests to override only the fields they care about. This keeps tests readable and resilient to schema changes.

test/factories/user.factory.ts
import { faker } from "@faker-js/faker";

interface User {
  id: string;
  email: string;
  name: string;
  role: "admin" | "member" | "viewer";
  teamId: string;
  createdAt: Date;
  isVerified: boolean;
}

type UserOverrides = Partial<User>;

export function buildUser(overrides: UserOverrides = {}): User {
  return {
    id: faker.string.uuid(),
    email: faker.internet.email(),
    name: faker.person.fullName(),
    role: "member",
    teamId: faker.string.uuid(),
    createdAt: faker.date.recent({ days: 30 }),
    isVerified: true,
    ...overrides,
  };
}

// Specialized builders for common scenarios
export function buildAdmin(overrides: UserOverrides = {}): User {
  return buildUser({ role: "admin", ...overrides });
}

export function buildUnverifiedUser(overrides: UserOverrides = {}): User {
  return buildUser({ isVerified: false, ...overrides });
}

// Build multiple users with the same team
export function buildTeam(count: number, teamId?: string): User[] {
  const sharedTeamId = teamId ?? faker.string.uuid();
  return Array.from({ length: count }, (_, i) =>
    buildUser({
      teamId: sharedTeamId,
      role: i === 0 ? "admin" : "member",
    })
  );
}

The key principle is that each test expresses only the data properties it actually tests. A test verifying that admins can delete users only needs to specify role: "admin". All other fields get reasonable defaults. When you add a new required field to the User interface, you update the factory once instead of modifying hundreds of test files.

test/factories/order.factory.ts
import { faker } from "@faker-js/faker";
import { buildUser } from "./user.factory";

interface OrderItem {
  productId: string;
  name: string;
  quantity: number;
  priceInCents: number;
}

interface Order {
  id: string;
  userId: string;
  items: OrderItem[];
  status: "pending" | "paid" | "shipped" | "delivered";
  totalInCents: number;
  createdAt: Date;
}

function buildOrderItem(
  overrides: Partial<OrderItem> = {}
): OrderItem {
  const quantity = overrides.quantity ?? faker.number.int({ min: 1, max: 5 });
  const priceInCents =
    overrides.priceInCents ?? faker.number.int({ min: 500, max: 50000 });
  return {
    productId: faker.string.uuid(),
    name: faker.commerce.productName(),
    quantity,
    priceInCents,
    ...overrides,
  };
}

export function buildOrder(overrides: Partial<Order> = {}): Order {
  const items = overrides.items ?? [buildOrderItem(), buildOrderItem()];
  const totalInCents = items.reduce(
    (sum, item) => sum + item.priceInCents * item.quantity,
    0
  );
  return {
    id: faker.string.uuid(),
    userId: buildUser().id,
    items,
    status: "pending",
    totalInCents,
    createdAt: faker.date.recent({ days: 7 }),
    ...overrides,
    // Recalculate total if items were overridden
    ...(overrides.items && !overrides.totalInCents
      ? { totalInCents }
      : {}),
  };
}

For complex domain models, factories can compose other factories. An order factory calls the user factory to generate a valid user ID and the order item factory to generate line items. Computed fields like totals are derived automatically, so your test data is always internally consistent.

4. Database Seeding for E2E Tests

End-to-end tests need data in a real database, not just in-memory objects. The challenge is seeding the database quickly, ensuring isolation between tests, and cleaning up reliably even when tests fail. There are three primary approaches, each with distinct tradeoffs.

Transaction rollback

Wrap each test in a database transaction and roll it back when the test completes. This is the fastest cleanup strategy because it avoids writing data to disk. The limitation is that it only works when the test and the application share the same database connection, which is typically only true for integration tests, not full E2E tests where the app runs as a separate process.

Isolated schemas or databases

Create a separate database schema (or entire database) for each test worker. Playwright supports multiple workers for parallel execution, and each worker can target its own schema. This provides complete isolation at the cost of higher setup time and resource consumption.

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

export default defineConfig({
  workers: 4,
  globalSetup: "./test/global-setup.ts",
  globalTeardown: "./test/global-teardown.ts",
  use: {
    baseURL: "http://localhost:3000",
  },
  projects: [
    {
      name: "chromium",
      use: { browserName: "chromium" },
    },
  ],
});
test/global-setup.ts
import { Pool } from "pg";

const NUM_WORKERS = 4;

export default async function globalSetup() {
  const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
  });

  for (let i = 0; i < NUM_WORKERS; i++) {
    const schema = `test_worker_${i}`;
    await pool.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
    await pool.query(`CREATE SCHEMA ${schema}`);

    // Run migrations against the worker schema
    await pool.query(`SET search_path TO ${schema}`);
    // ... apply migrations here
  }

  await pool.end();
}

Before/after hooks with truncation

The most common approach for E2E tests is to truncate all tables before each test (or test suite) and seed the required data. This is simpler than schema isolation but prevents parallel execution unless you use separate databases.

test/fixtures/db.fixture.ts
import { test as base } from "@playwright/test";
import { Pool } from "pg";
import { buildUser, buildAdmin } from "../factories/user.factory";

type DbFixture = {
  db: Pool;
  seedUser: (overrides?: Partial<User>) => Promise<User>;
};

export const test = base.extend<DbFixture>({
  db: async ({}, use) => {
    const pool = new Pool({
      connectionString: process.env.DATABASE_URL,
    });
    await use(pool);
    await pool.end();
  },

  seedUser: async ({ db }, use) => {
    const createdIds: string[] = [];

    const seedUser = async (overrides = {}) => {
      const user = buildUser(overrides);
      await db.query(
        "INSERT INTO users (id, email, name, role) VALUES ($1, $2, $3, $4)",
        [user.id, user.email, user.name, user.role]
      );
      createdIds.push(user.id);
      return user;
    };

    await use(seedUser);

    // Cleanup: delete all users created during this test
    for (const id of createdIds) {
      await db.query("DELETE FROM users WHERE id = $1", [id]);
    }
  },
});

5. Handling Authentication Data

Authentication is the most common bottleneck in E2E test suites. Every test that requires a logged-in user either needs to perform the login flow (slow) or reuse saved authentication state (fast). Playwright provides built-in support for both approaches through its storageState mechanism.

Test accounts

Create dedicated test accounts for each role in your application. Store credentials in environment variables, never in test source code. Each test environment (local, CI, staging) should have its own set of test accounts to avoid conflicts when multiple environments run tests simultaneously.

Token management with storageState

Playwright can save and restore browser storage state (cookies, local storage, session storage) to a JSON file. You log in once during global setup and reuse that state across all tests, eliminating repeated login flows.

test/auth.setup.ts
import { test as setup } from "@playwright/test";
import path from "path";

const authFile = path.join(__dirname, ".auth/user.json");
const adminAuthFile = path.join(__dirname, ".auth/admin.json");

setup("authenticate as user", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.waitForURL("/dashboard");
  await page.context().storageState({ path: authFile });
});

setup("authenticate as admin", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.TEST_ADMIN_EMAIL!);
  await page.getByLabel("Password").fill(process.env.TEST_ADMIN_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.waitForURL("/dashboard");
  await page.context().storageState({ path: adminAuthFile });
});
playwright.config.ts (projects section)
projects: [
  { name: "setup", testMatch: /auth\.setup\.ts/ },
  {
    name: "user-tests",
    dependencies: ["setup"],
    use: {
      storageState: "test/.auth/user.json",
    },
  },
  {
    name: "admin-tests",
    dependencies: ["setup"],
    use: {
      storageState: "test/.auth/admin.json",
    },
  },
],

This pattern reduces total test suite execution time significantly. Instead of each test spending 2 to 5 seconds on login, the entire suite authenticates once per role during setup. For a suite with 50 tests, that saves 100 to 250 seconds of wall clock time.

6. Data Masking and Privacy

If your test strategy involves any form of production data (snapshots, database copies, log replays), you must implement data masking before that data enters any test environment. This is not optional. GDPR Article 32 requires appropriate technical measures to protect personal data, and using unmasked production data in test environments violates that requirement.

Identifying PII fields

Start by cataloging every field in your database that contains personally identifiable information. This includes obvious fields like names, emails, and phone numbers, but also less obvious ones: IP addresses, device fingerprints, geolocation data, free-text fields that users might fill with personal information, and external IDs that could be used for cross-referencing.

Masking strategies

Different fields require different masking approaches. Emails should be replaced with synthetic emails that maintain the format (user@example.com) but contain no real data. Names should be replaced with faker-generated names. Numeric IDs can often be kept as-is since they are not personally identifiable in isolation. Free-text fields should be replaced entirely with synthetic text.

scripts/mask-production-data.ts
import { faker } from "@faker-js/faker";
import { createHash } from "crypto";

// Deterministic masking: same input always produces same output
// This preserves referential integrity across tables
function deterministicEmail(original: string): string {
  const hash = createHash("sha256")
    .update(original + process.env.MASK_SEED)
    .digest("hex")
    .slice(0, 8);
  return `user_${hash}@test.example.com`;
}

function deterministicName(original: string): string {
  const seed = createHash("sha256")
    .update(original + process.env.MASK_SEED)
    .digest("hex");
  faker.seed(parseInt(seed.slice(0, 8), 16));
  return faker.person.fullName();
}

const MASKING_RULES: Record<string, (val: string) => string> = {
  email: deterministicEmail,
  name: deterministicName,
  phone: () => faker.phone.number(),
  address: () => faker.location.streetAddress(),
  ip_address: () => faker.internet.ipv4(),
};

export async function maskTable(
  table: string,
  columns: string[]
) {
  // Apply masking rules column by column
  for (const col of columns) {
    const rule = MASKING_RULES[col];
    if (!rule) {
      throw new Error(`No masking rule for column: ${col}`);
    }
    // ... apply rule to all rows
  }
}

The key insight is deterministic masking. By using a seeded hash function, the same input always produces the same masked output. This preserves foreign key relationships and referential integrity. If user A's email appears in the users table and the orders table, both instances get masked to the same synthetic email.

7. Environment-Specific Data

Your test suite runs in multiple environments: local development machines, CI runners, staging servers, and sometimes preview deployments. Each environment has different database contents, API endpoints, and third-party service configurations. Your test data strategy must account for these differences without scattering environment-specific logic throughout your test files.

Environment variables for data configuration

Centralize all environment-specific values in environment variables. Database connection strings, API base URLs, test account credentials, and feature flags should all come from the environment, never from hardcoded values in test files.

test/config.ts
export const testConfig = {
  baseURL: process.env.BASE_URL ?? "http://localhost:3000",
  databaseURL: process.env.DATABASE_URL ?? "postgres://localhost:5432/app_test",
  apiKey: process.env.TEST_API_KEY ?? "test-key-local",
  seedStrategy: process.env.SEED_STRATEGY ?? "truncate-and-seed",
  environment: process.env.TEST_ENV ?? "local",
} as const;

// Fixtures that differ per environment
export const fixtures = {
  local: {
    stripeKey: "sk_test_local_xxx",
    webhookSecret: "whsec_local_xxx",
  },
  ci: {
    stripeKey: process.env.STRIPE_TEST_KEY!,
    webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
  },
  staging: {
    stripeKey: process.env.STRIPE_TEST_KEY!,
    webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
  },
} as const;

export function getFixtures() {
  const env = testConfig.environment as keyof typeof fixtures;
  return fixtures[env] ?? fixtures.local;
}

Fixtures per environment

For data that must differ between environments (third-party API keys, webhook endpoints, OAuth redirect URLs), maintain separate fixture files or configuration objects keyed by environment name. Your test setup code selects the correct fixture set based on the current environment, keeping individual test files clean and environment-agnostic.

Avoid the temptation to maintain completely separate seed scripts per environment. Instead, parameterize a single seed script with environment-specific values. This reduces duplication and ensures that your seeding logic stays consistent as your schema evolves.

8. Integration with CI/CD

CI/CD pipelines introduce unique constraints for test data management. You need fast setup, reliable cleanup, and complete isolation between parallel pipeline runs. The database must be provisioned, seeded, tested against, and torn down within the lifetime of a single pipeline execution.

Data setup in pipelines

Use a dedicated seed script that runs as part of your CI pipeline before the test step. This script should be idempotent so it can be rerun safely if the pipeline retries. It should also be fast: if your seed script takes 60 seconds, that is 60 seconds added to every CI run.

.github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: app_test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci
      - run: npx prisma migrate deploy
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/app_test

      - run: npm run db:seed:test
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/app_test

      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/app_test
          BASE_URL: http://localhost:3000
          TEST_ENV: ci

Parallel test data isolation

When running tests in parallel (multiple Playwright workers or multiple CI jobs), each parallel execution needs its own isolated data. The simplest approach is to use unique prefixes or namespaces for data created by each worker. Playwright exposes the worker index through testInfo.parallelIndex, which you can use to partition data.

test/fixtures/isolated-data.ts
import { test as base } from "@playwright/test";

export const test = base.extend<{
  workerPrefix: string;
}>({
  workerPrefix: async ({}, use, testInfo) => {
    const prefix = `w${testInfo.parallelIndex}_`;
    await use(prefix);
  },
});

// Usage in tests:
test("create organization", async ({ page, workerPrefix }) => {
  const orgName = `${workerPrefix}Acme Corp`;
  await page.getByLabel("Organization name").fill(orgName);
  // Each worker creates uniquely-named data
  // No conflicts even with parallel execution
});

Cleanup strategies

In CI environments, cleanup is less critical than in persistent environments because the database service is destroyed at the end of the pipeline. However, for shared staging databases, cleanup is essential. Implement cleanup as afterEach/afterAll hooks or as a separate pipeline step. Always design cleanup to be safe to skip: if cleanup fails, the next test run should still work because setup is idempotent and creates fresh data.

The most resilient CI data strategy combines ephemeral databases (created and destroyed per pipeline run) with factory-generated data (no shared state between tests) and worker-scoped isolation (parallel-safe). This combination eliminates every common source of data-related test flakiness.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk