Extending¶
Add your own assertions to assertpy2 with add_extension().
A custom assertion¶
from assertpy2 import add_extension, assert_that
def is_5(self):
if self.val != 5:
return self.error(f"{self.val} is NOT 5!")
return self
add_extension(is_5)
assert_that(5).is_5()
assert_that(6).is_5() # fails!
Both lines run, but a type checker rejects the first one, since 5 resolves to the numeric protocol
and that declares no is_5.
An extension is visible to a checker only where the value keeps the whole builder, which is a narrow
set: a value with a capability but no overload of its own, such as an iterable you wrote or a mapping
that is not a dict, plus anything typed Any. Everything else reaches either its own protocol or
the core surface. The reason and the ways round it are in
Where the typed surface ends.
remove_extension() takes the same function and unregisters it, so a temporary assertion does not
leak into other tests:
When you want the assertion typed¶
add_extension() registers a name at run time. No checker learns that name or its signature: where a
value keeps the whole builder the call is accepted as Any, and everywhere else it is rejected.
Nothing adds it to the inferred type for you. You can declare a Protocol of your own carrying is_5()
and cast() the builder to it, which types the call, but that protocol then carries only what you
declare on it: is_5().is_greater_than(0) stops type-checking unless you write is_greater_than there
too. The protocols the views are built from are private and stay that way, so the typed surface can keep
moving.
What is supported is writing the check as a matcher. It is typed end to end, it restricts the type it accepts, and it imports nothing private:
from assertpy2 import Matcher, assert_that
class MultipleOf:
def __init__(self, other: int) -> None:
self.other = other
def matches(self, value: int) -> bool:
return value % self.other == 0
def describe(self) -> str:
return f"a multiple of <{self.other}>"
def describe_mismatch(self, value: int) -> str:
return f"was <{value}>"
def multiple_of(other: int) -> Matcher[int]:
return MultipleOf(other)
assert_that(24).satisfies(multiple_of(6)).is_greater_than(0)
The chain keeps the numeric assertions after satisfies(), and a checker refuses multiple_of(6) on a
string. What it does not do is narrow the value: satisfies() hands back the same view it was called
on, and only a TypeIs predicate changes that. What you give up is the spelling:
satisfies(multiple_of(6)) rather than is_multiple_of(6).
Call the factory directly rather than registering the matcher under a dynamic name, for the same reason
add_extension is untyped: a name resolved at run time is a name a checker cannot read.
Project-wide reuse¶
is_5() is only available in the file where add_extension() is called. To share extensions across
all test files, register them in a pytest fixture in conftest.py:
import pytest
from assertpy2 import add_extension
def is_5(self):
if self.val != 5:
return self.error(f"{self.val} is NOT 5!")
return self
@pytest.fixture(scope="module")
def my_extensions():
add_extension(is_5)
Then request the fixture in any test that needs the extensions:
from assertpy2 import assert_that
def test_foo(my_extensions):
assert_that(5).is_5()
assert_that(6).is_5() # fails!
Writing custom assertions¶
A few conventions keep custom assertions consistent with the built-ins:
- Use
selfas the first parameter, as if the function were an instance method. - Read the actual value from
self.val. - Test the negative case and fail if it holds.
- Fail via
self.error(...), which raisesAssertionErrorand prints your message. - Raise
TypeError/ValueErrorfor bad input (a programming error), notself.error(). The built-ins word every type refusal the same way,<subject> must be <expectation>, but was <value> (<type>), so a custom assertion reads like one when it follows the same shape. - Always
return selfso the assertion chains.
import numbers
def is_multiple_of(self, other):
if isinstance(self.val, numbers.Integral) is False or self.val <= 0:
raise TypeError(f"val must be a positive integer, but was <{self.val}> ({type(self.val).__name__})")
if isinstance(other, numbers.Integral) is False or other <= 0:
raise TypeError(
f"given other arg must be a positive integer, but was <{other}> ({type(other).__name__})"
)
_, rem = divmod(self.val, other)
if rem > 0:
return self.error(
f"Expected <{self.val}> to be multiple of <{other}>, but was not."
)
return self
Wrapping a library that raises¶
When the assertion delegates to a library, the failure is usually caught and folded into the message.
Doing that inside except leaves the caught exception in the traceback, and the reader sees the same
diagnostic twice, once under "During handling of the above exception". Pass suppress_context=True
to drop it:
def is_valid_config(self):
try:
some_library.validate(self.val)
except some_library.ValidationError as exc:
return self.error(
f"Expected a valid config, but it was rejected:\n{exc}",
suppress_context=True,
)
return self
Only pass it when the caught exception's text is already in your message, or carries nothing. Leave it off when the caught exception is the caller's own, as in an assertion about a callable they gave you: there its traceback is the point of the failure, not noise.