Skip to content

Context Layering Architecture

pytest-httpchain uses Python's ChainMap to manage variable scoping across scenario execution. Understanding this layering helps debug variable resolution issues and design complex test scenarios.

Context Hierarchy

Variables are resolved through a layered context system. When you reference {{ variable }}, the system searches layers from top to bottom:

flowchart TB
    subgraph lookup["Variable Lookup Order"]
        direction TB
        saves["💾 Response Saves<br/><small>(current stage)</small>"]
        iter["🔄 Iteration Variables<br/><small>(parallel execution)</small>"]
        stage_sub["📝 Stage Substitutions"]
        fixtures["🔧 Fixtures<br/><small>(stage + scenario)</small>"]
        global["🌐 Global Context<br/><small>(previous saves)</small>"]
        scenario["📋 Scenario Substitutions"]

        saves --> iter --> stage_sub --> fixtures --> global --> scenario
    end

    style iter fill:#e1f5fe
    style saves fill:#e8f5e9
    style stage_sub fill:#fff3e0
    style fixtures fill:#fce4ec
    style global fill:#f3e5f5
    style scenario fill:#eceff1

Highest priority → Response Saves (current stage, checked first) Lowest priority → Scenario Substitutions (checked last)

How ChainMap Works

A ChainMap groups multiple dictionaries and searches them as one. The first match wins:

from collections import ChainMap

base = {"url": "https://api.example.com", "timeout": 30}
stage = {"timeout": 10}  # Override
context = ChainMap(stage, base)

context["url"]      # "https://api.example.com" (from base)
context["timeout"]  # 10 (from stage, shadows base)

Execution Flow

1. Scenario Initialization

When a test scenario starts, the global context is initialized with scenario-level substitutions:

{
    "substitutions": [
        {"vars": {"base_url": "https://api.example.com"}}
    ]
}

Results in:

global_context = ChainMap({"base_url": "https://api.example.com"})

2. Stage Execution

For each stage, a local context is built by layering:

{
    "stages": [
        {
            "name": "get_users",
            "fixtures": ["api_key"],
            "substitutions": [
                {"vars": {"endpoint": "/users"}}
            ],
            "request": {
                "url": "{{ base_url }}{{ endpoint }}"
            }
        }
    ]
}

Context layers (top = highest priority):

local_context = ChainMap(
    {"endpoint": "/users"},        # Stage substitutions
    {"api_key": "test-key-123"},   # Fixtures
    global_context                  # Inherited
)

Scenario-level fixtures are requested by every stage and land in the same fixtures layer as stage-level ones. Because that layer sits above the global context, a fixture value always shadows a previously saved variable of the same name.

3. Response Saves

After a request, saved values are added to the global context as a new layer:

{
    "response": [
        {
            "save": {
                "jmespath": {
                    "user_ids": "[*].id"
                }
            }
        }
    ]
}

After this stage:

global_context = ChainMap(
    {"user_ids": [1, 2, 3]},           # New layer from save
    {"base_url": "https://..."}         # Original layer
)

4. Parallel Execution

In parallel stages, each iteration gets its own context layer with iteration-specific variables:

{
    "parallel": {
        "foreach": [
            {"individual": {"user_id": [1, 2, 3]}}
        ]
    }
}

Each iteration has:

iter_context = ChainMap(
    {"user_id": 1},     # Iteration variable (unique per iteration)
    local_context       # Shared stage context
)

Variable Shadowing

Later layers shadow earlier ones. This enables stage-level overrides:

{
    "substitutions": [
        {"vars": {"timeout": 30}}
    ],
    "stages": [
        {
            "name": "slow_endpoint",
            "substitutions": [
                {"vars": {"timeout": 120}}
            ],
            "request": {
                "url": "{{ base_url }}/slow",
                "timeout": "{{ timeout }}"
            }
        }
    ]
}

Here timeout resolves to 120 because the stage substitution shadows the scenario-level one.

Save Accumulation

Saves from all stages accumulate in the global context, making values available to later stages:

{
    "stages": [
        {
            "name": "login",
            "request": {"url": "{{ base_url }}/auth/login", "method": "POST"},
            "response": [
                {"save": {"jmespath": {"token": "access_token"}}}
            ]
        },
        {
            "name": "get_profile",
            "request": {"url": "{{ base_url }}/profile"},
            "response": [
                {"save": {"jmespath": {"user_id": "id"}}}
            ]
        },
        {
            "name": "get_orders",
            "request": {
                "url": "{{ base_url }}/users/{{ user_id }}/orders",
                "headers": {
                    "Authorization": "Bearer {{ token }}"
                }
            }
        }
    ]
}

After stage 2, global context contains both token and user_id.

Debugging Context Issues

Variable Not Found

If you get "undefined variable" errors:

  1. Check spelling in the template expression
  2. Verify the variable is defined before the stage that uses it
  3. Ensure saves from previous stages completed successfully

Wrong Value

If a variable has an unexpected value:

  1. Check for shadowing in stage substitutions
  2. Verify parallel iteration variables aren't overwriting
  3. Use explicit variable names to avoid conflicts

Context Inspection

Enable debug logging to see context state:

pytest test_scenario.http.json -v --log-cli-level=INFO

Log output shows:

INFO - global context on start: {"base_url": "https://..."}
INFO - local context on start: {"endpoint": "/users", "base_url": "..."}
INFO - updates for global context: {"user_ids": [1, 2, 3]}

Secrets and sensitive output

pytest-httpchain is built for full visibility while debugging — and that visibility extends to secrets. By design, nothing is redacted:

  • Logs (--log-cli-level=INFO, as shown above) print the entire context as JSON — fixture values, substitution variables, and saved values such as auth tokens.
  • Report sections attach the full HTTP request and response of a failing stage, including Authorization and other credential headers.
  • HAR export (--output-dir) writes complete requests and responses — headers, cookies, and bodies — to disk.

This is intentional (you usually want to see the real token when a chained request fails), but it means logs, JUnit/terminal reports, and .har files all contain whatever credentials your scenario uses. Treat them as sensitive:

  • Scrub or avoid uploading CI logs, test reports, and HAR artifacts that were produced against real credentials.
  • Prefer throwaway/test credentials in scenarios that run in shared CI.
  • The template engine and user functions execute arbitrary Python by design — scenario files are trusted input, not a sandbox boundary.

Best Practices

  1. Use descriptive variable names to avoid accidental shadowing
  2. Define shared variables at scenario level for consistency
  3. Use stage substitutions sparingly for true overrides only
  4. Keep parallel iteration variables unique to prevent conflicts
  5. Log context during debugging to understand variable resolution