Snapshot assertions¶
Compare a value against a stored snapshot, recording it on first run.
Assertions that compare a value against a stored snapshot, recording it on the first run.
Capture a python data structure to disk as JSON and compare the current value against it on every later run - a few well-placed snapshot tests cover a lot of surface with very little code.
On-disk format
Snapshots are stored as readable JSON. For example:
assert_that({'a': 1, 'b': 2, 'c': 3}).snapshot()
is stored as:
{
"a": 1,
"b": 2,
"c": 3
}
Most python structures are supported (dict, list, object, and so on). Values without a native
JSON form round-trip through typed markers: set, complex, datetime/date/time
(timezone-aware included), decimal.Decimal, bytes (base64-encoded), uuid.UUID, and
Enum members. Any other type can be handled by registering a serializer with
register_snapshot_serializer().
Updating
Run pytest with --assertpy-snapshot-update (or set ASSERTPY_SNAPSHOT_UPDATE for other
runners) and every failing comparison overwrites the stored value instead of failing.
Each overwrite emits a SnapshotUpdatedWarning, and
matching snapshots are left untouched. Deleting the snapshot files and re-running works too - each
fresh capture emits a SnapshotCreatedWarning. So
neither a first run nor an update run is ever silent, and both fail explicitly under -W error.
CI mode
On a first run a missing snapshot is created and the test passes. That is convenient locally, but a hazard in CI: a snapshot test whose golden was never committed would create it in the ephemeral workspace, pass, and silently disable drift detection for that test.
In CI mode a missing snapshot is instead a hard failure. Enable it with the
--assertpy-snapshot-ci pytest flag or the ASSERTPY_SNAPSHOT_CI environment variable; it
is also auto-enabled when a CI environment variable is set (the near-universal CI marker).
Disable the autodetection with --assertpy-snapshot-no-ci or ASSERTPY_SNAPSHOT_CI=0.
Local runs are unaffected.
snapshot ¶
snapshot(
id: str | None = None,
path: str = "__snapshots",
*,
ignore: object = None,
include: object = None,
tolerance: float | None = None,
rel: float | None = None,
comparators: dict[Any, Callable[[Any, Any], Any]]
| None = None,
placeholders: Mapping[
Any, Matcher[Any] | Callable[[Any], object]
]
| None = None,
) -> Self
Asserts that val is identical to the on-disk snapshot stored previously.
On the first run, before the snapshot file exists, the value is captured to disk, a
SnapshotCreatedWarning is emitted, and the test
passes. On every later run the value is compared to the stored snapshot and the test fails on
any mismatch.
Snapshots live in the __snapshots directory by default (commit them to source control) and
are identified by test filename plus line number, unless you pass id or path.
In update mode (--assertpy-snapshot-update, or the ASSERTPY_SNAPSHOT_UPDATE env
var) a failing comparison overwrites the stored snapshot and passes, emitting a
SnapshotUpdatedWarning; a matching snapshot is
left untouched.
In CI mode (--assertpy-snapshot-ci, ASSERTPY_SNAPSHOT_CI, or an auto-detected
CI environment) a missing snapshot is a hard AssertionError instead of being created,
so an uncommitted golden fails the build rather than silently disabling drift detection.
The comparison accepts the same selective options as
is_equal_to(), so volatile fields (timestamps,
generated ids) or float noise don't break snapshots. The snapshot file always stores the
full value; the options only shape the comparison.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
id
|
str | None
|
a custom snapshot identifier (defaults to test filename plus line number) |
None
|
path
|
str
|
the directory where snapshots are stored (defaults to |
'__snapshots'
|
Other Parameters:
| Name | Type | Description |
|---|---|---|
ignore |
Hashable | list | set | frozenset | None
|
the key/field (or collection of
keys/fields) to ignore when comparing; accepts the same nested-path tuples,
|
include |
Hashable | list | set | frozenset | None
|
the key/field (or collection of keys/fields) to compare, everything else ignored. |
tolerance |
float | None
|
an absolute tolerance that widens |
rel |
float | None
|
a tolerance relative to the stored leaf of each pair of real-number leaves,
either of the two holding being enough, as in |
comparators |
dict | None
|
a dict mapping a |
placeholders |
dict | None
|
a dict mapping a top-level key of a dict-like val to a
|
Examples:
Usage:
assert_that(None).snapshot()
assert_that(True).snapshot()
assert_that(1).snapshot()
assert_that(123.4).snapshot()
assert_that('foo').snapshot()
assert_that([1, 2, 3]).snapshot()
assert_that({'a': 1, 'b': 2, 'c': 3}).snapshot()
assert_that({'a', 'b', 'c'}).snapshot()
assert_that(1 + 2j).snapshot()
assert_that(someobj).snapshot()
By default, snapshots are identified by test filename plus line number.
Alternately, you can specify a custom identifier using the id arg:
assert_that({'a': 1, 'b': 2, 'c': 3}).snapshot(id='foo-id')
By default, snapshots are stored in the __snapshots directory.
Alternately, you can specify a custom path using the path arg:
assert_that({'a': 1, 'b': 2, 'c': 3}).snapshot(path='my-custom-folder')
Ignore volatile fields, or tolerate float noise, without touching the stored snapshot:
assert_that(api_response).snapshot(id='order', ignore=['created_at', ('user', 'session_id')])
assert_that(metrics).snapshot(id='latency', tolerance=0.001)
Store a shape token for a volatile field, and assert its shape on every run:
from assertpy import match
assert_that(response).snapshot(id='order', placeholders={'id': match.is_uuid()})
# stored as {"id": {"__placeholder__": "a valid UUID string"}, ...}
Returns:
| Name | Type | Description |
|---|---|---|
AssertionBuilder |
Self
|
returns this instance to chain to the next assertion |
Raises:
| Type | Description |
|---|---|
AssertionError
|
if val does not equal to on-disk snapshot |
TypeError
|
if |
ValueError
|
if |
Warns:
| Type | Description |
|---|---|
SnapshotCreatedWarning
|
when this run captured a new snapshot instead of comparing |
SnapshotUpdatedWarning
|
when update mode overwrote a stale snapshot instead of failing |
Source code in assertpy/snapshot.py
641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 | |
matches_inline ¶
matches_inline(
expected: object = _UNSET,
*,
ignore: object = None,
include: object = None,
tolerance: float | None = None,
rel: float | None = None,
comparators: dict[Any, Callable[[Any, Any], Any]]
| None = None,
placeholders: Mapping[
Any, Matcher[Any] | Callable[[Any], object]
]
| None = None,
) -> Self
Asserts that val equals an inline snapshot literal written at the call site.
Unlike snapshot(), which stores the value in a
separate file, an inline snapshot lives as a literal argument in the test source.
Call it empty the first time and run with --assertpy-snapshot-update to record the value
into the source; later runs compare against it. The same selective knobs as snapshot()
apply, so volatile fields never make the snapshot brittle.
The comparison itself is an ordinary equality check with no source introspection, so it works
under pytest-xdist and needs neither the [inline] extra nor any assertion rewriting;
only recording (empty call under update mode) reads the source.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
expected
|
object
|
the recorded literal; omit it to record on the next update run. |
_UNSET
|
ignore
|
object
|
key(s)/path(s) to skip in the comparison (as in |
None
|
include
|
object
|
restrict the comparison to these key(s)/path(s). |
None
|
tolerance
|
float | None
|
absolute numeric tolerance applied at every depth. |
None
|
rel
|
float | None
|
numeric tolerance relative to the leaf the literal holds, applied at every depth, either of the two holding being enough. |
None
|
comparators
|
dict[Any, Callable[[Any, Any], Any]] | None
|
per-type custom equality callables. |
None
|
placeholders
|
Mapping[Any, Matcher[Any] | Callable[[Any], object]] | None
|
|
None
|
Examples:
Usage:
assert_that({"id": 1, "name": "Alice"}).matches_inline({"id": 1, "name": "Alice"})
Returns:
| Name | Type | Description |
|---|---|---|
AssertionBuilder |
Self
|
returns this instance to chain to the next assertion |
One call holds one literal: every case that reaches it under update mode has to pass against
the literal written there, or nothing is written and the run fails naming the call. Under
not_ nothing is recorded, since the literal would be the value the negation differs from.
Raises:
| Type | Description |
|---|---|
AssertionError
|
if val does not equal the recorded literal, or the snapshot is still empty |
TypeError
|
if update mode has to rewrite an expected value that is not a literal written in the call, such as a name or an unpacked argument |
Source code in assertpy/snapshot.py
847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 | |
matches_contract_snapshot ¶
Asserts that val's structure matches a contract snapshot stored previously.
Records the shape - paths and type categories, never values - on the first run, then on later runs fails only on structural drift: a field added, removed, or retyped.
It is value-tolerant by construction, so dynamic ids, timestamps, and amounts change freely
without breaking the snapshot, and it needs no hand-written model - the contract is inferred
from the first response. Numbers are one category (5 and 5.0 do not drift), and a
null sample is a nullable wildcard.
The model-driven counterpart is
assert_conforms(..., exact=True): reach for that when you
already have a pydantic model, and for this when you would rather capture the shape from a real
response.
Honors the same update mode (--assertpy-snapshot-update), CI mode
(--assertpy-snapshot-ci), and storage layout as
snapshot().
Because a contract is inferred from a single observation it cannot know which fields are
optional, so a legitimately sometimes-absent field reads as removed; re-record with update
mode when the contract really changed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
id
|
str | None
|
a custom snapshot identifier (defaults to test filename plus line number) |
None
|
path
|
str
|
the directory where snapshots are stored (defaults to |
'__snapshots'
|
Examples:
Usage:
assert_that(response.json()).matches_contract_snapshot()
Returns:
| Name | Type | Description |
|---|---|---|
AssertionBuilder |
Self
|
returns this instance to chain to the next assertion |
Raises:
| Type | Description |
|---|---|
AssertionError
|
if val's structure drifts from the stored contract snapshot |
Warns:
| Type | Description |
|---|---|
SnapshotCreatedWarning
|
when this run captured a new contract instead of comparing |
SnapshotUpdatedWarning
|
when update mode overwrote a drifted contract instead of failing |
Source code in assertpy/snapshot.py
924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 | |
register_snapshot_serializer ¶
register_snapshot_serializer(
cls: type,
encode: Callable[[Any], object],
decode: Callable[[Any], object],
*,
tag: str | None = None,
) -> None
Register a custom (encode, decode) pair for snapshotting values of type cls.
The typed codec covers common non-JSON types (set, complex, datetime/date/time,
Decimal, bytes, uuid.UUID, Enum). Register a serializer for anything else - a
domain object, an ORM row, a pathlib.Path - so snapshot() stores and round-trips it instead
of raising TypeError.
Matching is by isinstance (so subclasses are covered), the registry is consulted before the
built-ins, and a later registration wins over an earlier one for overlapping types.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cls
|
type
|
the type (matched by |
required |
encode
|
Callable[[Any], object]
|
|
required |
decode
|
Callable[[Any], object]
|
|
required |
tag
|
str | None
|
a stable identifier stored in the snapshot to route decoding (defaults to the type's fully-qualified name); change it only deliberately, since existing snapshots key on it |
None
|
Examples:
Usage:
import pathlib
register_snapshot_serializer(pathlib.PurePath, str, pathlib.PurePath)
Raises:
| Type | Description |
|---|---|
TypeError
|
if |
Source code in assertpy/snapshot.py
SnapshotKeyReusedWarning ¶
Emitted when one snapshot key was reached by more than one test.
The default key is the line of the snapshot() call, so every case of a parametrised test shares
it: the first case stores its value and the rest compare against that one. Where the values differ
the run fails with a message that names two unrelated cases, and where they agree it passes while
checking one case out of however many - the second is the reason this warning exists, because
nothing else reports it.
Two calls inside one test are not this: a helper that snapshots twice asserts both values, so the metric is distinct tests rather than accesses.
Its own category, so it can be raised to an error with a single filterwarnings entry.
SnapshotCreatedWarning ¶
Emitted when snapshot() writes a new snapshot instead of comparing.
The first run of a snapshot assertion captures the current value and passes without comparing
anything, so a wrong first capture would silently become the reference. This warning makes that
capture visible; suites running with -W error turn it into an explicit failure.
SnapshotUpdatedWarning ¶
Emitted when snapshot() overwrites a stored snapshot in update mode.
Update mode replaces failing snapshots with the current value instead of failing; turn it on with
the --assertpy-snapshot-update pytest flag or the ASSERTPY_SNAPSHOT_UPDATE environment
variable.
Each overwrite emits this warning, so an update run reports exactly which snapshots changed instead of rewriting them silently.