スナップショットテストを導入してアプリケーション保守性を向上させよう

記事タイトルとURLをコピーする

本記事はサーバーワークス Advent Calendar 2023 の 13日目のエントリーです。

qiita.com

はじめに

最近、AWS CDK を使った開発をしているのですが、AWS CDK のテスト手法としてスナップショットテストと呼ばれる手法があり、これを AWS CDK 以外の開発にも応用できそうだなと着想を得たのが本記事を書くきっかけです。

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

ChatGPT によると以下のようなテスト手法を指します。

スナップショットテストは、プログラムやコードのテスト手法の一つです。このテストでは、特定の時点でのアプリケーションの出力や状態を「スナップショット」として記録し、その後のアプリケーションの変更が期待通りの結果をもたらしているかを確認します。

AWS CDK の場合、AWS リソースをプログラミング言語で記述した後、Synthesize(合成)と呼ばれる操作にて CloudFormation に変換するような流れをとります。変換した CloudFormation テンプレートをスナップショットとして Git 管理しておき、次回以降の変換結果と比較することでレグリッションの確認、あるいは意図した変更ができていることを確認することができます。

スナップショットテストで差分を検知する様(AWS Lambda のタイムアウト値を変更)

AWS CDK の他にも、フロントエンド開発の領域でコンポーネントのスナップショットテストを実施していることを観測していました。

他の領域でも使えるのでは

筆者は上述の AWS CDK を使った開発の他にも、Python を使ったサーバーサイド・バッチ処理等の開発にも従事しており、この領域にもスナップショットテストが活きる場面があると考えました。例えば以下のようなアプリケーションです。

説明のため非常に簡単な例としていますが、ファイルを入力し加工して出力するようなアプリケーションです。このようなアプリケーションを保守していく場合、以下のような点を注意する必要があります。

  • 仕様変更時、変更していない部分に影響がないことを担保する必要がある
  • ランタイムや依存ライブラリのアップデート時、出力結果が変わらないことを担保する必要がある

出力ファイルが別プログラムの入力となる等、確実に同一性を担保する必要がある場合は特に注意が必要です。

ファイル出力プログラムにスナップショットテストを導入する

以下のようなプログラムにスナップショットテストを導入してみます。json ファイルを入力とし、その内容を基に csv ファイルを出力するアプリケーションです。

from pathlib import Path
import json
from csv import DictWriter


def json_to_csv(input_json_path: Path, output_csv_path: Path):
    with open(input_json_path, "r") as infile:
        json_data = json.load(infile)
        users = json_data["users"]
        with open(output_csv_path, "w") as outfile:
            writer = DictWriter(outfile, fieldnames=users[0].keys())
            writer.writerows(users)

if __name__ == "__main__":
    json_to_csv(Path("input.json"), Path("output.csv"))

以下のような json ファイルを入力とします。

{
  "users": [
    { "name": "John", "age": 22, "height": 1.75 },
    { "name": "Peter", "age": 30, "height": 1.8 },
    { "name": "Mary", "age": 25, "height": 1.65 }
  ]
}

出力結果は以下の通りになります。

John,22,1.75
Peter,30,1.8
Mary,25,1.65

今回はPython のスナップショットテストライブラリとして、pytest のプラグインとして使用可能な pytest-snapshot を使用します。

前述のプログラム内の json_to_csv に対するスナップショットテストは以下の通りです。

from src.main import json_to_csv
from pathlib import Path


# pytest-snapshot を導入すると `snapshot` という名前の fixture が利用可能
def test_json_to_csv(snapshot, tmp_path):
    output_csv_path = Path(tmp_path / "output.csv")
    # テスト対象メソッドの実行
    json_to_csv(Path("input.json"), output_csv_path)

    # スナップショットファイルの出力先を指定
    snapshot.snapshot_dir = Path("tests/snapshots")
    # スナップショット差分の有無を assert
    # tests/snapshots/snapshot.csv にスナップショットが出力される
    snapshot.assert_match(output_csv_path.read_text(), "snapshot.csv")

このテストコードを実行します。

$ pytest     
============================================================== test session starts ==============================================================
platform darwin -- Python 3.11.4, pytest-7.4.3, pluggy-1.3.0
rootdir: /Users/t3yamoto/dev/src/github.com/t3yamoto/snapshot-testing-for-file-write
plugins: snapshot-0.9.0
collected 1 item                                                                                                                                

tests/test_main.py F                                                                                                                      [100%]

=================================================================== FAILURES ====================================================================
_______________________________________________________________ test_json_to_csv ________________________________________________________________

snapshot = <pytest_snapshot.plugin.Snapshot object at 0x105731950>
tmp_path = PosixPath('/private/var/folders/_3/v6w_pffd2zg106nh1040xjnw0000gn/T/pytest-of-t3yamoto/pytest-10/test_json_to_csv0')

    def test_json_to_csv(snapshot, tmp_path):
        output_csv_path = Path(tmp_path / "output.csv")
        json_to_csv(Path("input.json"), output_csv_path)
    
        snapshot.snapshot_dir = Path("tests/snapshots")
