SuperAPI AI Directory

test-data

Data & analytics

View repository

Updated 10-16 09:49

About this skill

Test Data Management

Apply these rules when deciding where to create test data, how to clean it up, and how to keep parallel runs from colliding.

Decision Framework: Where Should Test Data Live?

Scenario Strategy
Simple record (1–2 fields, no relationships) Create in the test, clean up in finally
Complex record (multiple relationships, prerequisites) Precreate in the test database project (deployed with the DB), reference by stable description in the test
Read-only validation (asserting on existing data shape) Use existing precreated data, no cleanup
Specific data state (e.g., a closed order, an inactive user) Seed via API or SQL before the test, clean up after

The rule of thumb: the more complex the setup, the more it belongs outside the test body. A long Given section obscures what's actually being tested.

The try/finally Cleanup Pattern

Tests that modify data must clean up. Use try/finally, never try/catch:

test('PREFIX-NNNNN: Add a customer', async ({ customersPage, customerEditPage, newDescription }) => {
  let cleanupNeeded = false;
  await test.step('When User clicks Add New', async () => {
    await customersPage.addNewButton.click();
  });
  try {
    await test.step('And saves the record', async () => {
      await customerEditPage.nameInput.fill(newDescription);
      await customerEditPage.emailInput.fill(`${newDescription}@example.com`);
      await customerEditPage.saveAndClose();
      cleanupNeeded = true;
    });
    await test.step('Then the record is displayed in the grid', async () => {
      await customersPage.verifyExists(`${newDescription}@example.com`);
    });
  } finally {
    if (cleanupNeeded) {
      await test.step('Cleanup', async () => {
        await customersPage.deleteRow(`${newDescription}@example.com`);
      });
    }
  }
});

Why try/finally, not try/catch

catch swallows the error and may skip cleanup. finally always runs. The pattern is: let the assertion failure propagate, but always clean up first.

Why the cleanupNeeded flag

Without the flag, if the save step fails before the record was actually created, the cleanup step would try to delete a non-existent record — which fails noisily and confuses the test report. Set cleanupNeeded = true only after the data-modifying action succeeds. The cleanup runs only if there's something to clean up.

Why the step is named exactly "Cleanup"

Not "Cleanup: delete the customer", not "Clean up the created data". Just "Cleanup". This keeps the step name stable across tests and matches the project's convention. The implementation goes in the step body, where the IDE and reviewers can see it.

Read-Only Tests — No Cleanup

Tests that don't modify state need no cleanup block. Document the intent by simply omitting the step:

test('PREFIX-NNNNN: Default grid display', async ({ customersPage }) => {
  await test.step('When the grid displays', async () => {
    await expect(customersPage.grid).toBeVisible();
  });
  await test.step('Then the default columns are visible', async () => {
    await customersPage.verifyGridColumnNames(customersPage.grid, ['Name', 'Email', 'Status']);
  });
  // No Cleanup step — this test does not modify state.
});

Do NOT add an empty await test.step('Cleanup', async () => {}); — it's noise.

Delete Tests Are Self-Cleaning

When the test action IS deletion, no separate cleanup is needed — the test cleans up its own data:

test('PREFIX-NNNNN Scenario 3: Delete a customer', async ({ customersPage, customerEditPage, newDescription }) => {
  await test.step('Given a customer exists', async () => {
    await customersPage.addNewButton.click();
    await customerEditPage.nameInput.fill(newDescription);
    await customerEditPage.saveAndClose();
  });
  await test.step('When User deletes the customer', async () => {
    await customersPage.deleteRow(newDescription);
  });
  await test.step('Then the customer is no longer in the grid', async () => {
    await customersPage.verifyNotExists(newDescription);
  });
  // No Cleanup — the test action removed the record.
});

Add a brief comment if it's not obvious.

Unique Data Identifiers for Parallel Safety

Tests within a file run serially, but tests across files run in parallel. If two tests in different files both create a customer named "Test Customer", they may collide.

Use random identifiers in test data:

test('PREFIX-NNNNN: Add', async ({ common, customersPage, customerEditPage }) => {
  const description = `Customer_${common.randomElement()}`;  // e.g., Customer_xZ9pq2K
  // ... or use the newDescription fixture
});

