Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

不安定なテストスイートの実例から学ぶ、決定論的なテスト設計の重要性

最近、信頼性の低さゆえに非常にストレスを感じるテストスイートを扱う機会がありました。本記事では、その実体験をもとに、テストスイートが不安定になる原因と、決定論的で信頼できるテストを設計するための考え方を解説します。

問題のテストスイートの概要

このテストスイートが対象としていたのは、Railsで構築されたAPI専用アプリケーションでした。テストコードはJavaScriptで書かれており、Chakramという「JSON RESTエンドポイントに対するE2E(エンド・トゥ・エンド)テストの実行を目的としたAPIテストフレームワーク」が使用されていました。

最大の問題は、このテストスイートが決定論的ではなかったことです。決定論的であるとは、同じ入力に対して常に同じ結果を返すことを意味します。

具体的には、このテストスイートは1回目は成功、2回目も成功するのに、3回目には失敗する——しかもアプリケーションコードにもテストコードにも一切変更を加えていないにもかかわらず、という挙動を示しました。

rake db:reset で一時的に復旧できた理由

試行錯誤の末、rake db:reset を実行すればテストスイートが再び成功するようになることに気づきました。このテスト群は、Railsアプリケーションのtest環境ではなくdevelopment環境に対して動作しており、テストスイートの実行開始時にデータベースが特定の状態にあることに依存していたのです。

新しくシードしたばかりのデータベースから始めれば、テストを2回、3回と連続で成功させられることもありました。しかし多くの場合、テストの実行によってデータが壊れてしまい、再び rake db:reset を実行して、テストが正常に利用できる状態までデータベースを戻す必要がありました。

もちろん、これはあるべき姿ではありません。テストスイートが失敗する理由はただ一つ、「テスト対象のコードが正常に動作しなくなったとき」だけであるべきです。

本来あるべきテストの設計とは

では、このような問題のある構成のテストスイートを、正しく構築するにはどうすればよかったのでしょうか。

問題の根本原因は、データベースのリセットを人間が手動で行うことにテストスイートが依存していた点にあります。本来であれば、テストスイート内の各テストが実行前に自動的にデータベースをクリーンな状態に初期化しておくべきでした。テスト実行後にデータベースを消去し、他のテストへデータを「リーク」させないようにするのも一般的な手法です。すべてのテストが実行前にデータベースをクリアするのであれば厳密には必須ではありませんが、防御策としては有効です。

さらに、テストスイートはRailsアプリケーションのtest環境を対象とすべきでした。開発者がdevelopment環境で行う操作とテストスイートの動作が互いに干渉しないようにする必要があります。

その他の問題となりうる依存関係

今回の事例での問題の依存先は「適切にクリーンアップされないデータベース」でしたが、これ以外にも注意すべき依存関係があります。

ネットワークリクエストへの依存

典型的な例がネットワークリクエストです。たとえばTwilio APIを呼び出すテストスイートがあるとします。火曜日に実行すると成功し、水曜日に同じテストを実行すると失敗する。実は、あなたの知らないところでTwilio側に障害が発生しており、それがテスト失敗の原因だった——数分後に障害が解消されれば、テストは再び成功するようになります。

これも結局は同じ原則に行き着きます。テストが失敗するのは、自分のアプリケーションコードが正常に動作しなくなった場合のみであるべき、ということです。

ネットワークとやり取りするコードのテストが必要な場合、Ruby/RailsであればVCRのようなツールを活用するのが効果的です。VCRを使えば、ネットワークリクエストを一度記録し、以降は実際にインターネットへ接続せずにその記録を再生してテストできます。

外部依存が許容されるケース

「アプリケーションがTwilio APIに依存しており、実際にTwilioがダウンしたなら、アプリケーション自体が壊れたのだからテストが失敗するのは当然ではないか」という反論もあるでしょう。この考え方と「テストは外部条件に依存すべきではない」という原則をどう両立させるのか。

答えは、複数のレイヤーやシステムをまとめて検証する統合テスト(integration test)と、単一の機能を切り離して検証する単体テスト(unit test)を区別することです。

もしチームが意識的な判断として、一部のテストをネットワーク経由で外部サービスにアクセスさせ、複数のシステムをまとめて検証すると決めたのであれば、それ自体は何ら「間違って」いません(もう一つの選択肢は、そうしたシステム同士の統合テストを最初から行わないことです)。ただしその場合、ネットワーク依存の統合テストスイートは決定論性が保証されず、時々不安定になる(flaky)可能性があることをチーム全体が認識しておく必要があります。こうしたテストスイートは、開発者が毎日の回帰チェックのためにローカルマシン上で実行するテスト群とは別に管理すべきです。

テストが外部条件に依存してはならないというルールには例外もありますが、ほとんどの場面ではこのルールを守ることが重要です。そうすることで初めて、テストスイートは決定論的で信頼できるものになります。

  1. VCR Gem × WebMockでRubyのテストスイートを高速化する方法

    Rubyアプリケーションが外部APIを利用しているなら、テストが遅い・APIのレート制限に引っかかるといった問題に直面したことがあるのではないでしょうか。 解決策は何があるでしょう? クライアントライブラリのHTTPメソッドを手動でスタブ化し、事前に用意したレスポンスを返す方法もあります。 しかし、これは手間がかかる上に、コードも読みにくくなってしまいます。 より良い解決策は、WebMock + VCRという強力なGemの組み合わせを使うことです。 WebMockは、主要なHTTPライブラリからのHTTPリクエストを横取り(インターセプト)します。対応しているのは例えば: net/http

  2. Rubyのcaseステートメントの多彩な活用法と仕組みを徹底解説

    Rubyでif / elsifを使おうとしている場面では、代わりにcaseステートメントを使うことを検討してみてください。この記事では、caseステートメントのさまざまな活用例と、その内部で実際にどのような仕組みで動作しているのかを解説します。 補足:他のプログラミング言語では、これはswitch文として知られています。 Rubyにおけるcaseステートメントの構成要素は以下の通りです。 キーワード 説明 case caseステートメントの定義を開始します。処理対象となる変数を受け取ります。 when マッチ可能な各条件が、1つのwhen句に相当します。 else どの