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 --assertpy2-snapshot-update (or set ASSERTPY2_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
--assertpy2-snapshot-ci pytest flag or the ASSERTPY2_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 --assertpy2-snapshot-no-ci or ASSERTPY2_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,
comparators: dict | None = None,
placeholders: dict | 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 (--assertpy2-snapshot-update, or the ASSERTPY2_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 (--assertpy2-snapshot-ci, ASSERTPY2_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 applied to every real-number leaf. |
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 assertpy2 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 assertpy2/snapshot.py
503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 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 | |
matches_inline ¶
matches_inline(
expected: object = _UNSET,
*,
ignore: object = None,
include: object = None,
tolerance: float | None = None,
comparators: dict[object, Callable[..., bool]]
| None = None,
placeholders: dict[object, 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 --assertpy2-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
|
comparators
|
dict[object, Callable[..., bool]] | None
|
per-type custom equality callables. |
None
|
placeholders
|
dict[object, 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 |
Raises:
| Type | Description |
|---|---|
AssertionError
|
if val does not equal the recorded literal, or the snapshot is still empty |
Source code in assertpy2/snapshot.py
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 | |
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 (--assertpy2-snapshot-update), CI mode
(--assertpy2-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 assertpy2/snapshot.py
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 846 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 | |
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 assertpy2/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 --assertpy2-snapshot-update pytest flag or the ASSERTPY2_SNAPSHOT_UPDATE environment
variable.
Each overwrite emits this warning, so an update run reports exactly which snapshots changed instead of rewriting them silently.