>       snapshot.assert_match(output_csv_path.read_text(), "snapshot.csv")
E       AssertionError: snapshot tests/snapshots/snapshot.csv doesn't exist. (run pytest with --snapshot-update to create it)

tests/test_main.py:10: AssertionError
============================================================ short test summary info ============================================================
FAILED tests/test_main.py::test_json_to_csv - AssertionError: snapshot tests/snapshots/snapshot.csv doesn't exist. (run pytest with --snapshot-update to create it)
=============================================================== 1 failed in 0.02s ===============================================================

E AssertionError: snapshot tests/snapshots/snapshot.csv doesn't exist. (run pytest with --snapshot-update to create it)

初回はスナップショットが存在しないためエラーになります。スナップショット作成のため、--snapshot-update オプションをつけて実行します。

$  pytest --snapshot-update
============================================================== test session starts ==============================================================
platform darwin -- Python 3.11.4, pytest-7.4.3, pluggy-1.3.0
rootdir: /Users/t3yamoto/dev/src/github.com/t3yamoto/snapshot-testing-for-file-write
plugins: snapshot-0.9.0
collected 1 item                                                                                                                                

tests/test_main.py .E                                                                                                                     [100%]

==================================================================== ERRORS =====================================================================
_____________________________________________________ ERROR at teardown of test_json_to_csv _____________________________________________________
Snapshot directory was modified: tests/snapshots
  (verify that the changes are expected before committing them to version control)
  Created snapshots:
    snapshot.csv
============================================================ short test summary info ============================================================
ERROR tests/test_main.py::test_json_to_csv - Failed: Snapshot directory was modified: tests/snapshots
========================================================== 1 passed, 1 error in 0.01s ===========================================================

tests/snapshots/snapshot.csv にスナップショットが出力されました。これを Git の管理対象としておきます。
この状態で再度テストを実行すると...

$ pytest
============================================================== test session starts ==============================================================
platform darwin -- Python 3.11.4, pytest-7.4.3, pluggy-1.3.0
rootdir: /Users/t3yamoto/dev/src/github.com/t3yamoto/snapshot-testing-for-file-write
plugins: snapshot-0.9.0
collected 1 item                                                                                                                                

tests/test_main.py .                                                                                                                      [100%]

=============================================================== 1 passed in 0.01s ===============================================================

テストがパスしました。これは前回保存したスナップショットと json_to_csv メソッドの出力結果に差分がなかったためです。

では、json_to_csv メソッドに変更を加えてみます。

from pathlib import Path
import json
from csv import DictWriter


def json_to_csv(input_json_path: Path, output_csv_path: Path):
    with open(input_json_path, "r") as infile:
        json_data = json.load(infile)
        users = json_data["users"]
        with open(output_csv_path, "w") as outfile:
            writer = DictWriter(outfile, fieldnames=users[0].keys())
            writer.writeheader() # 見出し行が出力される
            writer.writerows(users)

if __name__ == "__main__":
    json_to_csv(Path("input.json"), Path("output.csv"))

10行目の通り、見出し行が出力されるように変更しました。これは意図しない変更です。

スナップショットテストを実行します。

$ pytest
============================================================== test session starts ==============================================================
platform darwin -- Python 3.11.4, pytest-7.4.3, pluggy-1.3.0
rootdir: /Users/t3yamoto/dev/src/github.com/t3yamoto/snapshot-testing-for-file-write
plugins: snapshot-0.9.0
collected 1 item                                                                                                                                

tests/test_main.py F                                                                                                                      [100%]

=================================================================== FAILURES ====================================================================
_______________________________________________________________ test_json_to_csv ________________________________________________________________

snapshot = <pytest_snapshot.plugin.Snapshot object at 0x102499cd0>
tmp_path = PosixPath('/private/var/folders/_3/v6w_pffd2zg106nh1040xjnw0000gn/T/pytest-of-t3yamoto/pytest-15/test_json_to_csv0')

    def test_json_to_csv(snapshot, tmp_path):
        output_csv_path = Path(tmp_path / "output.csv")
        json_to_csv(Path("input.json"), output_csv_path)
    
        snapshot.snapshot_dir = Path("tests/snapshots")
>       snapshot.assert_match(output_csv_path.read_text(), "snapshot.csv")
E       AssertionError: value does not match the expected value in snapshot tests/snapshots/snapshot.csv
E         (run pytest with --snapshot-update to update snapshots)
E       assert 'name,age,hei...ary,25,1.65\n' == 'John,22,1.75...ary,25,1.65\n'
E         + name,age,height
E           John,22,1.75
E           Peter,30,1.8
E           Mary,25,1.65

tests/test_main.py:10: AssertionError
============================================================ short test summary info ============================================================
FAILED tests/test_main.py::test_json_to_csv - AssertionError: value does not match the expected value in snapshot tests/snapshots/snapshot.csv
=============================================================== 1 failed in 0.02s ===============================================================

スナップショットテストが失敗し、意図しない差分を検知することができました。E + name,age,height の部分が差分です。

まとめ

ファイル出力を行うアプリケーションに、スナップショットテストを導入しました。長期的に保守するアプリケーションのレグリッションテストとして特に効果を発揮するのではないかと思います。

以下、検証を実施したリポジトリです。全体像の把握にご利用ください。 github.com