Skip to content

Commit 03a084b

Browse files
Add support for snapshot testing of custom objects and enhance serialization (#6)
Introduce `ObjectExpect` for snapshot testing of generic custom objects. Fix an issue where test results were deleted during test discovery. Enhance serialization methods for consistent JSON output across platforms. Update changelog and README for clarity. Add a new command to show diffs of snapshots in VSCode.
2 parents 18474bb + 682f1c1 commit 03a084b

File tree

15 files changed

+403
-96
lines changed

15 files changed

+403
-96
lines changed

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ All notable changes to this project will be documented in this file.
33

44
*NOTE:* Version 0.X.X might have breaking changes in bumps of the minor version number. This is because the project is still in early development and the API is not yet stable. It will still be marked clearly in the release notes.
55

6-
## [0.5.1] - 2025-08-31
6+
## [0.6.0] - 2025-08-23
7+
- 🆕 Added: `snappylapy diff` CLI command for comparing changed test results with the snapshot. Currently it only supports VScode for showing diffs.
8+
- 🆕 Support generic types. Now custom objects can be used. It can serialize and deserialize any that jsonpickle supports.
9+
- 🐞 Fix issue where files in __test_results__ is being deleted when test discovery is ran
10+
- 🐞 Ensure cross-platform consistancy on how snapshot files are serialized and deserialized
11+
- 🐞 Fixed issue where pandas dataframe types did not resolve correctly at runtime to the dataframe expect class in older python versions
12+
13+
## [0.5.1] - 2025-07-31
714
- 🐞 Fix missing submodule `expectation_classes`, missing in build version of the package.
815

916
## [0.5.0] - 2025-07-30

README.md

Lines changed: 32 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,28 @@
11
# Snappylapy
22

3-
Welcome to **Snappylapy**, a powerful and intuitive snapshot testing tool for Python's pytest framework. Snappylapy simplifies the process of capturing and verifying snapshots of your data, ensuring your code behaves as expected across different runs. With Snappylapy, you can save snapshots in a human-readable format and deserialize them for robust integration testing, providing a clear separation layer to help isolate errors and maintain code integrity.
3+
Welcome to **Snappylapy**, a powerful and intuitive snapshot testing plugin for Python's pytest framework. Snappylapy simplifies the process of capturing and verifying snapshots of your data, ensuring your code behaves as expected across different runs. With Snappylapy, you can save snapshots in a human-readable format and deserialize them for robust integration testing, providing a clear separation layer to help isolate errors and maintain code integrity.
44

55
## Installation
6-
To get started with Snappylapy, install the package via pip:
6+
To get started with Snappylapy, install the package via pip, uv or poetry:
77

88
```bash
99
pip install snappylapy
1010
```
1111

12-
## Key Features
13-
Legend:
14-
- ✅ Implemented
15-
- ❌ Not implemented yet
12+
```bash
13+
uv add snappylapy
14+
```
1615

17-
The features are in development:
18-
19-
- **Human-Readable Snapshots**✅: Save snapshots in a format that's easy to read and understand, making it simpler to review changes and debug issues.
20-
- **Serialization and Deserialization**✅: Snapshots can be serialized and deserialized, allowing for flexible and reusable test cases.
21-
- **Integration Testing**✅: Use snapshots for integration testing with a clear separation layer, preventing interdependencies between code components and making it easier to isolate and identify errors.
22-
- **Easy to Use**✅: Seamlessly integrates with pytest, allowing you to start capturing and verifying snapshots with minimal setup. For a good developer experience the package is fully typed, with docstrings to provide good editor code completion.
23-
- **Customizable Output**✅: Store snapshots in a location of your choice, enabling you to organize and manage your test data effectively.
24-
- **Diff Report Generation**❌: Generate a diff report in html format for easy comparison between test results and snapshots.
25-
- **Provides a wide set of assertions**❌: The package provides a wide set of assertions to make it easy to compare different types of data, for do fuzzy matching or ignore certain parts of the data that are variable.
16+
```bash
17+
poetry add snappylapy
18+
```
19+
20+
## Key Features
21+
- **Human-Readable Snapshots**: Save snapshots in a format that's easy to read and understand, making it simpler to review changes and debug issues.
22+
- **Serialization and Deserialization**: Snapshots can be serialized and deserialized, allowing for flexible and reusable test cases.
23+
- **Easy to Use**: Seamlessly integrates with pytest, enabling you to capture and verify snapshots with minimal setup. The fully typed fixtures and rich editor support provide intuitive code completion and guidance, making your workflow faster and more productive.
24+
- **Customizable Output**: Store snapshots in a location (static or dynamic) of your choice, enabling you to organize and manage your test data effectively.
25+
- **Editor Integration**: Can show a diff comparison in VS code when a snapshot test fails, for easy comparison between test results and snapshots.
2626

2727
## Benefits of Snapshot Testing
2828
Snapshot testing is a powerful technique for verifying the output of your code by comparing it to a stored snapshot. This approach offers several benefits, including:
@@ -96,46 +96,25 @@ snappylapy update
9696
```
9797

9898
## Fixtures and roadmap
99-
Registers fixtures:
100-
- expect
101-
- load_snapshot
99+
Registers pytest fixtures:
100+
- expect
101+
- load_snapshot
102102

103103
Supported data types
104-
- .txt ✅
105-
- .json ✅
106-
- .csv ❌
107-
- .yaml ❌
108-
- .jsonl ❌
109-
110-
Planned data types:
111-
112-
| Python Type | Default Output file type | Implementation Status |
113-
|---------------------|--------------------------|---------------------------|
114-
| bytes | .txt ||
115-
| pd.DataFrame | .csv | ✅ (missing csv support) |
116-
| pd.Series | .csv ||
117-
| np.ndarray | .csv ||
118-
| dict | .json ||
119-
| list | .json ||
120-
| tuple | .json ||
121-
| set | .json ||
122-
| str | .txt ||
123-
| int | .txt ||
124-
| float | .txt ||
125-
| bool | .txt ||
126-
| datetime.datetime | .txt ||
127-
| datetime.date | .txt ||
128-
| datetime.time | .txt ||
129-
| pathlib.Path | .txt ||
130-
| decimal.Decimal | .txt ||
131-
| uuid.UUID | .txt ||
132-
| pydantic.BaseModel | .json ||
133-
| python dataclasses | .json ||
134-
135-
136-
137-
---
138-
104+
- ✅ .txt - if you provide a string
105+
- ✅ .json - for all other objects
106+
- ✅ custom (decode the data yourself and provide a file extension)
107+
108+
### Supported data types to snapshot test
109+
Snappylapy uses jsonpickle to serialize into json, this means that it can handle almost any Python object out of the box, including:
110+
111+
- Built-in types: str, int, float, bool, None
112+
- Collections: list, tuple, set, dict
113+
- NumPy arrays and pandas DataFrames (with optional dependencies)
114+
- Custom classes (with jsonpickle support)
115+
116+
It is also possible to serialize objects yourself and provide them as a string or bytes data. Then it will be stored and loaded as-is. This means that with snappylapy it is possible to serialize and deserialize any Python object, even those not natively supported.
117+
139118
Snappylapy is your go-to tool for efficient and reliable snapshot testing in Python. By maintaining clear boundaries between different parts of your code, Snappylapy helps you isolate errors, streamline debugging, and ensure your code remains robust and maintainable.
140119

141120
## Contributing
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"py/object": "test_snappylappy_new.test_custom_type.<locals>.CustomObject"
3+
}

pyproject.toml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
[project]
2+
name = "snappylapy"
23
authors = [
34
{name = "Martin Møldrup"},
45
]
56
requires-python = "<4.0,>=3.9"
6-
dependencies = [
7-
"pytest>=7.0",
8-
"jsonpickle>=1.0",
9-
"typer",
10-
"typing-extensions ; python_full_version == '3.9.*'",
11-
]
12-
name = "snappylapy"
13-
version = "0.5.1"
7+
version = "0.6.0"
148
description = "A snapshot library for python optimized for easy of use, human readable snapshots and enabling decoupling of chained integration tests."
159
readme = "README.md"
1610
classifiers = [
@@ -39,6 +33,12 @@ keywords = [
3933
"integration-testing",
4034
"unit-testing",
4135
]
36+
dependencies = [
37+
"pytest>=7.0",
38+
"jsonpickle>=1.0",
39+
"typer",
40+
"typing-extensions ; python_full_version == '3.9.*'",
41+
]
4242

4343
[project.urls]
4444
Homepage = "https://martinmoldrup.github.io/snappylapy"

snappylapy/_cli.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import re
44
import typer
55
import pathlib
6+
import subprocess # noqa: S404
67
from enum import Enum
78
from snappylapy._utils_directories import DirectoryNamesUtil
89
from snappylapy.constants import DIRECTORY_NAMES
@@ -102,6 +103,35 @@ def update() -> None:
102103
typer.echo(f"Updated snapshot: {snapshot_file}")
103104

104105

106+
@app.command()
107+
def diff() -> None:
108+
"""Show the differences between the test results and the snapshots."""
109+
files_test_results = DirectoryNamesUtil().get_all_file_paths_test_results()
110+
file_statuses = check_file_statuses(files_test_results)
111+
files_to_diff = [file for file, status in file_statuses.items() if status == FileStatus.CHANGED]
112+
if not files_to_diff:
113+
status_counts: dict[FileStatus, int] = dict.fromkeys(FileStatus, 0)
114+
for status in file_statuses.values():
115+
status_counts[status] += 1
116+
117+
typer.secho("File status counts:", underline=True, bold=True)
118+
for status, count in status_counts.items():
119+
typer.echo(f"- {status.value}: {count} file(s)")
120+
typer.echo("No files have changed, not opening any diffs.")
121+
return
122+
typer.echo(f"Opening diffs for {len(files_to_diff)} changed files.")
123+
for file in files_to_diff:
124+
snapshot_file = file.parent.parent / DIRECTORY_NAMES.snapshot_dir_name / file.name
125+
success: bool = _try_open_diff(file, snapshot_file)
126+
if not success:
127+
typer.secho(
128+
f"Could not open diff tool. Files to compare:\n"
129+
f" Test result: {file.resolve()}\n"
130+
f" Snapshot: {snapshot_file.resolve()}",
131+
fg=typer.colors.YELLOW,
132+
)
133+
134+
105135
def delete_files(list_of_files_to_delete: list[pathlib.Path]) -> None:
106136
"""Delete files."""
107137
# Delete files
@@ -116,6 +146,29 @@ def delete_files(list_of_files_to_delete: list[pathlib.Path]) -> None:
116146
root_dir.rmdir()
117147

118148

149+
def _try_open_diff(file1: pathlib.Path, file2: pathlib.Path) -> bool:
150+
"""Try to open diff using available tools, return True if successful."""
151+
diff_commands: list[list[str]] = [
152+
["code", "--diff", str(file1.resolve()), str(file2.resolve())],
153+
["code.cmd", "--diff", str(file1.resolve()), str(file2.resolve())], # Windows alternative
154+
]
155+
156+
for command in diff_commands:
157+
try:
158+
subprocess.run(command, check=True, timeout=10) # noqa: S603 - shell=False and args as list, safe usage
159+
except subprocess.TimeoutExpired: # noqa: PERF203
160+
typer.secho(
161+
f"Diff tool timed out for command: {' '.join(command)}",
162+
fg=typer.colors.RED,
163+
)
164+
continue
165+
except (subprocess.CalledProcessError, FileNotFoundError):
166+
continue
167+
else:
168+
return True
169+
return False
170+
171+
119172
class FileStatus(Enum):
120173
"""Enum to represent the status of a file."""
121174

snappylapy/_plugin.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,16 @@ def pytest_addoption(parser: pytest.Parser) -> None:
148148

149149

150150
def pytest_sessionstart(session: pytest.Session) -> None:
151-
"""Initialize the snapshot session."""
151+
"""Initialize the snapshot session before running tests."""
152+
# Check if we're in discovery/collection mode
153+
if getattr(session.config.option, "collectonly", False) or getattr(session.config.option, "collect_only", False):
154+
return
155+
152156
session.config.snappylapy_session = SnapshotSession() # type: ignore[attr-defined]
153-
list_of_files_to_delete = DirectoryNamesUtil().get_all_file_paths_test_results()
154-
for file in list_of_files_to_delete:
155-
file.unlink()
157+
directory_util: DirectoryNamesUtil = DirectoryNamesUtil()
158+
files_to_delete: list[pathlib.Path] = directory_util.get_all_file_paths_test_results()
159+
for file_path in files_to_delete:
160+
file_path.unlink()
156161

157162

158163
class ExceptionDuringTestSetupError(Exception):

snappylapy/expectation_classes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
from .expect_dataframe import DataframeExpect
55
from .expect_dict import DictExpect
66
from .expect_list import ListExpect
7+
from .expect_object import ObjectExpect
78
from .expect_string import StringExpect
89

910
__all__ = [
1011
"BytesExpect",
1112
"DataframeExpect",
1213
"DictExpect",
1314
"ListExpect",
15+
"ObjectExpect",
1416
"StringExpect",
1517
]

snappylapy/expectation_classes/base_snapshot.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def to_match_snapshot(self) -> None:
3939
"""Assert test results match the snapshot."""
4040
if not (self.settings.snapshot_dir / self.settings.filename).exists():
4141
if not self.settings.snapshot_update:
42-
error_msg = f"Snapshot file not found: {self.settings.filename}, run pytest with the --snapshot-update flag to create it." # noqa: E501
42+
error_msg = f"Snapshot file not found: {self.settings.filename}, run 'snappylapy update' command in the terminal, or run pytest with the --snapshot-update flag to create it." # noqa: E501
4343
raise FileNotFoundError(error_msg)
4444
self.snappylapy_session.add_created_snapshot(
4545
self.settings.filename)
@@ -52,7 +52,9 @@ def to_match_snapshot(self) -> None:
5252
self.settings.filename)
5353
try:
5454
snapshot_data_str = snapshot_data.decode()
55+
snapshot_data_str = snapshot_data_str.replace("\r\n", "\n").replace("\r", "\n")
5556
test_data_str = test_data.decode()
57+
test_data_str = test_data_str.replace("\r\n", "\n").replace("\r", "\n")
5658
assert snapshot_data_str == test_data_str
5759
except AssertionError as error:
5860
if self.settings.snapshot_update:
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Snapshot testing and expectations for generic custom objects."""
2+
from __future__ import annotations
3+
4+
from .base_snapshot import BaseSnapshot
5+
from snappylapy.serialization import JsonPickleSerializer
6+
7+
8+
class ObjectExpect(BaseSnapshot[object]):
9+
"""Snapshot testing for generic objects."""
10+
11+
serializer_class = JsonPickleSerializer[object]
12+
13+
def __call__(
14+
self,
15+
data_to_snapshot: object,
16+
name: str | None = None,
17+
filetype: str = "object.json",
18+
) -> ObjectExpect:
19+
"""Prepare an object for snapshot testing."""
20+
self._prepare_test(data_to_snapshot, name, filetype)
21+
return self

snappylapy/fixtures.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
DataframeExpect,
1717
DictExpect,
1818
ListExpect,
19+
ObjectExpect,
1920
StringExpect,
2021
)
2122
from .models import Settings
@@ -205,6 +206,32 @@ def test_dataframe(expect: Expect) -> None:
205206
```
206207
"""
207208

209+
self.object = ObjectExpect(self.settings, snappylapy_session)
210+
"""ObjectExpect instance for configuring snapshot testing of generic objects.
211+
212+
The instance is callable with the following parameters:
213+
214+
Parameters
215+
----------
216+
data_to_snapshot : object
217+
The object data to be snapshotted.
218+
name : str, optional
219+
The name of the snapshot, by default "".
220+
filetype : str, optional
221+
The file type of the snapshot, by default "object.json".
222+
223+
Returns
224+
-------
225+
ObjectExpect
226+
The instance of the ObjectExpect class.
227+
228+
Example
229+
-------
230+
```python
231+
expect.object({"key": "value"}).to_match_snapshot()
232+
```
233+
"""
234+
208235
def read_snapshot(self) -> bytes:
209236
"""Read the snapshot file."""
210237
return (self.settings.snapshot_dir / self.settings.filename).read_bytes()
@@ -243,13 +270,21 @@ def __call__(
243270
filetype: str | None = None,
244271
) -> DataframeExpect: ...
245272

273+
@overload
274+
def __call__(
275+
self,
276+
data_to_snapshot: Any, # noqa: ANN401
277+
name: str | None = None,
278+
filetype: str | None = None,
279+
) -> ObjectExpect: ...
280+
246281
def __call__(
247282
self,
248283
data_to_snapshot: dict | list[Any] | str | bytes | DataframeExpect.DataFrame,
249284
name: str | None = None,
250285
filetype: str | None = None,
251-
) -> DictExpect | ListExpect | StringExpect | BytesExpect | DataframeExpect:
252-
"""Call the fixture with the given parameters."""
286+
) -> DictExpect | ListExpect | StringExpect | BytesExpect | DataframeExpect | ObjectExpect:
287+
"""Call the fixture with the given parameters. Falls back to object handler for custom objects."""
253288
kwargs: dict[str, str] = {}
254289
if name is not None:
255290
kwargs["name"] = name
@@ -268,11 +303,16 @@ def __call__(
268303
if isinstance(typ, type) and isinstance(data_to_snapshot, typ):
269304
return func(data_to_snapshot, **kwargs)
270305

271-
supported_types: list[str] = [
272-
getattr(typ, "__name__", str(typ)) if isinstance(typ, type) else typ for typ in type_map.keys() # noqa: SIM118
273-
]
274-
error_message = f"Unsupported type: {type(data_to_snapshot)}. Supported types: {', '.join(supported_types)}."
275-
raise TypeError(error_message)
306+
# Check if the object is a pandas DataFrame without importing pandas directly
307+
if (
308+
type(data_to_snapshot).__module__.startswith("pandas")
309+
and type(data_to_snapshot).__name__ == "DataFrame"
310+
# TODO: Create a protocol class instead that contains all the dependencies we are depending on
311+
):
312+
return self.dataframe(data_to_snapshot, **kwargs) # type: ignore[arg-type]
313+
314+
# Fallback: treat custom objects as dicts for snapshotting
315+
return self.object(data_to_snapshot, **kwargs)
276316

277317

278318
class LoadSnapshot:

0 commit comments

Comments
 (0)