Software Design連載 2021年10月号 スナップショットテストの可能性を追求する

こんにちは、辰巳です。

第3回は「スナップショットテスト」をテーマにお送りします!

「組織が拡大する中で、十分な設計情報がない状況でも、複雑に改修が積み重なったソフトウェアをいかに安全かつ正確に変更できるか?」

本記事では、数多くの大幅なシステム変更の経験を経て、この課題に対してモノタロウがいま実践しているグッドプラクティスを紹介します。

本記事の初出は、 Software Design2021年10月号「Pythonモダン化計画(第3回)」になります。過去の連載記事は以下を参照ください。


スナップショットテストの可能性を追求する

モノタロウは、事業者向けの間接資材を販売する大規模なECサイトを運営しています。取り扱っている商品点数は、2021年現在で1,800万点を超えています。 このECサイトを支えるアプリケーションサーバや基幹システムの多くは、2002年から自社で開発、保守、運用を続けてきたPython製のシステムです。

モノタロウのECサイトの規模はこの20年で著しく拡大し、そのシステムを取り巻く技術も大きく変わりました。 その変化の中でモノタロウの開発チームでは、リアーキテクティングやPython2からPython3への移行など、数多くの大幅なシステム変更を経験してきました。

組織と業務が成長する中では、たとえ十分な設計情報と開発リソースがない状況でも、複雑に改修が積み重なったソフトウェアを安全かつ正確に変更する方法が求められます。 モノタロウでもいろいろな方法を試してきましたが、今回はそのうちスナップショットテストの考え方を用いたテスト手法について紹介します。

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

Webサイト開発でソフトウェアを修正する際には、Webサーバの処理結果だけでなく、フロントエンド側の挙動、つまり「ブラウザで実際に表示される内容が変化しないこと」も保証したい場合があります。 たとえば、Webサイトの機能を新しいアプリケーションサーバへと移行する場合、ユーザーには移行の前後で同じ内容のページが表示されるように保証したいでしょう。

とはいえ、実際のWebサイトにおけるページ表示は、「どのブラウザで、誰が、ログインしたか」などといった条件に応じて変化します。 通常、フロントエンドではJavaScriptが動作していることもあり、こうした条件をアプリケーションサーバに対するテストで網羅するのは現実的ではありません。

そこでよく利用されるのが、新旧のサーバのそれぞれの挙動の「スナップショット」を取り、それらの差分を確認するという手法です(図)。 こうした「UIコンポーネントの変更前後のレンダリング結果の同一性を確認するテスト」は、一般にスナップショットテストと呼ばれています。

f:id:nihsuy:20211012110432p:plain
UIコンポーネントのスナップショットテストの動作イメージ

ただしモノタロウでは、このスナップショットテストをより一般化し、「ソフトウェアの変更前後の入力に対する出力の同一性を確認するテスト」として捉えています。 そして、ページ生成ロジックのコンポーネント化、クリーンアーキテクチャの導入、さらにはPython2から3への移行など、変更の影響が大きなさまざまな改修プロジェクトにおいて「システムの振る舞いが変わらないこと」を確認するために利用しています。

スナップショットテスト導入の3つの観点

一口に「スナップショットテストを導入する」といっても、これをうまく機能させるためには検討すべき事項がいくつかあります。 本稿ではそれらを「ツールの準備」、「テストパターンの網羅」、「テストの自動化」の3つの観点から整理していきます。

ツールの準備

まずは、誰でも簡単にスナップショットテストを実行できるようなツールが必要です。 UIに対するスナップショットテストであれば、Jest のように、JavaScriptの挙動を考慮したDOMを比較する既存のツールがあります。 レスポンスヘッダやDOMの記録には、Puppeteer のようなヘッドレスブラウザを使ってサーバへのリクエストを発行すればいいでしょう。

一方、目的にかなう既存のツールがない場合には自分たちで作るしかありません。 本稿の後半ではそのような事例を紹介します。

十分なテストパターンの用意

次に、機能仕様をカバーするのに十分なテストパターンを用意する必要があります。 これには、本番環境で起こりうるケースを網羅すべく、アプリのアクセスログなどを駆使してパターンをリストアップすることになります。

