I'm a big fan of snapshot testing - writing tests where you compare the output of some function to a previously saved version, and can re-generate that version from scratch any time something changes.
I usually do this by hand - I run pytest -x --pdb
to stop at the first failing test and drop into a debugger, then copy out the representation of the generated value and copy it into the test. I wrote about how I use this pattern a few years ago in How to cheat at unit tests with pytest and Black.
Today I learned how to do the same thing with the Syrupy plugin for pytest. I think I'll be using this for many of my future projects.
I created a tests/test_stuff.py
file with the following contents:
def test_one(snapshot):
assert "hello" == snapshot
def test_two(snapshot):
assert snapshot == {"foo": [1, 2, 3], "bar": {"baz": "qux"}}
Then I installed both pytest
and syrupy
:
pip install pytest syrupy
Now in my parent folder I can run this:
pytest
And the tests fail:
tests/test_stuff.py FF [100%]
======================================== FAILURES =========================================
________________________________________ test_one _________________________________________
snapshot = SnapshotAssertion(name='snapshot', num_executions=1)
def test_one(snapshot):
> assert "hello" == snapshot
E AssertionError: assert [+ received] == [- snapshot]
E Snapshot 'test_one' does not exist!
E + 'hello'
tests/test_stuff.py:2: AssertionError
________________________________________ test_two _________________________________________
snapshot = SnapshotAssertion(name='snapshot', num_executions=1)
def test_two(snapshot):
> assert snapshot == {"foo": [1, 2, 3], "bar": {"baz": "qux"}}
E AssertionError: assert [- snapshot] == [+ received]
E Snapshot 'test_two' does not exist!
E + dict({
E + 'bar':
E
E ...Full output truncated (9 lines hidden), use '-vv' to show
tests/test_stuff.py:5: AssertionError
--------------------------------- snapshot report summary ---------------------------------
2 snapshots failed.
================================= short test summary info =================================
FAILED tests/test_stuff.py::test_one - AssertionError: assert [+ received] == [- snapshot]
FAILED tests/test_stuff.py::test_two - AssertionError: assert [- snapshot] == [+ received]
==================================== 2 failed in 0.05s ====================================
The snapshots don't exist yet. But I can create them automatically by running this:
pytest --snapshot-update
Which outputs passing tests along with:
--------------------------------- snapshot report summary ---------------------------------
2 snapshots generated.
==================================== 2 passed in 0.01s ====================================
And sure enough, there's now a new folder called tests/__snapshots__
with a file called test_stuff.ambr
which contains this:
# serializer version: 1
# name: test_one
'hello'
# ---
# name: test_two
dict({
'bar': dict({
'baz': 'qux',
}),
'foo': list([
1,
2,
3,
]),
})
# ---
Running pytest
again passes, because the snapshots exist and continue to match the test output.
The serialized snapshot format is designed to be checked into Git. It's pleasantly readable - I can review that and see what it's testing, and I could even update it by hand - though I'll much more likely use the --snapshot-update
flag and then eyeball the differences.
My snapshots so far are pretty simple - a string and a nested dictionary. I decided to add a dataclass to my code and see what that looks like:
import dataclasses
@dataclasses.dataclass
class Foo:
bar: int
baz: str
def test_one(snapshot):
assert "hello" == snapshot
def test_two(snapshot):
assert snapshot == {"foo": [1, 2, 3], "bar": {"baz": "qux"}}
def test_three(snapshot):
assert Foo(1, "hello") == snapshot
Running pytest
again failed. pytest --snapshot-update
passed and updated my snapshot file, adding this to it:
# name: test_three
Foo(bar=1, baz='hello')
OK, neat - it looks like it's using the Dataclass's __repr__
method to serialize the object.
I tried it with a custom non-dataclass object... and it worked too!
class WeirdClass:
def __init__(self, foo, bar):
self.foo = foo
self.bar = bar
def test_four(snapshot):
assert WeirdClass(1, 2) == snapshot
Serialized to:
# name: test_four
WeirdClass(
bar=2,
foo=1,
)
I wasn't expecting this to work. The Syrupy documentation says:
The default serializer supports all python built-in types and provides a sensible default for custom objects.
It looks like there are a bunch of more advanced ways to customize objects to make them work well with Syrupy, but I haven't dived into those yet.
First impressions are that this looks like exactly the snapshot tool I've been waiting for.
Created 2023-09-26T16:39:12-07:00 · Edit