Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ report:

# Serve the Allure report interactively
serve-report:
python scripts/allure_helper.py --serve
poetry run python scripts/allure_helper.py --serve

# Clean test artifacts and reports
clean:
Expand Down
22 changes: 15 additions & 7 deletions src/api_testing_framework/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,35 +175,43 @@ def _request(
path: URL path relative to base_url
params: Query parameters for the request
json: JSON body for the request
attach: If True, attach request/response to Allure report
attach: If True, attach request/response to Allure report immediately

Returns:
Parsed JSON response as dict

Raises:
APIError: If the response status indicates an error

Environment Variables:
ATTACH_ON_FAILURE: If "true", records request/response for all calls.
Pytest hook can then attach on test failure.
"""
self._refresh_token_if_needed()

# Check if we should record for later attachment (ATTACH_ON_FAILURE mode)
attach_on_failure = os.getenv("ATTACH_ON_FAILURE", "").lower() == "true"
should_record = attach or attach_on_failure

# Build request with appropriate parameters
request = self._client.build_request(method, path, params=params, json=json)
if attach:
if should_record:
self._record_request(request)

# Send and record response
response = self._client.send(request)
if attach:
if should_record:
self._last_response = response

# Handle status; if it errors, attach before raising
# Handle status; if it errors, attach ONLY if explicit attach=True
try:
data = self._handle_response(response)
except APIError:
if attach:
if attach: # Only attach immediately if explicitly requested
self._attach_last_exchange_to_allure()
raise
raise # Pytest hook will handle attachment if ATTACH_ON_FAILURE=true

# On success, attach if requested
# On success, attach ONLY if explicit attach=True
if attach:
self._attach_last_exchange_to_allure()
return data
Expand Down
44 changes: 44 additions & 0 deletions tests/spotify/test_attach_on_failure_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""
Demo test that intentionally fails to show ATTACH_ON_FAILURE in action.
This test should be run manually to see attachments in Allure report.
"""
import os

import pytest


@pytest.fixture(autouse=True)
def enable_attach_on_failure(monkeypatch):
"""Enable ATTACH_ON_FAILURE for this test module."""
monkeypatch.setenv("ATTACH_ON_FAILURE", "true")
yield
monkeypatch.delenv("ATTACH_ON_FAILURE", raising=False)


@pytest.mark.integration
@pytest.mark.skip(reason="Demo test - unskip to see ATTACH_ON_FAILURE in action")
def test_intentional_failure_to_demo_attach_on_failure(api_client):
"""
This test intentionally fails to demonstrate ATTACH_ON_FAILURE.

When this test fails:
1. The pytest hook detects the failure
2. It finds the api_client attached to the test node
3. It calls _attach_last_exchange_to_allure()
4. The HTTP request/response appear in the Allure report

To see it in action:
1. Remove the @pytest.mark.skip decorator
2. Run: poetry run pytest tests/spotify/test_attach_on_failure_demo.py --alluredir=allure-results
3. Run: make serve-report
4. Check the Allure report for this failing test - it will have HTTP attachments
"""
# Make a real API call
result = api_client.get("/search", params={"q": "test", "type": "track", "limit": 1})

# Verify the exchange was recorded
assert api_client._last_request is not None
assert api_client._last_response is not None

# This assertion intentionally fails to trigger the pytest hook
assert False, "Intentional failure to demonstrate ATTACH_ON_FAILURE - check Allure report for HTTP attachments!"
103 changes: 103 additions & 0 deletions tests/spotify/test_attach_on_failure_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""
Real-world integration test for ATTACH_ON_FAILURE feature.
Tests against live Spotify API with no mocking or monkeypatching.
"""
import os

import pytest

from api_testing_framework.exceptions import APIError


@pytest.fixture(autouse=True)
def enable_attach_on_failure(monkeypatch):
"""Enable ATTACH_ON_FAILURE for this test module."""
monkeypatch.setenv("ATTACH_ON_FAILURE", "true")
yield
monkeypatch.delenv("ATTACH_ON_FAILURE", raising=False)


@pytest.mark.integration
def test_attach_on_failure_with_valid_spotify_request(spotify_client):
"""
Test ATTACH_ON_FAILURE records exchanges on successful API calls.
Uses real Spotify API - no mocking.
"""
# Clear any previous state
spotify_client._last_request = None
spotify_client._last_response = None

# Make a real API call to Spotify
result = spotify_client.get("/search", params={"q": "test", "type": "track", "limit": 1})

# Verify the exchange was recorded (ATTACH_ON_FAILURE=true)
assert spotify_client._last_request is not None, "Request should be recorded with ATTACH_ON_FAILURE=true"
assert spotify_client._last_response is not None, "Response should be recorded with ATTACH_ON_FAILURE=true"

# Verify request details
assert spotify_client._last_request.method == "GET"
assert "/search" in str(spotify_client._last_request.url)

# Verify response details
assert spotify_client._last_response.status_code == 200
assert spotify_client._last_response.is_success

# Verify actual API response structure
assert "tracks" in result
assert isinstance(result["tracks"], dict)


@pytest.mark.integration
def test_attach_on_failure_with_invalid_spotify_endpoint(spotify_client):
"""
Test ATTACH_ON_FAILURE records exchanges when API call fails.
Uses real Spotify API with invalid endpoint - no mocking.
"""
# Clear any previous state
spotify_client._last_request = None
spotify_client._last_response = None

# Make a real API call to an invalid Spotify endpoint
with pytest.raises(APIError) as exc_info:
spotify_client.get("/this-endpoint-does-not-exist")

# Verify the exchange was recorded even on failure
assert spotify_client._last_request is not None, "Request should be recorded on failure"
assert spotify_client._last_response is not None, "Response should be recorded on failure"

# Verify request details
assert spotify_client._last_request.method == "GET"
assert "/this-endpoint-does-not-exist" in str(spotify_client._last_request.url)

# Verify response details - should be 404 or similar error
assert not spotify_client._last_response.is_success
assert spotify_client._last_response.status_code >= 400

# Verify exception contains error info
api_error = exc_info.value
assert api_error.status_code >= 400


@pytest.mark.integration
def test_attach_on_failure_without_flag(spotify_client, monkeypatch):
"""
Test that exchanges are NOT recorded when ATTACH_ON_FAILURE=false.
Uses real Spotify API - no mocking.
"""
# Disable ATTACH_ON_FAILURE
monkeypatch.setenv("ATTACH_ON_FAILURE", "false")

# Clear any previous state
spotify_client._last_request = None
spotify_client._last_response = None

# Make a real API call to Spotify
result = spotify_client.get("/search", params={"q": "test", "type": "track", "limit": 1})

# Verify the exchange was NOT recorded (ATTACH_ON_FAILURE=false)
assert spotify_client._last_request is None, "Request should NOT be recorded with ATTACH_ON_FAILURE=false"
assert spotify_client._last_response is None, "Response should NOT be recorded with ATTACH_ON_FAILURE=false"

# Verify API call still worked
assert "tracks" in result
assert isinstance(result["tracks"], dict)
59 changes: 59 additions & 0 deletions tests/test_attach_on_failure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import os

import allure
import httpx
import pytest

from api_testing_framework.client import APIClient
from api_testing_framework.exceptions import APIError


class ErrorTransport(httpx.BaseTransport):
"""Always returns HTTP 500 with a simple JSON error body"""

def handle_request(self, request):
return httpx.Response(500, json={"error": "internal_server_error"})


@pytest.fixture(autouse=True)
def enable_attach_on_failure(monkeypatch):
"""
Automatically enable the ATTACH_ON_FAILURE flag for this test module.
"""
monkeypatch.setenv("ATTACH_ON_FAILURE", "true")
yield
monkeypatch.delenv("ATTACH_ON_FAILURE", raising=False)


def test_http_exchange_attached_on_error(monkeypatch, request):
attached = []

monkeypatch.setattr(
allure, "attach", lambda content, name, attachment_type: attached.append(name)
)

client = APIClient(
base_url="https://api.example.com",
transport=ErrorTransport(),
token="dummy-token",
)
request.node._api_client = client

# Make API call that errors (with ATTACH_ON_FAILURE=true)
with pytest.raises(APIError):
client.get("/endpoint-that-errors")

# Manually trigger what the pytest hook would do
# (The real hook runs after test completion, so we simulate it here)
client._attach_last_exchange_to_allure()

expected = {
"HTTP Request",
"Request Headers",
"HTTP Response Status",
"Response Headers",
"Response Body",
}
assert expected.issubset(
set(attached)
), f"Missing attachments: {expected - set(attached)}"
66 changes: 66 additions & 0 deletions tests/test_pytest_hook_attach_on_failure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import allure
import httpx
import pytest

from api_testing_framework.client import APIClient
from api_testing_framework.exceptions import APIError


class ErrorTransport(httpx.BaseTransport):
"""Dummy transport that always returns 500 with a JSON error body."""

def handle_request(self, request):
return httpx.Response(500, json={"error": "internal_server_error"})


@pytest.fixture(autouse=True)
def enable_global_recording(monkeypatch):
"""Turn on the ATTACH_ON_FAILURE flag for every test in this module."""
monkeypatch.setenv("ATTACH_ON_FAILURE", "true")
yield
monkeypatch.delenv("ATTACH_ON_FAILURE", raising=False)


@pytest.fixture
def api_client(request):
"""
Override the global api_client fixture with one that uses ErrorTransport,
and register it on the pytest node for the makereport hook.
"""
client = APIClient(
base_url="https://doesnotmatter.local",
transport=ErrorTransport(),
token="dummy-token",
)
# Bind it so pytest_runtest_makereport can find it:
request.node._api_client = client
return client


def test_pytest_hook_attaches_on_failure(monkeypatch, api_client):
# Capture the names of all allure.attach calls:
attached = []
monkeypatch.setattr(
allure,
"attach",
lambda content, name=None, attachment_type=None: attached.append(name),
)

# Invoke a GET that 500s (no per-call attach=True)
with pytest.raises(APIError):
api_client.get("/endpoint-that-errors")

# Manually trigger what the pytest hook would do
# (The real hook runs after test completion, so we simulate it here)
api_client._attach_last_exchange_to_allure()

# Assert that the hook attached the five expected pieces:
expected = {
"HTTP Request",
"Request Headers",
"HTTP Response Status",
"Response Headers",
"Response Body",
}
missing = expected - set(attached)
assert not missing, f"Missing attachments: {missing}"