Working with JSON Fixtures

8 min read

Hardcoding test data inline ages badly. Twenty tests with email="alice@test.com" baked into each one is twenty places to update when the test user's domain changes. The Pythonic answer is to put data in JSON files — version-controlled, diff-friendly, editable by non-engineers — and load them through fixtures. This lesson covers the directory layout, the loader patterns that scale, the typed-data approach via dataclass (introduced in the Python for QA course), and how JSON fixtures plug into route mocking from earlier in this chapter.

A scalable fixture directory

Group fixtures by domain, not by use:

fixtures/
├── users/
│   ├── admin.json
│   └── standard_user.json
├── products/
│   └── product_list.json
└── api_responses/
    ├── success.json
    └── error_500.json

Three reasons to organise it this way:

  • Predictable filenames. users/admin.json is unambiguous; auth_data_v2.json is not.
  • Reusable across tests. The same admin.json is used by login tests, permission tests, and audit-log tests — one file, one source of truth.
  • Diff-friendly. When a non-engineer (PM, ops) updates the canonical test user, they edit one file with familiar JSON syntax — no Python, no string escaping.

Loading a fixture in a test

The simplest pattern: a fixture that opens, reads, parses:

import json
from pathlib import Path
 
import pytest
from playwright.sync_api import Page, expect
 
 
@pytest.fixture
def admin_user():
    fixture_path = Path(__file__).parent / "fixtures" / "users" / "admin.json"
    with open(fixture_path) as f:
        return json.load(f)
 
 
def test_admin_login(page: Page, admin_user):
    page.goto("/login")
    page.get_by_label("Email").fill(admin_user["email"])
    page.get_by_label("Password").fill(admin_user["password"])
    page.get_by_role("button", name="Login").click()
    expect(page).to_have_url("/admin")

Path(__file__).parent makes the path relative to the conftest file — no current-working-directory assumptions, works the same on every developer's machine and in CI.

A generic fixture loader — DRY across many fixtures

Hardcoding Path(__file__).parent / "fixtures" / "users" / "admin.json" per fixture gets repetitive. Hoist the loader logic:

@pytest.fixture
def load_fixture():
    def _load(name: str):
        path = Path(__file__).parent / "fixtures" / name
        with open(path) as f:
            return json.load(f)
    return _load
 
 
def test_with_data(page: Page, load_fixture):
    admin = load_fixture("users/admin.json")
    products = load_fixture("products/product_list.json")
    # ... use admin and products ...

The fixture returns a function. The test calls that function with whatever fixture name it needs. One fixture in conftest, every test gets a one-liner loader, and adding a new JSON file requires zero conftest changes.

Combining JSON fixtures with route mocking

The two patterns from earlier in this chapter — JSON fixtures and page.route — compose beautifully. Mock an API response from a fixture file:

import json
from playwright.sync_api import Page, expect
 
 
def test_product_list_with_mock(page: Page, load_fixture):
    mock_data = load_fixture("products/product_list.json")
 
    page.route("**/api/products", lambda route: route.fulfill(
        status=200,
        content_type="application/json",
        body=json.dumps(mock_data),
    ))
 
    page.goto("/products")
    expect(page.get_by_test_id("product-card")).to_have_count(len(mock_data))

The JSON file contains the canned API response; the route handler returns it. Now the page renders against deterministic data that lives in version control next to the test. Want to test the empty state? Load a different fixture (products/empty.json). Want to test 100 products? Load products/large_list.json.

Because page.route is registered before page.goto, the page never hits the real API — same speed and determinism as a true unit test, with a real browser rendering the result.

Typed fixtures with dataclass

Loading raw dicts from JSON works, but you lose type safety — admin["email"] doesn't autocomplete, and a typo (admin["emial"]) fails at runtime, not at lint time. Wrap fixtures in a dataclass (covered in the Python for QA course):

from dataclasses import dataclass
import pytest
 
 
@dataclass
class User:
    name: str
    email: str
    password: str
    role: str = "tester"
 
 
@pytest.fixture
def admin_user() -> User:
    return User(
        name="Admin",
        email="admin@test.com",
        password="AdminPass",
        role="admin",
    )
 
 
def test_admin_login(page: Page, admin_user: User):
    page.goto("/login")
    page.get_by_label("Email").fill(admin_user.email)         # autocomplete + typecheck
    page.get_by_label("Password").fill(admin_user.password)
    page.get_by_role("button", name="Login").click()

The -> User return annotation lights up IDE autocomplete on every admin_user.<field> access. mypy/pyright catch typos at lint time. For tests that touch the same data shape repeatedly, the dataclass version dramatically cuts debugging time.

Hybrid: JSON file + dataclass loader

Best of both worlds — JSON for the data, dataclass for the typing:

@dataclass
class User:
    name: str
    email: str
    password: str
    role: str = "tester"
 
 
@pytest.fixture
def admin_user() -> User:
    path = Path(__file__).parent / "fixtures" / "users" / "admin.json"
    with open(path) as f:
        return User(**json.load(f))

User(**json.load(f)) unpacks the dict into keyword arguments. admin.json contains:

{
  "name": "Admin",
  "email": "admin@test.com",
  "password": "AdminPass",
  "role": "admin"
}

The data lives in JSON (editable, diff-friendly); the test sees a typed User. Renaming a field in the dataclass and forgetting to update the JSON fails at fixture load with a clear error — TypeError: __init__() got an unexpected keyword argument 'foo' — caught at the first test that uses it, not silently propagated.

Generating fixtures programmatically

Sometimes the right "fixture" is dynamic — a fresh user with a unique email per test. Mix dataclass with computation:

import time
from uuid import uuid4
 
