🛡️

Pythonで型ヒントがついた関数のモックを作る

2022/09/13に公開

「テストのコードも例外なく型ヒントをつけたいなあ」

そんな気持ちでPythonを書いていたところ、型ヒントがついている関数のモックを作れないかなという興味からやってみたところなんとなくそれっぽいものができたのでご紹介します。

やりたいことのイメージとしては

function_mock = FunctionMock(引数).returns(返り値)
ret = function_mock() # ここでassertionが走る
use(ret) # 指定した返り値が帰っている

モック本体のコード

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

解説

関数のモックに型ヒントをつける際のポイントとして「引数の型ヒントをどうするか」が意外と曲者です。というのもPythonの関数はキーワード引数の存在によりただ単純に引数列をタプルとして比較できるわけではないのです。そこでParamSpecを使っていきます。

ParamSpec は関数の引数を表現するための特別な型パラメータを作るクラスで P = ParamSpec('P') という型パラメータを定義すると Callable[P, R] と関数の型を定義できたり P.argsP.kwargs という型で引数やキーワード引数に型ヒントをつけることができます。ただし一つだけ制約があって、P.argsP.kwargs はそれぞれ *args**kwargs の型ヒントにしか使用できません。つまり事実上は関数の引数の型ヒントにしか利用することができません。そのためこれらの型ヒントを利用したい場合にはうまく関数を使って表現してあげる必要があります。この点に注意しつつ書いてきます。

型パラメータの定義

モックを作る上で引数と返り値の型を明示したいので引数の型は ParamSpec、返り値の型は TypeVar で表現します。

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

関数呼び出しのオブジェクト化

関数のモックに求められる挙動として「呼び出された時の引数を突き合わせる」ことと「隠蔽した振る舞いに変わって値を返す」ことの2つがあります。先述の通り、ParamSpecargskwargs は関数の引数の型ヒントにしか使用できないためクラスのコンストラクタで使います。本当はコンストラクタで返り値も指定できるようにしたいのですが、ここで Callable[Params, Return] と違う型付けになってしまうのが微妙に感じたので別途 returns メソッドを追加してあります(いい回避方法があったらぜひご教示願いたいです)。

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

モック本体

モック本体の挙動としては3つの挙動が期待されます。「呼び出された際に突き合わせる引数と返す値のリストを与えられる」こと、「呼び出されるたびに期待された引数が渡されていることを確認して指定の値を返す」こと、そして「指定回数だけ呼び出されたかどうかを確認する」こと。FunctionMock はこれらを愚直に実装しています。

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

モックのテスト

使い方のデモを兼ねてpytestでテストを書いてモックが期待通りに動作しているかを確認します。

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)

まとめ

型ヒントで守られた関数のモックは作れる!その気になれば unittest.Mock みたいな高機能なモックも作れる気がしますが、ミニマリストとしてはこのくらいでいいんじゃないかという気がしています。パッケージ化するつもりは(執筆時点では)ないですが、反応次第では小さいパッケージとして公開するかもしません。ハッピー型ヒントライフ。

Discussion