例えばECサイトにおける商品情報ページの表示は、商品在庫の有無、商品説明の動画の有無、商品バリエーションの多寡、サイズや色の絞り込みの有無など、さまざまな条件の組み合わせによって変わります。 また、ログインの前後、使っているブラウザ、検索サイトの広告からの訪問かどうか、モバイルデバイスかどうかなど、ユーザーがページにアクセスするときのURLやヘッダ情報(Cookieなど)に応じても異なります。 ページ表示のスナップショットテストを実施する際には、まずこれらの条件を網羅したテストパターンを用意する必要があります。

さらに、ECサイトのページには、「おすすめ商品」の表示のようにアクセスごとに内容が変化する箇所があります。 このような箇所はスナップショットを比較する対象からは除外したいところです。 これには Cheerio のようなツールが利用できるでしょう。

できるだけテストを自動化する

最後に、テストの実施にかかる開発チームの時間とストレスを最小化する必要があります。 そのためには、できるだけテストの自動化を進めるのが得策です。 ただし、これは必ずしもCIの導入が必須という意味ではなく、むしろテストを頻繁に実施するような開発の進め方への切り替えがポイントになるでしょう。

スナップショットテスト導入前夜

次節でモノタロウのECサイトにおけるスナップショットテストの導入事例を紹介する前に、ここで開発チームがどんな課題に直面していたのかを明らかにしておきましょう。

モノタロウのECサイトでは、2015~2016年にかけて、商品情報を表示する機能のリファクタリングを実施することになりました。 しかし、このときの開発は計画通りに進まず、工数が予想からはみ出したり、デリバリーが遅れることさえありました。

このとき開発チームが直面していた課題を整理すると、以下の3つになります。

  1. メインストリームのブランチとリファクタリング用のブランチを分離して作業していたので、それらの正確な同期を取るのが難しく、作業時間もストレスもかかっていた
  2. メインストリームの開発スピードが速く、機能仕様書やテスト仕様書の整備が追いつかないので、社歴の長いメンバーでさえ細部の仕様を把握しきれていなかった
  3. テストは開発の最終フェーズで、テスト仕様書に沿って手動で実施していた。そのため、テストの実施漏れや実行時のミスがあった

これらの課題を解決するためにモノタロウの開発チームが選択した手法がスナップショットテストの導入でした。 さらに、スナップショットテストの効果を最大限に発揮できるように、開発の進め方を次のように変えることにしました。

まず、リファクタリングの案件を分割して小さなリリースを随時実行するようにし、通常の開発案件を担当するチームではその修正内容を前提として開発ができるようにしました。 「分割された単位ごとにレビューとテストを終えた時点で小さなリリースを繰り返し、それによってゴールを目指す」という開発フローへの切り替えです(図)。

f:id:nihsuy:20211012110813p:plain
開発フローの切り替え

また、テストの実行をできるだけ自動化することで、テストが漏れなく実施されることを担保しました。

そして、移行作業中は外部仕様の変更を抑えることにしました。 具体的には、修正の前後でリクエストに対するレスポンスが変わらないことを担保することにしました。 「外部仕様が変わっていない」ことを前提にできるなら、さまざまなリクエストパターンを準備しておき、それに対するレスポンスを修正の前後について比較することで、振る舞いのデグレーションが起きていないことを機械的に確認できます。 これはつまり、スナップショットテストが可能になるということです。

結果的にこのときのリファクタリングは、さまざまな課題を解消する前に完了することができました。 しかし、このときの経験を踏まえ、次節で紹介するPython3化をはじめとする以降のさまざまなプロジェクトではスナップショットテストの考え方を活用しています。

APIのPython3対応でスナップショットテストを活用

スナップショットテストの考え方は、商品ページのようなUIの移行だけでなく、APIの開発でも役立ちます。 ここでは、商品検索APIの対応ランタイムをPython2からPython3へ移行する際にスナップショットテストを活用した事例をご紹介します。

