aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--edify/testing/__init__.py7
-rw-r--r--edify/testing/snapshots.py87
-rw-r--r--tests/testing/snapshots.test.py90
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)