Asserting on any value
The assertions every value has, whatever its type: equality, identity, None, truthiness, membership, types and predicates.
They live on Expect[T], the root of every subject — a string subject has them,
a mapping subject has them, and so does a subject you
write yourself. If you learn one page of the catalogue, learn
this one.
tip
Full signatures for everything here:
Expect[T]in the reference.
Equality
from lovely_assertions import expect
expect("ord-118").is_equal_to("ord-118")
expect(3).is_not_equal_to(4)
print("ok")ok
is_equal_to is ==, with one addition that matters: where the two reprs alone
do not show what went wrong, the failure carries an indented difference
block — the key that differs for a mapping, the index for a sequence, a
unified diff for multi-line text, the column for a long single line, and, for
two values whose reprs print the same, the fact that they are unequal at all,
with the cause where there is one to name. Short strings are read faster side by
side than as a diff, so the failure below is one line — see
Reading a failure
for the other forms.
from lovely_assertions import expect, AssertionFailure
order_id = "ord-118"
try:
expect(order_id).is_equal_to("ord-119")
except AssertionFailure as failure:
print(failure)Expected order_id to equal 'ord-119', but was 'ord-118'.
When == is the wrong question — a dict against a dataclass, a field you do
not care about, two timestamps a millisecond apart — reach for
is_equivalent_to instead.
Identity
is_same_as is is, not ==. Use it when sharing the same object is the
thing under test — a cache returning the instance it stored, a singleton, a
sentinel.
from lovely_assertions import expect, AssertionFailure
cached = [1]
try:
expect(cached).is_same_as([1])
except AssertionFailure as failure:
print(failure)Expected cached to be the same object as [1], but was [1].
That message reads oddly on purpose: two values that print identically and are
not the same object is exactly the bug is_same_as exists to catch, and the
message shows you what the confusion looks like. is_not_same_as is the
complement.
None
is_none() is identity against None, never == None, so a type with a
permissive __eq__ cannot talk its way past. is_not_none() is the complement:
from lovely_assertions import expect, AssertionFailure
maybe_user = None
try:
expect(maybe_user).is_not_none()
except AssertionFailure as failure:
print(failure)Expected maybe_user not to be None, but it was.
is_not_none() is also the narrowing
workhorse: expect(raw).is_not_none().subject is a str when raw was
str | None.
Truthiness
is_truthy and is_falsy are bool(subject). When is_truthy fails, the
message names which kind of falsy applied — a zero, an empty container,
None, or a __bool__ that said no:
from lovely_assertions import expect, AssertionFailure
retry_budget = 0
try:
expect(retry_budget).is_truthy()
except AssertionFailure as failure:
print(failure)Expected retry_budget to be truthy, but it is 0.
For a zero a bare assert retry_budget says as much — pytest rewrites it to
assert 0. The last kind is the one it cannot help with: a rewritten assert on
a domain object prints its repr, which is an address.
from lovely_assertions import expect, AssertionFailure
class Cart:
def __init__(self) -> None:
self.items: list[str] = []
def __bool__(self) -> bool:
return bool(self.items)
cart = Cart()
try:
expect(cart).is_truthy()
except AssertionFailure as failure:
print(failure)Expected cart to be truthy, but Cart.__bool__ returned False.
Prefer the specific assertion where you have one. is_not_empty() on a
collection and is_positive() on a number both say more than is_truthy(), and
0, "", [] and None are four different bugs that truthiness flattens into
one.
Membership
is_one_of compares the subject to each option with ==. is_in asks the
container's own __contains__, which is not the same question:
expect("draft").is_in("drafts") passes on the substring, and a range answers
arithmetically without materialising anything. Write the alternatives out with
is_one_of; reach for is_in when you already hold the container and mean its
own idea of membership.
from lovely_assertions import expect, AssertionFailure
status = "draft"
try:
expect(status).is_one_of("open", "closed", "shipped")
except AssertionFailure as failure:
print(failure)Expected status to be one of ('open', 'closed', 'shipped'), but was 'draft'.from lovely_assertions import expect, AssertionFailure
environment = "staging"
try:
expect(environment).is_in(("prod", "dev"))
except AssertionFailure as failure:
print(failure)Expected environment to be in ('prod', 'dev'), but was 'staging'.is_not_in is the complement. There is no is_not_one_of: pass the options as
a container to is_not_in instead. is_one_of with no options at all raises
ValueError, since a call with nothing to look for could never pass.
Types
Three assertions, and the difference between them is the whole point:
| Asks | A subclass | |
|---|---|---|
is_instance_of(T) |
isinstance(subject, T) |
counts |
is_exactly_instance_of(T) |
type(subject) is T |
does not count |
as_type(T) |
is_instance_of(T).which, in one step |
counts |
from lovely_assertions import expect, AssertionFailure
payload = 42
try:
expect(payload).is_instance_of(str)
except AssertionFailure as failure:
print(failure)
try:
expect(True).is_exactly_instance_of(int)
except AssertionFailure as failure:
print(failure)Expected payload to be an instance of str, but was int. Expected True to be exactly int, but was bool.
That second one is the reason is_exactly_instance_of exists: isinstance(True, int)
is True in Python, and sometimes that is precisely what you need to rule out.
is_not_instance_of and is_not_exactly_instance_of are the complements, and
they differ on exactly the same point: is_not_instance_of(int) fails for a
bool, because a bool is an int.
is_instance_of and is_exactly_instance_of hand back a Found, not the
subject. .which continues on the value re-typed — and re-dispatched, so
.which after is_instance_of(str) carries the string catalogue — while
.and_ goes back to the original subject.
from lovely_assertions import expect
incoming: object = "ord-118"
expect(incoming).is_instance_of(str).which.starts_with("ord-")
print("ok")ok
as_type(T) is those two steps spelled as one, which is why it drops the
original subject. See
Chaining and narrowing.
Every assertion here asks about the type of a value. To assert about a class
itself — is_subclass_of, implements, is_abstract — pass the class to
expect() and see types and enums.
Predicates: matches
For a one-off condition with no assertion of its own. Note that on a string
subject matches takes a regular expression or a predicate — the example below
uses the predicate form, and matches on a string is
usually reached for the other one.
from lovely_assertions import expect, AssertionFailure
order_id = "ord-118"
try:
expect(order_id).matches(lambda value: value.endswith("999"))
except AssertionFailure as failure:
print(failure)Expected order_id to match the predicate, but 'ord-118' did not.
The message has the subject and the value, and nothing about what the predicate wanted — a lambda has no name to print. Give the predicate one and the message uses it, which is usually the cheaper fix:
from lovely_assertions import expect, AssertionFailure
def ends_with_999(value: str) -> bool:
return value.endswith("999")
try:
expect(order_id).matches(ends_with_999)
except AssertionFailure as failure:
print(failure)Expected order_id to match ends_with_999, but 'ord-118' did not.
Where the reason matters and not just the name, use satisfies instead: its
nested assertions each explain themselves.
Nested assertions: satisfies
satisfies runs assertions inside a value and collects them: one failure
listing everything that was wrong, rather than stopping at the first.
The callback it takes is an inspector — a callable that asserts. A
callable that returns a verdict is a predicate, which is what matches
above takes. The library refuses to confuse the two: lambda o: o["total"] > 0
returns a verdict, which would have passed whatever the subject was, so it
raises TypeError at the call and points you at matches. Neither checker
catches that one.
from lovely_assertions import expect, AssertionFailure
order = {"subtotal": 4000, "shipping": 0, "total": 0}
try:
expect(order).satisfies(lambda o: expect(o["total"]).described_as("total").is_positive())
except AssertionFailure as failure:
print(failure)Expected the value to satisfy the inspection. - Expected total to be positive, but was 0
Both names in that message are worth understanding. The statement contains
two expect() calls, so name recovery has no unambiguous answer for either one —
and an ambiguous guess would be a wrong name, which is worse than none. So the
outer subject falls back to the value, and the inner one reads total only
because described_as said so.
Name both, and the finding reads properly:
from lovely_assertions import expect, AssertionFailure
order = {"subtotal": 4000, "shipping": 0, "total": 0}
try:
expect(order, name="order").satisfies(
lambda o: expect(o["total"]).described_as("its total").is_positive()
)
except AssertionFailure as failure:
print(failure)Expected order to satisfy the inspection. - Expected its total to be positive, but was 0
Naming is needed there because the lambda puts both expect() calls in one
statement. Lift the inspector into a function and they are in separate
statements again, so recovery answers for each of them. The inner names are read
from the helper's source, so what they show is its parameter — name it after
what it holds:
from collections.abc import Mapping
from lovely_assertions import expect, AssertionFailure
def is_a_paid_order(order: Mapping[str, int]) -> None:
expect(order["total"]).is_positive()
expect(order["shipping"]).is_positive()
order = {"subtotal": 4000, "shipping": 0, "total": 0}
try:
expect(order).satisfies(is_a_paid_order)
except AssertionFailure as failure:
print(failure)Expected order to satisfy the inspection. - Expected order["total"] to be positive, but was 0 - Expected order["shipping"] to be positive, but was 0
Two findings from one call: the second assertion ran even though the first had already failed.
One of several: satisfies_any and satisfies_none
satisfies_any passes when at least one branch holds, and when none does, it
tells you what each branch wanted:
from lovely_assertions import expect, AssertionFailure
status = "draft"
try:
expect(status).satisfies_any(
lambda s: s.is_equal_to("open"),
lambda s: s.is_equal_to("closed"),
)
except AssertionFailure as failure:
print(failure)Expected status to satisfy at least one of 2 alternatives, but none did.
alternative 1:
- Expected status to equal 'open', but was 'draft'
alternative 2:
- Expected status to equal 'closed', but was 'draft'A hand-written assert a or b can only re-print the expression it was given —
pytest interleaves both comparisons and their diffs into one blob. Here each
branch is a whole assertion chain, so every alternative explains itself in the
words of the assertion that failed.
satisfies_none is the complement, and names the branch that held:
from lovely_assertions import expect, AssertionFailure
status = "draft"
try:
expect(status).satisfies_none(lambda s: s.starts_with("dr"))
except AssertionFailure as failure:
print(failure)Expected status to satisfy none of 1 alternatives, but alternative 1 held.
Note the branches take the subject, not the raw value — s there is a
StringExpect, which is why s.starts_with(...) works. This is the opposite of
satisfies above, which hands your callback the plain value and leaves you to
wrap it in expect() yourself.
Naming and continuing
Three more members that are not assertions at all:
| Does | |
|---|---|
described_as("...") |
names the subject for every failure on that chain — a new subject from .which does not inherit it |
.and_ |
hands back the same subject, so a chain reads as a sentence |
.subject |
hands back the value, re-typed by what you have proved |
from lovely_assertions import expect, AssertionFailure
for index, row in enumerate([{"ok": True}, {"ok": False}]):
try:
expect(row["ok"]).described_as(f"rows[{index}].ok").is_true()
except AssertionFailure as failure:
print(failure)Expected rows[1].ok to be True, but was False.
expect(value, name="...") says the same thing one step earlier.