TechByteByByte

Part 21: Test Data Management

Create, isolate and clean up test data for dependable automation suites.

Test data is the users, products, and records a test needs. Reliable tests control who creates and removes it.

create unique data → test → verify → clean up

Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com

Every test in this series so far has used the exact same, hardcoded username: standard_user. That’s been fine for learning — but it quietly hides a real, common problem: what happens when two tests, running in parallel (Part 27 covers this properly), both try to create a user with the exact same email address? Or when test data from last week’s run is still sitting in a database, silently affecting today’s results? This part is about handling data deliberately, rather than letting it become an accidental source of flaky, unreliable tests.


Hardcoded Data — Fine, Until It Isn’t

await page.getByPlaceholder("Username").fill("standard_user");

Hardcoded values are perfectly reasonable for genuinely fixed, known, reusable test accounts — exactly what SauceDemo’s own standard_user is: a permanent, designed-for-testing account that’s meant to be reused indefinitely, precisely because logging in doesn’t create any new, potentially conflicting data.

The problem arises specifically with data that represents something being created — a new user registration, a new order — where reusing the exact same hardcoded value across multiple test runs risks a collision: a second run might fail simply because “this email already exists,” a failure that has nothing to do with whether the registration feature actually works correctly.

JSON Data Files

For data you want to keep separate from your test logic — genuinely useful once you have many tests sharing a set of related data — a JSON file works well:

// test-data/users.json
{
  "standardUser": { "username": "standard_user", "password": "secret_sauce" },
  "lockedOutUser": {
    "username": "locked_out_user",
    "password": "secret_sauce"
  },
  "problemUser": { "username": "problem_user", "password": "secret_sauce" }
}
import users from "../test-data/users.json";

test("locked out user cannot log in", async ({ page }) => {
  await loginPage.login(
    users.lockedOutUser.username,
    users.lockedOutUser.password,
  );
  // ...
});

This separates what data a test uses from what a test does with it — genuinely useful once the same data is referenced across many test files, since it can be updated in exactly one place.


Dynamic and Random Data with Faker.js

For data that genuinely needs to be unique per test run — new user registrations being the clearest example — hardcoding a value is actively risky, for exactly the collision reason above. Faker.js generates realistic, random test data on demand:

npm install @faker-js/faker --save-dev
import { faker } from "@faker-js/faker";

test("user can register a new account", async ({ page }) => {
  const email = faker.internet.email();
  const password = faker.internet.password();
  const firstName = faker.person.firstName();

  console.log(email); // e.g., "Destiny.Runolfsdottir23@hotmail.com" — different every run

  await page.getByLabel("Email").fill(email);
  await page.getByLabel("Password").fill(password);
  await page.getByLabel("First Name").fill(firstName);
  await page.getByRole("button", { name: "Register" }).click();

  await expect(page.getByText(`Welcome, ${firstName}`)).toBeVisible();
});

Every single run of this test generates a genuinely new, unique email address — eliminating the collision problem entirely, without you needing to manually track which values have already been “used up” by previous runs. This is worth connecting directly back to Part 2’s discussion of functions: faker.internet.email() is simply a function call, returning a new value each time it’s invoked, exactly like any other function you’ve already learned to reason about.

Analogy: Disposable Lab Flasks vs. The Water Fountain

  • Hardcoded Data (Lobby Water Fountain): Reusing static credentials like standard_user is like taking a sip from the lobby water fountain. It’s clean, permanent, and doesn’t change. Anyone can drink from it safely as long as they don’t contaminate it.
  • Dynamic Data (Disposable Flasks): Testing a new account registration is like running a chemical reaction in a lab. You cannot reuse the same dirty flask from yesterday’s experiment (colliding duplicate email records). You grab a sterile, single-use disposable flask (Faker.js dynamic value), run your experiment, and immediately dispose of it/neutralize it afterward (cleanup in afterEach hook) to ensure the next researcher starts with clean workspace conditions.

📊 Visual Flowchart: Dynamic Data Lifecycle and Cleanup

Here is how test data is created, tracked, utilized, and cleaned up to prevent database bloat:

graph TD
    Start["1. Test starts"] --> Gen["2. Faker.js generates email:<br>'john.doe.123@example.com'"]
    Gen --> Track["3. Add email to 'recordsToDelete' array"]
    Track --> Action["4. Submit registration form UI"]
    Action --> Verify["5. Assert registration confirmation visible"]
    Verify --> Teardown["6. afterEach hook executes"]

Teardown --> DB_Query["7. Run delete query or API request<br>for 'john.doe.123@example.com'"]
    DB_Query --> End["8. Test environment remains clean"]

Test Data Isolation in Parallel Runs

Here’s a subtlety worth being explicit about, since it becomes genuinely important once Part 27’s parallel execution enters the picture: if ten tests run simultaneously, and all ten happen to call faker.internet.email() at effectively the same moment, could two of them ever generate the exact same value by pure chance, causing an intermittent, hard-to-reproduce collision anyway?

In practice, Faker’s randomness space is large enough that a genuine collision is extremely unlikely for something like an email address — but the principle is worth internalizing beyond just this one library: whenever multiple tests might run concurrently and each needs its own independent, uncontaminated slice of data, generating that data dynamically and uniquely per test — rather than relying on a shared, fixed value, or a shared, mutable resource like “the first row of a database table” — is the safer, more deliberately isolated design.

Cleanup

Data your tests create often needs to be cleaned up afterward — otherwise, a test database can accumulate an ever-growing pile of leftover test accounts, orders, and other artifacts across every single run, which can itself eventually cause new problems (slower queries, confusing manual inspection of the database, and in some cases genuine data limits).