モノタロウのECサイトにおける商品検索APIは、1,800万点以上あるモノタロウの商品ラインナップからお客様が必要としている商品を検索するための最も重要な機能の一つです。 この機能は、検索エンジン(Apache Solr)のバックエンドと、検索エンジンへのクエリを構築するフロントエンドとして機能するWebアプリケーションとで構成されています。 2011年ごろからPython2ベースで開発が行われてきましたが、Python2がEOLを迎えるにあたり、ランタイムをPython3に移行する必要がありました。 エンドポイントの数にして19あり、Python3化にあたっては3つの開発チームで手分けして実装を行いました。

実装にあたっては、同時にWebアプリケーションを独自開発のフレームワークからFlaskへと移行するリアーキテクティングも実施することになりました。 メンバーの中には、このプロジェクトで初めて商品検索APIのコードを触る開発者も多く、そのため品質面での心配もありました。 結果的には、本稿で紹介するスナップショットテストをしっかりと整備したことにより、まったくバグを出すことなくリリースに成功しています。

テストパターンが複雑すぎた

既存のシステムには、長年の稼働実績に基づいた信頼性があります。 その信頼性を崩さずにシステムを移行するには、適切なリグレッションテストが欠かせません。 しかし、モノタロウの商品検索APIには「テスト仕様を定義しづらい」という難点がありました。

商品検索APIの役割は、リクエストに基づいて検索エンジンへの問い合わせクエリを構築することです。 このリクエストにはさまざまなパターンがありえます。パラメータも多岐にわたり、検索エンジンへと渡すクエリを構築するロジックも複雑でした。 さらに、検索対象の商品が持っている情報もたくさんのフィールドで構成されていて、商材ごとにすべて異なります。 組み合わせ網羅的なやり方で人間が手作業でテストパターンを網羅するのは不可能という状況だったのです。

過去のアクセス履歴をスナップショットテストのためのデータとして使う

そこで、手作業でテストケースを作ることは諦め、商品検索APIのアクセスログをテストパターンとしたスナップショットテストを行うことにしました。 具体的には、既存の旧APIのクローンと、開発中の新API、そしてフルセットの商品情報が入った検索エンジンを用意し、新旧のシステムに「過去のアクセスログから生成したリクエストパターン」を投入していきます。 新旧のレスポンスを比較することで、従来の動きを正とする「スナップショット」に対するテストが行えるというわけです。

前述したように、スナップショットテストの導入では「ツールの準備」と「テストパターンの用意」、そして「テストの自動化」が鍵となります。 以降の各節では、今回の事例において、モノタロウがこの3つにどのように取り組んだかを紹介します。

スナップショットテスト用のツール開発

スナップショットテストのためにはツールが必要です。 必要な機能は、2台のサーバに同じHTTPリクエストを送信し、返されたJSONのレスポンスを比較するというものです。 既存のツールで使えそうなものがないか探してみましたが、自分たちの用途に合致するものが見つからなかったのでPythonで内製することにしました(図)。

f:id:nihsuy:20211012110937p:plain
APIのスナップショットテストの動作イメージ

当初はさほど工数もかけずに実装できるだろうと考えていましたが、使っていくうちに下記のようなさまざまな機能が追加され、最終的には約1,000行のPythonプログラムになりました。

  • 比較結果の差分表示
  • 特定のレスポンスヘッダの差異を無視
  • レスポンス本体のJSONで特定のキーの差異を無視
  • リクエストメソッドとしてGETとPOSTに対応
  • 特定のCookieを付加した状態でのリクエスト送信
  • 特定のURLパラメータをすべてのテストケースに追加
  • リクエストを並列実行してテストの実行時間を短縮
  • テスト対象の商品検索APIのレスポンスタイムを集計して表示

以下、このうち「比較結果の差分表示」と「リクエストの並列実行」について、実際にどのような実装をしたか簡単に紹介します。

比較結果の差分表示

商品検索APIから返されるレスポンスは、配列やオブジェクトが入れ子になったデータ構造です。 そのため、新旧のAPIが返すデータが異なるケースとしては、「同じデータ構造で対応する値が違う」という場合だけでなく、「そもそも片方に値がない」という場合もありえます。 こうした多階層の構造を比較するために、今回は Dictdifferというパッケージを使いました。

比較のロジックを単純に書くと次のようになります。

diff_result = dictdiffer.diff(response_body1, response_body2)

