Test Data, Fixtures and Hooks

An automated test needs a known starting point: a customer who exists, an order with one item, a page open in the browser. Test data is the values a test uses; a fixture is reusable setup that creates the state a test needs, hands it to the test and removes it afterwards; hooks are functions the test runner calls automatically before or after tests, such as beforeEach and afterEach. A checkout test, for example, receives a fresh customer from a fixture, opens the cart in a beforeEach hook, and the fixture deletes the customer when the test ends, whether it passed or failed.

Parameterised, or data-driven, tests solve the opposite problem: one test body runs once for every row of a data table, so six discount codes become six reported tests without six copies of the code. The lesson on test data covered which values to choose; this lesson covers where those values live in automated tests, and how they are created and cleaned up.

Why it matters

Many unreliable automated suites fail because of state, not because of the product. A test passes alone and fails in the full run because an earlier test changed the shared account; a run fails on Monday because the coupon the test relies on has expired; leftover orders from thousands of runs slow a list page until its tests time out. How a suite sets up and cleans up decides whether a red result means a defect or just noise, and noise teaches a team to ignore red results.

Setup also decides maintenance cost. When the sign-in flow changes, a fixture is one place to update; twenty copied setup blocks are twenty. Data-driven tests let you add a case by adding a row, and a readable table lets a reviewer or a product owner check the cases without reading code.

Where test data lives in an automated test

A test runner executes tests unattended, in any order and often in parallel, so every value a test depends on must come from somewhere the test controls. A test that silently relies on a record someone created by hand in a shared environment will fail on the day that record is edited or deleted, and the failure will look like a product defect.

Two rules apply wherever the data lives. A test owns the data it changes: only read-only reference data, such as the list of countries or a product catalogue no test edits, may be shared. And a field that must be unique gets a unique value per run, for example sara+${runId}@example.com, so a second run or a parallel worker does not fail with "already exists".

The data itself can live in four places; choose by what the reader of the test needs to see.

  • Inline in the test: the one or two values that explain the test, such as quantity: 0 in a test named "rejects a zero quantity". Keep the value that matters visible next to the assertion.
  • A builder or factory function: makeCustomer({ country: "JO" }) returns a complete, valid record with sensible defaults, and the test overrides only the field it is about. When a new mandatory field appears, you change the factory once.
  • A data file: a CSV or JSON table in the repository, next to the tests, when many cases share one test body. It is reviewed and versioned like code.
  • State created before the test: records that must exist, created through the API or a seed script rather than by clicking through screens, which is slower and fails for reasons the test is not about.

Try it: Test Data Factory Try it: UUID Generator

Hooks: code the runner calls before and after tests

Most runners offer four hooks. beforeAll runs once before the first test of a file or group, beforeEach before every test, afterEach after every test and afterAll once after the last one. Jest and Vitest use exactly these names; Playwright Test writes them as test.beforeEach and so on; JUnit 5 uses the annotations @BeforeAll, @BeforeEach, @AfterEach and @AfterAll. pytest supports simple setup and teardown functions too, but its usual tool is the fixture, described next.

For each test the order is: the beforeEach hooks, the test body, the afterEach hooks. In nested groups, the outer group's beforeEach runs before the inner one's. The important property is that after-hooks run even when the test fails. A failed assertion stops the test body at that line, so cleanup written as the last lines of the body never runs, while cleanup in afterEach does.

Use beforeEach for state that tests change, so each test starts fresh. Use beforeAll only for expensive setup that tests read but never change, such as starting a stub server: mutable data created once and changed by several tests makes the result depend on the order the tests ran in. In Playwright Test, beforeAll runs once per worker process, and again in the new worker that replaces one after a test fails, so it must be safe to run more than once.

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

test.describe('saved addresses', () => {
  const created: string[] = [];              // ids of addresses these tests created

  test.beforeEach(async ({ page }) => {
    await page.goto('/account/addresses');   // every test starts on the same page
  });

  test.afterEach(async ({ request }) => {      // request: an HTTP client for the API
    for (const id of created) {              // runs even when the test failed
      await request.delete(`/api/addresses/${id}`);
    }
    created.length = 0;
  });

  test('lists an address added through the API', async ({ page, request }) => {
    const res = await request.post('/api/addresses', { data: { city: 'Riyadh' } });
    created.push((await res.json()).id);
    await page.reload();
    await expect(page.getByText('Riyadh')).toBeVisible();
  });
});

