qtatsuの週報

Python/Django/TypeScript/React/AWS

【Python】並び順を無視してlistの要素を比較する方法3つ【sort, assertCountEqual, deepdiff】

(※ qiitaに書いた記事の、削る前バージョンです)

【Python】並び順を無視してリストを比較するテスト(DeepDiff) - Qiita

結論: deepdiffを使う

実現したい条件は以下の二つ.

  1. 辞書を要素として持つリストがあり、「同じリスト」かを比べたい.
    • ただし並び順は異なっていても良いこととする。
  2. 辞書のあるvalue(下例ではspells)もリストとなっている。
    • こちらの要素も並び順は異なっていても良いこととする。
>>> dict_in_list1
[
    {'name': 'Reimu', 'spells': ['Musouhuin', 'niju-kekkai']},
    {'name': 'Marisa', 'spells': ['non-directional laser', 'star-dust reverie']},
    {'name': 'Alice', 'spells': ['hourai-doll', 'shanghai-doll']}
]

>>> dict_in_list2
[
    {'name': 'Marisa', 'spells': ['star-dust reverie', 'non-directional laser']},
    {'name': 'Reimu', 'spells': ['Musouhuin', 'niju-kekkai']},
    {'name': 'Alice', 'spells': ['hourai-doll', 'shanghai-doll']}
]

DeepDiffを使うと、以下のようにして同じデータであるかを比較できる.

pytest

assert not DeepDiff(dict_in_list1, dict_in_list2, ignore_order=True)

unittest

self.assertEqual(DeepDiff(dict_in_list1, dict_in_list2, ignore_order=True), {})

前書き

テストコードを書いていると、assert部分が巨大になってしまい、見通しや目的の把握が難しくなってくることがあります。

もちろん、なるべくテストを小さい単位で書く、クリティカルな部分のみチェックするなどの工夫をすることが第一ではあります。

しかし、APIの返り値やバッチ処理の結果などを、そのまま確かめたい..という以下のようなケースもあると思います.

  • ある程度のデータの組みが揃うと意味の通るデータになるもの.
  • テストを書きながら開発/リファクタしており、現在の返り値が壊れていないことを、リファクタ中に確かめるテストをサッと用意したい.

このようなケースでは、オブジェクトをそのまま比較したくなりますが、リストの要素を並び順を無視して比較するときは、要素の型によってとても難しくなってしまいます。

今回は、DeepDiffの他、sortする方法やassertCoutEqualメソッドを用いた方法も紹介したいと思います.

参考リンク

GitHub - seperman/deepdiff: Deep Difference and search of any Python object/data.

unittest --- ユニットテストフレームワーク — Python 3.10.0b2 ドキュメント

環境

バージョン
MacOS Big Sur 11.6
Python3 3.9.1
deepdiff 5.7.0

文字列のリスト: sortをつかう.

同僚の方はsortをよく使うとお聞きしました。

見た目にも何をやっているか分かりやすく、シンプルにかけるため、可能な限りこちらを使うべきだと思います .

文字列など、ソートできる(つまり<演算子で大小を比べることができる、__lt__が定義されている)場合は簡単です.

以下のふたつのリストを比較します。

含まれている要素は同じですが、順番が異なっていることに注意します。(同じ結果だと判定したい.)

names1 = ["Reimu", "Alice", "Marisa"]
names2 = ["Reimu", "Marisa", "Alice"]

リストの比較は要素を頭から順番に比較するので、そのままではダメです.

>>> names1 == names2
False

ソートします。

>>> sorted(names1) == sorted(names2)
True

辞書のリスト: keyを指定してソートする.

今度の例は辞書(dict)がリストの要素として並んでいます。

先ほどと似ていますが、今回はそのままではソートできません。

今回の例も、含まれている要素は同じですが、順番が異なっていることに注意します。(同じ結果だと判定したい.)

names1 = [
    {"name": "Reimu"},
    {"name": "Marisa"},
    {"name": "Alice"},
]
names2 = [
    {"name": "Alice"},
    {"name": "Reimu"},
    {"name": "Marisa"},
]

