From ce95fee197f23a1b91fb133db19c1c491b83de7c Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 16 Oct 2026 11:10:14 +1100 Subject: [PATCH 1/3] feat: scope the Pact test run ID to each pytest test Register a pytest plugin through the pytest11 entry point with one autouse fixture, pact_test_run_id, which sets the node ID as the Pact test run ID for the test's duration. Log records from the library and from plugins then name the test which caused them without any conftest set-up. An autouse fixture rather than a documented one, because opting in would require every test suite to remember the set-up; disabling it is a single `-p no:pact` instead. Signed-off-by: JP-Ellis Assisted-by: Claude Code:claude-opus-5-5 --- pyproject.toml | 3 ++ src/pact/__init__.py | 2 ++ src/pact/testing/__init__.py | 8 +++++ src/pact/testing/pytest.py | 42 +++++++++++++++++++++++++ tests/conftest.py | 2 ++ tests/testing/__init__.py | 0 tests/testing/test_pytest.py | 59 ++++++++++++++++++++++++++++++++++++ 7 files changed, 116 insertions(+) create mode 100644 src/pact/testing/__init__.py create mode 100644 src/pact/testing/pytest.py create mode 100644 tests/testing/__init__.py create mode 100644 tests/testing/test_pytest.py diff --git a/pyproject.toml b/pyproject.toml index e4c5ee799..b8086f5bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,9 @@ dependencies = [ issues = "https://github.com/pact-foundation/pact-python/issues" source = "https://github.com/pact-foundation/pact-python" + [project.entry-points.pytest11] + pact = "pact.testing.pytest" + [dependency-groups] # Linting and formatting tools use a more narrow specification to ensure # developper consistency. All other dependencies are as above. diff --git a/src/pact/__init__.py b/src/pact/__init__.py index f8f4bf740..e546c34e4 100644 --- a/src/pact/__init__.py +++ b/src/pact/__init__.py @@ -112,6 +112,7 @@ from __future__ import annotations from pact import logging as logging +from pact import testing as testing from pact import xml as xml from pact.__version__ import __version__, __version_tuple__ from pact.pact import Pact @@ -127,5 +128,6 @@ "__version__", "__version_tuple__", "logging", + "testing", "xml", ] diff --git a/src/pact/testing/__init__.py b/src/pact/testing/__init__.py new file mode 100644 index 000000000..9a706f77d --- /dev/null +++ b/src/pact/testing/__init__.py @@ -0,0 +1,8 @@ +""" +Test runner integrations. + +- [`pact.testing.pytest`][pact.testing.pytest] is a pytest plugin, enabled + automatically when `pact-python` is installed. +- [`pact.testing.unittest`][pact.testing.unittest] provides a mixin for + `unittest.TestCase` subclasses. +""" diff --git a/src/pact/testing/pytest.py b/src/pact/testing/pytest.py new file mode 100644 index 000000000..0b88015ed --- /dev/null +++ b/src/pact/testing/pytest.py @@ -0,0 +1,42 @@ +""" +Pytest plugin scoping the Pact test run ID to each test. + +The plugin is registered through the `pytest11` entry point and is active +whenever `pact-python` is installed. It provides one autouse fixture, +[`pact_test_run_id`][pact.testing.pytest.pact_test_run_id], which sets the +test's node ID as the Pact test run ID for the test's duration. The Pact +library's records from the test's thread, and the log entries a plugin sends +back for the test's requests, then carry the test which caused them. + +Disable the plugin with `-p no:pact`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from collections.abc import Generator + + +@pytest.fixture(autouse=True) +def pact_test_run_id(request: pytest.FixtureRequest) -> Generator[str, None, None]: + """ + Set the test's node ID as the Pact test run ID. + + Args: + request: + The pytest request for the running test. + + Yields: + The node ID set as the run ID. It is cleared on teardown. + """ + # Imported here so that collecting the plugin does not load the FFI shared + # library in every environment where pact-python is installed. + from pact import logging as pact_logging # noqa: PLC0415 + + pact_logging.set_test_run_id(request.node.nodeid) + yield request.node.nodeid + pact_logging.set_test_run_id(None) diff --git a/tests/conftest.py b/tests/conftest.py index 31ccf665d..f9064aa60 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,8 @@ import pytest +pytest_plugins = ["pytester"] + @pytest.fixture def temp_assets(tmp_path: Path) -> Path: diff --git a/tests/testing/__init__.py b/tests/testing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/testing/test_pytest.py b/tests/testing/test_pytest.py new file mode 100644 index 000000000..d3f6e6434 --- /dev/null +++ b/tests/testing/test_pytest.py @@ -0,0 +1,59 @@ +""" +Tests for the pytest plugin in [`pact.testing.pytest`][pact.testing.pytest]. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pytest + +CONFTEST = """ +import pytest + +import pact.logging + +calls = [] + + +# Session scope so the recorder is in place before the plugin's function-scoped +# autouse fixture runs. +@pytest.fixture(autouse=True, scope="session") +def _record(): + original = pact.logging.set_test_run_id + pact.logging.set_test_run_id = calls.append + yield + pact.logging.set_test_run_id = original + + +def pytest_sessionfinish(session): + session.config.rootpath.joinpath("calls.txt").write_text( + "\\n".join(str(c) for c in calls) + ) +""" + +TEST_FILE = """ +def test_one(pact_test_run_id, request): + assert pact_test_run_id == request.node.nodeid +""" + + +def test_fixture_scopes_the_run_id(pytester: pytest.Pytester) -> None: + pytester.makeconftest(CONFTEST) + pytester.makepyfile(test_example=TEST_FILE) + + result = pytester.runpytest("-p", "no:cacheprovider") + + result.assert_outcomes(passed=1) + calls = pytester.path.joinpath("calls.txt").read_text().splitlines() + assert calls == ["test_example.py::test_one", "None"] + + +def test_plugin_can_be_disabled(pytester: pytest.Pytester) -> None: + pytester.makepyfile(test_example=TEST_FILE) + + result = pytester.runpytest("-p", "no:pact", "-p", "no:cacheprovider") + + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines(["*fixture 'pact_test_run_id' not found*"]) From 7e2173fcac68e607f163482e1adfd82a5bef3308 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 17 Sep 2026 23:12:55 +1000 Subject: [PATCH 2/3] feat: add a unittest mixin for Pact logs unittest has no plugin entry point and no log capture on failure, so PactTestMixin wraps run() to set the test ID as the Pact test run ID, buffer records on the pact logger, and print them to stderr when the test fails or errors. A logger hands a record to its handlers only when the record clears the logger's effective level, and outside pytest that level is the root logger's WARNING. The mixin therefore lowers the pact logger to the forwarding level while the test runs, when the logger has no explicit level of its own, and restores it afterwards. Signed-off-by: JP-Ellis Assisted-by: Claude Code:claude-opus-5 --- src/pact/testing/unittest.py | 103 +++++++++++++++++++++++++++ tests/testing/test_unittest.py | 125 +++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 src/pact/testing/unittest.py create mode 100644 tests/testing/test_unittest.py diff --git a/src/pact/testing/unittest.py b/src/pact/testing/unittest.py new file mode 100644 index 000000000..89bb7351e --- /dev/null +++ b/src/pact/testing/unittest.py @@ -0,0 +1,103 @@ +""" +`unittest` integration for Pact logs. + +[`PactTestMixin`][pact.testing.unittest.PactTestMixin] scopes the Pact test run +ID to each test and prints the Pact log records collected during a test to +stderr when the test fails or errors: + +```python +import unittest + +from pact.testing.unittest import PactTestMixin + + +class ConsumerTest(PactTestMixin, unittest.TestCase): + def test_something(self) -> None: ... +``` + +List the mixin before `unittest.TestCase` so that its `run` wraps the test. +""" + +from __future__ import annotations + +import logging +import sys +import unittest + +from pact import logging as pact_logging + +__all__ = ["PactTestMixin"] + + +class _ListHandler(logging.Handler): + """Handler appending every record to a list.""" + + def __init__(self) -> None: + super().__init__(logging.NOTSET) + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + +class PactTestMixin: + """ + Mixin for `unittest.TestCase` subclasses using Pact. + + While a test runs, its `id()` is set as the Pact test run ID and every + record on the `pact` logger is buffered. If the test fails or errors, the + buffered records are written to stderr under a banner naming the test. + + A logger only hands records at or above its effective level to its + handlers, so when the `pact` logger has no explicit level it is lowered to + [`forwarding_level`][pact_logging.forwarding_level] for the duration of the + test and restored afterwards. + """ + + def run( + self, + result: unittest.TestResult | None = None, + ) -> unittest.TestResult | None: + """ + Run the test with the Pact run ID set and Pact logs buffered. + + Args: + result: + The result to record into. When omitted, a new + `unittest.TestResult` is created, as `unittest.TestCase.run` + does. + + Returns: + The result returned by `unittest.TestCase.run`. + """ + if result is None: + result = unittest.TestResult() + test_id: str = self.id() # type: ignore[attr-defined] + + handler = _ListHandler() + pact_logger = logging.getLogger("pact") + failures = len(result.failures) + len(result.errors) + + pact_logging.set_test_run_id(test_id) + pact_logger.addHandler(handler) + lowered = pact_logger.level == logging.NOTSET + if lowered: + pact_logger.setLevel(pact_logging.forwarding_level()) + try: + outcome = super().run(result) # type: ignore[misc] + if len(result.failures) + len(result.errors) > failures: + self._dump(test_id, handler.records) + return outcome + finally: + if lowered: + pact_logger.setLevel(logging.NOTSET) + pact_logger.removeHandler(handler) + pact_logging.set_test_run_id(None) + + @staticmethod + def _dump(test_id: str, records: list[logging.LogRecord]) -> None: + formatter = logging.Formatter("%(levelname)s %(name)s: %(message)s") + lines = [f"--- Pact logs for {test_id} ---"] + lines.extend(formatter.format(record) for record in records) + lines.append("--- end of Pact logs ---") + sys.stderr.write("\n".join(lines) + "\n") diff --git a/tests/testing/test_unittest.py b/tests/testing/test_unittest.py new file mode 100644 index 000000000..db40d1b00 --- /dev/null +++ b/tests/testing/test_unittest.py @@ -0,0 +1,125 @@ +""" +Tests for [`PactTestMixin`][pact.testing.unittest.PactTestMixin]. +""" + +from __future__ import annotations + +import contextlib +import logging +import unittest +from typing import TYPE_CHECKING + +import pytest + +import pact.logging +from pact.testing.unittest import PactTestMixin + +if TYPE_CHECKING: + from collections.abc import Generator + + +@pytest.fixture +def run_ids(monkeypatch: pytest.MonkeyPatch) -> list[str | None]: + calls: list[str | None] = [] + monkeypatch.setattr(pact.logging, "set_test_run_id", calls.append) + return calls + + +class Example(PactTestMixin, unittest.TestCase): + """`unittest.TestCase` exercising `PactTestMixin` for the tests below.""" + + # Keep pytest from collecting the deliberately failing cases. + __test__ = False + + def test_passes(self) -> None: + logging.getLogger("pact.ffi.x").info("quiet") + + def test_fails(self) -> None: + logging.getLogger("pact.ffi.x").info("noisy") + self.fail("boom") + + def test_errors(self) -> None: + logging.getLogger("pact.plugins").warning("plugin noise") + raise RuntimeError + + +def run(name: str) -> unittest.TestResult: + result = unittest.TestResult() + Example(name).run(result) + return result + + +def test_sets_and_clears_run_id(run_ids: list[str | None]) -> None: + run("test_passes") + assert run_ids == [f"{Example.__module__}.Example.test_passes", None] + + +def test_silent_on_success(capsys: pytest.CaptureFixture[str]) -> None: + result = run("test_passes") + assert result.wasSuccessful() + assert capsys.readouterr().err == "" + + +def test_dumps_logs_on_failure(capsys: pytest.CaptureFixture[str]) -> None: + result = run("test_fails") + assert len(result.failures) == 1 + output = capsys.readouterr().err + assert "--- Pact logs for" in output + assert "test_fails" in output + assert "noisy" in output + + +def test_dumps_logs_on_error(capsys: pytest.CaptureFixture[str]) -> None: + result = run("test_errors") + assert len(result.errors) == 1 + assert "plugin noise" in capsys.readouterr().err + + +@contextlib.contextmanager +def quiet_root() -> Generator[None, None, None]: + """ + Root logger at `WARNING` and the `pact` logger unset, as outside pytest. + + Pytest's `log_level = "NOTSET"` setting lowers the root logger at the + start of each test phase, so this is entered inside the test body. + """ + root = logging.getLogger() + pact_logger = logging.getLogger("pact") + root_level, pact_level = root.level, pact_logger.level + root.setLevel(logging.WARNING) + pact_logger.setLevel(logging.NOTSET) + try: + yield + finally: + root.setLevel(root_level) + pact_logger.setLevel(pact_level) + + +def test_dumps_logs_below_root_level(capsys: pytest.CaptureFixture[str]) -> None: + with quiet_root(): + assert not logging.getLogger("pact.ffi.x").isEnabledFor(logging.INFO) + result = run("test_fails") + assert logging.getLogger("pact").level == logging.NOTSET + assert len(result.failures) == 1 + assert "noisy" in capsys.readouterr().err + + +def test_keeps_explicit_pact_level() -> None: + pact_logger = logging.getLogger("pact") + pact_logger.setLevel(logging.ERROR) + try: + run("test_fails") + assert pact_logger.level == logging.ERROR + finally: + pact_logger.setLevel(logging.NOTSET) + + +def test_handler_is_removed() -> None: + before = list(logging.getLogger("pact").handlers) + run("test_fails") + assert logging.getLogger("pact").handlers == before + + +def test_run_without_result(capsys: pytest.CaptureFixture[str]) -> None: + Example("test_fails").run() + assert "noisy" in capsys.readouterr().err From ca936ff86b97dc6bbde7015a3f3a5fefa39d48f3 Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Fri, 16 Oct 2026 11:10:38 +1100 Subject: [PATCH 3/3] docs: describe the pytest plugin and unittest mixin Add the pytest and unittest integrations to the logging page, under correlating logs with tests, and point the pact.logging module docstring at them. Fix cross-reference syntax in src/pact/testing/{__init__,pytest, unittest}.py: `mkdocs build --strict` reported them unresolved. mkdocstrings' scoped_crossrefs prepends the package prefix again to a `pact.`-prefixed target from a module below the package root, so cross-module references use the path relative to the `pact` package. Signed-off-by: JP-Ellis Assisted-by: Claude Code:claude-opus-5-5 --- docs/logging.md | 30 ++++++++++++++++++++++++++++++ src/pact/logging.py | 4 +++- src/pact/testing/__init__.py | 4 ++-- src/pact/testing/pytest.py | 2 +- src/pact/testing/unittest.py | 2 +- 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/docs/logging.md b/docs/logging.md index 20cc79a59..530d9a247 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -93,6 +93,34 @@ Records from the library's own threads do not carry this ID. Group them by `log_id` instead: the records of one mock server share its ID, and the records of one provider verification share `verify:`. +### Pytest + +The pytest plugin in [`pact.testing.pytest`][pact.testing.pytest] is enabled +automatically. Its autouse fixture +[`pact_test_run_id`][pact.testing.pytest.pact_test_run_id] sets each test's +node ID as the run ID. Pytest's own log capture then shows Pact records in the +"Captured log call" section of a failing test. Disable the plugin with `-p +no:pact`. + +### Unittest + +Use [`PactTestMixin`][pact.testing.unittest.PactTestMixin] on test cases which +use Pact. It sets the test ID as the run ID and, when a test fails or errors, +writes the Pact records collected during the test to stderr. While the test +runs, the `pact` logger is lowered to the forwarding level +([`forwarding_level`][pact.logging.forwarding_level]) if it has no explicit +level of its own, so records below the root logger's level are collected too: + +```python +import unittest + +from pact.testing.unittest import PactTestMixin + + +class ConsumerTest(PactTestMixin, unittest.TestCase): + def test_something(self) -> None: ... +``` + ## Migrating from `pact_ffi.log_to_stderr` Earlier releases left the library's logging unconfigured, and test suites @@ -122,4 +150,6 @@ Under pytest, `--log-cli-level=INFO` shows the records as each test runs. ## Further Information - [`pact.logging`][pact.logging] +- [`pact.testing.pytest`][pact.testing.pytest] +- [`pact.testing.unittest`][pact.testing.unittest] - [`pact_ffi.LevelFilter`][pact_ffi.LevelFilter] diff --git a/src/pact/logging.py b/src/pact/logging.py index 23a9d93a1..24bd4984d 100644 --- a/src/pact/logging.py +++ b/src/pact/logging.py @@ -33,7 +33,9 @@ library's `TRACE` level is exposed as [`TRACE`][TRACE], below `logging.DEBUG`. Log records can be attributed to the test which caused them by setting a test -run ID with [`set_test_run_id`][set_test_run_id]. +run ID with [`set_test_run_id`][set_test_run_id]. The pytest plugin in +[`pact.testing.pytest`][testing.pytest] and the `unittest` mixin in +[`pact.testing.unittest`][testing.unittest] do this automatically. Importing `pact` replaces `pact_ffi.init`, `pact_ffi.init_with_log_level`, `pact_ffi.log_to_stderr`, `pact_ffi.log_to_stdout` and `pact_ffi.log_to_file` diff --git a/src/pact/testing/__init__.py b/src/pact/testing/__init__.py index 9a706f77d..92148767c 100644 --- a/src/pact/testing/__init__.py +++ b/src/pact/testing/__init__.py @@ -1,8 +1,8 @@ """ Test runner integrations. -- [`pact.testing.pytest`][pact.testing.pytest] is a pytest plugin, enabled +- [`pact.testing.pytest`][testing.pytest] is a pytest plugin, enabled automatically when `pact-python` is installed. -- [`pact.testing.unittest`][pact.testing.unittest] provides a mixin for +- [`pact.testing.unittest`][testing.unittest] provides a mixin for `unittest.TestCase` subclasses. """ diff --git a/src/pact/testing/pytest.py b/src/pact/testing/pytest.py index 0b88015ed..0f005d861 100644 --- a/src/pact/testing/pytest.py +++ b/src/pact/testing/pytest.py @@ -3,7 +3,7 @@ The plugin is registered through the `pytest11` entry point and is active whenever `pact-python` is installed. It provides one autouse fixture, -[`pact_test_run_id`][pact.testing.pytest.pact_test_run_id], which sets the +[`pact_test_run_id`][testing.pytest.pact_test_run_id], which sets the test's node ID as the Pact test run ID for the test's duration. The Pact library's records from the test's thread, and the log entries a plugin sends back for the test's requests, then carry the test which caused them. diff --git a/src/pact/testing/unittest.py b/src/pact/testing/unittest.py index 89bb7351e..dd44dfa86 100644 --- a/src/pact/testing/unittest.py +++ b/src/pact/testing/unittest.py @@ -1,7 +1,7 @@ """ `unittest` integration for Pact logs. -[`PactTestMixin`][pact.testing.unittest.PactTestMixin] scopes the Pact test run +[`PactTestMixin`][testing.unittest.PactTestMixin] scopes the Pact test run ID to each test and prints the Pact log records collected during a test to stderr when the test fails or errors: