🎨

Pythonで画像のビジュアルリグレッションテストを書く

2022/06/13に公開

小ネタです🍣

仕事でDjangoでできたWebアプリケーションの開発をおこなっているので、Pythonのバージョンのアップデートを継続的におこなう必要があります
当アプリケーションは、特定のエンドポイントが期待するレスポンスを返すかとか、共通モジュールが適切に動作するかといったテストがそれなりに書かれており、
普段の機能開発に限らずバージョンアップにおいても重要な役割を果たしていますが、
例えば画像生成のような、OSにインストールされたライブラリへの依存度が高い機能については、それらと異なる観点でのテストが必要になるものと思います

例えば、以下のようなユーザーの公開URLに対して固有のOGP画像を定期的に更新して生成している…といったようなことです

https://lapras.com/public/yktakaha4


TwitterにおけるOGP画像の表示イメージ

画像生成のテストを、画像が生成できるかのような適当なアサーションで済ませていると、
例えばフォントが読み込めなくなってTOFUが表示されてしまっているとか、デザイン崩れが発生しているといったデグレードに気づけません

そのような時に役立つ、ビジュアルリグレッションテストの書き方のメモです✍

作り方

Pillow を用意します🛌
(画像処理をする場合、依存関係で既にインストールされているかもしれませんが)

https://github.com/python-pillow/Pillow

テストを格納するディレクトリには、テストコードに加えて、正解となる画像ファイル expected.png をコミットしておきます
これは、テストを始める段階で実際のコードから出力した内容になります

今回は簡便のためテストケースをひとつだけにしていますが、複数のテストパターンを用意する場合はそれに合わせて格納してください

$ tree .
.
├── .gitignore
├── expected.png
└── test_ogp_creator.py

.gitignore では、テスト実行時に出力される actual.pngdiff.png をignoreします

actual.png
diff.png

テストコードは以下のようになります

from os.path import dirname, join
from unittest import TestCase
from PIL import Image, ImageChops

# テスト対象のクラス
from hogehoge.ogp import OgpCreator


class OgpCreatorTest(TestCase):
    def test_generate_ogp(self):
        actual_file_path = join(dirname(__file__), 'actual.png')
        expected_file_path = join(dirname(__file__), 'expected.png')
        diff_file_path = join(dirname(__file__), 'diff.png')

        params = {
          # 省略
        }

        ogp_creator = OgpCreator()
        result = ogp_creator.generate_ogp(params)

        self.assertIsNotNone(result)

        # actual.png を保存
        with open(actual_file_path, 'wb') as f:
            f.write(result)

        # expected.png と actual.png を比較する
        actual_image = Image.open(actual_file_path)
        expected_image = Image.open(expected_file_path)

        diff = ImageChops.difference(actual_image, expected_image)
        if diff.getbbox():
            # 差分ありの場合は、 diff.png を出力しエラーとする
            diff.save(diff_file_path)
            self.fail(msg=f'{actual_file_path}{expected_file_path} を比較し、差分を検知しました。'
                          f'{diff_file_path} を確認し、'
                          f'想定された差分の場合は expected.png を actual.png で上書きしてください')

正答である expected.png と、テスト環境で今回生成した actual.png について ImageChops.difference() でピクセル単位の差分を抽出した後、 Image.getbbox() で画像のバウンディングボックス を取得します
両画像で差分が全く無かった場合は画像が空ファイルとなり、その時のバウンディングボックスは None が返却される仕様のため、if文で簡単に確認できます🍮

https://pillow.readthedocs.io/en/stable/reference/ImageChops.html#PIL.ImageChops.difference

https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.getbbox

差分があった場合はその画像をファイル出力すれば、具体的にどこが変わったか確認できます👁

動作

テスト実行時のイメージを示します
今回は、テストが落ちた時の状況を示したいため、以下のようにわざとグレーの四角形で一部を塗りつぶした画像を expected.png として配置してみます


expected.png

テストを実行してみます

$ python test_ogp_creator.py

(略)

F
======================================================================
FAIL: test_generate_ogp (test_ogp_creator.OgpCreatorTest)
----------------------------------------------------------------------

(略)

AssertionError: /xxx/actual.png と /xxx/expected.png を比較し、差分を検知しました。/xxx/diff.png を確認し、想定された差分の場合は expected.png を actual.png で上書きしてください

----------------------------------------------------------------------
Ran 1 test in 0.317s

FAILED (failures=1)
Preserving test database for alias 'default'...

actual.png は以下のようになりました
今回は、これが本来想定した問題ない出力結果であるとします


actual.png

expected.pngactual.png の差分を表現する diff.png は以下でした
グレーに塗りつぶした部分が差分として表示されており、正しく差分検知ができていることがわかります


diff.png

目視の結果、 actual.png が正しい結果であることが分かっているため、メッセージで示された通りに expected.png を上書きします
再度テストを実行すると、今度は差分がないため正常終了します🍕

$ cp actual.png expected.png
cp: 'expected.png' を上書きしますか? y

$ python test_ogp_creator.py
.
----------------------------------------------------------------------
Ran 1 test in 0.252s

OK

そんだけ😌

Discussion