Type Assertions¶
assert_that() returns a type-specific set of assertions. The sections below group them by value type.
Strings¶
assert_that("").is_empty().is_false().is_type_of(str)
assert_that("foo").is_length(3).is_not_empty().is_alpha().is_lower()
assert_that("123").is_digit()
assert_that("FOO").is_upper()
assert_that("foo").is_equal_to("foo").is_not_equal_to("bar")
assert_that("foo").is_equal_to_ignoring_case("FOO")
assert_that("foo bar").is_equal_to_ignoring_whitespace("foobar")
assert_that("foo").is_length_between(1, 5)
assert_that("foo123").is_alphanumeric()
assert_that(" ").is_whitespace()
assert_that("foo").contains("f", "oo")
assert_that("foo").contains_ignoring_case("F", "oO")
assert_that("foo").does_not_contain("x")
assert_that("foo").contains_only("f", "o")
assert_that("foo").contains_sequence("o", "o")
assert_that("foobar").contains_any_of("foo", "xyz")
assert_that("foobar").contains_none_of("xyz", "abc")
assert_that("foo").contains_duplicates()
assert_that("fox").does_not_contain_duplicates()
assert_that("foo").is_in("foo", "bar", "baz")
assert_that("foo").is_subset_of("abcdefghijklmnopqrstuvwxyz")
assert_that("foo").starts_with("f").ends_with("oo")
assert_that("FooBar").starts_with_ignoring_case("foo").ends_with_ignoring_case("BAR")
assert_that("foo").matches(r"\w")
assert_that("123-456-7890").matches(r"\d{3}-\d{3}-\d{4}")
assert_that("foo").does_not_match(r"\d+")
An empty prefix or suffix is refused
This one does not follow from Python, where "foo".startswith("") is True. An empty prefix holds
for every value, so the assertion cannot fail and checks nothing. starts_with(), ends_with(),
their _ignoring_case spellings and starts_with_bytes() raise ValueError rather than pass.
An empty value is refused the same way when the subject is a non-string iterable, since there is no first element to compare against.
Regex matching
Use raw strings (r"...") for patterns. matches() passes on partial matches (like the
underlying re.match). Anchor the pattern (^...$) to match the whole string. Inline flags such as
(?m) and (?s) work, even though matches() takes no flags argument.
Numbers¶
assert_that(0).is_zero().is_false().is_type_of(int)
assert_that(1).is_not_zero().is_positive()
assert_that(-1).is_negative()
assert_that(4).is_even()
assert_that(3).is_odd()
assert_that(9).is_divisible_by(3)
assert_that(123).is_equal_to(123).is_not_equal_to(456)
assert_that(123).is_greater_than(100).is_greater_than_or_equal_to(123)
assert_that(123).is_less_than(200).is_less_than_or_equal_to(200)
assert_that(123).is_between(100, 200)
assert_that(123).is_close_to(100, 25)
assert_that(1).is_in(0, 1, 2, 3).is_not_in(-1, -2, -3)
# floats
assert_that(123.4).is_close_to(123, 0.5)
assert_that(123.4).is_between(100.1, 200.2)
assert_that(float("NaN")).is_nan()
assert_that(123.4).is_not_nan()
assert_that(float("Inf")).is_inf()
assert_that(123.4).is_not_inf()
Floats and equality
Avoid is_equal_to() with float values. Use is_close_to() or is_between() instead.
Lists¶
assert_that([]).is_empty().is_type_of(list).is_iterable()
assert_that(["a", "b"]).is_length(2).is_not_empty()
assert_that(["a", "b"]).is_equal_to(["a", "b"]).is_not_equal_to(["b", "a"])
assert_that(["a", "b"]).contains("b", "a")
assert_that(["a", "b"]).does_not_contain("x", "y")
assert_that([1, 2, 3]).does_not_contain(match.greater_than(99)) # matchers, same as contains
assert_that(["a", "b"]).contains_only("a", "b")
assert_that(["a", "b", "c"]).contains_sequence("b", "c")
assert_that(["a", "b", "c"]).contains_exactly("a", "b", "c")
assert_that(["c", "a", "b"]).contains_exactly_in_any_order("a", "b", "c")
assert_that(["a", "x", "b", "y", "c"]).contains_in_order("a", "b", "c")
assert_that(["a", "b"]).is_subset_of(["a", "b", "c"])
assert_that(["a", "b", "c"]).is_sorted()
assert_that(["c", "b", "a"]).is_sorted(reverse=True)
assert_that(["a", "x", "x"]).contains_duplicates()
assert_that(["a", "b", "c"]).does_not_contain_duplicates()
assert_that(["a", "b", "c"]).starts_with("a").ends_with("c")
assert_that([1, -2, 3]).any_satisfy(lambda x: x < 0)
assert_that([1, 2, 3]).all_satisfy(lambda x: x > 0)
assert_that([1, 2, 3]).none_satisfy(lambda x: x < 0)
assert_that([2, 4, 6]).satisfies_exactly(
lambda x: x == 2, lambda x: x == 4, lambda x: x == 6
)
assert_that([4, 2]).satisfies_exactly_in_any_order(lambda x: x == 2, lambda x: x == 4)
assert_that([1, 2, 3]).zip_satisfies([2, 4, 6], lambda actual, other: other == actual * 2)
assert_that([1, 2, 3]).contains_only_once(1, 3)
assert_that([1, 2, 3]).has_same_size_as(("a", "b", "c"))
assert_that([1, 2, 3]).has_size_greater_than(2).has_size_less_than(4).has_size_between(
1, 5
)
any_satisfy, all_satisfy, and none_satisfy accept both callables and matchers.
Assertions that checked nothing¶
all_satisfy over an empty collection passes, the way all([]) is true: no item failed.
That is the correct answer to the question asked. It is also the most common silent false pass in a suite, because a query that returned no rows leaves the assertion with nothing to examine.
Turn the guard on and those cases say so:
VacuousAssertionWarning: all_satisfy() passed over an empty value, so nothing
was checked. Pass allow_empty=True if that is intended.
The warning points at your line, not at library code. To escalate or relocate it:
-W error::assertpy2.VacuousAssertionWarningturns it into a failureASSERTPY2_VACUOUS=1enables it for runners other than pytest
It is off by default for two reasons. A suite running filterwarnings = ["error"] would start failing
on upgrade, and a property-based test generates empty collections as a matter of course.
When emptiness is the point of the test, say so per call and the guard stays quiet:
archived_orders = [] # a fixture that yields nothing on a clean database
assert_that(archived_orders).all_satisfy(lambda o: o["closed"], allow_empty=True)
allow_empty is accepted by the universal assertions:
each,all_satisfy,all_fields_satisfy,has_no_none_fieldszip_satisfies,is_sorted,is_subset_of
The negative ones never warn. For none_satisfy, does_not_contain and
does_not_contain_duplicates an empty subject is the expected pass, since "no errors were logged" is
exactly what such a test wanted to hear.
The exact-pairing and multiset assertions:
satisfies_exactly- pairs the i-th item with the i-th matcher (equal length required).satisfies_exactly_in_any_order- any one-to-one pairing instead.zip_satisfies- checks a two-arg predicate over items zipped with another iterable.contains_only_once- each given item must occur exactly once.has_same_size_as- compares lengths against another sized object.contains_exactly_in_any_order- multiset equality: exact items and counts, order ignored.
Assertions that never ran¶
The guard above catches an assertion that ran and examined nothing. The other half of the same problem is an assertion that never ran at all:
async def test_the_user_is_active():
assert_that(user) # builds a builder and asserts nothing
assert_that(user.age).is_positive # looked up, never called
assert_that(probe).eventually().is_positive() # never awaited, so it polls nothing
All three are green forever, and none of them is possible with a bare assert. That is the price of a
fluent API, so the library owes you a way to find them.
A fourth shape needs no check, because it is refused where it happens. An async def predicate hands
back a coroutine, which is truthy and never runs, so the assertion around it would pass whatever the
value was:
async def is_positive(value: int) -> bool:
return value > 0
assert_that(-1).satisfies(is_positive) # TypeError: handed back a coroutine instead of an answer
Await the call and assert on what it returned, assert_that(await is_positive(-1)).is_true(). Every
place that reads an answer as truth refuses a coroutine, whether it came from a plain callable, from a
custom matcher's matches(), or from a comparators= entry, and whether it is reached directly or
through a composed matcher. eventually() is the other half of the story and a different one: it polls
an async probe for you, so an async callable belongs there as the thing being polled.
Ruff already covers part of it, and this check does not repeat what it does:
- the second line is ruff's
B018 - a builder bound to a name and never read is its
F841
The other two are reported by nothing, because a call may have side effects and no linter can know these do not.
Writing assert in front does not save you, and that shape is the worst of the family:
assert reads the bound method, and a bound method is truthy. The line passes on every value while
reading as though it checked one. B018 does not apply here, since the value is consumed rather than
discarded, and coverage counts the line as run.
For a project that wants it on every run, set it in the config instead. The flag still wins, so one person can try it without editing a file the whole team shares:
The check reads your test files rather than running anything, so it costs nothing at runtime and cannot move a stack frame.
A warning on its own leaves the run green. pytest exits zero with warnings, so a finding that should stop a merge has to be promoted to an error. The finding is attached to the test containing the line, so promoting it fails that test and nothing else:
Without that line the check is a report you have to go and read, which is a fair choice for a first look and a poor one for CI.
When asserting nothing is the point - a benchmark measuring what building a builder costs, a test of the assertion machinery itself - say so on the line and the check passes over it:
The marker is namespaced rather than borrowing ruff's, so a line silenced for one tool is not silently silenced for the other. It covers the statement it sits on, and on a call broken over several lines the closing line works.
Most suites of any size wrap assert_that in a helper of their own, and the check cannot see through
one it has never heard of. Name yours and it reads them the same way:
Only list a wrapper that builds something to assert on, the way assert_that does. A helper that
asserts inside its own body is complete as a statement, and listing it would report working code. The
name has to arrive through from ... import check, aliases included. That import is the whole
difference between your wrapper and any other function in the world that happens to be called check,
so a helper reached as helpers.check(...) is out of scope.
Two limits worth knowing before you rely on it:
- a name this module binds itself is dropped for the whole file. One
assert_that = somethinganywhere in it turns the check off for that name everywhere in that file. The trade buys no false alarms at the cost of missed ones. - several dangling statements in one test arrive as a single warning naming the extra lines. Under
filterwarnings = ["error"]the first one ends the test, so a second warning would never be seen.
What it deliberately leaves alone:
- anything inside
pytest.raises(),pytest.warns()orpytest.deprecated_call(). The silent pass this check exists for cannot happen there: the chain either raises, which is what the test asserts, or it does not and the block turns the test red by itself - a builder bound to a name (
b = assert_that(x)), because whetherbis used later is a question about the rest of the function, not about that statement - a chain ending on a bare
check(), since reporting it would mean deciding that the name is this library's, and a project can register an extension calledcheckthat asserts by itself assert_conforms(),fail()andsoft_fail(), which assert on their own, so a bare call is correct
A soft_assertions() block is not one of those: it collects failures and reaches its end, so a chain
that asserted nothing inside one leaves the test as green as it would anywhere else.
One limit worth knowing before you count on it: the check reads the test modules pytest collected, and nothing else.
So a project that keeps its assertion layer in a package of its own, framework/asserts/ rather than
the test files, gets no coverage of that package from a pytest run. Nothing warns you either, because
from inside a run a clean package and an uncollected one look identical.
Another cost: the check is static, and a
dynamic assertion is resolved at runtime, so
assert_that(fred).has_first_name without its parentheses is caught by B018 rather than here.
Lists of lists can be flattened by index with extracting (see dict flattening):
people = [["Fred", "Smith"], ["Bob", "Barr"]]
assert_that(people).extracting(0).is_equal_to(["Fred", "Bob"])
assert_that(people).extracting(-1).is_equal_to(["Smith", "Barr"])
Tuples¶
Tuples support the same membership, ordering, and duplicate assertions as lists:
assert_that(()).is_empty().is_type_of(tuple).is_iterable()
assert_that((1, 2, 3)).is_length(3).is_equal_to((1, 2, 3))
assert_that((1, 2, 3)).contains(3, 2, 1).contains_only(1, 2, 3)
assert_that((1, 2, 3)).contains_sequence(2, 3).contains_exactly(1, 2, 3)
assert_that((1, 5, 2, 8, 3)).contains_in_order(1, 2, 3)
assert_that((1, 2, 3)).is_subset_of((1, 2, 3, 4)).is_sorted()
assert_that((1, 2, 2)).contains_duplicates()
assert_that((1, 2, 3)).starts_with(1).ends_with(3)
Tuples of tuples flatten by index with extracting:
points = ((1, 2, 3), (4, 5, 6))
assert_that(points).extracting(0).is_equal_to([1, 4])
assert_that(points).extracting(-1).is_equal_to([3, 6])
Dicts¶
assert_that({}).is_empty().is_type_of(dict)
assert_that({"a": 1, "b": 2}).is_length(2).is_not_empty()
assert_that({"a": 1, "b": 2}).is_equal_to({"b": 2, "a": 1})
assert_that({"a": 1, "b": 2}).contains("b", "a")
assert_that({"a": 1, "b": 2}).does_not_contain("x", "y")
assert_that({"a": 1, "b": 2}).contains_only("a", "b")
assert_that({"a": 1, "b": 2}).is_subset_of({"a": 1, "b": 2, "c": 3})
# contains_key / does_not_contain_key are aliases of contains / does_not_contain
assert_that({"a": 1, "b": 2}).contains_key("b", "a")
assert_that({"a": 1, "b": 2}).does_not_contain_key("x", "y")
assert_that({"a": 1, "b": 2}).contains_value(2, 1)
assert_that({"a": 1, "b": 2}).does_not_contain_value(3, 4)
assert_that({"a": 1, "b": 2}).contains_entry({"a": 1}, {"b": 2})
assert_that({"a": 1, "b": 2}).does_not_contain_entry({"a": 2})
Selective comparison (ignore / include)¶
is_equal_to() can ignore or include specific keys or fields. It works across dicts, dataclasses,
namedtuples, Pydantic models, attrs classes, and plain objects. For a sequence, each element is
compared pairwise under the same filters.
The filter accepts a single key, a nested-path tuple, or a list/set/frozenset of those. Any other
iterable (a generator, an iterator, dict.keys()) raises TypeError.
# ignore keys (single, list/set/frozenset, or nested tuple)
assert_that({"a": 1, "b": 2}).is_equal_to({"a": 1}, ignore="b")
assert_that({"a": 1, "b": 2, "c": 3}).is_equal_to({"a": 1}, ignore={"b", "c"})
assert_that({"a": 1, "b": {"c": 2, "d": 3}}).is_equal_to(
{"a": 1, "b": {"c": 2}}, ignore=("b", "d")
)
# include only specific keys
assert_that({"a": 1, "b": 2, "c": 3}).is_equal_to({"a": 1, "b": 2}, include=["a", "b"])
# objects with introspectable fields
@dataclass
class User:
id: int
name: str
email: str
assert_that(User(1, "Alice", "a@x.com")).is_equal_to(
User(99, "Alice", "a@x.com"), ignore="id"
)
# attrs instances work the same, including nested paths
@attrs.define
class Account:
id: int
owner: str
assert_that(Account(1, "Alice")).is_equal_to(Account(99, "Alice"), ignore="id")
ignore and include also accept a re.Pattern (matched against field names) or a type (matched
against field values):
import re
# ignore private-ish keys (matched against field names)
assert_that(payload).is_equal_to(expected, ignore=re.compile(r"^_"))
# ignore all float fields
assert_that(payload).is_equal_to(expected, ignore=float)
Recursive comparison (tolerance / custom comparators)¶
is_equal_to() can compare two concrete nested structures with a numeric tolerance or with custom
comparators, anywhere in the graph:
tolerance- an absolute tolerance applied to every real-number leaf (abs(actual - expected) <= tolerance).comparators- maps atypeor a field name to an(actual, expected) -> boolpredicate (a field-name key wins over a type key).
Tolerated or comparator-equal leaves are reported in neither the message nor the diff.
# absolute float tolerance, at any depth
assert_that({"point": {"x": 1.0001, "y": 2.0}}).is_equal_to(
{"point": {"x": 1.0, "y": 2.0}}, tolerance=0.001
)
# comparator by type, or by field name
assert_that(order).is_equal_to(
expected, comparators={float: lambda a, e: round(a, 2) == round(e, 2)}
)
# case-insensitive comparator by field name
assert_that(order).is_equal_to(
expected, comparators={"name": lambda a, e: a.lower() == e.lower()}
)
Use comparators to change how a field or type is compared. To drop a field from the comparison
entirely, use ignore (above) rather than an always-true comparator (ignore also handles fields missing
on one side or with incomparable types).
ignore_null=True skips any named field the expected side leaves None, at any depth - handy for a
partial expected/template. Only the expected side is skipped, so an unexpectedly None actual field is
still reported (never masked):
# compare only the fields the expected template sets;
# age and address, left None, are ignored
assert_that(user).is_equal_to(User(name="Alice"), ignore_null=True)
Sequence elements have no field name, so a comparators field-name key does not apply to them (use a type
key or tolerance). Sets compare by standard equality.
Requiring the same type¶
Python's == compares across types, and a payload keeps that property all the way down. A JSON true
read back as a bool equals 1, a Decimal from a database column equals an int, and neither says a
word:
from decimal import Decimal
assert_that({"active": True}).is_equal_to({"active": 1}) # passes
assert_that({"n": Decimal(1)}).is_equal_to({"n": 1}) # passes
assert_that({"a": {"b": [{"c": True}]}}).is_equal_to({"a": {"b": [{"c": 1}]}}) # passes, three levels down
strict_types=True requires both sides of every node to be the same type, at any depth:
Because it is opt-in it can afford to be blunt, but the bluntness is worth knowing before you turn it
on. It also rejects pairs some callers read as equal: IntEnum against int, a dict subclass
against dict, float against int.
When several comparison options meet on one leaf, they resolve in a fixed order:
ignore_null → comparators → identity → strict_types → tolerance.
It reads as two groups. The first two decide whether the leaf is compared at all (ignore_null) and
whether your own code replaces the comparison (comparators). The rest then compute equality itself,
from the most decisive test to the most forgiving: the same object, then the same type, then a numeric
allowance. Two consequences follow that are worth saying out loud:
toleranceandstrict_typesonly ever combine within one type,floatagainstfloat. The classic tolerance case, aDecimalfrom a database column against afloatfrom JSON, fails on the type before the tolerance is consulted.- a matcher on the expected side is exempt, so composed matchers keep working, and a spec that mixes literals with matchers gets strictness on its literal half only.
Identity sits in that chain because forcing the walk would otherwise take it away. A subnode shared by
both sides is matched by identity and not walked again, exactly as Python does inside a container, so
a config object placed in two expected blocks stays cheap and a container holding the same NaN on
both sides keeps comparing equal.
Anything matched by hash is covered too, which means dictionary keys and set members.
1, 1.0 and True hash alike and compare equal, so a mapping finds the pair before anything looks
at its type. Reaching the type takes a second pass:
Sets read the same way: {1} against {1.0} fails a strict comparison for the same reason.
The one limit is cost. Strictness turns off the fast path, because a container's own == says nothing
about the types inside it, so every comparison walks the whole structure in Python.
On a list of 20 000 dicts that is 0.3 ms against 41 ms. It matters only if you compare large dumps in a loop.
Inside a structural spec the same relation is spelled
match.equal_to(value, strict_types=True), one matcher covering value and type together:
assert_that({"active": True}).matches_structure({"active": match.equal_to(True, strict_types=True)})
To assert the type alone, match.is_type_of() rejects a subclass where match.is_instance_of()
accepts one:
assert_that({"n": 1}).matches_structure({"n": match.is_type_of(int)}) # passes
assert_that({"n": True}).matches_structure({"n": match.is_instance_of(int)}) # passes: bool is an int
To negate it, invert the whole assertion with .not_ rather than looking for the flag on
is_not_equal_to, which takes no comparison options:
Dict flattening¶
Lists of dicts can be flattened on a key with extracting (see
extracting attributes):
people = [{"first_name": "Fred"}, {"first_name": "Bob"}]
assert_that(people).extracting("first_name").is_equal_to(["Fred", "Bob"])
Dict key assertions¶
Assert against the value of a key by prepending has_ to the key name (see
dynamic assertions):
fred = {"first_name": "Fred", "last_name": "Smith", "shoe_size": 12}
assert_that(fred).has_first_name("Fred").has_shoe_size(12)
Sets¶
assert_that(set()).is_empty().is_type_of(set)
assert_that({"a", "b"}).is_length(2).is_equal_to({"b", "a"})
assert_that({"a", "b"}).contains("b", "a").does_not_contain("x")
assert_that({"a", "b"}).contains_only("a", "b")
assert_that({"a", "b"}).is_subset_of({"a", "b", "c"})
assert_that({"a", "b"}).is_subset_of({"a"}, {"b"})
Booleans¶
None¶
Dates¶
assertpy2 supports dates via the datetime type.
import datetime
today = datetime.datetime.today()
yesterday = today - datetime.timedelta(days=1)
assert_that(yesterday).is_before(today)
assert_that(today).is_after(yesterday)
assert_that(today).is_before_or_equal_to(today)
assert_that(today).is_after_or_equal_to(yesterday)
Both operands must agree on awareness.
Comparing a timezone-naive datetime with an aware one raises a TypeError rather than answering. The
naive value carries no zone, so there is no instant to compare it by.
This holds for the ignoring_* assertions too. They would otherwise compare wall-clock fields and call
two moments hours apart equal.
naive = datetime.datetime(2020, 1, 2, 3, 4, 5)
aware = datetime.datetime(2020, 1, 2, 3, 4, 5, tzinfo=datetime.UTC)
assert_that(naive).is_before(aware) # TypeError
assert_that(naive).is_equal_to_ignoring_seconds(aware) # TypeError
Make both aware or both naive first, then the comparison is well defined.
Equality can ignore units of time, and the numeric comparisons work on dates too:
assert_that(today).is_equal_to_ignoring_milliseconds(today_0us)
assert_that(today).is_equal_to_ignoring_seconds(today_0s)
assert_that(today).is_equal_to_ignoring_time(today_0h)
assert_that(middle).is_between(yesterday, today)
# tolerance is a timedelta
assert_that(yesterday).is_close_to(today, datetime.timedelta(hours=24))
Date properties can be asserted dynamically with has_<property> (see
dynamic assertions):
x = datetime.datetime(1980, 1, 2, 3, 4, 5, 6)
assert_that(x).has_year(1980).has_month(1).has_day(2)
assert_that(x).has_hour(3).has_minute(4).has_second(5).has_microsecond(6)
Files¶
assert_that("foo.txt").exists().is_file()
assert_that("missing.txt").does_not_exist()
assert_that("mydir").is_directory()
assert_that("foo.txt").is_named("foo.txt").is_child_of("mydir")
assert_that("foo.txt").is_readable().is_writable()
assert_that("/usr/bin/python").is_executable()
Read a file into a string with contents_of() (default encoding utf-8) and continue with string
assertions:
from assertpy2 import assert_that, contents_of
assert_that(contents_of("foo.txt", "ascii")).starts_with("foo").ends_with(
"bar"
).contains("oob")
Bytes / bytearray¶
Assertions for bytes and bytearray values:
assert_that(b"hello world").is_valid_utf8()
assert_that(b"hello").is_valid_encoding("ascii")
assert_that(b"\x89PNG").has_byte_at(0, 0x89) # IndexError if out of range
assert_that(b"\xab\xcd\xef").is_hex_equal_to("abcdef")
starts_with(), ends_with() and contains() handle byte strings themselves, so a prefix, a suffix
and a subsequence read the same way they do for text:
assert_that(b"\x89PNG\r\n\x1a\n").starts_with(b"\x89PNG")
assert_that(b"hello world").ends_with(b"world")
assert_that(b"hello world").contains(b"world")
starts_with_bytes() and contains_bytes() are the bytes-only spellings of the first and the last, kept
for the code that already uses them. They delegate, so all four report the same way.
decoded_as() returns a new builder with the decoded string so string assertions can continue
(UnicodeDecodeError is raised if decoding fails):
assert_that(b"hello").decoded_as("utf-8").starts_with("hel").is_length(5)
assert_that(b"hello").decoded_as().is_equal_to("hello") # default encoding utf-8
All bytes assertions work with soft assertions, warn mode, and .not_ negation.
Objects¶
fred = Person("Fred", "Smith")
assert_that(fred).is_not_none().is_type_of(Person).is_instance_of(object)
assert_that(fred).is_instance_of_any(Person, dict)
assert_that(fred).is_instance_of(Person | dict) # a union
assert_that(fred).is_instance_of((Person, (dict, str))) # a tuple, nested to any depth
assert_that(Person).is_subclass_of(object)
assert_that(fred).is_same_as(fred)
assert_that(fred.say_hello).is_callable()
assert_that(fred.first_name).is_not_callable()
assert_that(fred.first_name).is_equal_to("Fred")
assert_that(fred.name).is_equal_to("Fred Smith") # property
assert_that(fred.say_hello()).is_equal_to("Hello, Fred!") # method
is_equal_to compares values. It does not ask whether the corresponding parts of two structures are the
same object, so a copy that rebuilt what it should have shared still passes. To require two members to
be one object, compare them with is_same_as:
Recursive field assertions¶
all_fields_satisfy walks the whole object graph (mappings, dataclasses, namedtuples, attrs classes,
Pydantic models, lists, tuples) and applies one matcher or callable to every scalar leaf, reporting the path
of each leaf that does not satisfy it. has_no_none_fields is the common special case:
assert_that({"a": 1, "nested": {"b": 2}}).all_fields_satisfy(match.is_positive())
assert_that([1, [2, 3]]).all_fields_satisfy(lambda x: x > 0)
assert_that({"id": 1, "profile": {"name": "Alice"}}).has_no_none_fields()
assert_that({"a": 1, "b": {"c": -2}}).all_fields_satisfy(match.is_positive()) # fails
# Expected all fields to satisfy a positive value, but 1 field did not.
# b.c: expected a positive value, but was -2
Scalars, strings and sets are treated as single leaves (use each / all_satisfy for element-wise set
checks), and circular references are reported once rather than recursed into.
Extracting attributes from objects¶
Flatten a collection of objects on an attribute, property, or zero-argument method with extracting:
people = [Person("Fred", "Smith"), Person("Bob", "Barr")]
assert_that(people).extracting("first_name").contains("Fred", "Bob")
assert_that(people).extracting("first_name", "last_name").contains(
("Fred", "Smith"), ("Bob", "Barr")
)
# property
assert_that(people).extracting("name").contains("Fred Smith", "Bob Barr")
# zero-argument method
assert_that(people).extracting("say_hello").contains("Hello, Fred!", "Hello, Bob!")
It also works on collections of dicts (extracting by key), Pydantic models, and across subclasses in a mixed collection.
Filtering¶
filter keeps only items for which it is truthy. It may be a key/attribute name, a dict of
key-value pairs that must all match, or a predicate:
users = [
{"user": "Fred", "active": True, "age": 25},
{"user": "Johnny", "active": True, "age": 18},
{"user": "Bob", "active": False, "age": 30},
]
assert_that(users).extracting("user", filter="active").is_equal_to(["Fred", "Johnny"])
assert_that(users).extracting("user", filter={"active": False}).is_equal_to(["Bob"])
assert_that(users).extracting("user", filter=lambda x: x["age"] > 20).is_equal_to(
["Fred", "Bob"]
)
Sorting¶
sort orders the extracted items. It may be a key/attribute name, an iterable of names (tie-breaking
left to right), or a key function. None means no ordering. Anything else is a mistake and raises a
TypeError, rather than quietly handing back unsorted items for a later assertion to trip over:
assert_that(users).extracting("user", sort="age").is_equal_to(["Johnny", "Fred", "Bob"])
assert_that(users).extracting("user", sort=["active", "age"]).is_equal_to(
["Bob", "Johnny", "Fred"]
)
assert_that(users).extracting("user", sort=lambda x: -x["age"]).is_equal_to(
["Bob", "Fred", "Johnny"]
)
Dynamic assertions on objects¶
assertpy2 exposes has_<name>() for any attribute, property, or zero-argument method on the value,
so attribute checks stay compact:
fred = Person("Fred", "Smith")
assert_that(fred).has_first_name("Fred") # attribute
assert_that(fred).has_name("Fred Smith") # property
assert_that(fred).has_say_hello("Hello, Fred!") # zero-arg method
Dynamic assertions also work on dicts, keyed by entry name:
assert_that(
{"first_name": "Fred", "last_name": "Smith"}
).has_first_name("Fred").has_last_name("Smith")
Exceptions¶
Exception and warning assertions wrap a callable rather than a value: you assert on what calling the function does, then chain assertions on the resulting message.
assert_that(some_func).raises(RuntimeError).when_called_with("foo")
assert_that(deprecated_func).warns(DeprecationWarning).when_called_with("foo")
See Errors & Reporting for the full set:
- expected exceptions and warnings
- the cause chain (
caused_by(),has_root_cause()) and exception groups (contains_error(),does_not_contain_error(),matches_error_tree(),errors(),error_of()) - pivoting to the raised exception (
raised()) or the call's return value (returned()).