Fixtures: setup a test asks for by name

A fixture packages three things: the setup, the value the test receives, and the teardown. The test asks for a fixture by name instead of calling setup code itself. In Playwright Test, page (a browser page) and request (an HTTP client for calling the API directly) are built-in fixtures, and you add your own with test.extend: code before await use(value) is setup, the test runs during use, and the code after it is teardown, which runs whether the test passed or failed. In pytest, a fixture is a function marked @pytest.fixture that a test requests by naming it as a parameter; it hands over the value with yield, and the code after yield is the teardown.

Compared with hooks, fixtures run on demand: only the tests that ask for customer pay for creating one. They compose: an order fixture can ask for the customer fixture, and teardown runs in reverse order, so the order is deleted before its customer. They have a scope: per test by default, or shared more widely, per worker in Playwright or per module or session in pytest, for expensive things the tests only read. In Playwright Test, a fixture is by default set up lazily, just before the first hook or test that asks for it: page, used by a beforeEach hook, is ready before that hook runs, while a customer that only the test body asks for is created after the beforeEach hooks. Test-scoped fixtures are torn down after the afterEach hooks.

Keep the values that matter visible. A fixture should hide how a customer is created, not what kind of customer the test needs; when a test depends on a detail such as the customer's country, pass it in or create a separate, clearly named fixture.

In Playwright Test a fixture is set up just before the first hook or test body that asks for it and torn down after the afterEach hooks, and afterEach and teardown still run when an assertion fails.
  1. 1beforeAll hookBefore the first test of the file or group, once per worker; only setup the tests read and never changeonce
  2. 2page fixture setupSet up first because the beforeEach hook asks for page
  3. 3beforeEach hookOpens the page every test starts on
  4. 4customer fixture setupOnly the test body asks for it, so it is set up now: code before await use() creates a fresh customer through the API
  5. 5Test bodyActs and asserts, using the customer the fixture handed over

    Branch: An assertion fails: the body stops at that line, and steps 6 and 7 still runFail

  6. 6afterEach hookRuns whether the test passed or failed, for example to delete ids the test recorded
  7. 7Fixture teardownIn reverse order of setup: code after use() deletes the customer, then page is closed
  8. 8afterAll hookAfter the last test of the file or grouponce

Throughout steps 2–7 Repeated for every test

Order as documented for Playwright Test. Jest, Vitest and JUnit run the same four hooks; pytest builds these steps from fixtures.

// fixtures.ts
import { test as base, expect } from '@playwright/test';
import { randomUUID } from 'node:crypto';

type Customer = { id: string; email: string };

export const test = base.extend<{ customer: Customer }>({
  customer: async ({ request }, use) => {
    // setup: a fresh customer for this test only
    const email = `sara+${randomUUID()}@example.com`;
    const res = await request.post('/api/customers', { data: { email } });
    const customer: Customer = await res.json();

    await use(customer);                     // the test runs here

    // teardown: runs after the test, passed or failed
    await request.delete(`/api/customers/${customer.id}`);
  },
});
export { expect };

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

test('a new customer has no orders', async ({ request, customer }) => {
  const res = await request.get(`/api/customers/${customer.id}/orders`);
  expect(await res.json()).toEqual([]);
});

Try it: Fake User Generator

Cleanup: leave the environment as you found it

Leftover data is not harmless. It fills the lists and search results other tests assert on, collides with unique constraints, and confuses manual testers in a shared test environment. Every test that creates something needs a plan for removing it, or an environment that is thrown away after use.

  • Put cleanup in an after-hook or in fixture teardown, never only at the end of the test body.
  • Delete exactly what the test created, by id. Never clean up with a broad query such as "delete every customer whose email ends in example.com": in a shared environment it removes other people's data and the data of parallel runs.
  • Keep setup steps separate, so that one failing step does not skip the teardown of the steps before it; the pytest documentation recommends one state-changing action per fixture for this reason.
  • Make teardown tolerant of a record that is already gone, for example because the test deleted it itself, but report any other cleanup failure instead of swallowing it.
  • Prefer data that disposes of itself where the setup allows it: a database transaction rolled back after each test, in integration tests that share the database connection with the code under test; a fresh container or a reset environment per run; or a separate tenant per run.
  • Mark created data with a run prefix such as AT-20261002-0412-, so a scheduled job can find and remove what crashed runs left behind.

Worked example: one test, a table of discount codes

