In Python versions <3.11 where the assert_type
(source) isn't available, how does one assert a type annotation via the unittest
TestCase
class? Example of the problem:
from typing import List
from unittest import TestCase
def dummy() -> List[str]:
return ["one", "two", "three"]
class TestDummy(TestCase):
def test_dummy(self):
self.assertEqual(
List[str],
type(dummy())
)
The test fails with the following output:
<class 'list'> != typing.List[str]
Expected :typing.List[str]
Actual :<class 'list'>
<Click to see difference>
Traceback (most recent call last):
File "C:\Users\z\dev\mercata\scratch\mock_testing.py", line 12, in test_dummy
self.assertEqual(
AssertionError: typing.List[str] != <class 'list'>
The approach I currently use is as follows:
data = dummy()
self.assertTrue(
type(data) == list
)
self.assertTrue(all([
type(d) == str for d in data
]))
This works but requires iterating the entirety of the object which is unwieldy with larger datasets. Is there a more efficient approach for Python versions <3.11 (not requiring a third-party package)?
CodePudding user response:
assert_type
is used to ask a static type checker to confirm a value is of some type. At normal runtime this method doesn't do anything. If you want to use it, then you should use static analysis tooling, for example mypy or pyright. Checking assertEqual
is a runtime operation, and unlike some languages, instances of generics in python do not retain their type info at runtime, which is why the class is being shown as the standard <class 'list'>
and not the generic one from the method type annotation.
Because assert_type
doesn't perform anything at runtime, it will not check for the contents of the actual list. It is used to add an explicit typecheck into the code, and only useful if all of the inputs for how a variable was constructed have been properly type checked as well. So it would also not be useful within unit testing as you have it.
For example, the following script only produces one error:
from typing import assert_type
def dummy() -> list[str]:
return [1]
res = dummy()
assert_type(res, list[str])
(venv) $ mypy test.py
test.py:4: error: List item 0 has incompatible type "int"; expected "str" [list-item]
Found 1 error in 1 file (checked 1 source file)
This detects the error of an int list being returned by dummy
, but the assert_type
succeeds because it would be correct if dummy
had respected its contract.
If we fixed dummy like below, then at this point we would get the expected assert_type
error:
from typing import assert_type
def dummy() -> list[int]:
return [1]
res = dummy()
assert_type(res, list[str])
(venv) $ mypy test.py
test.py:7: error: Expression is of type "List[int]", not "List[str]" [assert-type]
Found 1 error in 1 file (checked 1 source file)
CodePudding user response:
While I agree with the general sentiment the commenters have expressed that this type of thing should probably be left to static type checkers rather than unit tests, just for academic purposes, you can construct your own assertion without too much effort.
Something like list[str]
is a specified version of the generic type list
. By subscripting a type like like list
, you are actually calling its __class_getitem__
method, which returns the specified type. The type argument is actually stored and the typing module provides the get_args
/get_origin
functions to extract more detailed type information from generic types at runtime.
from typing import get_args
print(get_args(list[str])) # (<class 'str'>,)
The problem is more that any concrete list
object (like ["one", "two", "three"]
) does not store any information about the type of the items it holds (for obvious reasons). This means, at runtime, we would have to check the type of the elements ourselves.
The question thus becomes how pedantic you want your check to be. Lists for example can be as long as you want them to be (or as your memory allows). If you a list object of a million elements, do you really want to check each and every one of them? A possible compromise might be to only check the very first element's type or something like that.
Here is an example of a function checking arbitrary iterable types that are parameterized by "regular" types only (i.e. not something like list[tuple[int]]
):
from collections.abc import Iterable
from types import GenericAlias
from typing import Union, cast, get_origin, get_args
def is_of_iter_type(
value: object,
type_: Union[type[Iterable[object]], GenericAlias],
pedantic: bool = False,
) -> bool:
if isinstance(type_, type): # something like unspecified `list`
return isinstance(value, type_)
if isinstance(type_, GenericAlias): # a specified generic like `list[str]`
origin, args = get_origin(type_), get_args(type_)
if not isinstance(origin, type) or not issubclass(origin, Iterable):
raise TypeError
arg = cast(type, args[0])
if not isinstance(arg, type): # type arg is a type var or another generic alias
raise TypeError
if not isinstance(value, origin):
return False
if pedantic:
return all(isinstance(item, arg) for item in value)
else:
return isinstance(next(iter(value)), arg)
raise TypeError
Note also that depending on what iterable you actually pass to this function, it may be a terrible idea to (try to) consume the resulting iterator (via next
or all
). It would be up to you to ensure that this does not have any bad side effects.
Here is a demo:
print(is_of_iter_type("a", list[str])) # False
print(is_of_iter_type(["a"], list[str])) # True
print(is_of_iter_type(["a"], list)) # True
print(is_of_iter_type(["a", 1], list[str])) # True
print(is_of_iter_type(["a", 1], list[str], pedantic=True)) # False
To incorporate it into a unittest.TestCase
you could do this:
...
from unittest import TestCase
class ExtendedTestCase(TestCase):
def assert_is_of_iter_type(
self,
value: object,
type_: Union[type[Iterable[object]], GenericAlias],
pedantic: bool = False,
) -> None:
if not is_of_iter_type(value, type_, pedantic=pedantic):
self.fail(f"{value} is not of type {type_}")
def test(self) -> None:
self.assert_is_of_iter_type(["a", 1], list[str], pedantic=True)
But again, this is most likely not a good idea because something like mypy
in --strict
mode will probably do a better job at ensuring type safety throughout your code than you could hope to do at runtime. Meaning if you declare def dummy() -> list[str]: ...
, but the in the function body you return ["a", 1]
, then mypy
will pick that up and yell at you. Thus, there would be no need for such a test.