Available random helpers (on CommonTestFunctions)

Helper Returns
common.randomBoolean() true or false
common.randomNumber(length?) random integer, default 7 digits
common.randomElement() alphanumeric string with _ prefix, e.g., _xZ9pq2K
common.getRandomNumberByLength(length) numeric string of given length
common.getDateNDaysFromNow(days, culture?) formatted date string (e.g., for date inputs)

newDescription fixture (recommended)

The central fixture registry typically exposes a newDescription fixture that returns a unique random string per test:

test('PREFIX-NNNNN: Add', async ({ newDescription, customersPage }) => {
  // newDescription is a fresh random string scoped to this test
  // e.g., "Test_xZ9pq2K"
});

Use this fixture whenever you need a unique-data label. It's both convenient and self-documenting.

Random helpers on the page object

BasePage provides similar helpers without needing the common fixture:

const email = page.getRandomEmail();          // user12345@test.com
const desc = page.getRandomString(10);        // 10-char alphanumeric
const flip = page.coinFlip();                 // true 50% of the time

Anti-Patterns

Hardcoded record IDs

// WRONG — record IDs differ across environments
await customersPage.editById('12345').click();

// CORRECT — use stable descriptions for precreated records
await customersPage.editRowByDescription('Test Customer (Pre-created)');

Hardcoded dates

// WRONG — breaks in future years, locale-fragile
await page.typeDateValue(input, '03152024');

// CORRECT
await page.typeDateValue(input, `0315${page.thisYear}`);

Complex setup chains in the test

// WRONG — too much setup obscures what's under test
await test.step('Given complex preconditions', async () => {
  await api.createUser(...);
  await api.createOrg(...);
  await api.addUserToOrg(...);
  await api.createProject(...);
  await api.addUserToProject(...);
  await api.createTask(...);
  await api.assignTask(...);
  // ... 12 more steps ...
});

Move the setup to:

  • The test database project (precreate it during deploy)
  • A reusable helper method that wraps the API calls
  • A beforeAll that creates shared precondition data (only for truly shared state)

Shared mutable state

// WRONG — module-level state breaks parallel tests
let createdCustomerId: string;

test('Test A', async ({ ... }) => {
  createdCustomerId = await createCustomer();
});

test('Test B', async ({ ... }) => {
  await editCustomer(createdCustomerId);  // depends on Test A having run first
});

Every test must be independent — runnable on its own, in any order, in parallel.

try/catch instead of try/finally

// WRONG — catch swallows the error, cleanup may be skipped, reports look green
try {
  await test.step('When ...', async () => { /* ... */ });
} catch (e) {
  await test.step('Cleanup', async () => { /* ... */ });
  throw e;
}

// CORRECT
let cleanupNeeded = false;
try {
  await test.step('When ...', async () => {
    /* ... */
    cleanupNeeded = true;
  });
} finally {
  if (cleanupNeeded) {
    await test.step('Cleanup', async () => { /* ... */ });
  }
}

Cleanup that fails the test

Cleanup should be defensive — if the data-modifying step partially failed and left things in an unexpected state, cleanup should still try to leave the system clean. Consider wrapping deletes in try/catch internally:

await test.step('Cleanup', async () => {
  try {
    await customersPage.deleteRow(description);
  } catch (e) {
    console.warn(`Cleanup of ${description} failed: ${e.message}`);
    // Do not rethrow — let the test report the original failure if any
  }
});

(This is the rare case where try/catch inside cleanup is acceptable; the outer try/finally structure stays the same.)

API Seeding for Permission Tests

For tests that require a specific user-permission state, seed via API in beforeEach rather than navigating through the UI to configure permissions:

test.beforeEach(async ({ executeLocalProcedure }) => {
  await executeLocalProcedure.createUserGroupForPermissionsTest(
    'Customers', /* adding */ true, /* deleting */ false, /* editing */ true, /* viewing */ true
  );
  await executeLocalProcedure.associateUserWithUserGroup(testUserId, userGroupId, true);
});

This keeps the test focused on what it's actually testing (the permission's UI effect) rather than the setup of the permission state.

Further Reading

Additional reference docs (recipes, deep-dives, edge cases) will be added based on team feedback. PRs welcome.