KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

PyConAPACで登壇したスナップショットテストの話の続き

PyConAPAC 2023 で登壇してきました

株式会社カケハシのバックエンドエンジニアの横田です。

二日目(10/28(土))の LT 会で「Python でスナップショットテスト」というタイトルで登壇してきました。

登壇時の写真

PyCon には二日目の途中から参加し、登壇までは主にスポンサーブースを周り、様々な企業様のお話を伺っておりました。
スポンサーブースでは名前を聞いたことのある企業様から失礼ながら初めて聞く企業様まで、様々な企業様がブースを出展されており、事業の内容やどういったところで Python を活用されているのかなど話を聞くことができて、とても楽しかったです。

Python は API 開発・データ分析・機械学習など、さまざまな分野で活用されていることを改めて実感しました。
特に印象に残っているのは、DROBEさんがファッション業界で機械学習とプロのスタイリストの力を組み合わせてお客様に似合う服を提案しているというお話だったり、New Relicさんの機能の話や裏側の DB は独自開発しているよという話だったり、Helpfeelさんの Q&A システムが高速な理由の話だったり・・。二日目だったからか、スポンサーブースは人も少なく、ゆっくりと話を聞くことができました。

今回は、セッションで話を聞くことはできませんでしたが、また参加したいと思いました!
来年は、9 月に PyConJP が開催され、10 月には PyConAPAC がインドネシアで開催されるようですね!

LT 会での内容の続き

今回、「Python でスナップショット」というタイトルで話したのですが、発表準備をする中で削った内容もありましたので、こちらのブログで詳しく書いていきたいと思います。

スナップショットテストとは

改めてスナップショットテストについて説明します。

テストを書くときに、前回実行時の関数の結果をそのままテストの期待値においてテストを実行することをスナップショットテストと呼びます。

Python でもスナップショットテストを簡単に実現できるライブラリがいくつかあり、LT では pytest-snapshot と inline-snapshot の二つについて紹介させていただきました。

それらの違いなどについては、LT のスライドをご覧ください。

スナップショットテストの Tips

スナップショットテストを導入する際に気をつけたいこととして、実行毎に結果が変わるケースに気をつけようと話したのですが、その対処法について紹介させていただきます。

実行毎に結果が変わる関数の例として下記の例で話していきます。

# sample.py
# 以下の関数はdatetime.today()の結果によって結果が変わる
from datetime import datetime
def get_today_text():
    today = datetime.today()
    return today.strftime("今日は%Y-%m-%dです")

こちらの関数は、実行時によって結果が変わるため、スナップショットテストを実行すると、毎回結果が変わってしまい、テストが失敗してしまいます。
例えば、inline-snapshot を使って、テストを実装すると下記のようなります。

# test_sample.py
from sample import get_now_text
from inline_snapshot import snapshot

def test_get_today_text():
    assert get_today_text() == snapshot()

pytest --inline-snapshot=createを実行すると、スナップショットが作成され、下記のようになります。

# test_sample.py
from sample import get_now_text
from inline_snapshot import snapshot

def test_get_today_text():
    assert get_today_text() == snapshot("今日は2023-10-30です")

このテストは、翌日の 2023/11/1 になると落ちてしまいます。
これに対処するには、テスト実行時に固定の日付を返すようにする必要があります。
対応方法を二つほど紹介します

1. patch を使う

一つの方法として、unittest.mock.patch を使って 日付生成関数の中身を置き換えるという方法があります。
注意としては、datetime.today 関数自体の patch は下記のエラーが出てしまうため、関数でラップしてから patch する必要があります。
(もしくはfreezegunというライブラリを使うことができます)

self = <unittest.mock._patch object at 0x101b227d0>, exc_info = (<class 'TypeError'>, TypeError("cannot set 'today' attribute of immutable type 'datetime.datetime'"), <traceback object at 0x102103fc0>)

    def __exit__(self, *exc_info):
        """Undo the patch."""
        if self.is_local and self.temp_original is not DEFAULT:
>           setattr(self.target, self.attribute, self.temp_original)
E           TypeError: cannot set 'today' attribute of immutable type 'datetime.datetime'

上記を避けるための手段を、今回は関数でラップするアプローチで説明します。

元々のファイルを下記のように修正し、

# sample.py
# 以下の関数はdatetime.now()の結果によって結果が変わる
from datetime import datetime

def get_today() -> date:
    return datetime.today()

def get_today_text():
    today = get_today()
    return today.strftime("今日は%Y-%m-%dです")
# test_sample.py
from sample import get_now_text
from inline_snapshot import snapshot
from unittest.mock import patch
from datetime import date

