Syrupy: Snapshot Testing for pytest — Complete Guide
Syrupy is the most popular snapshot testing plugin for pytest. It stores snapshots in __snapshots__/ directories alongside your test files, uses YAML-like .ambr format for human-readable diffs, and integrates with pytest's assertion rewriting for clear failure messages. This guide covers installation, writing snapshot tests, updating snapshots, and handling non-deterministic data.
Key Takeaways
snapshot is a pytest fixture — no imports needed. Just add snapshot to your test function signature and use assert result == snapshot. Syrupy injects it automatically.
Snapshots are stored as .ambr files in __snapshots__/. These files are YAML-like text — human-readable in code review. Commit them.
Update snapshots with --snapshot-update. When behavior changes intentionally, run pytest --snapshot-update to regenerate snapshot files. Review the diff before committing.
First run creates the snapshot automatically. Unlike some tools, Syrupy creates the snapshot on first run and passes (not fails). Subsequent runs compare against it.
Use snapshot.with_name() to give meaningful snapshot names. By default the snapshot key is the test name. Parameterized tests benefit from explicit names.
What Is Syrupy?
Syrupy is a pytest plugin that adds snapshot testing to your test suite. A snapshot test captures the output of your code in a file, then compares against that file on subsequent runs.
Unlike Jest snapshots (JavaScript) or Verify (C#), Syrupy stores snapshots in .ambr files (Amber format) — a YAML-like text format designed to be readable in code review diffs.
Installation
pip install syrupyNo further configuration needed — Syrupy registers its fixtures with pytest automatically.
Basic Snapshot Test
# tests/test_order.py
from myapp.orders import format_order_summary
def test_order_summary_snapshot(snapshot):
order = {
"id": "ORD-001",
"customer": "Alice",
"items": [{"product": "Widget", "qty": 2, "price": 14.99}],
"total": 29.98
}
result = format_order_summary(order)
assert result == snapshotRun it for the first time:
pytest tests/test_order.pySyrupy creates the snapshot file and the test passes on first run:
# tests/__snapshots__/test_order.ambr
# serializer version: 1
# name: test_order_summary_snapshot
'''
Order ORD-001 for Alice
2x Widget @ $14.99 each
Total: $29.98
'''Future runs compare against this file. Change the code and the test fails with a clear diff.
Snapshot Format for Different Types
Syrupy handles Python objects, not just strings:
def test_user_dict_snapshot(snapshot):
user = {"id": 1, "name": "Bob", "role": "admin", "active": True}
assert user == snapshot
def test_list_snapshot(snapshot):
items = [{"id": i, "value": i * 10} for i in range(3)]
assert items == snapshot
def test_nested_object_snapshot(snapshot):
from dataclasses import dataclass
@dataclass
class Product:
id: str
name: str
price: float
product = Product(id="p1", name="Gadget", price=99.99)
assert product == snapshotGenerated .ambr file:
# name: test_user_dict_snapshot
{
'active': True,
'id': 1,
'name': 'Bob',
'role': 'admin',
}
# name: test_list_snapshot
[
{
'id': 0,
'value': 0,
},
...
]Updating Snapshots
When behavior changes intentionally, update the snapshots:
# Update all snapshots
pytest --snapshot-update
<span class="hljs-comment"># Update snapshots for a specific test file
pytest tests/test_order.py --snapshot-update
<span class="hljs-comment"># Update snapshot for a specific test
pytest tests/test_order.py::test_order_summary_snapshot --snapshot-updateAfter updating, review the diff in git:
git diff tests/__snapshots__/If the diff looks correct, commit it. The updated snapshot becomes the new baseline.
Handling Non-Deterministic Data
Non-deterministic values (timestamps, UUIDs, random IDs) cause snapshots to fail on every run. Handle them by normalizing before snapshotting:
Option 1: Scrub before assertion
import re
def scrub_snapshot(data: dict) -> dict:
"""Remove non-deterministic fields before snapshotting."""
result = dict(data)
for key in ['created_at', 'updated_at', 'id']:
if key in result:
result[key] = f'<{key.upper()}>'
return result
def test_order_with_timestamp(snapshot):
order = OrderService.create({"customer": "Alice", "items": []})
# Scrub before snapshot
assert scrub_snapshot(order) == snapshotOption 2: Freeze time
from freezegun import freeze_time
@freeze_time("2024-01-15 10:00:00")
def test_order_created_at(snapshot):
order = OrderService.create({"customer": "Alice"})
# created_at is now deterministic
assert order == snapshotOption 3: Use pytest fixtures to control IDs
@pytest.fixture(autouse=True)
def fixed_uuid(monkeypatch):
counter = itertools.count(1)
monkeypatch.setattr(uuid, 'uuid4', lambda: f"test-id-{next(counter)}")
def test_with_stable_ids(snapshot):
result = create_entities(count=3)
assert result == snapshot # IDs are now test-id-1, test-id-2, test-id-3Parameterized Snapshot Tests
Syrupy works well with @pytest.mark.parametrize:
@pytest.mark.parametrize("discount_code,expected_discount", [
("SAVE10", 10),
("SAVE20", 20),
("INVALID", 0),
(None, 0),
])
def test_discount_application(snapshot, discount_code, expected_discount):
result = apply_discount(base_price=100.0, code=discount_code)
assert result == snapshotEach parameter combination gets its own snapshot entry:
# name: test_discount_application[SAVE10-10]
{
'discount_percent': 10,
'final_price': 90.0,
}
# name: test_discount_application[None-0]
{
'discount_percent': 0,
'final_price': 100.0,
}Custom Snapshot Names
For clearer snapshot files:
def test_complex_report(snapshot):
report = generate_monthly_report(month="2024-01")
# Custom name instead of test function name
assert report == snapshot(name="monthly_report_january_2024")Testing API Responses
Syrupy works well for HTTP response bodies:
from fastapi.testclient import TestClient
from myapp.main import app
client = TestClient(app)
def test_get_users_response(snapshot):
response = client.get("/api/users")
# Normalize non-deterministic fields
data = response.json()
for user in data.get("users", []):
user["created_at"] = "<TIMESTAMP>"
user["id"] = "<ID>"
assert {
"status_code": response.status_code,
"body": data
} == snapshotDetecting Unused Snapshots
Over time, tests get deleted but their snapshots remain. Find unused snapshots:
pytest --snapshot-warn-unusedDelete unused snapshots:
pytest --snapshot-delete-unusedRun this periodically to keep your snapshot files clean.
CI Configuration
Snapshots should never auto-update in CI. Configure the pipeline to fail if snapshots are stale:
# .github/workflows/test.yml
- name: Run snapshot tests
run: pytest --snapshot-default-extension syrupy.extensions.amber.AmberSnapshotSerializer
# Do NOT pass --snapshot-update in CI
# If snapshots don't match, the test fails with a diffIf a developer merges a code change that affects snapshots without updating them, CI catches it.
Syrupy vs. Other Python Snapshot Libraries
| Syrupy | pytest-snapshot | inline-snapshot | |
|---|---|---|---|
| pytest integration | Native fixture | Native | Native |
| File format | Amber (.ambr) |
.txt |
Inline in source |
| Object serialization | Built-in | Manual | Built-in |
| Parameterized test support | ✓ | Limited | ✓ |
| Custom serializers | ✓ | Limited | ✓ |
| Update command | --snapshot-update |
--snapshot-update |
--update-snapshots |
Syrupy is the most widely used with the most active maintenance. Use it for new projects.
Summary
Syrupy integrates snapshot testing into pytest cleanly:
- Add
snapshotfixture to any test function assert result == snapshot— first run creates the file, subsequent runs compare- Update with
pytest --snapshot-updatewhen behavior changes intentionally - Commit
.ambrfiles — they're human-readable specifications - Scrub non-deterministic data (timestamps, IDs) before snapshotting
Start with your most complex output format — the kind you'd otherwise need 20 assertions to verify. One snapshot assertion replaces them all.