Skip to main content

Why Separate Test Data?

Separating test data from test logic offers several key benefits:

Maintainability

Update data in one place without touching test code

Reusability

Share data across multiple tests and test suites

Security

Keep sensitive credentials out of source code

Scalability

Easily add new test scenarios by adding data

Test Data Approaches

Our test suite demonstrates three common approaches to test data management:
Best for: Structured, static test data that’s reused across tests
testData/users.json
{
  "validUser": { "username": "standard_user", "password": "secret_sauce" },
  "invalidUser": { "username": "locked_out_user", "password": "secret_sauce" }
}
JSON files are ideal for maintaining multiple test scenarios with different data sets.

JSON Test Data

Structure and Organization

Let’s examine our actual test data file:
testData/users.json
{
  "validUser": { "username": "standard_user", "password": "secret_sauce" },
  "invalidUser": { "username": "locked_out_user", "password": "secret_sauce" }
}
The JSON uses a keyed dictionary approach:
  • Top-level keys: validUser, invalidUser
  • Each key contains an object with related fields
  • Easy to access specific test scenarios: users["validUser"]
Benefits:
  • Self-documenting (key names explain the data)
  • Easy to extend with new user types
  • Type-safe access in tests
You could also organize data as arrays:
{
  "users": [
    {"type": "valid", "username": "standard_user", "password": "secret_sauce"},
    {"type": "invalid", "username": "locked_out_user", "password": "secret_sauce"}
  ]
}
Or grouped by test type:
{
  "login": {
    "valid": {"username": "standard_user", "password": "secret_sauce"},
    "invalid": {"username": "locked_out_user", "password": "secret_sauce"}
  },
  "products": [
    {"id": "sauce-labs-backpack", "name": "Sauce Labs Backpack"},
    {"id": "sauce-labs-bike-light", "name": "Sauce Labs Bike Light"}
  ]
}

Loading JSON Data with Fixtures

Here’s how our test suite loads JSON data:
tests/conftest.py
import json
from pathlib import Path
import pytest

@pytest.fixture(scope="session")
def users():
    """
    Loads testData/users.json and returns a dict.
    Accessible in any test as the 'users' fixture.
    """
    root = Path(__file__).parent.parent  # go from /tests to project root
    data_path = root / "testData" / "users.json"
    with data_path.open(encoding="utf-8") as f:
        return json.load(f)
1

Locate the Data File

root = Path(__file__).parent.parent  # Navigate to project root
data_path = root / "testData" / "users.json"
Uses Path for cross-platform compatibility
2

Open and Parse

with data_path.open(encoding="utf-8") as f:
    return json.load(f)
Reads file and parses JSON into Python dictionary
3

Use in Tests

def test_with_testdata(page, users):
    # users is automatically provided by pytest
    username = users["validUser"]["username"]
    password = users["validUser"]["password"]

Using JSON Data in Tests

Real example from our test suite:
tests/test_functionalities.py
from pages.login import LoginPage

def test_with_testdata(page, users):
    login_page = LoginPage(page)
    login_page.navigate()
    login_page.login(users["validUser"]["username"], users["validUser"]["password"])

    assert page.get_by_test_id("title").is_visible
How it works:
  1. Test declares users parameter
  2. pytest calls the users fixture from conftest.py:line_8
  3. Fixture loads and parses testData/users.json:line_1
  4. Test accesses nested data: users["validUser"]["username"]
  5. Values passed to page object methods
The fixture uses scope="session" so the JSON file is only loaded once for all tests, improving performance.

Environment Variables

Setting Up Environment Variables

Create a .env file in your project root:
.env
USERNAME=standard_user
PASSWORD=secret_sauce
BASE_URL=https://www.saucedemo.com
API_KEY=your_secret_api_key_here
Security Best Practice: Always add .env to your .gitignore:
.gitignore
.env
.env.local
*.env

Loading Environment Variables

Our conftest.py demonstrates best practices:
tests/conftest.py
import os
from pathlib import Path
import pytest
from dotenv import load_dotenv

# Load .env file at module level
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")

@pytest.fixture(scope="session")
def creds():
    """
    Provides credentials from environment variables.
    Fails early with a clear message if missing.
    """
    user = os.getenv("USERNAME")
    pwd = os.getenv("PASSWORD")
    if not user or not pwd:
        raise RuntimeError("Missing USERNAME/PASSWORD in environment. ")
    return {"valid_user": user, "pwd": pwd}
from dotenv import load_dotenv
load_dotenv(dotenv_path=Path(__file__).parent.parent / ".env")
  • Called at module level (runs when conftest.py is imported)
  • Loads variables from .env into os.environ
  • Explicit path ensures correct file is loaded
user = os.getenv("USERNAME")
pwd = os.getenv("PASSWORD")
  • os.getenv() returns None if variable not found
  • Better than os.environ["KEY"] which raises KeyError
  • Can provide default: os.getenv("API_URL", "https://api.example.com")