Requirement: "The code SAVE10 gives 10% off orders of 200.00 SAR or more and is refused below that with a minimum-order error. Codes are not case-sensitive. An expired or unknown code leaves the total unchanged and shows an error." Six near-identical tests would copy the same steps six times. A data-driven test keeps one test body and moves the cases into a table; the ISTQB glossary describes data-driven testing as a scripting technique in which data files hold the test data and the expected results.

Every major runner supports this. In Jest and Vitest, test.each(rows)("$case", fn) creates one test per row and puts the case value in its title. In pytest, @pytest.mark.parametrize("row", rows) does the same, and pytest.param(row, id=...) names each case. In JUnit 5, @ParameterizedTest combined with @CsvFileSource(resources = "/discount-cases.csv", numLinesToSkip = 1) reads the rows from a CSV file and skips its header line. Playwright Test has no test.each; its documentation creates one test() per row inside a loop, and every title must be unique.

The table below holds inputs and expected results only, and each expected total was worked out by hand from the requirement. The test reads the file and runs each row with a fresh cart from a fixture.

  • Give every row a name column. The report then says "expired code failed" instead of "row 5 failed".
  • Write values exactly as the system shows them: 200.00, not 200. In CSV, a value that contains a comma, a double quote or a line break should be enclosed in double quotes, and a double quote inside it is written twice (RFC 4180).
  • Pitfall, one giant table: a 200-row file with columns for every feature cannot be reviewed, and every change touches it. Keep one table per rule, small enough to read in a minute, and choose the rows with test design techniques such as boundary value analysis instead of piling rows up.
  • Pitfall, test logic hidden in data: columns such as click_apply_twice or skip_login turn the table into a program nobody tests. When rows need different steps, they are different tests. The same goes for expected values the test computes with the product's own formula: a defect in the formula then hides on both sides.
data/discount-cases.csv
case,code,cart_total,expected_total,error
valid code,SAVE10,250.00,225.00,
lower-case code,save10,200.00,180.00,
minimum order exactly,SAVE10,200.00,180.00,
just below minimum,SAVE10,199.99,199.99,MIN_ORDER
expired code,OLD5,200.00,200.00,EXPIRED
unknown code,NOPE10,200.00,200.00,NOT_FOUND

test_discounts.py  (pytest: one test body, six reported tests)
import csv
from decimal import Decimal
import pytest

def load_cases(path):
    with open(path, newline="", encoding="utf-8") as f:
        return [pytest.param(row, id=row["case"]) for row in csv.DictReader(f)]

@pytest.mark.parametrize("row", load_cases("data/discount-cases.csv"))
def test_discount_code(cart, row):           # cart: a fixture, fresh per test
    cart.set_total(Decimal(row["cart_total"]))
    result = cart.apply_code(row["code"])
    assert result.total == Decimal(row["expected_total"])
    assert result.error == (row["error"] or None)

Try it: CSV Dataset Generator Try it: CSV to JSON

Common mistakes

  • Writing cleanup as the last lines of the test body, where the first failed assertion skips it.
  • Creating data once in beforeAll and letting several tests change it, so a test passes or fails depending on which tests ran before it.
  • Depending on records created by hand in a shared environment, such as "customer 1001", which anyone can edit or delete.
  • Using a fixed value such as test@example.com for a field that must be unique, so the second run or a parallel worker fails with "already exists".
  • Hiding the values that matter inside a fixture, so a reader sees test("works", async ({ setup }) => …) and cannot tell what the test needs or checks.
  • Generating random values for the inputs that decide the expected result without logging them, so a failure cannot be reproduced.

The QA perspective

When you review an automated test, yours or a developer's, ask four questions about its data. Where does it come from? Who else can change it? What happens to it after the test? Can the test run alone, twice in a row and in parallel with itself? A test that fails one of these questions will one day fail for a reason that has nothing to do with the product. Agree with the developers early on how tests create and remove data: test-only API endpoints or seed scripts that are never deployed to production, conventions for unique values, and how often shared environments are reset.

Data-driven tables are where a tester adds the most value. The rows come from test design techniques such as equivalence partitioning, boundary value analysis and decision tables; each turns a rule into a few meaningful rows. A product owner can review a well-named table without reading code. When a row fails, the bug report should name the row, its data and the ids the fixtures created, so the developer can reproduce exactly the same state.

Practice exercise

Refactor a brittle coupon test

Your task

