diff --git a/src/syrupy/__init__.py b/src/syrupy/__init__.py index d351be5f..3c42f7fe 100644 --- a/src/syrupy/__init__.py +++ b/src/syrupy/__init__.py @@ -146,14 +146,15 @@ def pytest_collection_finish(session: Any) -> None: session.config._syrupy.select_items(session.items) -def pytest_runtest_logfinish(nodeid: str) -> None: +def pytest_runtest_logreport(report: pytest.TestReport) -> None: """ - At the end of running the runtest protocol for a single item. - https://docs.pytest.org/en/latest/reference.html#_pytest.hookspec.pytest_runtest_logfinish + After each of the setup, call and teardown runtest phases of an item. + https://docs.pytest.org/en/8.0.x/reference/reference.html#pytest.hookspec.pytest_runtest_logreport """ global _syrupy - if _syrupy: - _syrupy.ran_item(nodeid) + # The outcome will be passed in the teardown phase even if skipped + if _syrupy and report.when != "teardown": + _syrupy.ran_item(report.nodeid, report.outcome) @pytest.hookimpl(tryfirst=True) diff --git a/src/syrupy/report.py b/src/syrupy/report.py index 5eaa4b66..7d07e14c 100644 --- a/src/syrupy/report.py +++ b/src/syrupy/report.py @@ -46,6 +46,7 @@ import pytest from .assertion import SnapshotAssertion + from .session import ItemStatus @dataclass @@ -59,7 +60,7 @@ class SnapshotReport: # Initial arguments to the report base_dir: Path collected_items: Set["pytest.Item"] - selected_items: Dict[str, bool] + selected_items: Dict[str, "ItemStatus"] options: "argparse.Namespace" assertions: List["SnapshotAssertion"] @@ -196,6 +197,14 @@ def num_unused(self) -> int: def selected_all_collected_items(self) -> bool: return self._collected_items_by_nodeid.keys() == self.selected_items.keys() + @property + def skipped_items(self) -> Iterator["pytest.Item"]: + return ( + self._collected_items_by_nodeid[nodeid] + for nodeid in self.selected_items + if self.selected_items[nodeid].value == "skipped" + ) + @property def ran_items(self) -> Iterator["pytest.Item"]: return ( @@ -230,7 +239,13 @@ def unused(self) -> "SnapshotCollections": if self.selected_all_collected_items and not any(provided_nodes): # All collected tests were run and files were not filtered by ::node # therefore the snapshot collection file at this location can be deleted - unused_snapshots = {*unused_snapshot_collection} + unused_snapshots = { + snapshot + for snapshot in unused_snapshot_collection + if not self._skipped_items_match_name( + snapshot_location=snapshot_location, snapshot_name=snapshot.name + ) + } mark_for_removal = snapshot_location not in self.used else: unused_snapshots = { @@ -244,6 +259,9 @@ def unused(self) -> "SnapshotCollections": snapshot_name=snapshot.name, provided_nodes=provided_nodes, ) + and not self._skipped_items_match_name( + snapshot_location=snapshot_location, snapshot_name=snapshot.name + ) } mark_for_removal = False @@ -451,6 +469,21 @@ def _ran_items_match_name(self, snapshot_location: str, snapshot_name: str) -> b return True return False + def _skipped_items_match_name( + self, snapshot_location: str, snapshot_name: str + ) -> bool: + """ + Check that a snapshot name should be treated as skipped by the current session + This being true means that it will not be deleted even if the it is unused + """ + for item in self.skipped_items: + location = PyTestLocation(item) + if location.matches_snapshot_location( + snapshot_location + ) and location.matches_snapshot_name(snapshot_name): + return True + return False + def _selected_items_match_name( self, snapshot_location: str, snapshot_name: str ) -> bool: diff --git a/src/syrupy/session.py b/src/syrupy/session.py index 6b612145..9ec6aca1 100644 --- a/src/syrupy/session.py +++ b/src/syrupy/session.py @@ -3,6 +3,7 @@ dataclass, field, ) +from enum import Enum from pathlib import Path from typing import ( TYPE_CHECKING, @@ -11,6 +12,7 @@ Dict, Iterable, List, + Literal, Optional, Set, Tuple, @@ -37,6 +39,13 @@ from .extensions.base import AbstractSyrupyExtension +class ItemStatus(Enum): + NOT_RUN = False + PASSED = "passed" + FAILED = "failed" + SKIPPED = "skipped" + + @dataclass class SnapshotSession: pytest_session: "pytest.Session" @@ -45,7 +54,7 @@ class SnapshotSession: # All the collected test items _collected_items: Set["pytest.Item"] = field(default_factory=set) # All the selected test items. Will be set to False until the test item is run. - _selected_items: Dict[str, bool] = field(default_factory=dict) + _selected_items: Dict[str, ItemStatus] = field(default_factory=dict) _assertions: List["SnapshotAssertion"] = field(default_factory=list) _extensions: Dict[str, "AbstractSyrupyExtension"] = field(default_factory=dict) @@ -97,7 +106,9 @@ def collect_items(self, items: List["pytest.Item"]) -> None: def select_items(self, items: List["pytest.Item"]) -> None: for item in self.filter_valid_items(items): - self._selected_items[getattr(item, "nodeid")] = False # noqa: B009 + self._selected_items[getattr(item, "nodeid")] = ( # noqa: B009 + ItemStatus.NOT_RUN + ) def start(self) -> None: self.report = None @@ -107,9 +118,11 @@ def start(self) -> None: self._extensions = {} self._locations_discovered = defaultdict(set) - def ran_item(self, nodeid: str) -> None: + def ran_item( + self, nodeid: str, outcome: Literal["passed", "skipped", "failed"] + ) -> None: if nodeid in self._selected_items: - self._selected_items[nodeid] = True + self._selected_items[nodeid] = ItemStatus(outcome) def finish(self) -> int: exitstatus = 0 diff --git a/tests/integration/test_snapshot_skipped.py b/tests/integration/test_snapshot_skipped.py new file mode 100644 index 00000000..c985bf80 --- /dev/null +++ b/tests/integration/test_snapshot_skipped.py @@ -0,0 +1,74 @@ +import pytest + + +@pytest.fixture +def testcases(): + return { + "used": ( + """ + def test_used(snapshot): + assert snapshot == 'used' + """ + ), + "raise-skipped": ( + """ + import pytest + def test_skipped(snapshot): + pytest.skip("Skipping...") + assert snapshot == 'unused' + """ + ), + "mark-skipped": ( + """ + import pytest + @pytest.mark.skip + def test_skipped(snapshot): + assert snapshot == 'unused' + """ + ), + "not-skipped": ( + """ + def test_skipped(snapshot): + assert snapshot == 'unused' + """ + ), + } + + +@pytest.fixture +def run_testcases(testdir, testcases): + pyfile_content = "\n\n".join([testcases["used"], testcases["not-skipped"]]) + testdir.makepyfile(test_file=pyfile_content) + result = testdir.runpytest("-v", "--snapshot-update") + result.stdout.re_match_lines(r"2 snapshots generated\.") + return testdir, testcases + + +def test_mark_skipped_snapshots(run_testcases): + testdir, testcases = run_testcases + pyfile_content = "\n\n".join([testcases["used"], testcases["mark-skipped"]]) + testdir.makepyfile(test_file=pyfile_content) + + result = testdir.runpytest("-v") + result.stdout.re_match_lines(r"1 snapshot passed\.$") + assert result.ret == 0 + + +def test_raise_skipped_snapshots(run_testcases): + testdir, testcases = run_testcases + pyfile_content = "\n\n".join([testcases["used"], testcases["raise-skipped"]]) + testdir.makepyfile(test_file=pyfile_content) + + result = testdir.runpytest("-v") + result.stdout.re_match_lines(r"1 snapshot passed\.$") + assert result.ret == 0 + + +def test_skipped_snapshots_update(run_testcases): + testdir, testcases = run_testcases + pyfile_content = "\n\n".join([testcases["used"], testcases["raise-skipped"]]) + testdir.makepyfile(test_file=pyfile_content) + + result = testdir.runpytest("-v", "--snapshot-update") + result.stdout.re_match_lines(r"1 snapshot passed\.$") + assert result.ret == 0