pytest-httpchain 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_httpchain/__init__.py +0 -0
- pytest_httpchain/carrier.py +139 -0
- pytest_httpchain/carrier_factory.py +92 -0
- pytest_httpchain/constants.py +10 -0
- pytest_httpchain/context.py +77 -0
- pytest_httpchain/exceptions.py +46 -0
- pytest_httpchain/helpers.py +43 -0
- pytest_httpchain/plugin.py +192 -0
- pytest_httpchain/request.py +102 -0
- pytest_httpchain/response.py +165 -0
- pytest_httpchain/stage_executor.py +111 -0
- pytest_httpchain-0.1.0.dist-info/METADATA +245 -0
- pytest_httpchain-0.1.0.dist-info/RECORD +16 -0
- pytest_httpchain-0.1.0.dist-info/WHEEL +4 -0
- pytest_httpchain-0.1.0.dist-info/entry_points.txt +3 -0
- pytest_httpchain-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Test carrier class for HTTP chain test execution.
|
|
2
|
+
|
|
3
|
+
The Carrier class manages the test lifecycle and infrastructure:
|
|
4
|
+
- HTTP session initialization and cleanup
|
|
5
|
+
- Global context state management
|
|
6
|
+
- Test flow control (abort handling)
|
|
7
|
+
- Integration with pytest (skip, fail)
|
|
8
|
+
|
|
9
|
+
The actual HTTP execution and data processing is delegated to stage_executor.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, ClassVar
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
import pytest_httpchain_templates.substitution
|
|
17
|
+
import requests
|
|
18
|
+
from pydantic import ValidationError
|
|
19
|
+
from pytest_httpchain_models.entities import Scenario, Stage
|
|
20
|
+
from pytest_httpchain_templates.exceptions import TemplatesError
|
|
21
|
+
from pytest_httpchain_userfunc.auth import call_auth_function
|
|
22
|
+
|
|
23
|
+
from . import stage_executor
|
|
24
|
+
from .exceptions import StageExecutionError
|
|
25
|
+
from .helpers import call_user_function
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Carrier:
|
|
31
|
+
"""Test carrier class that integrates HTTP chain test execution.
|
|
32
|
+
|
|
33
|
+
This base class is subclassed dynamically by carrier_factory to create
|
|
34
|
+
test classes with scenario-specific test methods. It manages the shared
|
|
35
|
+
state and execution flow for all stages in a test scenario.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
_scenario: The test scenario configuration
|
|
39
|
+
_session: Shared HTTP session for all stages
|
|
40
|
+
_data_context: Global context shared across all stages
|
|
41
|
+
_aborted: Flag indicating if test flow should be aborted
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
_scenario: ClassVar[Scenario]
|
|
45
|
+
_session: ClassVar[requests.Session | None] = None
|
|
46
|
+
_data_context: ClassVar[dict[str, Any]] = {}
|
|
47
|
+
_aborted: ClassVar[bool] = False
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def setup_class(cls) -> None:
|
|
51
|
+
"""Initialize the HTTP session and data context.
|
|
52
|
+
|
|
53
|
+
Called once before any test methods in the class are executed.
|
|
54
|
+
Sets up:
|
|
55
|
+
- Empty data context for variable storage
|
|
56
|
+
- HTTP session with SSL and authentication configuration
|
|
57
|
+
|
|
58
|
+
Note:
|
|
59
|
+
Authentication can be configured at scenario level and will
|
|
60
|
+
be applied to all requests unless overridden at stage level.
|
|
61
|
+
"""
|
|
62
|
+
cls._data_context = {}
|
|
63
|
+
cls._session = requests.Session()
|
|
64
|
+
|
|
65
|
+
# Configure SSL settings
|
|
66
|
+
cls._session.verify = cls._scenario.ssl.verify
|
|
67
|
+
if cls._scenario.ssl.cert is not None:
|
|
68
|
+
cls._session.cert = cls._scenario.ssl.cert
|
|
69
|
+
|
|
70
|
+
# Configure authentication
|
|
71
|
+
if cls._scenario.auth:
|
|
72
|
+
resolved_auth = pytest_httpchain_templates.substitution.walk(cls._scenario.auth, cls._data_context)
|
|
73
|
+
auth_instance = call_user_function(resolved_auth, call_auth_function)
|
|
74
|
+
cls._session.auth = auth_instance
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def teardown_class(cls) -> None:
|
|
78
|
+
"""Clean up the HTTP session and reset state.
|
|
79
|
+
|
|
80
|
+
Called once after all test methods in the class have been executed.
|
|
81
|
+
Ensures proper cleanup of resources and state reset for next test class.
|
|
82
|
+
"""
|
|
83
|
+
if cls._session:
|
|
84
|
+
cls._session.close()
|
|
85
|
+
cls._session = None
|
|
86
|
+
cls._data_context.clear()
|
|
87
|
+
cls._aborted = False
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def execute_stage(cls, stage_template: Stage, fixture_kwargs: dict[str, Any]) -> None:
|
|
91
|
+
"""Execute a test stage with abort handling and error management.
|
|
92
|
+
|
|
93
|
+
This method is called for each stage in the scenario. It handles:
|
|
94
|
+
- Checking abort status and skipping if needed
|
|
95
|
+
- Executing the stage via stage_executor
|
|
96
|
+
- Updating global context with saved variables
|
|
97
|
+
- Setting abort flag on errors
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
stage_template: The stage configuration containing request/response definitions
|
|
101
|
+
fixture_kwargs: Dictionary of pytest fixture values injected for this stage
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
pytest.skip: If flow is aborted and stage doesn't have always_run=True
|
|
105
|
+
pytest.fail: If stage execution fails with an error
|
|
106
|
+
|
|
107
|
+
Note:
|
|
108
|
+
Sets cls._aborted to True on failure, causing subsequent stages
|
|
109
|
+
to be skipped unless they have always_run=True.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
# Check abort status
|
|
113
|
+
if cls._aborted and not stage_template.always_run:
|
|
114
|
+
pytest.skip(reason="Flow aborted")
|
|
115
|
+
|
|
116
|
+
# Verify session is initialized
|
|
117
|
+
if cls._session is None:
|
|
118
|
+
raise RuntimeError("Session not initialized - setup_class was not called")
|
|
119
|
+
|
|
120
|
+
# Execute stage and get variables to save globally
|
|
121
|
+
context_updates = stage_executor.execute_stage(
|
|
122
|
+
stage_template=stage_template,
|
|
123
|
+
scenario=cls._scenario,
|
|
124
|
+
session=cls._session,
|
|
125
|
+
global_context=cls._data_context, # Pass current global state
|
|
126
|
+
fixture_kwargs=fixture_kwargs,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Merge returned updates into global context for next stages
|
|
130
|
+
cls._data_context.update(context_updates)
|
|
131
|
+
|
|
132
|
+
except (
|
|
133
|
+
TemplatesError,
|
|
134
|
+
StageExecutionError,
|
|
135
|
+
ValidationError,
|
|
136
|
+
) as e:
|
|
137
|
+
logger.exception(str(e))
|
|
138
|
+
cls._aborted = True
|
|
139
|
+
pytest.fail(reason=str(e), pytrace=False)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Factory for creating dynamic test classes.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to dynamically generate pytest test classes
|
|
4
|
+
from JSON scenario definitions. Each scenario becomes a test class with one
|
|
5
|
+
test method per stage.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from pytest_httpchain_models.entities import Scenario, Stage
|
|
14
|
+
from simpleeval import EvalWithCompoundTypes
|
|
15
|
+
|
|
16
|
+
from .carrier import Carrier
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_test_class(scenario: Scenario, class_name: str) -> type[Carrier]:
|
|
22
|
+
"""Create a dynamic test class for the given scenario.
|
|
23
|
+
|
|
24
|
+
This factory function generates a pytest test class with:
|
|
25
|
+
- One test method per stage in the scenario
|
|
26
|
+
- Automatic fixture injection based on stage requirements
|
|
27
|
+
- Marker application (order, skip, xfail, etc.)
|
|
28
|
+
- Shared session and context management
|
|
29
|
+
|
|
30
|
+
The generated class structure:
|
|
31
|
+
- Inherits from Carrier base class
|
|
32
|
+
- Has test_0_<stage_name>, test_1_<stage_name>, etc. methods
|
|
33
|
+
- Each method requests fixtures defined in stage and scenario
|
|
34
|
+
- Methods are ordered using pytest-order plugin
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
scenario: Validated scenario configuration containing stages
|
|
38
|
+
class_name: Name for the generated test class
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A Carrier subclass with test methods for each stage
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
>>> scenario = Scenario.model_validate(test_data)
|
|
45
|
+
>>> TestClass = create_test_class(scenario, Path("test.json"), "TestAPI")
|
|
46
|
+
>>> # TestClass will have methods: test_0_stage1, test_1_stage2, etc.
|
|
47
|
+
"""
|
|
48
|
+
# Create custom Carrier class with scenario bound
|
|
49
|
+
CustomCarrier = type(
|
|
50
|
+
class_name,
|
|
51
|
+
(Carrier,),
|
|
52
|
+
{
|
|
53
|
+
"_scenario": scenario,
|
|
54
|
+
"_session": None,
|
|
55
|
+
"_data_context": {},
|
|
56
|
+
"_aborted": False,
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Add stage methods dynamically
|
|
61
|
+
for i, stage in enumerate(scenario.stages):
|
|
62
|
+
# Create stage method - using default argument to capture stage
|
|
63
|
+
def stage_method(self, *, _stage: Stage = stage, **fixture_kwargs: dict[str, Any]) -> None:
|
|
64
|
+
"""Execute a single stage of the test scenario.
|
|
65
|
+
|
|
66
|
+
Auto-generated method that executes one stage of the HTTP chain test.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
**fixture_kwargs: Pytest fixtures requested by this stage
|
|
70
|
+
"""
|
|
71
|
+
CustomCarrier.execute_stage(_stage, fixture_kwargs)
|
|
72
|
+
|
|
73
|
+
# Set up method signature with fixtures
|
|
74
|
+
all_fixtures: list[str] = ["self"] + stage.fixtures + scenario.fixtures
|
|
75
|
+
stage_method.__signature__ = inspect.Signature([inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) for name in all_fixtures])
|
|
76
|
+
|
|
77
|
+
# Apply markers
|
|
78
|
+
all_marks: list[str] = [f"order({i})"] + stage.marks
|
|
79
|
+
evaluator = EvalWithCompoundTypes(names={"pytest": pytest})
|
|
80
|
+
for mark_str in all_marks:
|
|
81
|
+
try:
|
|
82
|
+
marker = evaluator.eval(f"pytest.mark.{mark_str}")
|
|
83
|
+
if marker:
|
|
84
|
+
stage_method = marker(stage_method)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.warning(f"Failed to create marker '{mark_str}': {e}")
|
|
87
|
+
|
|
88
|
+
# Add method to class with descriptive name
|
|
89
|
+
method_name = f"test_{i}_{stage.name}"
|
|
90
|
+
setattr(CustomCarrier, method_name, stage_method)
|
|
91
|
+
|
|
92
|
+
return CustomCarrier
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Context management for HTTP chain test execution.
|
|
2
|
+
|
|
3
|
+
This module handles the preparation and management of execution contexts,
|
|
4
|
+
maintaining the separation between global and local state.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections import ChainMap
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import pytest_httpchain_templates.substitution
|
|
11
|
+
from pytest_httpchain_models.entities import Scenario, Stage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def prepare_data_context(
|
|
15
|
+
scenario: Scenario,
|
|
16
|
+
stage_template: Stage,
|
|
17
|
+
global_context: dict[str, Any],
|
|
18
|
+
fixture_kwargs: dict[str, Any],
|
|
19
|
+
) -> ChainMap[str, Any]:
|
|
20
|
+
"""Prepare the complete data context for stage execution.
|
|
21
|
+
|
|
22
|
+
Uses ChainMap for efficient layered context management with lazy evaluation.
|
|
23
|
+
No copying occurs - all layers share references to original data.
|
|
24
|
+
|
|
25
|
+
Merges contexts in order of precedence (later overrides earlier):
|
|
26
|
+
1. Global context (shared across all stages) - base layer
|
|
27
|
+
2. Fixture values (from pytest fixtures)
|
|
28
|
+
3. Scenario variables (from scenario.vars)
|
|
29
|
+
4. Stage variables (from stage.vars) - top layer
|
|
30
|
+
|
|
31
|
+
Each level can reference variables from previous levels in templates.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
scenario: The scenario configuration
|
|
35
|
+
stage_template: The stage being executed
|
|
36
|
+
global_context: Shared context from previous stages
|
|
37
|
+
fixture_kwargs: Pytest fixture values for this stage
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
ChainMap with layered context for efficient lookups
|
|
41
|
+
|
|
42
|
+
Note:
|
|
43
|
+
Returns a ChainMap for full performance benefits:
|
|
44
|
+
- No data copying
|
|
45
|
+
- Lazy evaluation (only accesses what's needed)
|
|
46
|
+
- Memory efficient (shares references)
|
|
47
|
+
- O(1) for most lookups
|
|
48
|
+
"""
|
|
49
|
+
# Build layers incrementally - each layer can reference previous ones
|
|
50
|
+
# Template substitution now works directly with ChainMap
|
|
51
|
+
|
|
52
|
+
# Layer 1: Base context (global + fixtures)
|
|
53
|
+
base_context = ChainMap(fixture_kwargs, global_context)
|
|
54
|
+
|
|
55
|
+
# Layer 2: Scenario variables (can reference base)
|
|
56
|
+
scenario_vars = {}
|
|
57
|
+
if scenario.vars:
|
|
58
|
+
scenario_vars = pytest_httpchain_templates.substitution.walk(
|
|
59
|
+
scenario.vars,
|
|
60
|
+
base_context, # Pass ChainMap directly
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Layer 3: Stage variables (can reference base + scenario)
|
|
64
|
+
# Process stage vars incrementally so they can reference each other
|
|
65
|
+
stage_vars = {}
|
|
66
|
+
if stage_template.vars:
|
|
67
|
+
context_with_scenario = ChainMap({}, scenario_vars, fixture_kwargs, global_context)
|
|
68
|
+
for key, value in stage_template.vars.items():
|
|
69
|
+
resolved_value = pytest_httpchain_templates.substitution.walk(value, context_with_scenario)
|
|
70
|
+
stage_vars[key] = resolved_value
|
|
71
|
+
# Add resolved var to context so next vars can reference it
|
|
72
|
+
context_with_scenario.maps[0][key] = resolved_value
|
|
73
|
+
|
|
74
|
+
# Create final context with proper precedence order
|
|
75
|
+
# Stage vars override scenario vars, which override fixtures, which override global
|
|
76
|
+
# Returns ChainMap for full performance benefits
|
|
77
|
+
return ChainMap(stage_vars, scenario_vars, fixture_kwargs, global_context)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Exception classes for HTTP chain test execution.
|
|
2
|
+
|
|
3
|
+
This module defines the exception hierarchy used throughout the
|
|
4
|
+
pytest-httpchain test execution flow.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StageExecutionError(Exception):
|
|
9
|
+
"""Base exception for all stage execution errors.
|
|
10
|
+
|
|
11
|
+
This is the base class for all exceptions that can occur during
|
|
12
|
+
stage execution. Catching this will catch all stage-related errors.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RequestError(StageExecutionError):
|
|
17
|
+
"""Error during HTTP request preparation or execution.
|
|
18
|
+
|
|
19
|
+
Raised when:
|
|
20
|
+
- Request preparation fails (auth, file opening, etc.)
|
|
21
|
+
- HTTP request times out
|
|
22
|
+
- Connection errors occur
|
|
23
|
+
- Other request-related issues
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SaveError(StageExecutionError):
|
|
28
|
+
"""Error during response processing (save operations).
|
|
29
|
+
|
|
30
|
+
Raised when:
|
|
31
|
+
- JMESPath expression fails
|
|
32
|
+
- User save function fails
|
|
33
|
+
- Variable extraction fails
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class VerificationError(StageExecutionError):
|
|
38
|
+
"""Error during response verification.
|
|
39
|
+
|
|
40
|
+
Raised when:
|
|
41
|
+
- Status code doesn't match expected
|
|
42
|
+
- Headers don't match expected
|
|
43
|
+
- Response body validation fails
|
|
44
|
+
- User verify function returns False
|
|
45
|
+
- JSON schema validation fails
|
|
46
|
+
"""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Helper functions for common patterns in pytest-httpchain."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pytest_httpchain_models.entities import UserFunctionCall, UserFunctionKwargs
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def call_user_function(
|
|
10
|
+
model: UserFunctionCall,
|
|
11
|
+
call_function: Callable,
|
|
12
|
+
*args: Any,
|
|
13
|
+
**extra_kwargs: Any,
|
|
14
|
+
) -> Any:
|
|
15
|
+
"""Generic helper to call user functions from UserFunctionCall model.
|
|
16
|
+
|
|
17
|
+
This helper eliminates the repeated pattern of checking whether a UserFunctionCall
|
|
18
|
+
is a UserFunctionName or UserFunctionKwargs and calling the appropriate function.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
model: Either UserFunctionName or UserFunctionKwargs
|
|
22
|
+
call_function: The specific function caller (call_save_function, call_verify_function, etc.)
|
|
23
|
+
*args: Positional arguments to pass to call_function (e.g., response object)
|
|
24
|
+
**extra_kwargs: Additional keyword arguments for call_function
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Result from the called function
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
# Instead of:
|
|
31
|
+
if isinstance(func_item, UserFunctionKwargs):
|
|
32
|
+
result = call_save_function(func_item.function.root, response, **func_item.kwargs)
|
|
33
|
+
else:
|
|
34
|
+
result = call_save_function(func_item.root, response)
|
|
35
|
+
|
|
36
|
+
# Use:
|
|
37
|
+
result = call_user_function(func_item, call_save_function, response)
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(model, UserFunctionKwargs):
|
|
40
|
+
kwargs = {**extra_kwargs, **model.kwargs}
|
|
41
|
+
return call_function(model.function.root, *args, **kwargs)
|
|
42
|
+
else: # UserFunctionName
|
|
43
|
+
return call_function(model.root, *args, **extra_kwargs)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Pytest plugin for HTTP chain testing.
|
|
2
|
+
|
|
3
|
+
This module provides the pytest plugin hooks and collection logic for
|
|
4
|
+
discovering and executing HTTP chain tests from JSON files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import types
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
import pytest_httpchain_jsonref.loader
|
|
16
|
+
from _pytest import config, nodes, python, reports, runner
|
|
17
|
+
from _pytest.config import argparsing
|
|
18
|
+
from pydantic import ValidationError
|
|
19
|
+
from pytest_httpchain_jsonref.exceptions import ReferenceResolverError
|
|
20
|
+
from pytest_httpchain_models.entities import Scenario
|
|
21
|
+
from simpleeval import EvalWithCompoundTypes
|
|
22
|
+
|
|
23
|
+
from pytest_httpchain.constants import ConfigOptions
|
|
24
|
+
|
|
25
|
+
from .carrier_factory import create_test_class
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class JsonModule(python.Module):
|
|
31
|
+
"""JSON test module that collects and executes HTTP chain tests.
|
|
32
|
+
|
|
33
|
+
This class extends pytest's Module to handle JSON test files containing
|
|
34
|
+
HTTP chain test scenarios. It loads, validates, and converts JSON test
|
|
35
|
+
definitions into executable pytest test classes.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def collect(self) -> Iterable[nodes.Item | nodes.Collector]:
|
|
39
|
+
"""Collect test items from a JSON module.
|
|
40
|
+
|
|
41
|
+
This method:
|
|
42
|
+
1. Loads the JSON file with reference resolution
|
|
43
|
+
2. Validates the test scenario against the schema
|
|
44
|
+
3. Creates a dynamic test class using the factory
|
|
45
|
+
4. Yields the test class for pytest to execute
|
|
46
|
+
|
|
47
|
+
Yields:
|
|
48
|
+
python.Class: A pytest Class node containing test methods
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
Collector.CollectError: If JSON loading or validation fails
|
|
52
|
+
"""
|
|
53
|
+
# Load and validate the test scenario from JSON
|
|
54
|
+
ref_parent_traversal_depth = int(self.config.getini(ConfigOptions.REF_PARENT_TRAVERSAL_DEPTH))
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
test_data = pytest_httpchain_jsonref.loader.load_json(
|
|
58
|
+
self.path,
|
|
59
|
+
max_parent_traversal_depth=ref_parent_traversal_depth,
|
|
60
|
+
)
|
|
61
|
+
except ReferenceResolverError as e:
|
|
62
|
+
raise nodes.Collector.CollectError("Cannot load JSON file") from e
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
scenario = Scenario.model_validate(test_data)
|
|
66
|
+
except ValidationError as e:
|
|
67
|
+
raise nodes.Collector.CollectError("Cannot parse test scenario") from e
|
|
68
|
+
|
|
69
|
+
# Create test class using factory
|
|
70
|
+
CarrierClass = create_test_class(scenario, self.name)
|
|
71
|
+
|
|
72
|
+
# Create pytest Class node
|
|
73
|
+
dummy_module = types.ModuleType("generated")
|
|
74
|
+
setattr(dummy_module, self.name, CarrierClass)
|
|
75
|
+
self._getobj = lambda: dummy_module
|
|
76
|
+
|
|
77
|
+
json_class = python.Class.from_parent(
|
|
78
|
+
self,
|
|
79
|
+
path=self.path,
|
|
80
|
+
name=self.name,
|
|
81
|
+
obj=CarrierClass,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Apply scenario-level markers
|
|
85
|
+
evaluator = EvalWithCompoundTypes(names={"pytest": pytest})
|
|
86
|
+
for mark_str in scenario.marks:
|
|
87
|
+
try:
|
|
88
|
+
marker = evaluator.eval(f"pytest.mark.{mark_str}")
|
|
89
|
+
if marker:
|
|
90
|
+
json_class.add_marker(marker)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.warning(f"Failed to create marker '{mark_str}': {e}")
|
|
93
|
+
|
|
94
|
+
yield json_class
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pytest_addoption(parser: argparsing.Parser) -> None:
|
|
98
|
+
"""Add command-line options for the plugin.
|
|
99
|
+
|
|
100
|
+
Registers configuration options that can be set in pytest.ini:
|
|
101
|
+
- httpchain_suffix: File suffix for test files (default: "http")
|
|
102
|
+
- httpchain_ref_parent_traversal_depth: Max parent directory traversals in $ref paths
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
parser: Pytest's argument parser to add options to
|
|
106
|
+
"""
|
|
107
|
+
parser.addini(
|
|
108
|
+
name=ConfigOptions.SUFFIX,
|
|
109
|
+
help="File suffix for HTTP test files.",
|
|
110
|
+
type="string",
|
|
111
|
+
default="http",
|
|
112
|
+
)
|
|
113
|
+
parser.addini(
|
|
114
|
+
name=ConfigOptions.REF_PARENT_TRAVERSAL_DEPTH,
|
|
115
|
+
help="Maximum number of parent directory traversals allowed in $ref paths.",
|
|
116
|
+
type="string",
|
|
117
|
+
default="3",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def pytest_configure(config: config.Config) -> None:
|
|
122
|
+
"""Validate configuration settings.
|
|
123
|
+
|
|
124
|
+
Ensures that configuration values are valid:
|
|
125
|
+
- Suffix must be alphanumeric with underscores/hyphens, max 32 chars
|
|
126
|
+
- Reference traversal depth must be non-negative
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
config: Pytest configuration object
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ValueError: If configuration values are invalid
|
|
133
|
+
"""
|
|
134
|
+
suffix = str(config.getini(ConfigOptions.SUFFIX))
|
|
135
|
+
if not re.match(r"^[a-zA-Z0-9_-]{1,32}$", suffix):
|
|
136
|
+
raise ValueError("suffix must contain only alphanumeric characters, underscores, hyphens, and be ≤32 chars")
|
|
137
|
+
|
|
138
|
+
ref_parent_traversal_depth = int(config.getini(ConfigOptions.REF_PARENT_TRAVERSAL_DEPTH))
|
|
139
|
+
if ref_parent_traversal_depth < 0:
|
|
140
|
+
raise ValueError("Maximum number of parent directory traversals must be non-negative")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def pytest_collect_file(file_path: Path, parent: nodes.Collector) -> nodes.Collector | None:
|
|
144
|
+
"""Collect JSON test files matching the configured pattern.
|
|
145
|
+
|
|
146
|
+
This hook is called by pytest for each file in the test directory.
|
|
147
|
+
It checks if the file matches the pattern: test_<name>.<suffix>.json
|
|
148
|
+
where suffix is configurable (default: "http").
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
file_path: Path to the file being considered for collection
|
|
152
|
+
parent: The parent collector node
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
JsonModule collector if file matches pattern, None otherwise
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
For suffix="http", these files would be collected:
|
|
159
|
+
- test_api.http.json
|
|
160
|
+
- test_user_flow.http.json
|
|
161
|
+
|
|
162
|
+
Configuration:
|
|
163
|
+
The suffix can be configured in pytest.ini:
|
|
164
|
+
[tool.pytest.ini_options]
|
|
165
|
+
httpchain_suffix = "api" # Changes pattern to test_*.api.json
|
|
166
|
+
"""
|
|
167
|
+
suffix: str = parent.config.getini(ConfigOptions.SUFFIX)
|
|
168
|
+
pattern = re.compile(rf"^test_(?P<name>.+)\.{re.escape(suffix)}\.json$")
|
|
169
|
+
file_match = pattern.match(file_path.name)
|
|
170
|
+
if file_match:
|
|
171
|
+
return JsonModule.from_parent(parent, path=file_path, name=file_match.group("name"))
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
176
|
+
def pytest_runtest_makereport(item: nodes.Item, call: runner.CallInfo[Any]) -> Any:
|
|
177
|
+
"""Add custom sections to test reports.
|
|
178
|
+
|
|
179
|
+
This hook adds additional information to test reports that can be
|
|
180
|
+
displayed in pytest output or used by other plugins.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
item: The test item being reported on
|
|
184
|
+
call: Information about the test call
|
|
185
|
+
|
|
186
|
+
Yields:
|
|
187
|
+
The report with additional sections added
|
|
188
|
+
"""
|
|
189
|
+
outcome = yield
|
|
190
|
+
report: reports.TestReport = outcome.get_result()
|
|
191
|
+
if call.when == "call":
|
|
192
|
+
report.sections.append(("call_title", "call_value"))
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""HTTP request preparation and execution for chain tests.
|
|
2
|
+
|
|
3
|
+
This module handles the preparation of HTTP requests from test configurations
|
|
4
|
+
and their execution using the requests library.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from contextlib import ExitStack
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from pytest_httpchain_models.entities import (
|
|
13
|
+
FilesBody,
|
|
14
|
+
FormBody,
|
|
15
|
+
JsonBody,
|
|
16
|
+
RawBody,
|
|
17
|
+
XmlBody,
|
|
18
|
+
)
|
|
19
|
+
from pytest_httpchain_models.entities import (
|
|
20
|
+
Request as RequestModel,
|
|
21
|
+
)
|
|
22
|
+
from pytest_httpchain_userfunc.auth import call_auth_function
|
|
23
|
+
|
|
24
|
+
from .exceptions import RequestError
|
|
25
|
+
from .helpers import call_user_function
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def prepare_and_execute(
|
|
29
|
+
session: requests.Session,
|
|
30
|
+
request_model: RequestModel,
|
|
31
|
+
) -> requests.Response:
|
|
32
|
+
"""Prepare and execute an HTTP request.
|
|
33
|
+
|
|
34
|
+
This function combines preparation and execution to avoid unnecessary
|
|
35
|
+
complexity. It handles authentication, different body types, and file
|
|
36
|
+
uploads with proper resource management.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
session: HTTP session to use for the request
|
|
40
|
+
request_model: Validated request model
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
HTTP response object
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
RequestError: If request preparation or execution fails
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Base request kwargs
|
|
50
|
+
kwargs: dict[str, Any] = {
|
|
51
|
+
"method": request_model.method.value,
|
|
52
|
+
"url": str(request_model.url),
|
|
53
|
+
"headers": request_model.headers,
|
|
54
|
+
"params": request_model.params,
|
|
55
|
+
"timeout": request_model.timeout,
|
|
56
|
+
"allow_redirects": request_model.allow_redirects,
|
|
57
|
+
"verify": request_model.ssl.verify,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Add SSL cert if present
|
|
61
|
+
if request_model.ssl.cert:
|
|
62
|
+
kwargs["cert"] = request_model.ssl.cert
|
|
63
|
+
|
|
64
|
+
# Configure auth if present
|
|
65
|
+
if request_model.auth:
|
|
66
|
+
try:
|
|
67
|
+
kwargs["auth"] = call_user_function(request_model.auth, call_auth_function)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
raise RequestError("Failed to configure authentication") from e
|
|
70
|
+
|
|
71
|
+
# Handle different body types
|
|
72
|
+
match request_model.body:
|
|
73
|
+
case None:
|
|
74
|
+
pass
|
|
75
|
+
case JsonBody(json=data):
|
|
76
|
+
kwargs["json"] = data
|
|
77
|
+
case FormBody(form=data) | XmlBody(xml=data) | RawBody(raw=data):
|
|
78
|
+
kwargs["data"] = data
|
|
79
|
+
case FilesBody(files=file_paths):
|
|
80
|
+
# Handle file uploads with context manager
|
|
81
|
+
with ExitStack() as stack:
|
|
82
|
+
try:
|
|
83
|
+
files_dict = {}
|
|
84
|
+
for field_name, file_path in file_paths.items():
|
|
85
|
+
file_handle = stack.enter_context(open(file_path, "rb"))
|
|
86
|
+
files_dict[field_name] = (Path(file_path).name, file_handle)
|
|
87
|
+
kwargs["files"] = files_dict
|
|
88
|
+
|
|
89
|
+
return session.request(**kwargs)
|
|
90
|
+
except FileNotFoundError as e:
|
|
91
|
+
raise RequestError("File not found for upload") from e
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
return session.request(**kwargs)
|
|
95
|
+
except requests.Timeout as e:
|
|
96
|
+
raise RequestError("HTTP request timed out") from e
|
|
97
|
+
except requests.ConnectionError as e:
|
|
98
|
+
raise RequestError("HTTP connection error") from e
|
|
99
|
+
except requests.RequestException as e:
|
|
100
|
+
raise RequestError("HTTP request failed") from e
|
|
101
|
+
except Exception as e:
|
|
102
|
+
raise RequestError("Unexpected error") from e
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Response processing and verification for HTTP chain tests.
|
|
2
|
+
|
|
3
|
+
This module handles the processing of HTTP responses including data extraction
|
|
4
|
+
(save operations) and verification of response content.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from collections import ChainMap
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import jmespath
|
|
14
|
+
import jsonschema
|
|
15
|
+
import requests
|
|
16
|
+
from pytest_httpchain_models.entities import Save, Verify
|
|
17
|
+
from pytest_httpchain_models.types import check_json_schema
|
|
18
|
+
from pytest_httpchain_userfunc.save import call_save_function
|
|
19
|
+
from pytest_httpchain_userfunc.verify import call_verify_function
|
|
20
|
+
|
|
21
|
+
from .exceptions import SaveError, VerificationError
|
|
22
|
+
from .helpers import call_user_function
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def process_save_step(
|
|
26
|
+
save_model: Save,
|
|
27
|
+
response: requests.Response,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
"""Process a save step and return variables to be saved to global context.
|
|
30
|
+
|
|
31
|
+
Extracts data from the response using:
|
|
32
|
+
- JMESPath expressions for JSON responses
|
|
33
|
+
- User-defined save functions for custom extraction
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
save_model: Validated Save model
|
|
37
|
+
response: HTTP response object
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Dictionary of variables to add to global context
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ResponseError: If variable extraction fails
|
|
44
|
+
|
|
45
|
+
Note:
|
|
46
|
+
Save functions must conform to the SaveFunction protocol,
|
|
47
|
+
accepting a response and returning a dict[str, Any].
|
|
48
|
+
"""
|
|
49
|
+
result: dict[str, Any] = {}
|
|
50
|
+
|
|
51
|
+
# Extract JSON only if we need it for JMESPath expressions
|
|
52
|
+
if len(save_model.vars) > 0:
|
|
53
|
+
try:
|
|
54
|
+
response_json = response.json()
|
|
55
|
+
except (requests.JSONDecodeError, UnicodeDecodeError) as e:
|
|
56
|
+
raise SaveError("Cannot extract variables: response is not valid JSON") from e
|
|
57
|
+
|
|
58
|
+
for var_name, jmespath_expr in save_model.vars.items():
|
|
59
|
+
try:
|
|
60
|
+
saved_value = jmespath.search(jmespath_expr, response_json)
|
|
61
|
+
result[var_name] = saved_value
|
|
62
|
+
except jmespath.exceptions.JMESPathError as e:
|
|
63
|
+
raise SaveError(f"Error saving variable {var_name}") from e
|
|
64
|
+
|
|
65
|
+
for func_item in save_model.functions:
|
|
66
|
+
try:
|
|
67
|
+
func_result = call_user_function(func_item, call_save_function, response)
|
|
68
|
+
result.update(func_result)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
raise SaveError(f"Error calling user function {func_item}") from e
|
|
71
|
+
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def process_verify_step(
|
|
76
|
+
verify_model: Verify,
|
|
77
|
+
local_context: ChainMap[str, Any],
|
|
78
|
+
response: requests.Response,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Process a verify step and raise errors if verification fails.
|
|
81
|
+
|
|
82
|
+
Performs various verifications on the response:
|
|
83
|
+
- Status code matching
|
|
84
|
+
- Header value matching
|
|
85
|
+
- Variable value matching
|
|
86
|
+
- JSON schema validation
|
|
87
|
+
- Body content checks (contains/not_contains/matches/not_matches)
|
|
88
|
+
- User-defined verify functions
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
verify_model: Validated Verify model
|
|
92
|
+
local_context: Current execution context
|
|
93
|
+
response: HTTP response object
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
VerificationError: If any verification fails
|
|
97
|
+
|
|
98
|
+
Note:
|
|
99
|
+
Verify functions must conform to the VerifyFunction protocol,
|
|
100
|
+
accepting a response and returning a bool.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
if verify_model.status and response.status_code != verify_model.status.value:
|
|
104
|
+
raise VerificationError(f"Status code doesn't match: expected {verify_model.status.value}, got {response.status_code}")
|
|
105
|
+
|
|
106
|
+
for header_name, expected_value in verify_model.headers.items():
|
|
107
|
+
if response.headers.get(header_name) != expected_value:
|
|
108
|
+
raise VerificationError(f"Header '{header_name}' doesn't match: expected {expected_value}, got {response.headers.get(header_name)}")
|
|
109
|
+
|
|
110
|
+
for var_name, expected_value in verify_model.vars.items():
|
|
111
|
+
if var_name not in local_context:
|
|
112
|
+
raise VerificationError(f"Var '{var_name}' not found in data context")
|
|
113
|
+
if local_context[var_name] != expected_value:
|
|
114
|
+
raise VerificationError(f"Var '{var_name}' verification failed: expected {expected_value}, got {local_context[var_name]}")
|
|
115
|
+
|
|
116
|
+
for func_item in verify_model.functions:
|
|
117
|
+
try:
|
|
118
|
+
result = call_user_function(func_item, call_verify_function, response)
|
|
119
|
+
|
|
120
|
+
if not result:
|
|
121
|
+
raise VerificationError(f"Function '{func_item}' verification failed")
|
|
122
|
+
|
|
123
|
+
except Exception as e:
|
|
124
|
+
raise VerificationError(f"Error calling user function '{func_item}'") from e
|
|
125
|
+
|
|
126
|
+
if verify_model.body.schema:
|
|
127
|
+
schema = verify_model.body.schema
|
|
128
|
+
if isinstance(schema, str | Path):
|
|
129
|
+
schema_path = Path(schema)
|
|
130
|
+
try:
|
|
131
|
+
schema = json.loads(schema_path.read_text())
|
|
132
|
+
check_json_schema(schema)
|
|
133
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
134
|
+
raise VerificationError(f"Error reading body schema file '{schema_path}'") from e
|
|
135
|
+
except jsonschema.SchemaError as e:
|
|
136
|
+
raise VerificationError(f"Invalid JSON Schema in file '{schema_path}': {e.message}") from e
|
|
137
|
+
|
|
138
|
+
# Extract JSON for schema validation
|
|
139
|
+
try:
|
|
140
|
+
response_json = response.json()
|
|
141
|
+
except (requests.JSONDecodeError, UnicodeDecodeError) as e:
|
|
142
|
+
raise VerificationError("Cannot validate schema: response is not valid JSON") from e
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
jsonschema.validate(instance=response_json, schema=schema)
|
|
146
|
+
except jsonschema.ValidationError as e:
|
|
147
|
+
raise VerificationError("Body schema validation failed") from e
|
|
148
|
+
except jsonschema.SchemaError as e:
|
|
149
|
+
raise VerificationError("Invalid body validation schema") from e
|
|
150
|
+
|
|
151
|
+
for substring in verify_model.body.contains:
|
|
152
|
+
if substring not in response.text:
|
|
153
|
+
raise VerificationError(f"Body doesn't contain '{substring}'")
|
|
154
|
+
|
|
155
|
+
for substring in verify_model.body.not_contains:
|
|
156
|
+
if substring in response.text:
|
|
157
|
+
raise VerificationError(f"Body contains '{substring}' while it shouldn't")
|
|
158
|
+
|
|
159
|
+
for pattern in verify_model.body.matches:
|
|
160
|
+
if not re.search(pattern, response.text):
|
|
161
|
+
raise VerificationError(f"Body doesn't match '{pattern}'")
|
|
162
|
+
|
|
163
|
+
for pattern in verify_model.body.not_matches:
|
|
164
|
+
if re.search(pattern, response.text):
|
|
165
|
+
raise VerificationError(f"Body matches '{pattern}' while it shouldn't")
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Stage execution logic for HTTP chain tests.
|
|
2
|
+
|
|
3
|
+
This module orchestrates the execution of individual test stages by coordinating
|
|
4
|
+
between the specialized modules:
|
|
5
|
+
|
|
6
|
+
- context.py: Handles context preparation and management
|
|
7
|
+
- request.py: Manages HTTP request building and execution
|
|
8
|
+
- response.py: Processes responses (save and verify operations)
|
|
9
|
+
- exceptions.py: Defines the exception hierarchy
|
|
10
|
+
|
|
11
|
+
The main responsibility of this module is to coordinate the flow:
|
|
12
|
+
1. Build local context using context module
|
|
13
|
+
2. Prepare and execute request using request module
|
|
14
|
+
3. Process response using response module
|
|
15
|
+
4. Return updates for global context
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import pytest_httpchain_templates.substitution
|
|
22
|
+
import requests
|
|
23
|
+
from pytest_httpchain_models.entities import (
|
|
24
|
+
Request,
|
|
25
|
+
Response,
|
|
26
|
+
Save,
|
|
27
|
+
SaveStep,
|
|
28
|
+
Scenario,
|
|
29
|
+
Stage,
|
|
30
|
+
Verify,
|
|
31
|
+
VerifyStep,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from .context import prepare_data_context
|
|
35
|
+
from .request import prepare_and_execute
|
|
36
|
+
from .response import process_save_step, process_verify_step
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def execute_stage(
|
|
42
|
+
stage_template: Stage,
|
|
43
|
+
scenario: Scenario,
|
|
44
|
+
session: requests.Session,
|
|
45
|
+
global_context: dict[str, Any],
|
|
46
|
+
fixture_kwargs: dict[str, Any],
|
|
47
|
+
) -> dict[str, Any]:
|
|
48
|
+
"""Execute a single stage and return context updates.
|
|
49
|
+
|
|
50
|
+
This is the main entry point for stage execution. It orchestrates:
|
|
51
|
+
1. Context preparation (merge global + fixtures + variables)
|
|
52
|
+
2. Template substitution for all stage elements
|
|
53
|
+
3. HTTP request preparation and execution
|
|
54
|
+
4. Response processing (save and verify steps)
|
|
55
|
+
5. Return updates for global context
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
stage_template: The stage definition (with templates)
|
|
59
|
+
scenario: The scenario configuration
|
|
60
|
+
session: HTTP session for requests
|
|
61
|
+
global_context: Shared context from previous stages (read-only)
|
|
62
|
+
fixture_kwargs: Values from pytest fixtures
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Context updates to be merged into global context.
|
|
66
|
+
Only includes variables from SaveStep operations.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
RequestError: HTTP request preparation/execution failed
|
|
70
|
+
ResponseError: Response processing (save) failed
|
|
71
|
+
VerificationError: Response verification failed
|
|
72
|
+
|
|
73
|
+
Note:
|
|
74
|
+
The function maintains a clear separation between global and local
|
|
75
|
+
context. Only SaveStep results are returned for global updates.
|
|
76
|
+
"""
|
|
77
|
+
# Build local context for this stage (global + fixtures + vars)
|
|
78
|
+
local_context = prepare_data_context(scenario=scenario, stage_template=stage_template, global_context=global_context, fixture_kwargs=fixture_kwargs)
|
|
79
|
+
|
|
80
|
+
# Resolve stage template with complete local context
|
|
81
|
+
stage = pytest_httpchain_templates.substitution.walk(stage_template, local_context)
|
|
82
|
+
|
|
83
|
+
# Prepare and execute request
|
|
84
|
+
request_dict = pytest_httpchain_templates.substitution.walk(stage.request, local_context)
|
|
85
|
+
request_model = Request.model_validate(request_dict)
|
|
86
|
+
response = prepare_and_execute(session, request_model)
|
|
87
|
+
|
|
88
|
+
# Process response
|
|
89
|
+
response_dict = pytest_httpchain_templates.substitution.walk(stage.response, local_context)
|
|
90
|
+
response_model = Response.model_validate(response_dict)
|
|
91
|
+
|
|
92
|
+
# Track what needs to be saved to global context
|
|
93
|
+
global_context_updates: dict[str, Any] = {}
|
|
94
|
+
|
|
95
|
+
for step in response_model:
|
|
96
|
+
match step:
|
|
97
|
+
case SaveStep():
|
|
98
|
+
save_dict = pytest_httpchain_templates.substitution.walk(step.save, local_context)
|
|
99
|
+
save_model = Save.model_validate(save_dict)
|
|
100
|
+
saved_vars = process_save_step(save_model, response)
|
|
101
|
+
# Add saved vars as a new layer in ChainMap for subsequent steps
|
|
102
|
+
local_context = local_context.new_child(saved_vars)
|
|
103
|
+
global_context_updates.update(saved_vars)
|
|
104
|
+
|
|
105
|
+
case VerifyStep():
|
|
106
|
+
verify_dict = pytest_httpchain_templates.substitution.walk(step.verify, local_context)
|
|
107
|
+
verify_model = Verify.model_validate(verify_dict)
|
|
108
|
+
process_verify_step(verify_model, local_context, response)
|
|
109
|
+
|
|
110
|
+
# Return only the updates that should persist globally
|
|
111
|
+
return global_context_updates
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-httpchain
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: pytest plugin for HTTP testing using JSON files
|
|
5
|
+
Keywords: testing,pytest,requests
|
|
6
|
+
Author: Alexander Eresov
|
|
7
|
+
Author-email: Alexander Eresov <aeresov@gmail.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Framework :: Pytest
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Requires-Dist: pydantic>=2.11.7
|
|
17
|
+
Requires-Dist: pytest-httpchain-jsonref
|
|
18
|
+
Requires-Dist: pytest-httpchain-models
|
|
19
|
+
Requires-Dist: pytest-order>=1.3.0
|
|
20
|
+
Requires-Dist: rich>=13.7.0
|
|
21
|
+
Requires-Dist: pytest-httpchain-mcp ; extra == 'mcp'
|
|
22
|
+
Requires-Python: >=3.13, <4.0
|
|
23
|
+
Provides-Extra: mcp
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
[](https://pypi.python.org/pypi/pytest-httpchain)
|
|
27
|
+
[](https://github.com/aeresov/pytest-httpchain/blob/main/LICENSE)
|
|
28
|
+
[](https://pypi.python.org/pypi/pytest-httpchain)
|
|
29
|
+
|
|
30
|
+
# pytest-httpchain
|
|
31
|
+
|
|
32
|
+
A pytest plugin for testing HTTP endpoints.
|
|
33
|
+
|
|
34
|
+
## Overview
|
|
35
|
+
|
|
36
|
+
`pytest-httpchain` is an integration testing framework for HTTP APIs based on battle-hardened [requests](https://requests.readthedocs.io) lib.\
|
|
37
|
+
It aims at helping with common HTTP API testing scenarios, where user needs to make several calls in specific order using data obtained along the way, like auth tokens or resource ids.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
Install normally via package manager of your choice from PyPi:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install pytest-httpchain
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
or directly from Github, in case you need a particular ref:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install 'git+https://github.com/aeresov/pytest-httpchain@main'
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Optional dependencies
|
|
54
|
+
|
|
55
|
+
The following optional dependencies are available:
|
|
56
|
+
|
|
57
|
+
- `mcp`: installs MCP server package and its starting script. Details in [MCP Server](#mcp-server).
|
|
58
|
+
|
|
59
|
+
## Features
|
|
60
|
+
|
|
61
|
+
### Pytest integration
|
|
62
|
+
|
|
63
|
+
Most of pytest magic can be used: markers, fixtures, other plugins.\
|
|
64
|
+
|
|
65
|
+
> NOTE: parametrization is not yet implemented, therefore `parametrize` marker won't have any effect.
|
|
66
|
+
|
|
67
|
+
### Declarative format
|
|
68
|
+
|
|
69
|
+
Test scenarios are written declaratively in JSON files.\
|
|
70
|
+
`pytest-httpchain` supports JSONRef, so use can reuse arbitrary parts of your scenarios with `$ref` directive.\
|
|
71
|
+
Properties are merged in a greedy way with type checking.
|
|
72
|
+
|
|
73
|
+
### Multi-stage tests
|
|
74
|
+
|
|
75
|
+
Each test scenario contains 1+ stages; each stage is a single HTTP call.\
|
|
76
|
+
`pytest-httpchain` executes stages in the order they are listed in scenario file; one stage failure stops the execution chain.
|
|
77
|
+
|
|
78
|
+
### Common data context and variable substitution
|
|
79
|
+
|
|
80
|
+
`pytest-httpchain` maintains key-value data storage throughout the execution.\
|
|
81
|
+
This storage ("common data context") is populated with declared variables, fixtures and data saved by stages. The data remains there throughout the scenario execution.\
|
|
82
|
+
Writing scenarios, you can use Jinja-style expressions like `"{{ var }}"` for JSON values. `pytest-httpchain` does variable substitution dynamically right before executing a stage, and uses common data context keys as variables in these expressions.\
|
|
83
|
+
Values from common data context also might be verified during verified/asserted.
|
|
84
|
+
|
|
85
|
+
### User functions
|
|
86
|
+
|
|
87
|
+
`pytest-httpchain` can import and call regular python functions:
|
|
88
|
+
|
|
89
|
+
- to extract data from HTTP response
|
|
90
|
+
- to verify HTTP response and values in common data context
|
|
91
|
+
- to provide [custom authentication for requests](https://requests.readthedocs.io/en/latest/user/advanced/#custom-authentication)
|
|
92
|
+
|
|
93
|
+
### JMESPath support
|
|
94
|
+
|
|
95
|
+
`pytest-httpchain` can extract values from JSON responses using JMESPath expressions directly.
|
|
96
|
+
|
|
97
|
+
### JSON schema support
|
|
98
|
+
|
|
99
|
+
`pytest-httpchain` can verify JSON reponses against user-defined JSON schema.
|
|
100
|
+
|
|
101
|
+
## Quick Start
|
|
102
|
+
|
|
103
|
+
Create a JSON test file named like `test_<name>.<suffix>.json` (default suffix is `http`):
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
# conftest.py
|
|
107
|
+
import pytest
|
|
108
|
+
from datetime import datetime
|
|
109
|
+
|
|
110
|
+
@pytest.fixture
|
|
111
|
+
def now_utc():
|
|
112
|
+
return datetime.now()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"vars": {
|
|
118
|
+
"user_id": 1
|
|
119
|
+
},
|
|
120
|
+
"stages": [
|
|
121
|
+
{
|
|
122
|
+
"name": "get_user",
|
|
123
|
+
"request": {
|
|
124
|
+
"url": "https://api.example.com/users/{{ user_id }}"
|
|
125
|
+
},
|
|
126
|
+
"response": [
|
|
127
|
+
{
|
|
128
|
+
"verify": {
|
|
129
|
+
"status": 200
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"save": {
|
|
134
|
+
"vars": {
|
|
135
|
+
"user_name": "user.name"
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
]
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"name": "update_user",
|
|
143
|
+
"fixtures": ["now_utc"],
|
|
144
|
+
"request": {
|
|
145
|
+
"url": "https://api.example.com/users/{{ user_id }}",
|
|
146
|
+
"method": "PUT",
|
|
147
|
+
"body": {
|
|
148
|
+
"json": {
|
|
149
|
+
"user": {
|
|
150
|
+
"name": "{{ user_name }}_updated",
|
|
151
|
+
"timestamp": "{{ str(now_utc) }}"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
"response": [
|
|
157
|
+
{
|
|
158
|
+
"verify": {
|
|
159
|
+
"status": 200
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
]
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"name": "cleanup",
|
|
166
|
+
"always_run": true,
|
|
167
|
+
"request": {
|
|
168
|
+
"url": "https://api.example.com/cleanup",
|
|
169
|
+
"method": "POST"
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Scenario we created:
|
|
177
|
+
|
|
178
|
+
- common data context is seeded with the first variable `user_id`
|
|
179
|
+
- **get_user**\
|
|
180
|
+
url is assembled using `user_id` variable from common data context\
|
|
181
|
+
HTTP GET call is made\
|
|
182
|
+
we verify the call returned code 200\
|
|
183
|
+
assuming JSON body is returned, we extract a value by JMESPath expression `user.name` and save it to common data context under `user_name` key
|
|
184
|
+
- **update_user**\
|
|
185
|
+
`now_utc` fixture value is injected into common data context\
|
|
186
|
+
url is assembled using `user_id` variable from common data context\
|
|
187
|
+
we create JSON body in place using values from common data context, note that `now_utc` is converted to string in place\
|
|
188
|
+
HTTP PUT call with body is made\
|
|
189
|
+
we verify the call returned code 200\
|
|
190
|
+
- **cleanup**\
|
|
191
|
+
finalizing call meant for graceful exit\
|
|
192
|
+
`always_run` parameter means this stage will be executed regardless of errors in previous stages
|
|
193
|
+
|
|
194
|
+
For detailed examples see [USAGE.md](USAGE.md).
|
|
195
|
+
|
|
196
|
+
## Configuration
|
|
197
|
+
|
|
198
|
+
- Test file discovery is based on this name pattern: `test_<name>.<suffix>.json`.\
|
|
199
|
+
The `suffix` is configurable as pytest ini option, default value is **http**.
|
|
200
|
+
- `$ref` instructions can point to other files; absolute and relative paths are supported.\
|
|
201
|
+
You can limit the depth of relative path traversal using `ref_parent_traversal_depth` ini option, default value is **3**.
|
|
202
|
+
|
|
203
|
+
## MCP Server
|
|
204
|
+
|
|
205
|
+
`pytest-httpchain` includes an MCP (Model Context Protocol) server to aid AI code assistants.
|
|
206
|
+
|
|
207
|
+
### Installation
|
|
208
|
+
|
|
209
|
+
The optional dependency `mcp` installs MCP server's package and `pytest-httpchain-mcp` script.\
|
|
210
|
+
Use this script as call target for your MCP configuration.
|
|
211
|
+
|
|
212
|
+
Claude Code `.mcp.json` example:
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{
|
|
216
|
+
"mcpServers": {
|
|
217
|
+
"pytest-httpchain": {
|
|
218
|
+
"type": "stdio",
|
|
219
|
+
"command": "uv",
|
|
220
|
+
"args": ["run", "pytest-httpchain-mcp"],
|
|
221
|
+
"env": {}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Features
|
|
228
|
+
|
|
229
|
+
The MCP server provides:
|
|
230
|
+
|
|
231
|
+
- **Scenario validation** - validate test scenario and scan for possible problems
|
|
232
|
+
|
|
233
|
+
## Documentation
|
|
234
|
+
|
|
235
|
+
- [Usage Examples](USAGE.md) - Practical code examples
|
|
236
|
+
- [Full Documentation](https://aeresov.github.io/pytest-httpchain) - Complete guide
|
|
237
|
+
- [Changelog](CHANGELOG.md) - Release notes
|
|
238
|
+
|
|
239
|
+
## Thanks
|
|
240
|
+
|
|
241
|
+
`pytest-httpchain` was heavily inspired by [Tavern](https://github.com/taverntesting/tavern) and [pytest-play](https://github.com/davidemoro/pytest-play).
|
|
242
|
+
[requests](https://requests.readthedocs.io) does the comms.
|
|
243
|
+
[Pydantic](https://docs.pydantic.dev) keeps the structure.
|
|
244
|
+
[pytest-order](https://github.com/pytest-dev/pytest-order) powers the chaining.
|
|
245
|
+
[pytest-datadir](https://github.com/gabrielcnr/pytest-datadir) saved me a lot of elbow grease.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pytest_httpchain/__init__.py,sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855,0
|
|
2
|
+
pytest_httpchain/carrier.py,sha256=e696c8ea2e50e8c447816ea881bf4bb7cd4dc477e6f2b6f0d8e62dd107972b1b,5156
|
|
3
|
+
pytest_httpchain/carrier_factory.py,sha256=4d2f64a6c0cdf81ee4c826d30412f71f7ae742a77c9482e97d9971534dcd917d,3363
|
|
4
|
+
pytest_httpchain/constants.py,sha256=f7801ebd9317048468e1f4cc99858a3bd85dfafb6d475ab0f6bd344fa1814891,258
|
|
5
|
+
pytest_httpchain/context.py,sha256=b082e8026db698e61821e0884fa1a3629b3bc3535c8a5ed6bcf074318f805d20,3035
|
|
6
|
+
pytest_httpchain/exceptions.py,sha256=8cb12a71213596999b974064c0d89cc13c51fedfbf456ad5a6cbc8366f21772d,1209
|
|
7
|
+
pytest_httpchain/helpers.py,sha256=ed12adcedc1bafe3060d168007f2052cc208552a0b1615ab7c58bdbc6e3f1bab,1597
|
|
8
|
+
pytest_httpchain/plugin.py,sha256=522eaaa4a05f3b108c10c55867b28ee78167853f22ad9d4d092f75f880c0af1c,6701
|
|
9
|
+
pytest_httpchain/request.py,sha256=d81ea9f90e0fc0ad9d3304da74abb7902e6827d2646a8a7c5d44272dc2a565f0,3327
|
|
10
|
+
pytest_httpchain/response.py,sha256=9f023c48292fb7e87c57c00f37b5f782a23354a9f7c40a452397b9a746fa0007,6350
|
|
11
|
+
pytest_httpchain/stage_executor.py,sha256=fb2c887cd4c77cd06f8911e1e2aa5d6d7196cff08bd8d17eb28ffd7fb44dfcb0,4209
|
|
12
|
+
pytest_httpchain-0.1.0.dist-info/licenses/LICENSE,sha256=bcfbbc70256347518043fadc1e07484d958b8292c930f7745e6d083d583a35f5,1064
|
|
13
|
+
pytest_httpchain-0.1.0.dist-info/WHEEL,sha256=ab6157bc637547491fb4567cd7ddf26b04d63382916ca16c29a5c8e94c9c9ef7,79
|
|
14
|
+
pytest_httpchain-0.1.0.dist-info/entry_points.txt,sha256=7db8dc2819e062d7e780e1375205a8441e75757e09da2ace5eda7ae579f73616,55
|
|
15
|
+
pytest_httpchain-0.1.0.dist-info/METADATA,sha256=14d4bd19c84e522a5f3650fc6332e3135908fff65251ce35e2095c546b84dbd9,8426
|
|
16
|
+
pytest_httpchain-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 aeresov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|