Repository: tophat/syrupy

Issue:
Report which snapshots are unused
**Is your feature request related to a problem? Please describe.**

In test suites that are very long, it can be tedious and frustrating to try to find which snapshots are stale/unused after eg many tests are changed. The current report lists how many snapshots were unused, but not which ones. 

**Describe the solution you'd like**

I would like an optional flag that, when provided, will include the locations & names of unused snapshots in the final report.

**Describe alternatives you've considered**

I don't think there's a great alternative. Usually to find out which snapshots have become obsolete, I have to provide the `--snapshot-update` option and look at the generated diff. When syrupy is running as a CI step, this means I have to rerun the test suite locally, which can be very time consuming.

**Additional context**

I would be happy to make a PR to address this issue.

Discussion:
eaculb: Hm ok, looks like the Windows VMs ends up with a different order for the unused snapshots? I'll adjust.
noahnu: This looks great!

I ran the coverage report locally and can confirm it's still reporting 100%. We can probably add a different step for contributors that posts the codecov to the PR directly w/o going through codecov to avoid the token issue.


Patch:
diff --git a/README.md b/README.md
index 6e3354f..eae657e 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,7 @@ These are the cli options exposed to `pytest` by the plugin.
 | Option                         | Description                                                                                                                    | Default                                          |
 | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
 | `--snapshot-update`            | Snapshots will be updated to match assertions and unused snapshots will be deleted.                                            | `False`                                          |
+| `--snapshot-details`           | Includes details of unused snapshots (test name and snapshot location) in the final report.                                    | `False`                                          |
 | `--snapshot-warn-unused`       | Prints a warning on unused snapshots rather than fail the test suite.                                                          | `False`                                          |
 | `--snapshot-default-extension` | Use to change the default snapshot extension class.                                                                            | `syrupy.extensions.amber.AmberSnapshotExtension` |
 | `--snapshot-no-colors`         | Disable test results output highlighting. Equivalent to setting the environment variables `ANSI_COLORS_DISABLED` or `NO_COLOR` | Disabled by default if not in terminal.          |
diff --git a/src/syrupy/__init__.py b/src/syrupy/__init__.py
index 9a3346b..f370353 100644
--- a/src/syrupy/__init__.py
+++ b/src/syrupy/__init__.py
@@ -57,6 +57,13 @@ def pytest_addoption(parser: Any) -> None:
         dest="warn_unused_snapshots",
         help="Do not fail on unused snapshots",
     )