まず、そのまま比較したときはFalseとなります。(並び順は異なっていてもよいので, 実際はTrueだと判定したい)

>>> names1 == names2
False

dict同士は<の挙動が定義されていないのでsortできません。

>>> sorted(names1)

TypeError: '<' not supported between instances of 'dict' and 'dict'

この場合、それぞれのdictが必ずname属性をもち、重複しないならば、keyを指定することでソートすることが可能です.

keyを指定すると、key: nameのvalueの値で比較してソートすることになります。

>>> sorted(names1, key=lambda x: x["name"])
[
    {'name': 'Alice'},
    {'name': 'Marisa'},
    {'name': 'Reimu'}
]

keyに渡した関数lambdaの、引数xに各dictが順番に渡されます。そして、dictのnameキーにあたるvalueが取り出されて比較に使われるイメージです。

結果、順番に関係なく同じ要素を含んでいることが確かめられました.

>>> sorted(names1, key=lambda x: x["name"]) == sorted(names2, key=lambda x: x["name"])
True

keyで取得する値はタプルになっても良いので、nameキーだけではソートできない場合も対応できると思います(未検証)。

しかし、テストコードでの使用を想定している場合、結果の比較のために複雑なソート条件を書くことは慎重に考えた方が良いと思います。

辞書のリスト: assertCountEqualを使う.

このようなケースで、Python標準のツールにはもう一つ強力なものがあります。

unittestモジュールで、TestCaseクラスに実装されているassertヘルパーメソッドのひとつ、assertCountEqualです。

名前の印象とはかなり異なりますが、「順番によらず同じ要素が同じ数だけある」ことを検証できるassertメソッドとなっています。

unittest --- ユニットテストフレームワーク — Python 3.10.0b2 ドキュメント

unittestを使っている場合には、self.assertCountEqualを呼び出せば良いだけですので、今回はpytestなどからも使用できるよう、TestCaseをインスタンス化して使う手順を示します.

比較するのは、先ほどkey指定してソートしていたdict in listです。

names1 = [
    {"name": "Reimu"},
    {"name": "Marisa"},
    {"name": "Alice"},
]
names2 = [
    {"name": "Alice"},
    {"name": "Reimu"},
    {"name": "Marisa"},
]
>>> from unittest import TestCase
>>> case = TestCase()
>>> case.assertCountEqual(names1, names2)  # OK!!

とても楽ですね!

公式ドキュメントに仕組みについて説明がありますが、ほとんどの組み込み型は何も意識せずに比較できます。自分の場合も、大抵の場合はsortとassertCountEqualのどちらかで事足りています。

assertEqual() メソッドは、同じ型のオブジェクトの等価性確認のために、型ごとに特有のメソッドにディスパッチします。これらのメソッドは、ほとんどの組み込み型用のメソッドは既に実装されています。さらに、 addTypeEqualityFunc() を使う事で新たなメソッドを登録することができます.

難点は今回の目的では、メソッド名称が不自然になる 点かと思います。

この名前は、仕組み自体をとてもよく表しています。出現したオブジェクトを、(collectionモジュールの)Counterを使って数え上げているためです。

なので「順番を気にせずリストを比較したい!」というのは、可能ではあるのですが本来の使い方とはちょっとずれているのかな、と思います。

多分ですが、assertCountEqualの本来の使い方は、下のようなケースだと思います。

>>> fruits1 = ["りんご", "みかん", "みかん", "りんご", "りんご"]
>>> fruits2 = ["りんご", "みかん", "みかん", "りんご", "みかん"]
>>> case.assertCountEqual(fruits1, fruits2)

AssertionError: Element counts were not equal:
First has 3, Second has 2:  'りんご'
First has 2, Second has 3:  'みかん'

うーん、エラーメッセージも分かりやすいですね...!!!

valueにリストをもつ辞書のリスト: DeepDiffを使う

リストの比較は、大抵はsortedとassertCountEqualで可能かと思います。

というより、これ以上複雑な比較をするならそもそもテストの構成や比較の仕方を考え直した方がいいかと思います。