test("user can register a new account", async ({ page, request }) => {
  const email = faker.internet.email();
  // ... registration steps ...

  // Cleanup — remove the test data created, regardless of what happened above
});

test.afterEach(async ({ request }, testInfo) => {
  // A common pattern: track created data during the test, then delete it here,
  // ensuring cleanup happens even if the test itself failed partway through
});

The afterEach approach (recall Part 14) is worth using here specifically because it runs regardless of whether the test passed or failed — cleanup that only happens in the “happy path” of a test’s own code risks silently leaving orphaned data behind every time that test actually fails partway through, which is precisely the situation you’d most want cleanup to still occur.

Environment-Specific Data, Secrets, and Sensitive Information

Recall Part 16’s .env pattern — the same reasoning applies directly to sensitive test data, not just configuration values. A test account’s password, an API key needed to seed test data, a privileged admin account’s credentials — none of these belong hardcoded directly in a test file that’s committed to Git.

// .env (never committed — see Part 5)
ADMIN_PASSWORD = a_real_secret_value;

// test file
const adminPassword = process.env.ADMIN_PASSWORD;

It’s worth being honest about a nuance here specific to test data, though: SauceDemo’s standard_user / secret_sauce credentials are, deliberately, public and openly published precisely because SauceDemo is a demo application built for practicing exactly this kind of testing — treating them as a secret would be both unnecessary and slightly miss the point of what they’re for.

The real principle isn’t “every credential must always be hidden” — it’s “any credential that grants access to something genuinely sensitive, private, or belonging to a real system must never be hardcoded into version-controlled source code,” which is a meaningfully narrower, more precise rule than “hide all passwords always,” and worth being able to articulate the distinction clearly.


How It Works in a Real Test Run

Test data has a lifecycle: create a unique record, use it in one isolated scenario, verify the result, and clean it up or let an isolated environment expire it. Random values reduce collisions only when the test records the generated value for assertions and debugging.

Parallel tests need unique server-side identities, not merely separate browser contexts. A context isolates cookies and local storage, but two workers can still edit the same database customer.

Interview Questions

Q: Why is hardcoding a specific username generally fine, but hardcoding a specific email for a registration test genuinely risky?

Ans: A fixed username like SauceDemo’s standard_user is a permanent account designed specifically for reuse — logging in doesn’t create new, potentially conflicting data. A registration test, by contrast, creates a new record every time it runs; reusing the exact same hardcoded email across multiple runs risks a collision, where a run fails simply because that email already exists from a previous run — a failure unrelated to whether the registration feature actually works.

Q: What problem does a tool like Faker.js solve for test data?

Ans: It generates realistic, unique data on demand for each test run, eliminating the need to manually invent and track unique values yourself, and removing the collision risk that comes from reusing the same hardcoded values repeatedly — particularly important for data representing something being newly created, like a user registration.

Q: Why does cleanup logic for test-created data commonly belong in an afterEach hook rather than directly at the end of the test’s own code?

Ans: afterEach runs regardless of whether the test passed or failed, whereas cleanup written directly into a test’s own linear code would be skipped entirely if the test failed and stopped partway through, before reaching that cleanup step. Since a failed test is exactly the scenario most likely to leave behind orphaned or incomplete data, relying on afterEach for cleanup ensures it still runs even in that case.

Q: Why shouldn’t sensitive credentials, like a real admin account’s password, be hardcoded directly into a test file?

Ans: Test files are typically committed to Git, and Part 5 already established that anything committed to version control remains in the project’s history indefinitely, visible to anyone with repository access — hardcoding a real, sensitive credential there means it’s permanently exposed. Loading it from an untracked .env file, or a secrets manager in CI, keeps the actual value out of version control entirely while the code itself remains unchanged.

Q: Are SauceDemo’s public standard_user / secret_sauce credentials an exception to “never hardcode credentials”? Why or why not?

Ans: They’re a reasonable exception, because they’re deliberately public, non-sensitive demo credentials with no real access or data behind them — SauceDemo publishes them specifically for people to practice testing with. The actual underlying principle isn’t “hide every credential unconditionally,” but “never hardcode credentials that grant access to something genuinely sensitive or belonging to a real system” — a narrower, more precise rule that SauceDemo’s demo credentials simply don’t fall under.

Q: In a suite running tests in parallel, why might generating data dynamically per test be safer than relying on a shared, fixed resource, even beyond avoiding simple hardcoded-value collisions?

Ans: When multiple tests run concurrently, any shared, mutable resource — a fixed record they all read or modify, like “the first row in a table” — creates a real risk that one test’s actions interfere with another’s expectations, since there’s no guarantee of ordering between them. Generating independent, uniquely-scoped data per test avoids this kind of cross-test interference entirely, giving each test its own isolated slice of data to work with regardless of what else is running concurrently.


Exercises — Part 21

Understand: Explain, in your own words, why a hardcoded email address used in a registration test might cause that test to fail on its second run, even though the registration feature itself works perfectly correctly.

Simple Practice: Install Faker.js and write a test (against any public site with a registration or contact form, or a mocked one using Part 19’s techniques) that generates a random name and email, fills them into a form, and asserts on a resulting success message.

Real-World Scenario: Design a cleanup strategy, in writing, for a test suite that creates new orders in a test environment during each run. Consider: what would you track during the test to know what needs cleaning up, where would that cleanup logic live, and what would happen to that cleanup if the test itself failed partway through creating the order?

Challenge: Identify, in a real or hypothetical test suite, one piece of hardcoded data that’s genuinely fine to hardcode (a reusable, non-colliding value) and one that genuinely should be made dynamic or moved to an environment variable, and justify each classification using this part’s reasoning.


Next: Part 22 — Database Testing

— SQL basics, and verifying data all the way down to the database layer, connecting directly back to Part 0’s three-layer application model.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed