diff options
| -rw-r--r-- | edify/testing/__init__.py | 7 | ||||
| -rw-r--r-- | edify/testing/snapshots.py | 87 | ||||
| -rw-r--r-- | tests/testing/snapshots.test.py | 90 |
3 files changed, 184 insertions, 0 deletions
diff --git a/edify/testing/__init__.py b/edify/testing/__init__.py new file mode 100644 index 0000000..3511525 --- /dev/null +++ b/edify/testing/__init__.py @@ -0,0 +1,7 @@ +from edify.testing.snapshots import ( + SnapshotMismatchError, + SnapshotMissingError, + assert_snapshot, +) + +__all__ = ["SnapshotMismatchError", "SnapshotMissingError", "assert_snapshot"] diff --git a/edify/testing/snapshots.py b/edify/testing/snapshots.py new file mode 100644 index 0000000..7093b2d --- /dev/null +++ b/edify/testing/snapshots.py @@ -0,0 +1,87 @@ +"""String-snapshot helpers for pattern tests. + +The default flow compares a produced string against a committed reference +file and raises an AssertionError with a unified diff on mismatch. Setting +``EDIFY_UPDATE_SNAPSHOTS=1`` in the environment switches the helper into +regeneration mode: mismatched or missing snapshots are written to disk +instead of raising, so the reference set can be refreshed in one pytest +run. +""" + +from __future__ import annotations + +import difflib +import os +from pathlib import Path + +_UPDATE_ENVIRONMENT_VARIABLE = "EDIFY_UPDATE_SNAPSHOTS" + + +class SnapshotMismatchError(AssertionError): + """Raised when actual output diverges from the committed snapshot.""" + + def __init__(self, snapshot_path: Path, actual: str, expected: str) -> None: + rendered_diff = _render_diff(snapshot_path, expected, actual) + message = ( + f"snapshot mismatch at {snapshot_path}\n\n" + f"{rendered_diff}\n\n" + f"help: re-run pytest with {_UPDATE_ENVIRONMENT_VARIABLE}=1 to accept the new " + "output as the reference." + ) + super().__init__(message) + + +class SnapshotMissingError(AssertionError): + """Raised when the committed snapshot file does not exist.""" + + def __init__(self, snapshot_path: Path) -> None: + message = ( + f"snapshot file missing at {snapshot_path}\n\n" + f"help: re-run pytest with {_UPDATE_ENVIRONMENT_VARIABLE}=1 to create it." + ) + super().__init__(message) + + +def assert_snapshot(actual: str, snapshot_path: Path) -> None: + """Compare ``actual`` against the reference at ``snapshot_path``. + + Args: + actual: The string produced by the code under test. + snapshot_path: Absolute path to the committed reference file. + + Raises: + SnapshotMissingError: when the reference file does not exist and + update-mode is off. + SnapshotMismatchError: when ``actual`` differs from the reference and + update-mode is off. + """ + if _update_mode_enabled(): + _write_snapshot(snapshot_path, actual) + return + if not snapshot_path.exists(): + raise SnapshotMissingError(snapshot_path) + expected = snapshot_path.read_text() + if actual == expected: + return + raise SnapshotMismatchError(snapshot_path, actual, expected) + + +def _update_mode_enabled() -> bool: + return os.environ.get(_UPDATE_ENVIRONMENT_VARIABLE) == "1" + + +def _write_snapshot(snapshot_path: Path, actual: str) -> None: + snapshot_path.parent.mkdir(parents=True, exist_ok=True) + snapshot_path.write_text(actual) + + +def _render_diff(snapshot_path: Path, expected: str, actual: str) -> str: + expected_lines = expected.splitlines(keepends=True) + actual_lines = actual.splitlines(keepends=True) + diff_iterator = difflib.unified_diff( + expected_lines, + actual_lines, + fromfile=f"{snapshot_path} (snapshot)", + tofile=f"{snapshot_path} (actual)", + ) + return "".join(diff_iterator).rstrip() diff --git a/tests/testing/snapshots.test.py b/tests/testing/snapshots.test.py new file mode 100644 index 0000000..c2d8676 --- /dev/null +++ b/tests/testing/snapshots.test.py @@ -0,0 +1,90 @@ +"""Tests for the snapshot helper in :mod:`edify.testing.snapshots`.""" + +from pathlib import Path + +import pytest + +from edify.testing import SnapshotMismatchError, SnapshotMissingError, assert_snapshot + +_UPDATE_ENVIRONMENT_VARIABLE = "EDIFY_UPDATE_SNAPSHOTS" + + +def test_assert_snapshot_passes_when_actual_matches_committed_reference(tmp_path, monkeypatch): + monkeypatch.delenv(_UPDATE_ENVIRONMENT_VARIABLE, raising=False) + snapshot_path = tmp_path / "identical.snapshot" + snapshot_path.write_text("hello\n") + assert_snapshot("hello\n", snapshot_path) + + +def test_assert_snapshot_raises_snapshot_mismatch_when_content_differs(tmp_path, monkeypatch): + monkeypatch.delenv(_UPDATE_ENVIRONMENT_VARIABLE, raising=False) + snapshot_path = tmp_path / "drift.snapshot" + snapshot_path.write_text("expected\n") + with pytest.raises(SnapshotMismatchError) as excinfo: + assert_snapshot("actual\n", snapshot_path) + text = str(excinfo.value) + assert "snapshot mismatch" in text + assert "-expected" in text + assert "+actual" in text + assert "EDIFY_UPDATE_SNAPSHOTS=1" in text + + +def test_assert_snapshot_raises_snapshot_missing_when_reference_absent(tmp_path, monkeypatch): + monkeypatch.delenv(_UPDATE_ENVIRONMENT_VARIABLE, raising=False) + absent_snapshot = tmp_path / "never_written.snapshot" + with pytest.raises(SnapshotMissingError, match="snapshot file missing"): + assert_snapshot("any actual value", absent_snapshot) + + +def test_update_mode_writes_the_snapshot_when_the_file_is_missing(tmp_path, monkeypatch): + monkeypatch.setenv(_UPDATE_ENVIRONMENT_VARIABLE, "1") + snapshot_path = tmp_path / "created.snapshot" + assert_snapshot("fresh content\n", snapshot_path) + assert snapshot_path.read_text() == "fresh content\n" + + +def test_update_mode_overwrites_the_snapshot_on_drift(tmp_path, monkeypatch): + monkeypatch.setenv(_UPDATE_ENVIRONMENT_VARIABLE, "1") + snapshot_path = tmp_path / "regenerated.snapshot" + snapshot_path.write_text("stale\n") + assert_snapshot("current\n", snapshot_path) + assert snapshot_path.read_text() == "current\n" + + +def test_update_mode_is_off_when_env_variable_is_set_to_other_value(tmp_path, monkeypatch): + monkeypatch.setenv(_UPDATE_ENVIRONMENT_VARIABLE, "0") + snapshot_path = tmp_path / "no_write.snapshot" + with pytest.raises(SnapshotMissingError): + assert_snapshot("actual", snapshot_path) + + +def test_update_mode_creates_missing_parent_directories(tmp_path, monkeypatch): + monkeypatch.setenv(_UPDATE_ENVIRONMENT_VARIABLE, "1") + snapshot_path = tmp_path / "nested" / "dir" / "leaf.snapshot" + assert_snapshot("value", snapshot_path) + assert snapshot_path.read_text() == "value" + + +def test_snapshot_mismatch_error_names_the_snapshot_path(tmp_path, monkeypatch): + monkeypatch.delenv(_UPDATE_ENVIRONMENT_VARIABLE, raising=False) + snapshot_path = tmp_path / "named.snapshot" + snapshot_path.write_text("was") + with pytest.raises(SnapshotMismatchError) as excinfo: + assert_snapshot("is", snapshot_path) + assert str(snapshot_path) in str(excinfo.value) + + +def test_snapshot_missing_error_is_an_assertion_error_subclass(): + assert issubclass(SnapshotMissingError, AssertionError) + + +def test_snapshot_mismatch_error_is_an_assertion_error_subclass(): + assert issubclass(SnapshotMismatchError, AssertionError) + + +def test_snapshot_helper_is_reachable_via_edify_testing_namespace(tmp_path): + from edify import testing + + snapshot_path: Path = tmp_path / "namespace.snapshot" + snapshot_path.write_text("ok\n") + testing.assert_snapshot("ok\n", snapshot_path) |