@patch("sample.get_today", lambda: date(2023, 10, 30))
def test_get_today_text():
    assert get_today_text() == snapshot("今日は2023-10-30です")

上記のように、get_today 関数をパッチすることで、テスト実行時に日付を固定できます。

毎回パッチするのが面倒な場合は、pytest の fixture を使って、パッチすることもできます。
conftest.py に下記のように記述することで、テストセッション全体に対してテスト実行時に日付を固定することもできます。

# conftest.py
import pytest
from datetime import date
from unittest.mock import patch

@pytest.fixture(scope="session", autouse=True)
def fixed_datetime():
    with patch("sample.get_today", lambda: date(2023, 10, 30)):
        yield

テストは下記のようになり、patch を書く必要がなくなります。

# test_sample.py
from sample import get_today_text
from inline_snapshot import snapshot

def test_get_today_text():
    assert get_today_text() == snapshot("今日は2023-10-30です")

2. 日付生成関数を DI で渡す

もう一つの方法として、Dependency Injection のパターンを使って日付生成関数を渡す方法があります。

# sample.py
# 以下の関数はdatetime.today()の結果によって結果が変わる
from datetime import datetime, date

class DateGenerator:
    """
    日付を生成するクラス
    """
    @staticmethod
    def get_today() -> date:
        return datetime.today()

def get_today_text(date_generator: DateGenerator) -> str:
    today = date_generator.get_today()
    return today.strftime("今日は%Y-%m-%dです")

上記のように、日付を生成するための関数やクラスを作成し、それを DI で渡すことで、テスト実行時に固定の日付を返すようにすることができます。

# test_sample.py
from sample import DateGenerator, get_today_text
from inline_snapshot import snapshot

class MockDateGenerator(DateGenerator):
    """
    日付を生成するクラス
    """
    @staticmethod
    def get_today() -> date:
        return date(2023, 10, 30)

def test_get_today_text():
    assert get_today_text(MockDataGenerator) == snapshot("今日は2023-10-30です")

型を守るなら DateGenerator を継承した MockDateGenerator を作成し、get_today をオーバーライドすることで、日付を固定した DateGenerator を使ってテストを実行することができます。
型を守らなくても良いなら、ダックタイピングを使って、get_today()を持つオブジェクトを渡すことで、テスト実行時に固定の日付を返すようにすることができます。

弊社の場合、ETL 処理などで日付を扱うことが多いので、日付を生成する関数を DI で渡すことで、テスト実行時に固定の日付を返すようにしています。

# etl.py
from datetime import datetime
import pandas as pd

class DataExtractor:
    def __init__(self, conn):
        self.conn = conn

    @staticmethod
    def get_today() -> date:
        return datetime.today()

    def get_df_from_table(self, table_name: str) -> DataFrame:
        """
        テーブル名を受け取り、そのテーブルのデータを返す
        """
        return pd.read_sql(
            f"SELECT * FROM {table_name} where created_at > '{DataExtractor.get_today()}'",
            self.conn
        )

def transform(data_extractor: DataExtractor) -> DataFrame:
    ## 何かしらの集計処理
# test_etl.py
from etl import DataExtractor, transform
from inline_snapshot import snapshot

class MockDataExtractor(DataExtractor):
    """
    日付を生成するクラス
    """

    @staticmethod
    def get_today() -> date:
        return date(2023, 10, 30)

    def get_df_from_table(self, table_name: str) -> DataFrame:
        return pd.DataFrame([])

def test_transform():
    assert transform(MockDataExtractor).to_csv() == snapshot()

上記のように ETL の Extract 部分を担うクラスを作って、それを DI で渡すことで、テスト実行時に固定の日付を返すようにしています。

スナップショットテストがある時の開発サイクル

基本的にテストを書くときは、テストを書いてから実装をするという流れになりますが、スナップショットテストの環境がある場合は実装しながらテストを作ることができます。

スナップショットテストがない環境での開発サイクルを TDD で行うとすると、下記のようになります。

graph LR
    テストを書く --> 仮実装する --> テストを実行し失敗させる --> 実装する --> テストを実行し成功する -->|YES| 完成
    テストを実行し成功する -->|NO| 実装する

スナップショットテストの環境がある場合の開発サイクルは下記のようにできます。

graph LR
    仮実装する --> スナップショットを生成する --> スナップショットを手で理想の状態に修正する --> テストを実行し失敗させる --> 実装する --> テストを実行し成功する -->|YES| 完成
    テストを実行し成功する -->|NO| 実装する