if not user or not pwd:
    raise RuntimeError("Missing USERNAME/PASSWORD in environment. ")
Why fail early?
  • Tests won’t run with invalid setup
  • Clear error message for developers
  • Prevents cryptic failures later in test execution
return {"valid_user": user, "pwd": pwd}
Returns a dictionary for consistent access pattern:
# In test
creds["valid_user"]  # Always works
creds["pwd"]         # Always works

Using Environment Variables in Tests

tests/test_functionalities.py
from pages.login import LoginPage

def test_login_with_env_vars(page, creds):
    login_page = LoginPage(page)
    login_page.navigate()
    login_page.login(creds["valid_user"], creds["pwd"])

    assert page.get_by_test_id("title").is_visible
Flow:
  1. Test requests creds fixture
  2. Fixture reads from environment variables
  3. If missing, raises RuntimeError immediately
  4. If found, returns dictionary with credentials
  5. Test uses credentials without knowing their source
This approach allows the same test to work in different environments (local, CI/CD, staging) by simply changing environment variables.

Comparison: JSON vs Environment Variables

def test_with_testdata(page, users):
    login_page = LoginPage(page)
    login_page.navigate()
    login_page.login(
        users["validUser"]["username"],
        users["validUser"]["password"]
    )
    assert page.get_by_test_id("title").is_visible
Pros:
  • Structured data with multiple scenarios
  • Easy to add new test cases
  • Version controlled (safe to commit)
  • Great for data-driven testing
Cons:
  • Not suitable for secrets
  • Less flexible across environments

Advanced Patterns

Use @pytest.mark.parametrize for data-driven testing:
tests/test_login.py
@pytest.mark.parametrize("username, password",[
    ("error_user","secret_sauce"),
    ("performance_glitch_user","secret_sauce"),
    ("visual_user","secret_sauce")])

def test_multiple_users(page: Page, username, password):
    page.goto("https://www.saucedemo.com/")
    
    username_input = page.get_by_placeholder("Username")
    username_input.fill(username)

    password_input = page.get_by_placeholder("Password")
    password_input.fill(password)

    login_button = page.locator("input#login-button")
    login_button.click()

    assert page.get_by_test_id("title").is_visible
Test runs 3 times, once for each parameter set.

Best Practices

Data TypeStorage Method
User credentials (test accounts)JSON file (if not sensitive) or Environment Variables
API keys, tokensEnvironment Variables only
Product catalogsJSON file
Test URLsEnvironment Variables (environment-specific)
Form field valuesJSON file
Configuration flagsEnvironment Variables
// Good: Grouped by purpose
{
  "users": {
    "valid": {"username": "standard_user", "password": "secret_sauce"},
    "locked": {"username": "locked_out_user", "password": "secret_sauce"}
  },
  "products": {
    "backpack": {"id": "sauce-labs-backpack", "price": 29.99},
    "bikeLight": {"id": "sauce-labs-bike-light", "price": 9.99}
  }
}

// Avoid: Flat structure
{
  "validUsername": "standard_user",
  "validPassword": "secret_sauce",
  "backpackId": "sauce-labs-backpack",
  "backpackPrice": 29.99
}
@pytest.fixture(scope="session")
def users():
    root = Path(__file__).parent.parent
    data_path = root / "testData" / "users.json"
    
    if not data_path.exists():
        raise FileNotFoundError(f"Test data not found: {data_path}")
    
    with data_path.open(encoding="utf-8") as f:
        data = json.load(f)
    
    # Validate structure
    required_keys = ["validUser", "invalidUser"]
    for key in required_keys:
        if key not in data:
            raise ValueError(f"Missing required key in users.json: {key}")
    
    return data
Add comments to JSON (if using JSON5) or in fixture docstrings:
@pytest.fixture(scope="session")
def users():
    """
    Loads testData/users.json and returns a dict.
    
    Expected structure:
    {
        "validUser": {
            "username": str,
            "password": str
        },
        "invalidUser": {
            "username": str,
            "password": str
        }
    }
    """
    # implementation
from typing import Dict, Any

@pytest.fixture(scope="session")
def users() -> Dict[str, Dict[str, str]]:
    root = Path(__file__).parent.parent
    data_path = root / "testData" / "users.json"
    with data_path.open(encoding="utf-8") as f:
        return json.load(f)

Project Structure Example

source/
├── .env                      # Environment variables (DO NOT COMMIT)
├── .env.example              # Template for .env
├── .gitignore                # Include .env here
├── pages/
│   ├── login.py
│   └── cart_page.py
├── testData/
│   ├── users.json           # User test data
│   ├── products.json        # Product catalog
│   └── forms.json           # Form field data
└── tests/
    ├── conftest.py          # Fixtures for loading data
    ├── test_login.py
    └── test_functionalities.py
Keep test data files in a dedicated testData/ directory separate from test code for better organization.

Next Steps

Page Object Model

See how test data integrates with page objects

Fixtures

Deep dive into fixture patterns for test data

Build docs developers (and LLMs) love