@pytest.fixture
def fresh_user() -> User:
    return User(
        name=f"User-{uuid4().hex[:8]}",
        email=f"test-{int(time.time() * 1000)}@test.com",
        password="TestPass123",
    )

Each test gets a guaranteed-unique user, no JSON file involved. Combine with the API-driven setup from the previous lesson and you have a powerful pattern: dynamic data via dataclass, persisted via API, cleaned up in the fixture's teardown.

Test data flow — JSON, fixture, route, dataclass

The whole pipeline takes one read from disk per test (fast), produces typed data, and feeds both direct test usage and mocked API responses. Same fixture file can drive a UI test that fills a form and a route handler that mocks the API — single source of truth.

A real-world conftest combining everything

# tests/conftest.py
import json
from dataclasses import dataclass
from pathlib import Path
 
import pytest
 
FIXTURES_DIR = Path(__file__).parent / "fixtures"
 
 
@dataclass
class User:
    name: str
    email: str
    password: str
    role: str = "tester"
 
 
def _load_json(relative_path: str):
    with open(FIXTURES_DIR / relative_path) as f:
        return json.load(f)
 
 
@pytest.fixture
def admin_user() -> User:
    return User(**_load_json("users/admin.json"))
 
 
@pytest.fixture
def standard_user() -> User:
    return User(**_load_json("users/standard_user.json"))
 
 
@pytest.fixture
def product_list():
    return _load_json("products/product_list.json")
 
 
@pytest.fixture
def load_fixture():
    return _load_json

Three named fixtures for the data you'll use most, plus the generic loader for one-offs. New JSON file? Either add a named fixture (when you want types) or just use load_fixture("path/to/new.json") (when you don't).

Reference: JSON Formatter on qa.codes

When you're authoring or debugging JSON fixtures, the JSON Formatter utility on qa.codes pretty-prints, validates, and minifies JSON in your browser — useful for cleaning up exported responses before committing them to your fixtures folder. Pair it with the JSON Schema Generator when you want to assert your fixtures match the API's contract.

⚠️ Common mistakes

  • Reading the same JSON file in N tests. Without caching, you do disk I/O per test. For large fixtures or session-scoped data, mark the fixture scope="session" so the file is read once and the parsed dict is shared. For small files this doesn't matter — measure before optimising.
  • Storing secrets in fixture files. A users/admin.json committed to git with a real production password is a serious leak. Test fixtures should contain test credentials only — short, throwaway, valid only against the test environment. For real secrets, use environment variables and .env files explicitly excluded from git.
  • Forgetting to add the fixtures dir to your version control. .gitignore patterns like *.json accidentally ignore your fixture files. Always commit fixtures/ to git, with a small README documenting what each file represents and which tests consume it.

🎯 Practice task

Convert hardcoded test data to JSON fixtures. 25-30 minutes.

  1. Create the fixture directory:

    tests/
    ├── fixtures/
    │   ├── users/
    │   │   └── standard.json
    │   └── products/
    │       └── three_products.json
    
  2. Populate tests/fixtures/users/standard.json:

    {
      "name": "Alice",
      "email": "alice@test.com",
      "password": "TestPass123",
      "role": "user"
    }
  3. Populate tests/fixtures/products/three_products.json with a list of three product objects (id, name, price).

  4. Add to tests/conftest.py:

    import json
    from dataclasses import dataclass
    from pathlib import Path
    import pytest
     
    FIXTURES_DIR = Path(__file__).parent / "fixtures"
     
    @dataclass
    class User:
        name: str
        email: str
        password: str
        role: str = "tester"
     
    @pytest.fixture
    def standard_user() -> User:
        with open(FIXTURES_DIR / "users" / "standard.json") as f:
            return User(**json.load(f))
     
    @pytest.fixture
    def load_fixture():
        def _load(rel: str):
            with open(FIXTURES_DIR / rel) as f:
                return json.load(f)
        return _load
  5. Write tests/test_with_fixtures.py using both:

    import json
    from playwright.sync_api import Page, expect
     
    def test_login_with_standard_user(page: Page, standard_user):
        page.goto("https://www.saucedemo.com/")
        # Sauce Demo uses standard_user/secret_sauce — adapt the JSON or the test
        page.get_by_placeholder("Username").fill("standard_user")
        page.get_by_placeholder("Password").fill(standard_user.password if standard_user.password == "secret_sauce" else "secret_sauce")
        page.get_by_role("button", name="Login").click()
        expect(page).to_have_url("https://www.saucedemo.com/inventory.html")
     
    def test_renders_mocked_products(page: Page, load_fixture):
        products = load_fixture("products/three_products.json")
        page.route("**/api/products", lambda route: route.fulfill(
            status=200, content_type="application/json", body=json.dumps(products)
        ))
        # Navigate to a page that would call /api/products on your dev app:
        # page.goto("/products")
        # expect(page.get_by_test_id("product-card")).to_have_count(3)
  6. Run with pytest tests/test_with_fixtures.py -v. The login test passes; the route-mock test runs against the canned data.

  7. Force a typo at lint time. Change standard_user.password to standard_user.passwrod in the test. If you have mypy or Pylance configured, the squiggle appears immediately — that's the dataclass typing in action. Plain dict access wouldn't catch it.

  8. Stretch: add a scope="session" fixture that loads a large fixture once and shares it across all tests. Add a time.time() print inside the fixture body. Run the suite. The print appears once, not once per test — confirming session caching.

You've completed Chapter 4. JSON fixtures, route mocking, the request fixture, and the API-setup-then-UI-test pattern together cover everything you need for the network/API side of Playwright Python. The next chapter ramps up to the real-world patterns — Page Object Model, multi-browser and mobile emulation, authentication via storage state, and the popups/iframes/dialogs that real apps throw at you.

// tip to track lessons you complete and pick up where you left off across devices.