このフローの良いところは、テストを書く手間が減ることです。関数の返り値が明確であればテストから書く方が早いですが、実装を進めながら返り値のインターフェースを考えるという時などは、仮実装を先にやりつつ返り値も確認しながら実装を進められるのが便利です。特に、当初のクラス設計に設計漏れがあった場合など、関数の役割や返り値のインターフェースが変わってしまった場合の手戻りのコストが少なくて済むようになります。

また、スナップショットの更新だけで理想の期待値にするフローも便利です。

graph LR
    仮実装する --> スナップショットを作成する --> 実装する --> スナップショットを更新する --> スナップショットが期待の値になる -->|YES| 完成
    スナップショットが期待の値になる -->|NO| 実装する

例えば集計関数などの場合は期待値を作るのが大変なので、関数の入力だけしっかり考えて関数の出力はぼんやり考えた状態で実装を始めて、ひたすら更新モードでテストを実行してスナップショットが期待する値になるまで実装を続けることをよくやっています。

CI/CD で更新モードは使わずテストを失敗するようにしておけば、回帰バグを防ぐことができます。

インラインスナップショットテストの活用例

登壇では inline-snapshot のライブラリの特徴について parametrized テストも書けると話したのですが、その具体例も紹介します。
parametrized テストは pytest の機能で、同じテストコードを複数のパラメータで実行することができます。
inline_snapshot は parametrized テストでも利用することができ、下記のように利用することができます。

from inline_snapshot import snapshot
import pytest


@pytest.mark.parametrize(
    "num,expected",
    [
        (1, snapshot()),
        (2, snapshot()),
    ]
)
def to_pow2_test(num, expected):
    assert num ** 2 == expected

上記のようにテストを書いて、pytest --inline-snapshot=createを実行すると

from inline_snapshot import snapshot
import pytest


@pytest.mark.parametrize(
    "num,expected",
    [
        (1, snapshot(1)),
        (2, snapshot(4)),
    ]
)
def to_pow2_test(num, expected):
    assert num ** 2 == expected

のように、スナップショットが作成されます。pytest-snapshot の場合はスナップショットを str,bytes で保存するのですが、inline-snapshot の場合は dict や list などのオブジェクトもスナップショットにすることができます。(例えば、pd.DataFrame などのクラスなどはスナップショットにできません)

from inline_snapshot import snapshot
import pytest

@pytest.mark.parametrize(
    "num_dict,expected",
    [
        ([{"a": 1}], snapshot([{"a": 1}])),
        ([{"a": 2}, {"a": 3}], snapshot([{"a": 4}, {"a": 9}])),
    ]
)
def test_to_pow2(num_dict, expected):
    assert [{ k: v**2 for k, v in e.items()} for e in num_dict] == expected

クラスのインスタンスをスナップショットテストする実験を下記のようにしてみましたが、

from inline_snapshot import snapshot
import pytest

class ComplexNumber:
    def __init__(self, real, imaginary):
        self.real = real
        self.imaginary = imaginary

    def __repr__(self):
        return f"ComplexNumber(real={self.real}, imaginary={self.imaginary})"

    def __eq__(self, n):
        return self.real == n.real and self.imaginary == n.imaginary

    def __add__(self, n):
        return ComplexNumber(self.real + n.real, self.imaginary + n.imaginary)


@pytest.mark.parametrize(
    "num,expected",
    [
        (ComplexNumber(1, 1) + ComplexNumber(2, 1), snapshot()),
        (ComplexNumber(3, 1) + ComplexNumber(4, 1), snapshot()),
    ]
)
def test_complex_number(num, expected):
    assert num == expected

下記のようにスナップショットがエラーが出てしまいました。比較する際に snapshot のライブラリのクラスでラップされているため比較に失敗してしまっていそうですね・・。

self = ComplexNumber(real=7, imaginary=2), n = <inline_snapshot._inline_snapshot.Undefined object at 0x109a1b2d0>

    def __eq__(self, n):
>       return self.real == n.real and self.imaginary == n.imaginary
E       AttributeError: 'UndecidedValue' object has no attribute 'real'

test/test_sample.py:40: AttributeError

とはいえ、dict, list でスナップショットテストができるだけでも大分ありがたいですね!

まとめ

PyCon で登壇を聞いて下さった方ありがとうございました!
今回、より具体的にスナップショットテストの活用例を紹介させていただきましたが、改めてになりますが、スナップショットテストはテストを書く手間を減らすことができるので、ぜひ導入してみてください!

(カケハシではノベルティでラムネを配っていました!ラムネを食べながらだとブログを書くのも捗りますね!) ノベルティのラムネ