diff --git a/CHANGELOG.md b/CHANGELOG.md index e3653102..0e5af692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,9 @@ From v1.0.0 onwards, this project adheres to [Semantic Versioning](https://semve ## Master (Unreleased) +- Add support for custom objects repr (#101) - Add support for nested test classes (#99) -- Removes `_snapshot_subdirectory_name` from `SnapshotFossilizer` (#99) +- Remove `_snapshot_subdirectory_name` from `SnapshotFossilizer` (#99) ## v0.1.0 diff --git a/README.md b/README.md index 683ea43a..11ce08bd 100644 --- a/README.md +++ b/README.md @@ -62,37 +62,15 @@ A snapshot file should be generated under a `__snapshots__` directory in the sam #### Custom Objects -The default serializer supports all python built-in types and when in doubt it falls back to __repr__. +The default serializer supports all python built-in types and provides a sensible default for custom objects. -This means your custom object needs to implement `__repr__` to prevent non deterministic snapshots. +If you need to customise your object snapshot, is as easy as overriding the default `__repr__` implementation. ```python def __repr__(self) -> str: return "MyCustomClass(...)" ``` -It is recommended to split representation into multiple lines. - -```python -def __repr__(self): - state = "\n".join( - f" {a}={getattr(self, a)}" - for a in sorted(dir(self)) - if not a.startswith("__") - ) - return f"{self.__class__.__name__}(\n{state}\n)" -``` - -This makes the snapshot diff in case of failures or updates easy to review. - -```ambr -MyCustomSmartReprClass( - prop1=1 - prop2=a - prop3={1, 2, 3} -) -``` - ### Options These are the cli options exposed to `pytest` by the plugin. diff --git a/src/syrupy/extensions/amber.py b/src/syrupy/extensions/amber.py index 774f1ee1..ebdccd3c 100644 --- a/src/syrupy/extensions/amber.py +++ b/src/syrupy/extensions/amber.py @@ -192,7 +192,26 @@ def serialize_iterable( def serialize_unknown( cls, data: Any, *, depth: int = 0, visited: Optional[Set[Any]] = None ) -> str: - return cls.with_indent(repr(data), depth) + if data.__class__.__repr__ != object.__repr__: + return cls.with_indent(repr(data), depth) + + return ( + cls.with_indent(f"{cls.object_type(data)} {{\n", depth) + + "".join( + f"{serialized_key}={serialized_value.lstrip(cls._indent)}\n" + for serialized_key, serialized_value in ( + ( + cls.with_indent(name, depth=depth + 1), + cls.serialize( + data=getattr(data, name), depth=depth + 1, visited=visited + ), + ) + for name in cls.sort(dir(data)) + if not name.startswith("_") and not callable(getattr(data, name)) + ) + ) + + cls.with_indent("}", depth) + ) @classmethod def serialize( diff --git a/tests/__snapshots__/test_extension_amber.ambr b/tests/__snapshots__/test_extension_amber.ambr index e0ed533f..029a0e02 100644 --- a/tests/__snapshots__/test_extension_amber.ambr +++ b/tests/__snapshots__/test_extension_amber.ambr @@ -25,6 +25,41 @@ # name: test_bool[True] True --- +# name: test_custom_object_repr + { + a=1 + b='2' + c= [ + 1, + 2, + 3, + ..., + ] + d= { + 'a': 1, + 'b': 2, + 'c': 3, + 'd': ..., + } + x= { + a=1 + b='2' + c= [ + 1, + 2, + 3, + ..., + ] + d= { + 'a': 1, + 'b': 2, + 'c': 3, + 'd': ..., + } + x=None + } + } +--- # name: test_cycle[cyclic0] [ 1, diff --git a/tests/examples/__snapshots__/test_custom_object_repr.ambr b/tests/examples/__snapshots__/test_custom_object_repr.ambr index c1ab1d9f..0393a52e 100644 --- a/tests/examples/__snapshots__/test_custom_object_repr.ambr +++ b/tests/examples/__snapshots__/test_custom_object_repr.ambr @@ -1,10 +1,18 @@ -# name: test_snapshot_attr_class - MyCustomReprClass(prop1=1, prop2='a', prop3={1, 2, 3}) ---- -# name: test_snapshot_smart_class - MyCustomSmartReprClass( +# name: test_snapshot_custom_class + { prop1=1 - prop2=a - prop3={1, 2, 3} + prop2='a' + prop3= { + 1, + 2, + 3, + } + } +--- +# name: test_snapshot_custom_repr_class + MyCustomReprClass( + prop1=1, + prop2='a', + prop3={1, 2, 3}, ) --- diff --git a/tests/examples/test_custom_object_repr.py b/tests/examples/test_custom_object_repr.py index 747d04a8..aa070a92 100644 --- a/tests/examples/test_custom_object_repr.py +++ b/tests/examples/test_custom_object_repr.py @@ -1,30 +1,22 @@ -import attr - - -@attr.s -class MyCustomReprClass: - prop1 = attr.ib(default=1) - prop2 = attr.ib(default="a") - prop3 = attr.ib(default={1, 2, 3}) - - -def test_snapshot_attr_class(snapshot): - assert MyCustomReprClass() == snapshot - - -class MyCustomSmartReprClass: +class MyCustomClass: prop1 = 1 prop2 = "a" prop3 = {1, 2, 3} + +def test_snapshot_custom_class(snapshot): + assert MyCustomClass() == snapshot + + +class MyCustomReprClass(MyCustomClass): def __repr__(self): state = "\n".join( - f" {a}={getattr(self, a)}" + f" {a}={repr(getattr(self, a))}," for a in sorted(dir(self)) - if not a.startswith("__") + if not a.startswith("_") ) return f"{self.__class__.__name__}(\n{state}\n)" -def test_snapshot_smart_class(snapshot): - assert MyCustomSmartReprClass() == snapshot +def test_snapshot_custom_repr_class(snapshot): + assert MyCustomReprClass() == snapshot diff --git a/tests/test_extension_amber.py b/tests/test_extension_amber.py index b25f8a78..e49187f1 100644 --- a/tests/test_extension_amber.py +++ b/tests/test_extension_amber.py @@ -95,6 +95,33 @@ def test_cycle(cyclic, snapshot): assert cyclic == snapshot +class CustomClass: + a = 1 + b = "2" + c = list_cycle + d = dict_cycle + _protected_variable = None + __private_variable = None + + def __init__(self, x=None): + self.x = x + self._y = 1 + self.__z = 2 + + def public_method(self, a, b=1, *, c, d=None): + pass + + def _protected_method(self): + pass + + def __private_method(self): + pass + + +def test_custom_object_repr(snapshot): + assert CustomClass(CustomClass()) == snapshot + + class TestClass: def test_class_method_name(self, snapshot): assert snapshot == "this is in a test class"