The test below was written in a hurry and fails in CI about once a week. Read it and list every problem with its data, setup and cleanup. Then describe the fixtures, hooks and data table you would use instead, and how many tests the run should report.

Material
test('coupon tests', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('qa.shared@example.com');
  await page.getByLabel('Password').fill(process.env.SHARED_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  const res = await page.request.post('/api/orders', { data: { total: '200.00' } });
  const order = await res.json();

  for (const c of [
    { code: 'SAVE10', expected: '180.00', applyTwice: false },
    { code: 'save10', expected: '180.00', applyTwice: true },
    { code: 'OLD5', expected: 'error', applyTwice: false },
  ]) {
    await page.goto(`/orders/${order.id}`);
    await page.getByLabel('Coupon').fill(c.code);
    await page.getByRole('button', { name: 'Apply' }).click();
    if (c.applyTwice) await page.getByRole('button', { name: 'Apply' }).click();
    if (c.expected === 'error') await expect(page.getByRole('alert')).toBeVisible();
    else await expect(page.getByTestId('total')).toHaveText(c.expected);
  }

  await page.request.delete(`/api/orders/${order.id}`);
});
Steps
  1. Find the data the test does not own, and who else can change it.
  2. Find the cleanup that can be skipped, and the state that leaks from one case into the next.
  3. Find the logic that is hidden in the data.
  4. Design the replacement: the fixtures with their teardown, any hook, and the table with its columns and rows.
Show the model answer
Model answer

Data it does not own: the shared account qa.shared@example.com can be used, changed or locked by any other test, tester or parallel run. Replace it with a customer fixture that creates a unique customer through the API and deletes it in teardown, and sign in through the API or with a saved session instead of the login form, which deserves its own test.

Skipped cleanup and leaking state: the DELETE at the end of the body never runs when an assertion fails, so orders pile up. All three cases also share one order: after SAVE10 is applied, the second case starts from an order that is already discounted, and if the first case fails, the others do not run at all. The run reports one test for three cases.

Hidden logic: applyTwice and the magic value "error" change what the test does. "Applying a code twice keeps a single discount" is a test of its own, and an expected error belongs in its own column with the specific error, not in a generic alert check. The title coupon tests says nothing; each new test names its case.

Replacement: a customer fixture (create, then use, then delete) and a createOrder fixture that asks for customer and hands the test a function: it creates an order with the total from the row and records its id, and its teardown deletes every recorded order before the customer fixture deletes the customer. No beforeEach is needed: each test opens its own order page after creating the order, and a hook here would hide the one step that differs. The table has the columns case, code, cart_total, expected_total and error, with rows such as valid code, lower-case code, minimum order exactly, just below minimum, expired code and unknown code, and a loop creates one uniquely titled test per row. The run then reports one test per row plus the separate "applied twice" test, seven tests with those six rows, and each starts from its own fresh order.

Frequently asked questions

Should I use hooks or fixtures?

If your framework has fixtures, as Playwright Test and pytest do, use them for anything that produces a value and needs a teardown, such as a customer or an order: they run only for the tests that ask for them and keep setup and cleanup together. Hooks suit simple steps every test in a file needs, such as opening the same page. In Jest, Vitest and JUnit, hooks plus helper or factory functions do the same job.

Should the data file be CSV or JSON?

CSV suits flat tables: it opens in a spreadsheet, a non-programmer can review it and changes show clearly in a diff, but every value is text and values with commas need quoting. JSON suits nested data, typed values such as numbers, booleans and null, and API request bodies. Either way, keep the file in the repository next to the tests that use it.

Is random test data a good idea?

Random values are good for uniqueness, such as emails and ids that must not collide. For the values that decide the expected result, prefer fixed, deliberately chosen data. If you do generate inputs for wider coverage, log the values or the random seed so a failed run can be repeated exactly.

References

Practise with Mutqan tools

Generate CSV files with custom columns, data types, row counts and delimiters.

Test Data

Convert CSV or TSV data to a JSON array of objects.

JSON & Data

One-stop factory for synthetic test data: users, customers, companies, addresses, numbers, dates and strings in JSON, CSV, SQL or XML.

Test Data

Generate fake user records (name, username, email, password, avatar seed, role) for test accounts.

Test Data

Generate UUID v4 (random) and v7 (time-ordered) identifiers in bulk, and validate existing ones.

Utilities

Validate JSON syntax and get precise error locations with helpful fix suggestions.

JSON & Data

Go deeper

Your progress on this topic

Part of the QA Roadmap