しかし、冒頭にも書きましたが、APIの返り値などを「実際に叩いてみて」とった値をそのままテストに使いたいというシーンが時々あります。

使い捨てのスクリプトをデグレしないように修正するときの一時的なテストコードを作る時などには、自分はこのようなassertを書きたくなります。

この場合、「リストの要素が辞書」かつ、「辞書のvalueにもリスト」があり、そのリストの順番も無視して要素が一致しているか確認したいケースがあります。

例を出すと、こんな感じです。

>>> dict_in_list1
[
    {'name': 'Reimu', 'spells': ['Musouhuin', 'niju-kekkai']},
    {'name': 'Marisa', 'spells': ['non-directional laser', 'star-dust reverie']},
    {'name': 'Alice', 'spells': ['hourai-doll', 'shanghai-doll']}
]

>>> dict_in_list2
[
    {'name': 'Marisa', 'spells': ['star-dust reverie', 'non-directional laser']},
    {'name': 'Reimu', 'spells': ['Musouhuin', 'niju-kekkai']},
    {'name': 'Alice', 'spells': ['hourai-doll', 'shanghai-doll']}
]

上の2つのリストは、要素であるdictの順番が入れ替わっています。

さらに、name: Marisaの項目を見ると、spells要素はリストなのですが、下に抜き出したように順番が逆になっています。

'spells': ['non-directional laser', 'star-dust reverie']
'spells': ['star-dust reverie', 'non-directional laser']

これも含め、同一の物として判定したいです。

ちなみにassertCountEqualを使うと、以下のように異なる要素だと判定されてしまいます。

>>> case.assertCountEqual(dict_in_list1, dict_in_list2) 

AssertionError: Element counts were not equal:
First has 1, Second has 0:  {'name': 'Marisa', 'spells': ['non-directional laser', 'star-dust reverie']}
First has 0, Second has 1:  {'name': 'Marisa', 'spells': ['star-dust reverie', 'non-directional laser']}

このようなケースでも同じオブジェクトだと一発で判定できるサードパーティ製のライブラリがあります。それがdeepdiffです。

本来もっと多機能なのですが、今回はテストという観点のみから記述します.

導入はpipで簡単に行えます.

$ pip install deepdiff

今回のケース(リスト部分の順番を無視して同一かを判定)での使用は、以下のようにignore_order=Trueとしておこないます。

DeepDiff(dict_in_list1, dict_in_list2, ignore_order=True)
{}  # 空のdeepdiff.diff.DeepDiffオブジェクトが返ってくる.

あとは冒頭で示したように、assert文やassertメソッドで判定すればOKです。

なお、DeepDiffは差分がある場合には、どのキーのどの要素が、どんなふうに異なっているかを示してくれます。

>>> dict_in_list3 = [
        {"name": "Marisa", "spells": ["star-dust reverie", "non-directional laser"]},
        {"name": "Reimu", "spells": ["niju-kekkai"]},
    ]
    
>>> DeepDiff(dict_in_list1, dict_in_list3, ignore_order=True)
{
    'iterable_item_removed': {
        "root[0]['spells'][0]": 'Musouhuin',
        'root[2]': {
            'name': 'Alice',
            'spells': ['hourai-doll', 'shanghai-doll']
         }
     }
}

dict_in_list3で削除された情報が、階層情報とともに表示されました.

結論

  1. 可能な限りsortedでソートして比較する.
  2. sort条件が複雑になるなら、assertCountEqualメソッドの使用も検討する.
  3. もっと難しい状況ではDeepDiffをignore_order=Trueとして使うこともできる.

まとめ

順番によらず、同じ要素を持つリストであるかを検証する方法を3つ紹介しました。

もちろん、そもそも比較しにくいものを比較せずに済むテストが書けるならその方がよいです。あまり乱用すると、返って読み辛いテストになってしまうかもしれません。

しかし特定の文脈では、今回紹介したような方法を試すのも選択肢に入れても良いのではないでしょうか.

他にもいい方法、自分ならこうするよ!などのご意見いただけると嬉しいです。