pytestの辞書比較アダプタRequiredDict
PythonのUnitTestのデータ比較を楽にして、pytestの出力を見やすくする
動機
辞書のデータのうち必要なキーだけをガッと比較したい
assert 文をキーの数だけ用意すれば実現できるが、もうちょっと楽をしたい
比較した結果はpytestのdict比較のように見やすく表示したい
もうちょっと具体的に
構造化ログ(structlogを使用)の辞書データをテストしたい
APIのレスポンスでも似たニーズはあるかもしれない
そういった辞書データは多くのキーや入れ子構造のデータを持っている
テストではテストしたいキーだけ確認したいが、assert文をたくさんかくのは面倒
code:test.py
msg = caplog.records0.msg
assert msg"event" == "action log",
assert msg"request" == "POST /"
assert msg"message" == f"商品 id={resp.data'id'}, name='テスト' を登録しました。"
assert msg"parameters" == {"name": "テスト", "amount": 1234, "owner_id": 1}
これだと、4つのassertを行うため2つの問題がある。
期待するデータ構造(右辺)が4行に分散してしまい、把握しづらい。
エラーが出たとき、1つめを直して再実行したら次の行もエラーになる、など1回で把握できない
そこで、↓こうしたい
code:test.py
required = {
"event": "action log",
"request": "POST /",
"message": f"商品 id={resp.data'id'}, name='テスト' を登録しました。",
"parameters": {"name": "テスト", "amount": 1234, "owner_id": 1},
}
assert caplog.records0.msg == required
しかし、構造化ログのデータには他にも多くのキーが含まれていて、requiredに全部持たせるのは現実的ではない(ログの時刻などmockして比較するのは面倒すぎる)し、そういう値はテストの対象外。
↓のようにassertできるwrapperクラスが欲しい
code:test.py
assert caplog.records0.msg == RequiredDict(required)
RequiredDictのpytestの実行結果:
code:python
>> assert caplog.records0.msg == RequiredDict(required)
E AssertionError: assert {'event': 'us...ews', ...} == {'event': 'us..., 'extra': {}}
E Omitting 6 identical items, use -vv to show
E Differing items:
E {'parameters': {'amount': 1234, 'owner_id': 1, 'name': 'テスト'}}
!= {'parameters': {'amount': 12345, 'owner_id': 1, 'name': 'テスト'}}
E {'message': "商品 id=6, name='テスト' を登録しました。"}
!= {'message': "商品 id=1, name='テスト' を登録しました。"}
Omitting 6 identical items とあるように、valueが一致しているデータは表示が省略されている。差分のある parameters と message の2キーはと合わせて8キーが比較されている。 required には4キーしか指定していないので、未指定の4キー分はRequiredDictがうまく吸収してくれている。
実装コード
code:testing.py
class RequiredDict(dict):
"""required dataset for pytest assert comparer
**注意**: selfの辞書データは比較実行時に書き換わるため、RequiredDictインスタンスによる
比較は1度しか行えません。
"""
def __contains__(self, actual_dict: dict) -> bool:
return self.test(actual_dict)
def __eq__(self, actual_dict: dict) -> bool:
return self.test(actual_dict)
def test(self, actual_dict: dict) -> bool:
"""selfのkey,value全てがactual_dictと一致する場合Trueを返す
* self: 必須のkeyとvalueを持つ辞書
* actual_dict: テスト対象のデータ
"""
required_dict = self
required_keys = set(required_dict)
actual_keys = set(actual_dict)
# required_keys ではないactual_dict側の値をselfにコピーし、pytestの結果を見やすくする
# refs: https://github.com/pytest-dev/pytest/blob/6247a95/src/_pytest/assertion/util.py#L388
for key in actual_keys - required_keys:
required_dictkey = actual_dictkey
required_keys = set(required_dict)
# キーの過不足があれば、actual_keysが不足しているため、偽とする
if actual_keys != required_keys:
return False
# キーは一致しているはずなので、値を確認する。
# 再帰を防ぐため、生dictで確認する
return actual_dict == dict(required_dict)
コード解説
class RequiredDict(dict) でdictを継承しているのは、pytestにdictのassert表示をしてもらうため
_pytest.assertion.util:_compare_eq_dict
https://github.com/pytest-dev/pytest/blob/6247a95/src/_pytest/assertion/util.py#L388
pytestは型ごとにassert時の表示を調整している
https://github.com/pytest-dev/pytest/blob/6247a95/src/_pytest/assertion/util.py#L178
class, sequence, set, dict の調整表示の関数が用意されている
pytest の assertrepr_compare で出力の比較を行っている
https://github.com/pytest-dev/pytest/blob/6247a95/src/_pytest/assertion/util.py#L138
この比較間数がprivateなので、pytest pluginを作ったときにこの便利な出力を利用出来ない
#4806 Make assertrepr_compare public というissueも上がっている
独自のassert出力を定義する方法
How to write and report assertions in tests — pytest documentation
最初はこの仕組みに乗っかろうとおもったけど、pytestのdict assert実装を流用できないので、dictを継承したwrapperクラスを用意する方向で実現した。