+    group.addoption(
+        "--snapshot-details",
+        action="store_true",
+        default=False,
+        dest="include_snapshot_details",
+        help="Include details of unused snapshots in the final report",
+    )
     group.addoption(
         "--snapshot-default-extension",
         type=__default_extension_option,
@@ -112,6 +119,7 @@ def pytest_sessionstart(session: Any) -> None:
     config._syrupy = SnapshotSession(
         warn_unused_snapshots=config.option.warn_unused_snapshots,
         update_snapshots=config.option.update_snapshots,
+        include_snapshot_details=config.option.include_snapshot_details,
         base_dir=config.rootdir,
         invocation_args=config.invocation_params.args,
     )
diff --git a/src/syrupy/report.py b/src/syrupy/report.py
index 0a78ae0..7c4fdee 100644
--- a/src/syrupy/report.py
+++ b/src/syrupy/report.py
@@ -53,6 +53,7 @@ class SnapshotReport:
     selected_items: Dict[str, bool] = attr.ib()
     update_snapshots: bool = attr.ib()
     warn_unused_snapshots: bool = attr.ib()
+    include_snapshot_details: bool = attr.ib()
     assertions: List["SnapshotAssertion"] = attr.ib()
     discovered: "SnapshotFossils" = attr.ib(factory=SnapshotFossils)
     created: "SnapshotFossils" = attr.ib(factory=SnapshotFossils)
@@ -280,16 +281,17 @@ class SnapshotReport:
 
         if self.num_unused:
             yield ""
-            if self.update_snapshots:
+            if self.update_snapshots or self.include_snapshot_details:
+                base_message = "Deleted" if self.update_snapshots else "Unused"
                 for snapshot_fossil in self.unused:
                     filepath = snapshot_fossil.location
                     snapshots = (snapshot.name for snapshot in snapshot_fossil)
                     path_to_file = str(Path(filepath).relative_to(self.base_dir))
-                    deleted_snapshots = ", ".join(map(bold, sorted(snapshots)))
-                    yield warning_style(gettext("Deleted")) + " {} ({})".format(
-                        deleted_snapshots, path_to_file
+                    unused_snapshots = ", ".join(map(bold, sorted(snapshots)))
+                    yield warning_style(gettext(base_message)) + " {} ({})".format(
+                        unused_snapshots, path_to_file
                     )
-            else:
+            if not self.update_snapshots:
                 message = gettext(
                     "Re-run pytest with --snapshot-update to delete unused snapshots."
                 )
diff --git a/src/syrupy/session.py b/src/syrupy/session.py
index af4ad64..591c620 100644
--- a/src/syrupy/session.py
+++ b/src/syrupy/session.py
@@ -26,6 +26,7 @@ class SnapshotSession:
     base_dir: str = attr.ib()
     update_snapshots: bool = attr.ib()
     warn_unused_snapshots: bool = attr.ib()
+    include_snapshot_details: bool = attr.ib()
     _invocation_args: Tuple[str, ...] = attr.ib(factory=tuple)
     report: Optional["SnapshotReport"] = attr.ib(default=None)
     # All the collected test items
@@ -61,6 +62,7 @@ class SnapshotSession:
             assertions=self._assertions,
             update_snapshots=self.update_snapshots,
             warn_unused_snapshots=self.warn_unused_snapshots,
+            include_snapshot_details=self.include_snapshot_details,
             invocation_args=self._invocation_args,
         )
         if self.report.num_unused:


Test patch:
diff --git a/tests/integration/test_snapshot_option_include_details.py b/tests/integration/test_snapshot_option_include_details.py
new file mode 100644
index 0000000..8f2365c
--- /dev/null
+++ b/tests/integration/test_snapshot_option_include_details.py
@@ -0,0 +1,142 @@
+import pytest
+
+
+@pytest.fixture
+def testcases():
+    return {
+        "used": (
+            """
+            def test_used(snapshot):
+                assert snapshot == 'used'
+            """
+        ),
+        "unused": (
+            """
+            def test_unused(snapshot):
+                assert snapshot == 'unused'
+            """
+        ),
+    }
+
+
+@pytest.fixture
+def extra_testcases():
+    return {
+        "extra_a": (
+            """
+            def test_extra_a(snapshot):
+                assert snapshot == 'extra_a'
+            """
+        ),
+        "extra_b": (
+            """
+            def test_extra_b(snapshot):
+                assert snapshot == 'extra_b'
+            """
+        ),
+    }
+
+
+@pytest.fixture
+def run_testfiles_with_update(testdir):
+    def run_testfiles_with_update_impl(**testfiles):
+        testdir.makepyfile(
+            **{
+                filename: "\n\n".join(cases.values())
+                for (filename, cases) in testfiles.items()
+            }
+        )
+        result = testdir.runpytest("-v", "--snapshot-update")
+        result.stdout.re_match_lines((r"[0-9]+ snapshots generated\."))
+        return testdir
+
+    return run_testfiles_with_update_impl
+
+
+@pytest.mark.parametrize(
+    (
+        "options",
+        "expected_status_code",
+    ),
+    (
+        (("-v", "--snapshot-details"), 1),
+        (("-v", "--snapshot-details", "--snapshot-warn-unused"), 0),
+    ),
+)
+def test_unused_snapshots_details(
+    options, expected_status_code, run_testfiles_with_update, testcases
+):
+    testdir = run_testfiles_with_update(test_file=testcases)
+    testdir.makepyfile(test_file=testcases["used"])
+
+    result = testdir.runpytest(*options)
+    result.stdout.re_match_lines(
+        (
+            r"1 snapshot passed\. 1 snapshot unused\.",
+            r"Unused test_unused \(__snapshots__[\\/]test_file.ambr\)",
+            r"Re-run pytest with --snapshot-update to delete unused snapshots\.",
+        )
+    )
+    assert result.ret == expected_status_code
+
+
+def test_unused_snapshots_details_multiple_tests(
+    run_testfiles_with_update, testcases, extra_testcases
+):
+    testdir = run_testfiles_with_update(
+        test_file=testcases, test_second_file=extra_testcases
+    )
+    testdir.makepyfile(
+        test_file="\n\n".join(testcases.values()),
+        test_second_file="",
+    )
+
+    result = testdir.runpytest("-v", "--snapshot-details")
+    result.stdout.re_match_lines(
+        (
+            r"2 snapshots passed\. 2 snapshots unused\.",
+            r"Unused test_extra_a, test_extra_b "
+            r"\(__snapshots__[\\/]test_second_file.ambr\)",
+            r"Re-run pytest with --snapshot-update to delete unused snapshots\.",
+        )
+    )
+    assert result.ret == 1
+
+
+def test_unused_snapshots_details_multiple_locations(
+    run_testfiles_with_update, testcases, extra_testcases
+):
+    testdir = run_testfiles_with_update(
+        test_file=testcases, test_second_file=extra_testcases
+    )
+    testdir.makepyfile(
+        test_file=testcases["used"],
+        test_second_file=extra_testcases["extra_a"],
+    )
+
+    result = testdir.runpytest("-v", "--snapshot-details")
+    result.stdout.re_match_lines_random(
+        (
+            r"2 snapshots passed\. 2 snapshots unused\.",
+            r"Unused test_extra_b \(__snapshots__[\\/]test_second_file.ambr\)",
+            r"Unused test_unused \(__snapshots__[\\/]test_file.ambr\)",
+            r"Re-run pytest with --snapshot-update to delete unused snapshots\.",
+        )
+    )
+    assert result.ret == 1
+
+
+def test_unused_snapshots_details_no_details_on_deletion(
+    run_testfiles_with_update, testcases
+):
+    testdir = run_testfiles_with_update(test_file=testcases)
+    testdir.makepyfile(test_file=testcases["used"])
+
+    result = testdir.runpytest("-v", "--snapshot-details", "--snapshot-update")
+    result.stdout.re_match_lines(
+        (
+            r"1 snapshot passed\. 1 unused snapshot deleted\.",
+            r"Deleted test_unused \(__snapshots__[\\/]test_file.ambr\)",
+        )
+    )
+    assert result.ret == 0