Home > Software design >  Python Unit Test to Assert Type Annotation of Object
Python Unit Test to Assert Type Annotation of Object

Time:01-31

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.

  • Related