Skip to content

Commit

Permalink
feat(amber): add property matcher support (#245)
Browse files Browse the repository at this point in the history
* wip: add matcher named argument

* fix: make arguments optional when using call syntax

* feat(amber): add property matcher support

* test: property matcher

* test: property matcher

* test: property matcher

* style: lint fix

* docs: add documentation

* chore: update serialize kwargs

* feat: add path_type matcher factory helper

* docs: update path type signature

* wip: include parent types in property path

* refactor: move types to explicit argument

* chore: more docs

* cr: update doc wording

* refactor: split amber data serializer

* style: formatting

* refactor: reuse serialize logic

* chore: remove unused type def

* chore: use gettext for messages

* refactor: reuse repr and kwargs

* refactor: repr init param

* refactor: serializer to order functions by use

* chore: add specific path type error

* cr: update docs
  • Loading branch information
iamogbz committed Jun 9, 2020
1 parent e01266a commit 83ded3c
Show file tree
Hide file tree
Showing 22 changed files with 683 additions and 350 deletions.
1 change: 1 addition & 0 deletions .prettierrc.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tabWidth = 2
74 changes: 72 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def __repr__(self) -> str:
return "MyCustomClass(...)"
```

### Options
### CLI Options

These are the cli options exposed to `pytest` by the plugin.

Expand All @@ -80,7 +80,75 @@ These are the cli options exposed to `pytest` by the plugin.
| `--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` |

### Built-In Extensions
### Assertion Options

These are the options available on the `snapshot` assertion fixture.
Use of these options are one shot and do not persist across assertions.
For more persistent options see [advanced usage](#advanced-usage).

#### `matcher`

This allows you to match on a property path and value to control how specific object shapes are serialized.

The matcher is a function that takes two keyword arguments.
It should return the replacement value to be serialized or the original unmutated value.

| Argument | Description |
| -------- | ------------------------------------------------------------------------------------------------------------------ |
| `data` | Current serializable value being matched on |
| `path` | Ordered path traversed to the current value e.g. `(("a", dict), ("b", dict))` from `{ "a": { "b": { "c": 1 } } }`} |

**NOTE:** Do not mutate the value received as it could cause unintended side effects.

##### Built-In Matchers

Syrupy comes with built-in helpers that can be used to make easy work of using property matchers.

###### `path_type(mapping=None, *, types=(), strict=True)`

Easy way to build a matcher that uses the path and value type to replace serialized data.
When strict, this will raise a `ValueError` if the types specified are not matched.

| Argument | Description |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `mapping` | Dict of path string to tuples of class types, including primitives e.g. (MyClass, UUID, datetime, int, str) |
| `types` | Tuple of class types used if none of the path strings from the mapping are matched |
| `strict` | If a path is matched but the value at the path does not match one of the class types in the tuple then a `PathTypeError` is raised |

```py
from syrupy.matchers import path_type

def test_bar(snapshot):
actual = {
"date_created": datetime.now(),
"value": "Some computed value!",
}
assert actual == snapshot(matcher=path_type({
"date_created": (datetime,),
"nested.path.id": (int,),
}))
```

```ambr
# name: test_bar
<class 'dict'> {
'date_created': <class 'datetime'>,
'value': 'Some computed value!',
}
---
```

#### `extension_class`

This is a way to modify how the snapshot matches and serializes your data in a single assertion.

```py
def test_foo(snapshot):
actual_svg = "<svg></svg>"
assert actual_svg = snapshot(extension_class=SVGImageSnapshotExtension)
```

##### Built-In Extensions

Syrupy comes with a few built-in preset configurations for you to choose from. You should also feel free to extend the `AbstractSyrupyExtension` if your project has a need not captured by one our built-ins.

Expand Down Expand Up @@ -141,7 +209,9 @@ To develop locally, clone this repository and run `. script/bootstrap` to instal

<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->

<!-- ALL-CONTRIBUTORS-LIST:END -->

This section is automatically generated via tagging the all-contributors bot in a PR:

```text
Expand Down
4 changes: 2 additions & 2 deletions src/syrupy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,12 @@ def pytest_assertrepr_compare(op: str, left: Any, right: Any) -> Optional[List[s
assert_msg = reset(
f"{snapshot_style(left.name)} {op} {received_style('received')}"
)
return [assert_msg] + left.get_assert_diff(right)
return [assert_msg] + left.get_assert_diff()
elif isinstance(right, SnapshotAssertion):
assert_msg = reset(
f"{received_style('received')} {op} {snapshot_style(right.name)}"
)
return [assert_msg] + right.get_assert_diff(left)
return [assert_msg] + right.get_assert_diff()
return None


Expand Down
24 changes: 19 additions & 5 deletions src/syrupy/assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from .location import TestLocation
from .extensions.base import AbstractSyrupyExtension
from .session import SnapshotSession
from .types import SerializableData, SerializedData # noqa: F401
from .types import PropertyMatcher, SerializableData, SerializedData


@attr.s
Expand Down Expand Up @@ -45,6 +45,7 @@ class SnapshotAssertion:
_test_location: "TestLocation" = attr.ib(kw_only=True)
_update_snapshots: bool = attr.ib(kw_only=True)
_extension: Optional["AbstractSyrupyExtension"] = attr.ib(init=False, default=None)
_matcher: Optional["PropertyMatcher"] = attr.ib(init=False, default=None)
_executions: int = attr.ib(init=False, default=0)
_execution_results: Dict[int, "AssertionResult"] = attr.ib(init=False, factory=dict)
_post_assert_actions: List[Callable[..., None]] = attr.ib(init=False, factory=list)
Expand Down Expand Up @@ -88,10 +89,13 @@ def use_extension(
def assert_match(self, data: "SerializableData") -> None:
assert self == data

def get_assert_diff(self, data: "SerializableData") -> List[str]:
def _serialize(self, data: "SerializableData") -> "SerializedData":
return self.extension.serialize(data, matcher=self._matcher)

def get_assert_diff(self) -> List[str]:
assertion_result = self._execution_results[self.num_executions - 1]
snapshot_data = assertion_result.recalled_data
serialized_data = self.extension.serialize(data)
serialized_data = assertion_result.asserted_data or ""
diff: List[str] = []
if snapshot_data is None:
diff.append(gettext("Snapshot does not exist!"))
Expand All @@ -100,7 +104,10 @@ def get_assert_diff(self, data: "SerializableData") -> List[str]:
return diff

def __call__(
self, *, extension_class: Optional[Type["AbstractSyrupyExtension"]]
self,
*,
extension_class: Optional[Type["AbstractSyrupyExtension"]] = None,
matcher: Optional["PropertyMatcher"] = None,
) -> "SnapshotAssertion":
"""
Modifies assertion instance options
Expand All @@ -112,6 +119,13 @@ def clear_extension() -> None:
self._extension = None

self._post_assert_actions.append(clear_extension)
if matcher:
self._matcher = matcher

def clear_matcher() -> None:
self._matcher = None

self._post_assert_actions.append(clear_matcher)
return self

def __repr__(self) -> str:
Expand All @@ -131,7 +145,7 @@ def _assert(self, data: "SerializableData") -> bool:
assertion_success = False
try:
snapshot_data = self._recall_data(index=self.num_executions)
serialized_data = self.extension.serialize(data)
serialized_data = self._serialize(data)
matches = snapshot_data is not None and serialized_data == snapshot_data
assertion_success = matches
if not matches and self._update_snapshots:
Expand Down
2 changes: 1 addition & 1 deletion src/syrupy/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@


if TYPE_CHECKING:
from .types import SerializedData # noqa: F401
from .types import SerializedData


@attr.s(frozen=True)
Expand Down
Loading

0 comments on commit 83ded3c

Please sign in to comment.