unittest.TestCase provides a number of assert helpers. A number of these perform type narrowing but it is not currently possible to express this with existing type syntax because there is no way to indicate what I’m calling “user-defined assertions” (I’m sure there’s a better/pre-existing term). This results in duplicate code like the following:
def test_foo(self):
foo = optionally_get_thing()
self.assertIsInstance(foo, Foo)
assert isinstance(foo, Foo)
self.assertIn(foo.data, 'value')
assert 'value' in foo.data
self.assertTrue(foo.data['value'])
assert foo.data['value'] is True # not really needed but you get the idea
I would like to propose a number of additional typing primitives to allow us to describe these user-defined assertions. While my ostensible focus would be annotating the unittest (and testtools) stubs, I’m hoping that these are generic enough to have use elsewhere. I’d be happy to explore PEP work, but obviously want to see if this idea is sane first so, please, let me know what you think.
TypeAssertIs[T], TypeAssertIsNot[T]
These would behave similarly to TypeIs but instead of expecting a boolean return value, they would return None on success and raise an exception on error. Like TypeIs, the first positional non-self argument would be narrowed to T. This would allow us to describe assertIsInstance, assertIsNotInstance, assertIsNone and assertIsNotNone.
def assertIsInstance(
self, obj: object, cls: type[_T], msg: object = ...
) -> TypeAssertIs[_T]: ...
def assertNotIsInstance(
self, obj: object, cls: type[_T], msg: object = ...
) -> TypeAssertIsNot[_T]: ...
def assertIsNone(
self, expr: object, msg: object = ...
) -> TypeAssertIs[None]: ...
def assertIsNotNone(
self, expr: _T | None, msg: object = ...
) -> TypeAssertIsNot[None]: ...
Which for anyone not familar with unittest are used at runtime like so:
x: str | int = get_value()
self.assertIsInstance(x, str)
reveal_type(x) # str
x: str | int = get_value()
self.assertIsNotInstance(x, str)
reveal_type(x) # int
y: str | None = maybe_str()
self.assertIsNone(y)
reveal_type(y) # None
y: str | None = maybe_str()
self.assertIsNotNone(y)
reveal_type(y) # str
I believe Eric Traut previously suggested something similar here.
(note: we may also wish to explore adding TypeIsNot[T]to avoid leaving a gap here, though I don’t know how useful this would be in practice)
TypeAssertCondition, TypeAssertNotCondition
This one’s needed for assertTrue and assertFalse. In this case, the first positional non-self argument would be an expression that returns a boolean value.
def assertTrue(
self, expr: object, msg: object = ...
) -> TypeAssertCondition: ...
def assertFalse(
self, expr: object, msg: object = ...
) -> TypeAssertNotCondition: ...
This would give the following behavior:
x: str | int = get_value()
self.assertTrue(isinstance(x, str))
reveal_type(x) # str <-- propagated from isinstance() narrowing
y: str | None = maybe_str()
self.assertTrue(y is not None)
reveal_type(y) # str <-- propagated from is-not-None narrowing
z: Literal["a", "b", "c"] | int = get_val()
self.assertTrue(z == "b")
reveal_type(z) # Literal["b"] <-- propagated from equality narrowing
I suspect this one would be a lot trickier to implement and my understanding of type logic and the mypy/pyright/ty/pyre/… codebases is not strong enough to know if it’s actually feasible or not, but I expect this to be very powerful.
Additional, nice-to-haves
TypeAssertSubclass[T], TypeAssertNotSubclass[T]
These handle assertIsSubclass, assertIsNotSubclass. This behaves similar to TypeAssertIs but it works with two types instead of an instance and a type.
def assertIsSubclass(
self, cls: type, superclass: type[_T], msg: object = ...
) -> TypeAssertSubclass[_T]: ...
def assertIsNotSubclass(
self, cls: type, superclass: type[_T], msg: object = ...
) -> TypeAssertNotSubclass[_T]: ...
This would give the following behaviour:
klass: type = get_class()
self.assertIsSubclass(klass, Base)
reveal_type(klass) # type[Base]
Unlike the above, I don’t know how much value this would give for non-unittest users, though it certainly would be nice to avoid the aforementioned duplication of statements.
TypeAssertIn, TypeAssertNotIn
These handle assertIn, assertNotIn. The first non-self positional argument is the member and the second is the container. This would be helpful outside of unittest cases to e.g. distinguish between classes via a discriminated union.
def assertIn(
self, member: object, container: Container[_T], msg: object = ...
) -> TypeAssertIn: ...
def assertNotIn(
self, member: object, container: Container[_T], msg: object = ...
) -> TypeAssertNotIn: ...
With usage like so:
x: str = get_key()
VALID: Final = ("alpha", "beta", "gamma")
self.assertIn(x, VALID)
reveal_type(x) # Literal["alpha"] | Literal["beta"] | Literal["gamma"]
status: str | int = get_value()
self.assertIn(status, [200, 404, 500])
reveal_type(status) # Literal[200] | Literal[404] | Literal[500]