📝

pytestでスナップショットテスト syrupy

に公開

普段使ってるツールの使ってない機能を使ってみる Advent Calendar 2025です。

syrupyとは

pytestでSnapshotテストを実行するプラグインです。

GitHubリポジトリでは、

Syrupy is a zero-dependency pytest snapshot plugin. It enables developers to write tests which assert immutability of computed results.

と説明されています。

snapshotテストとは

定義や歴史見つけられてないのですが、(1)過去のある時点の実行結果と、(2)コード変更後の実行結果を比較するテストだと思っています。
pytestに限らず、CDKPlaywright等で出てきます。

試してみる

シンプルな場合

適当なテスト対象クラスを作ります。

class Calculator:
    def calculate(self, a, b, op):
        """基本的な計算結果を辞書で返す"""
        result = {
            "+": a + b,
            "-": a - b,
            "*": a * b,
            "/": a / b if b != 0 else None
        }.get(op)

        return {
            "operation": f"{a} {op} {b}",
            "result": result,
            "operands": [a, b],
            "status": "success",  # 新しいフィールドを追加
        }

syrupyを使うには、

  1. testメソッドのパラメータでsnapshotを受け取る
  2. test対象の処理を実行
  3. (1)と(2)を比較

するだけです。

import pytest
from unittest.mock import patch
from calculator import Calculator


@pytest.fixture
def calc():
    return Calculator()


def test_basic_snapshot(snapshot, calc):
    """辞書のスナップショットテスト"""
    result = calc.calculate(10, 5, "+")
    assert result == snapshot

一回目はスナップショットがないので失敗します。

> pytest  -k test_basic_snapshot
============================================================================================== test session starts ==============================================================================================
platform linux -- Python 3.11.11, pytest-9.0.1, pluggy-1.6.0
rootdir: /home/notrogue/project/pytest/pytest_sandbox
configfile: pyproject.toml
plugins: testmon-2.2.0, syrupy-5.0.0
collected 10 items / 9 deselected / 1 selected

test_calculator_snapshots.py F                                                                                                                                                                            [100%]

=================================================================================================== FAILURES ====================================================================================================
______________________________________________________________________________________________ test_basic_snapshot ______________________________________________________________________________________________

snapshot = SnapshotAssertion(name='snapshot', num_executions=1), calc = <calculator.Calculator object at 0x70a83bcf8b10>

    def test_basic_snapshot(snapshot, calc):
        """辞書のスナップショットテスト"""
        result = calc.calculate(10, 5, "+")
>       assert result == snapshot
E       AssertionError: assert [+ received] == [- snapshot]
E         Snapshot 'test_basic_snapshot' does not exist!
E         + dict({
E         +   'operan
E
E         ...Full output truncated (8 lines hidden), use '-vv' to show

test_calculator_snapshots.py:16: AssertionError
-------------------------------------------------------------------------------------------- snapshot report summary --------------------------------------------------------------------------------------------
1 snapshot failed.
============================================================================================ short test summary info ============================================================================================
FAILED test_calculator_snapshots.py::test_basic_snapshot - AssertionError: assert [+ received] == [- snapshot]

--snapshot-updateパラメータを付けて実行すると、その実行の結果を正としてスナップショットを保存します。

pytest  -k test_basic_snapshot --snapshot-update

もう一回実行すると、今度は実行結果とスナップショットが比較出来て(同じ値なので当然)成功します。

 pytest  -k test_basic_snapshot --snapshot-update
============================================================================================== test session starts ==============================================================================================
platform linux -- Python 3.11.11, pytest-9.0.1, pluggy-1.6.0
rootdir: /home/notrogue/project/pytest/pytest_sandbox
configfile: pyproject.toml
plugins: testmon-2.2.0, syrupy-5.0.0
collected 10 items / 9 deselected / 1 selected

test_calculator_snapshots.py .                                                                                                                                                                            [100%]

-------------------------------------------------------------------------------------------- snapshot report summary --------------------------------------------------------------------------------------------
1 snapshot passed.
======================================================================================

スナップショットは__snapshot__ディレクトリに保存されます。

> cat __snapshots__/test_calculator_snapshots.ambr
# serializer version: 1
# name: test_basic_snapshot
  dict({
    'operands': list([
      10,
      5,
    ]),
    'operation': '10 + 5',
    'result': 15,
    'status': 'success',
  })
# ---

変更を検知してみる

スナップショットで変更を検知できること確認するために、足し算を文字列の連結に変えてみます。

            "+": str(a) + str(b),
> pytest  -k test_basic_snapshot
============================================================================================== test session starts ==============================================================================================
platform linux -- Python 3.11.11, pytest-9.0.1, pluggy-1.6.0
rootdir: /home/notrogue/project/pytest/pytest_sandbox
configfile: pyproject.toml
plugins: testmon-2.2.0, syrupy-5.0.0
collected 10 items / 9 deselected / 1 selected

test_calculator_snapshots.py F                                                                                                                                                                            [100%]

=================================================================================================== FAILURES ====================================================================================================
______________________________________________________________________________________________ test_basic_snapshot ______________________________________________________________________________________________

snapshot = dict({
  'operands': list([
    10,
    5,
  ]),
  'operation': '10 + 5',
  'result': 15,
  'status': 'success',
}), calc = <calculator.Calculator object at 0x7f935aa99110>

    def test_basic_snapshot(snapshot, calc):
        """辞書のスナップショットテスト"""
        result = calc.calculate(10, 5, "+")
>       assert result == snapshot
E       AssertionError: assert [+ received] == [- snapshot]
E           dict({
E            ...
E             'operation': '10 + 5',
E         -   'result': 15,
E
E
E         ...Full output truncated (3 lines hidden), use '-vv' to show

test_calculator_snapshots.py:16: AssertionError
-------------------------------------------------------------------------------------------- snapshot report summary --------------------------------------------------------------------------------------------
1 snapshot failed.
============================================================================================ short test summary info ============================================================================================
FAILED test_calculator_snapshots.py::test_basic_snapshot - AssertionError: assert [+ received] == [- snapshot]

検知できてますね。

呼び出しのスナップショット

(たぶん)直接的にはスナップショット対象ではないのですが、下のようにpatchしてcall_args_listをスナップショットして保存すると、呼び出し内容もスナップショットテストできます。

def test_print(snapshot, calc):
    with patch('builtins.print') as mock_print:
        result = calc.print()
        print_calls = [str(call) for call in mock_print.call_args_list]

        assert result == snapshot(name="result")
        assert print_calls == snapshot(name="print")

Discussion