Dictdifferは、指定した2つのデータ構造の差分を返すので、その中から差異とはみなさないデータを除外し、それでも差異が残った場合にだけレポートを出力しています。

リクエストの並列実行

スナップショットテストのテストケースの網羅性を高めていった結果、APIエンドポイントによってはテストケースの総数が数万件になり、直列でテストを実行すると完了まで数十分かかるようになりました。 そこで、標準モジュールのconcurrent.futuresを使い、複数のプロセッサで効率的にリクエストの送受信と結果比較を処理するようにしました。

with ProcessPoolExecutor(max_workers=config.max_workers) as executor:
    futures: List[Future] = [
        executor.submit(
            self._execute_snapshot_test,
            (
                config.server1,
                config.server2,
                request_path,
            ),
        )
        for request_path in config.request.request_paths
    ]
    results: List[CompareResult] = [future.result() for future in futures]

テストパターンをどうやって網羅したか

スナップショットテストを導入するには、考えられる状況を網羅したテストパターンを用意する必要があります。 今回の事例の場合には、「商品検索APIがどのように利用されているか」のパターンを網羅することになります。

幸いモノタロウでは、ほぼすべてのサーバのアクセスログをGoogleのBigQueryサービス上に検索可能な形で保存しています。 このアクセスログから、商品検索APIに対するリクエストを取り出すことで、今回のスナップショットテストに必要なテストケースを作成できました。 その際には、巨大なデータに対するクエリを実行しやすいというBigQueryの特長を活用し、アクセスログをリクエストURLでGROUP BYすることで、「URLが同じでパラメータが異なるリクエスト」を分類しました。

もちろん、実際の検索クエリにはユーザが入力した文字列によるゆらぎがあることから、ある程度の前処理は必要になります。 たとえば、ユーザが検索キーワードとして利用された文字列による差をそのまま考慮するとパターンが膨大になってしまうので、任意の文字列を取るパラメータについては事前に「X」という固定の文字列に置換しています。 また、検索キーワードを指定した順序の違いで別のパターンができてしまわないような工夫もしています。

一方、このような工夫によって、今度は逆に本来であれば必要なテストパターンが漏れてしまうこともあります。 そこで、アクセスログから「アクセス頻度」や「レスポンスサイズの大小」といった観点に応じて抽出したテストパターンや、「ランダムな10,000件」を抽出したテストパターンなどを併用することで、スナップショットテストの網羅率を上げました。

スナップショットテストは半自動化

最後はテストの自動化についてです。 今回の事例では、テストの実行時間の都合から、CIに組み込んで完全に自動で毎回スナップショットテストを実行することまではしませんでした。

それでも、開発者が手軽にスナップショットテストを活用してもらえるように、Jenkinsの管理画面からテスト実行ジョブを投入して実行結果を確認できるようにはしました。 その際には、テストの対象サーバやAPIエンドポイントをジョブ投入時に選択したり、パラメータをいくつか指定できるようにしています。 また、複数のジョブを一度に投入した場合には順番に実行されるようにし、まとめて後で確認するといった使い方ができるようにしました。

スナップショットテストで開発に安心感を

本稿では、複雑なパラメータを持つシステムの改修や移行の際にモノタロウの開発チームが直面した課題を題材に、実効的な網羅性が高いスナップショットテストを活用した開発の進め方を紹介しました。

システム開発を安全に進めるうえでは、外部仕様を維持するフェーズとそれを変更するフェーズとを分離し、細かく動作確認していくことが重要になります。 スナップショットテストは、特に参照系のシステムにおいて、そのための大きな効果を発揮する手法となりえます。 実際、アプリケーションの出力を検証するスナップショットテストでは、フレームワークやミドルウェアも含めた検証ができることから、単体テストしかない状況よりも安心感が得られます。

本稿の商品検索APIの移行の事例は、すべてのエンドポイントがステートレスで副作用がなかったり、アクセスログがBigQueryに入っていて活用しやすかったり、かなり良い条件が揃っていたケースではあります。 それでも、みなさんの開発現場でも同様にスナップショットテストを活用できる場面があるかもしれません。その際にはぜひ本稿で紹介したアイデアを参考にしてみてください。