Approach: add a pytest fixture that exposes an artifacts directory per run and a hook (pytest_runtest_makereport) that, on test failure, captures a Playwright page screenshot and HTML dump. Use a run-id (env var or uuid), timestamped filenames, and robust path creation so it works locally or in CI.conftest.pypython
import os
import uuid
from datetime import datetime
from pathlib import Path
import pytest
# decide run id (CI can set CI_RUN_ID or CI_JOB_ID)
RUN_ID = os.environ.get("CI_RUN_ID") or os.environ.get("CI_JOB_ID") or str(uuid.uuid4())
BASE_ARTIFACTS = Path("/tmp/test-artifacts") / RUN_ID
@pytest.fixture(scope="session")
def artifacts_base():
BASE_ARTIFACTS.mkdir(parents=True, exist_ok=True)
return BASE_ARTIFACTS
@pytest.fixture
def test_artifact_dir(request, artifacts_base):
# sanitized test nodeid to folder-friendly name
name = request.node.nodeid.replace("::", "/").replace("/", "_")
dirpath = artifacts_base / name
dirpath.mkdir(parents=True, exist_ok=True)
return dirpath
# If using pytest-playwright, you likely have a "page" fixture; ensure we can access it.
def _timestamp():
return datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
def _safe_name(prefix, ext):
return f"{prefix}-{_timestamp()}.{ext}"
def pytest_runtest_makereport(item, call):
"""Hook: run after each test phase. On failure in call phase, attempt to capture artifacts."""
outcome = yield from pytest._hookimpl.pytest_runtest_makereport(item, call) if False else None
# The above yield trick is not necessary in recent pytest; simpler:
rep = item._store.get("_pytest_runtest_makereport") if False else None
# Simpler: use the documented pattern
def pytest_runtest_makereport(item, call):
rep = pytest.runner.FromHook.call_pluginmanager("pytest_runtest_makereport", item=item, call=call) if False else None
Cleaner recommended implementation (works and minimal; use real pytest hook directly):python
def pytest_runtest_makereport(item, call):
# standard approach:
from _pytest.runner import runtestprotocol
# Instead use built-in API:
rep = item._store.get("rep") # placeholder - not used; we'll compute below
# Use the documented pattern:
outcome = yield
rep = outcome.get_result()
if rep.when == "call" and rep.failed:
# try to get fixtures
page = item.funcargs.get("page") # pytest-playwright page fixture
artifact_dir = item.funcargs.get("test_artifact_dir")
if not artifact_dir:
return
try:
img_name = _safe_name("screenshot", "png")
html_name = _safe_name("page", "html")
page.screenshot(path=str(artifact_dir / img_name), full_page=True)
html = page.content()
(artifact_dir / html_name).write_text(html, encoding="utf-8")
except Exception as e:
# don't break test reporting; log to stdout
print(f"Failed to capture artifacts: {e}")
Example test usage:python
def test_example(page, test_artifact_dir):
page.goto("https://example.com")
assert page.title() == "Non-matching title" # will fail => artifacts saved
Notes and reasoning:- Use environment-provided run ids in CI for grouping; fallback to uuid for local runs.- Timestamped filenames avoid collisions and provide ordering.- Accessing item.funcargs["page"] safely handles tests that don't use Playwright.- Keep capture in hook non-fatal (wrap in try/except) so reporting continues even if artifacts fail.- This approach is lightweight, works both locally and in CI (as long as Playwright is available and tests run on a machine/container with access to /tmp).