本記事はサーバーワークス Advent Calendar 2023 の 13日目のエントリーです。
はじめに
最近、AWS CDK を使った開発をしているのですが、AWS CDK のテスト手法としてスナップショットテストと呼ばれる手法があり、これを AWS CDK 以外の開発にも応用できそうだなと着想を得たのが本記事を書くきっかけです。
スナップショットテストとは
ChatGPT によると以下のようなテスト手法を指します。
スナップショットテストは、プログラムやコードのテスト手法の一つです。このテストでは、特定の時点でのアプリケーションの出力や状態を「スナップショット」として記録し、その後のアプリケーションの変更が期待通りの結果をもたらしているかを確認します。
AWS CDK の場合、AWS リソースをプログラミング言語で記述した後、Synthesize(合成)と呼ばれる操作にて CloudFormation に変換するような流れをとります。変換した CloudFormation テンプレートをスナップショットとして Git 管理しておき、次回以降の変換結果と比較することでレグリッションの確認、あるいは意図した変更ができていることを確認することができます。
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