iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🛡️

Mocking Functions with Type Hints in Python

に公開

“I want to add type hints to test code as well, without exception.”

With that thought in mind while writing Python, I got curious about whether I could create a mock for a function that has type hints. I managed to create something that seems to work reasonably well, so I'd like to introduce it.

The idea of what I want to achieve looks like this:

function_mock = FunctionMock(args).returns(return_value)
ret = function_mock(value) # Assertion is performed here
use(ret) # The specified return value is returned

Mock Implementation Code

from __future__ import annotations

from typing import Callable, Generic, List, Optional, ParamSpec, TypeVar


Params = ParamSpec('Params')
Return = TypeVar('Return')


class Call(Generic[Params, Return]):
    retval: Optional[Return]

    def __init__(self, *args: Params.args, **kwargs: Params.kwargs) -> None:
        self.args = args
        self.kwargs = kwargs
        self.retval = None

    def returns(self, retval: Return) -> Call:
        self.retval = retval
        return self


class FunctionMock(Callable[Params, Return]):
    calls: List[Call[Params, Return]]
    count: int

    def __init__(self, calls: List[Call[Params, Return]]) -> None:
        self.calls = calls
        self.count = 0

    def __call__(self, *args: Params.args, **kwargs: Params.kwargs) -> Return:
        assert self.count < len(self.calls), f'Too many calls to function: want {len(self.calls)}.'
        call = self.calls[self.count]
        assert args == call.args, f'Argument mismatch: want {call.args}, got {args}'
        assert kwargs == call.kwargs, f'Keyword argument mismatch: want {call.kwargs}, got {kwargs}'
        self.count += 1
        return call.retval

    def close(self):
        assert self.count == len(self.calls), f'Not enough calls to function: want {len(self.calls)}, got {self.count}.'

    def __enter__(self):
        return self

    def __exit__(self, ex_type, ex_value, traceback):
        self.close()
        return ex_type is None

Explanation

A key point when adding type hints to a function mock is that "how to handle argument type hints" is surprisingly tricky. This is because, in Python, you cannot simply compare argument sequences as tuples due to the existence of keyword arguments. Therefore, we will use ParamSpec.

ParamSpec is a class for creating special type parameters to represent function arguments. By defining a type parameter like P = ParamSpec('P'), you can define a function type as Callable[P, R], and you can add type hints to arguments and keyword arguments using the types P.args and P.kwargs. However, there is one constraint: P.args and P.kwargs can only be used as type hints for *args and **kwargs, respectively. In other words, they can practically only be used for the type hints of a function's arguments. Therefore, if you want to use these type hints, you need to find a way to express them skillfully using functions. I will proceed while keeping this point in mind.

Defining Type Parameters

To specify the types of arguments and return values when creating a mock, the argument types are represented with ParamSpec, and the return value type is represented with TypeVar.

Params = ParamSpec('Params')
Return = TypeVar('Return')

Objectifying Function Calls

The behavior required for a function mock consists of two things: "matching the arguments when called" and "returning a value in place of the hidden behavior." As mentioned earlier, since ParamSpec's args and kwargs can only be used in function argument type hints, we use them in the class constructor. Ideally, I wanted to be able to specify the return value in the constructor as well, but since that felt slightly off from the Callable[Params, Return] typing, I added a separate returns method (if there's a better way to avoid this, please let me know).

class Call(Generic[Params, Return]):
    retval: Optional[Return]

    def __init__(self, *args: Params.args, **kwargs: Params.kwargs) -> None:
        self.args = args
        self.kwargs = kwargs
        self.retval = None

    def returns(self, retval: Return) -> Call:
        self.retval = retval
        return self

Mock Body

For the mock body, three behaviors are expected: "being given a list of arguments to match and values to return when called," "verifying that the expected arguments are passed and returning the specified value each time it is called," and "checking whether it was called the specified number of times." FunctionMock implements these straightforwardly.

class FunctionMock(Callable[Params, Return]):
    calls: List[Call[Params, Return]]
    count: int

    def __init__(self, calls: List[Call[Params, Return]]) -> None:
        self.calls = calls
        self.count = 0

    def __call__(self, *args: Params.args, **kwargs: Params.kwargs) -> Return:
        assert self.count < len(self.calls), f'Too many calls to function: want {len(self.calls)}.'
        call = self.calls[self.count]
        assert args == call.args, f'Argument mismatch: want {call.args}, got {args}'
        assert kwargs == call.kwargs, f'Keyword argument mismatch: want {call.kwargs}, got {kwargs}'
        self.count += 1
        return call.retval

    def close(self):
        assert self.count == len(self.calls), f'Not enough calls to function: want {len(self.calls)}, got {self.count}.'

    def __enter__(self):
        return self

    def __exit__(self, ex_type, ex_value, traceback):
        self.close()
        return ex_type is None

Testing the Mock

To demonstrate how to use it, let's write a test with pytest to confirm that the mock works as expected.

def test_function_mock():
    calls = [Call[[int], int](i).returns(i * 2) for i in range(10)]
    with FunctionMock[[int], int](calls) as double_mock:
        for i in range(10):
            double_mock(i)

Conclusion

Function mocks protected by type hints are possible! While I feel I could create a feature-rich mock like unittest.Mock if I really wanted to, as a minimalist, I think this is just about right. I don't have any plans to package it (as of this writing), but depending on the response, I might release it as a small package. Happy type-hinting life.

Discussion