テストにおける非決定性の撲滅

自動化されたリグレッションスイートは、ソフトウェアプロジェクトにおいて重要な役割を果たし、本番環境での欠陥の削減に役立ち、進化的な設計に不可欠です。開発チームとの会話の中で、非決定的なテスト、つまり、合格したり失敗したりするテストの問題についてよく耳にします。制御されていない非決定的なテストは、自動化されたリグレッションスイートの価値を完全に破壊する可能性があります。この記事では、非決定的なテストへの対処方法について概説します。最初は隔離することで、他のテストへの損害を軽減するのに役立ちますが、それでもすぐに修正する必要があります。そのため、非決定性の一般的な原因である分離の欠如、非同期動作、リモートサービス、時間、およびリソースリークの処理について説明します。

2011年4月14日



Thoughtworksが多くの困難なエンタープライズアプリケーションに取り組み、成功を収めたことがほとんどない多くのクライアントに成功をもたらしてきたのを見てきました。私たちの経験は、10年前にマニフェストを書いたときには非常に物議を醸し、不信感を抱かれていたアジャイル手法が、うまく活用できることを示す素晴らしい実例となっています。

アジャイル開発には多くの種類がありますが、私たちが行っていることの中心には、自動テストがあります。自動テストは、最初からエクストリームプログラミングの中核的なアプローチであり、その哲学は私たちのアジャイル作業に最大のインスピレーションを与えてきました。そのため、ソフトウェア開発の中核として自動テストを使用してきた豊富な経験があります。

自動テストは、教科書で紹介されていると簡単に見えることがあります。実際、基本的な考え方は非常にシンプルです。しかし、デリバリープロジェクトのプレッシャーの中で、テキストであまり注目されていない試練が発生します。よく知っているように、著者は核心となるポイントを伝えるために、多くの詳細を飛ばしてしまう傾向があります。私たちのデリバリーチームとの会話の中で、繰り返し発生する問題の1つは、信頼性が低くなったテストです。信頼性が低いため、合格または不合格に関係なく、人々はあまり注意を払わなくなります。この信頼性の低下の主な原因は、一部のテストが非決定性になっていることです。

テストは、コード、テスト、または環境に目立った変更がない場合に、合格したり失敗したりすると、非決定性になります。このようなテストは失敗し、再実行すると合格します。このようなテストのテストの失敗は、一見ランダムです。

非決定性はあらゆる種類のテストに影響を与える可能性がありますが、受け入れテストや機能テストなど、広範な範囲のテストに影響を与える可能性が特に高くなります。

非決定的なテストが問題となる理由

非決定的なテストには2つの問題があります。1つは役に立たないことで、2つ目は、テストスイート全体を完全に破滅させる可能性のある悪質な感染症であることです。その結果、デプロイメントパイプライン全体が危険にさらされる前に、できるだけ早く対処する必要があります。

まず、役に立たないことについて詳しく説明します。自動テストを行うことの主な利点は、リグレッションテスト[1]として機能することでバグ検出メカニズムを提供することです。リグレッションテストが赤になると、すぐに問題が発生していることがわかります。多くの場合、バグが気づかないうちにシステムに忍び込んでいるためです。

このようなバグ検出器があると、大きなメリットがあります。最も明白なことは、バグが導入された直後にバグを見つけて修正できるということです。バグをすぐに駆除できるため、気分が良くなるだけでなく、バグが最新の変更セットに含まれていることがわかっているため、バグを削除しやすくなります。その結果、バグの場所がわかるので、バグを修正するための戦いの半分以上が終わったと言えるでしょう。

2番目のレベルの利点は、バグ検出器への信頼が高まるにつれて、失敗したときにバグ検出器が作動し、ミスをすぐに修正できることを知って、大きな変更を行う勇気が得られることです。[2] これがないと、チームはコードをクリーンに保つために必要な変更を行うことを恐れてしまい、コードベースの腐敗と開発速度の低下につながります。

非決定的なテストの問題点は、赤くなったときに、それがバグによるものなのか、それとも単に非決定的な動作の一部なのかわからないことです。通常、これらのテストでは、非決定的な失敗が比較的一般的であるため、これらのテストが赤くなると、肩をすくめてしまうことになります。リグレッションテストの失敗を無視し始めると、そのテストは役に立たなくなり、捨ててしまっても構いません。[3]

実際、非決定的なテストは捨ててしまうべきです。そうしないと、感染力が強いためです。10個の非決定的なテストを含む100個のテストのスイートがある場合、そのスイートはしばしば失敗します。最初は、人々は失敗レポートを見て、非決定的なテストで失敗が発生していることに気付きますが、すぐにそうする規律を失います。その規律が失われると、正常な決定性テストの失敗も無視されるようになります。その時点で、ゲーム全体が失われ、すべてのテストを取り除いても構いません。

隔離

この記事の主な目的は、非決定的なテストの一般的なケースと、非決定性を排除する方法の概要を示すことです。しかし、本題に入る前に、1つの重要なアドバイスをさせていただきます。非決定的なテストを隔離してください。非決定的なテストがある場合は、正常なテストとは別のテストスイートに保管してください。そうすれば、正常なテストで何が起こっているかに引き続き注意を払い、そこから適切なフィードバックを得ることができます。

非決定的なテストはすべて隔離エリアに配置します。(ただし、隔離されたテストはすぐに修正してください。)

次に、隔離されたテストスイートをどうするかという問題です。これらはリグレッションテストとしては役に立たないが、クリーンアップ作業項目としては将来性があります。隔離されているテストはリグレッションカバレッジに役立っていないため、このようなテストを放棄しないでください。

ここでの危険性は、テストが隔離されて忘れ去られ続け、バグ検出システムが侵食されることです。そのため、テストが隔離されたままにならないようにするメカニズムを用意することをお勧めします。これを行うには、さまざまな方法があります。1つは単純な数値制限です。たとえば、隔離できるテストは8個までです。制限に達したら、すべてのテストをクリアするために時間をかける必要があります。これは、まとめてテストクリーニングを行う場合に便利です。別の方法は、テストを隔離できる期間に時間制限を設けることです。たとえば、1週間以内などです。

隔離の一般的なアプローチは、隔離されたテストをメインのデプロイメントパイプラインから外して、通常のビルドプロセスを維持することです。ただし、優秀なチームはより積極的になることができます。私たちのMingleチームは、正常なテストの1段階後に隔離スイートをデプロイメントパイプラインに配置します。こうすることで、正常なテストからのフィードバックを得ることができるだけでなく、隔離されたテストを迅速に解決する必要があります。[4]

分離の欠如

テストを確実に実行するには、テストを実行する環境を明確に制御し、テストの開始時に既知の状態にする必要があります。1つのテストでデータベースにデータを作成して放置しておくと、別のデータベース状態に依存している別のテストの実行が破損する可能性があります。

そのため、テストを分離しておくことに焦点を当てることが非常に重要です。適切に分離されたテストは、任意の順序で実行できます。機能テストの運用範囲が大きくなるにつれて、テストを分離しておくことがますます難しくなります。非決定性を追跡する場合、分離の欠如は一般的でイライラする原因となります。

テストを互いに分離して、1つのテストの実行が他のテストに影響を与えないようにします。

分離を実現するには、開始状態を常にゼロから再構築するか、各テストが後処理を適切に行うようにするかの2つの方法があります。一般的に、前者のほうが簡単であり、特に問題の原因を見つけやすいので、前者を好みます。初期状態を適切に構築できなかったためにテストが失敗した場合、どのテストにバグが含まれているかを簡単に確認できます。ただし、クリーンアップを行うと、1つのテストにバグが含まれますが、別のテストは失敗します。そのため、本当の問題を見つけるのは困難です。

空白の状態から始めることは、通常、単体テストでは簡単ですが、機能テスト[5]では、特にデータベースに大量のデータが存在する必要がある場合は、はるかに困難になる可能性があります。データベースを毎回再構築すると、テストの実行に多くの時間がかかるため、クリーンアップ戦略への切り替えが推奨されます。[6]

データベースを使用する場合に便利な1つの方法は、トランザクション内でテストを実施し、テストの最後にトランザクションをロールバックすることです。こうすることで、トランザクションマネージャーが後処理を行い、エラーの可能性を減らすことができます[7]

別のアプローチは、テストのグループを実行する前に、ほとんど不変な開始フィクスチャを1回ビルドすることです。次に、テストがその初期状態を変更しないようにします(または変更する場合は、ティアダウンで変更を元に戻します)。この戦術は、各テストのフィクスチャを再構築するよりもエラーが発生しやすいですが、フィクスチャを毎回ビルドするのに時間がかかりすぎる場合は、価値があるかもしれません。

データベースは分離の問題の一般的な原因ですが、メモリ内でも発生する可能性があります。特に、静的データとシングルトンに注意してください。この種の問題の良い例は、現在ログインしているユーザーなどのコンテキスト環境です。

テストに明示的なティアダウンがある場合は、ティアダウン中に発生する例外に注意してください。これが発生すると、テストは合格する可能性がありますが、後続のテストで分離エラーが発生する可能性があります。そのため、ティアダウンで問題が発生した場合は、大きなノイズが発生するようにしてください。

分離をあまり重視せず、明確な依存関係を定義してテストを特定の順序で実行することを好む人もいます。分離の方が、テストのサブセットを実行したり、テストを並列化したりする際の柔軟性が高いため、私は分離を好みます。

非同期動作

非同期処理は、長期タスクを実行しながらソフトウェアの応答性を維持できる便利な機能です。Ajax呼び出しにより、ブラウザはサーバーに戻って追加のデータを取得する間も応答性を維持できます。非同期メッセージにより、サーバープロセスは他のシステムと通信する際に、その遅延に縛られることなく通信できます。

しかし、テストでは、非同期処理は災いになる可能性があります。ここでよくある間違いは、スリープを入れることです

        //pseudo-code
        makeAsyncCall;
        sleep(aWhile);
        readResponse;
      

これは2つの点で問題になります. まず、応答を得るのに十分な時間を持たせるために、スリープ時間を十分に長く設定する必要があります。しかし、それは、応答を待つ間、多くの時間を無駄に過ごすことになり、テストの速度が低下することを意味します。2つ目の問題は、どれだけ長くスリープしても、時には十分ではないということです。環境に何らかの変化があり、スリープ時間を超過してしまうと、誤った失敗が発生します。そのため、このようなベアースリープは絶対に使用しないでください。

非同期応答を待つためにベアースリープを使用しないでください。コールバックまたはポーリングを使用してください。

非同期レスポンスのテストには、基本的に2つの戦術があります。1つ目は、非同期サービスが完了時に呼び出すコールバックを受け取るようにすることです。これは、必要以上に待つ必要がないため、最良の方法です[8]。この方法の最大の問題は、環境がこの処理を実行できることと、サービスプロバイダがこれを実装する必要があることです。これは、開発チームとテストチームが統合されていることの利点の1つです。開発チームがコールバックを提供できれば、テストチームはそれを使用できます。

2つ目の選択肢は、回答をポーリングすることです。これは、1回だけ確認するのではなく、定期的に確認することです。次のような感じです。

        //pseudo-code
        makeAsyncCall
        startTime = Time.now;
        while(! responseReceived) {
          if (Time.now - startTime > waitLimit) 
            throw new TestTimeoutException;
          sleep (pollingInterval);
        }
        readResponse
      

このアプローチのポイントは、`pollingInterval`を非常に小さな値に設定することで、レスポンスを待つためのデッドタイムを最大でその値に抑えることができることです。これは、`waitLimit`を非常に高く設定できることを意味し、重大な問題が発生しない限り、それに達する可能性を最小限に抑えます[9]

テストのタイムアウトが失敗したことを示す明確な例外クラスを使用してください。これは、問題が発生した場合に何が間違っていたかを明確にし、より高度なテストハーネスがこの情報を表示に利用できるようにするのに役立ちます。

時間値、特に`waitLimit`は、リテラル値であってはなりません。定数を使用するか、ランタイム環境を通じて設定することで、常にまとめて簡単に設定できる値であることを確認してください。そうすれば、それらを調整する必要がある場合(そして必要になるでしょう)、すべてをすばやく調整できます。

これらのアドバイスはすべて、プロバイダからのレスポンスが期待される非同期呼び出しに役立ちますが、レスポンスがない場合はどうでしょうか。これらは、何かにコマンドを呼び出し、確認なしにそれが発生することを期待する呼び出しです。これは、期待されるレスポンスをテストできますが、タイムアウト以外に障害を検出する方法がないため、最も難しいケースです。プロバイダが自分で構築しているものであれば、プロバイダが完了したことを示す方法(基本的に何らかの形式のコールバック)を実装することで、これを処理できます。テストコードのみがそれを使用する場合でも、それだけの価値があります。多くの場合、この種の機能は他の目的にも役立つことがわかります[10]。プロバイダが他人の作品である場合は、説得を試みることができますが、そうでない場合は行き詰まる可能性があります。これは、リモートサービスのテストダブルを使用する価値がある場合でもあります(次のセクションで詳しく説明します)。

非同期処理で一般的な障害が発生し、まったく応答しない場合は、常にタイムアウトを待つことになり、テストスイートの失敗に時間がかかります。これに対抗するために、スモークテストを使用して非同期サービスがまったく応答しているかどうかを確認し、応答していない場合はすぐにテストの実行を停止することをお勧めします。

非同期処理を完全に回避できる場合もあります。Gerard MeszarosのHumble Objectパターンは、テストが難しい環境にロジックがある場合は常に、テストする必要があるロジックをその環境から分離する必要があると述べています。この場合、テストする必要があるロジックのほとんどを、同期的にテストできる場所に配置することを意味します。非同期動作は可能な限り最小限(Humble)にする必要があります。そうすれば、それほど多くのテストは必要ありません。

リモートサービス

Thoughtworksが統合作業を行っているかどうかを尋ねられることがありますが、かなりの統合を必要としないプロジェクトはほとんどないため、私はそれをやや面白く思います。その性質上、エンタープライズアプリケーションは、異なるシステムからのデータを大量に組み合わせることを伴います。これらのシステムは、独自のスケジュールで運用されている他のチームによって保守されており、多くの場合、テスト駆動型の俊敏なアプローチとは非常に異なるソフトウェア哲学を使用しています。

このようなリモートシステムでのテストには、多くの問題が伴い、非決定性は大きな問題です。多くの場合、リモートシステムには呼び出すことができるテストシステムがないため、ライブシステムにアクセスすることになります。テストシステムがある場合でも、決定的なレスポンスを提供できるほど安定していない可能性があります。

このような状況では、決定性を確保することが不可欠であるため、テストダブルを使用する必要があります。これは、リモートサービスのように見えますが、実際にはリモートシステムの動作を模倣した偽のバージョンにすぎないコンポーネントです。ダブルは、システムとの相互作用において適切な種類のレスポンスを提供するように設定する必要がありますが、制御された方法で提供する必要があります。このようにして、決定性を確保できます。

ダブルの使用には欠点があり、特に広範囲にわたってテストする場合に顕著です。ダブルがリモートシステムと同じように動作することをどのように確認できるでしょうか。テストを使用して、これを再び対処できます。これは、私がコントラクトテストと呼ぶテストの一種です。これらは、リモートシステムとダブルで同じインタラクションを実行し、2つが一致することを確認します。この場合、「一致」とは、同じ結果になることではなく(非決定性のため)、同じ本質的な構造を共有する結果を意味する場合があります。統合コントラクトテストは頻繁に実行する必要がありますが、システムのデプロイメントパイプラインの一部ではありません。リモートシステムの変更率に基づいて定期的に実行するのが通常は最適です。

この種のテストダブルを作成するために、私は自己初期化フェイクの大ファンです。これらは管理が非常に簡単だからです。

エンドツーエンドの動作を保証するために実際の接続でテストする必要があると信じているため、機能テストでテストダブルを使用することに断固として反対する人もいます。彼らの主張には共感しますが、自動テストは非決定的な場合は役に立ちません。そのため、実際のシステムと通信することで得られる利点は、非決定性を排除する必要があることで圧倒されます[11]

時間

システムクロックの呼び出しほど非決定的なものはほとんどありません。呼び出すたびに新しい結果が得られ、それに依存するテストは変更される可能性があります。次の1時間に期限が来るすべてのToDoを要求すると、定期的に異なる回答が得られます[12]

ここで最も重要なことは、システムクロックを、テスト用にシード値で置き換えることができるルーチンで常にラップすることです。クロックスタブは特定の時間に設定してその時間にフリーズできるため、テストはその動きを完全に制御できます。そうすれば、テストデータをシードされたクロックの値に同期させることができます。[13][14]

テストのために簡単に置き換えられるように、常にシステムクロックをラップしてください。

これについて注意すべきことの1つは、最終的にテストデータが古すぎるために問題が発生し始め、アプリケーションの他の時間ベースの要素と競合する可能性があることです。この場合、データとクロックシードを新しい値に移動できます。これを行うときは、これが唯一の操作であることを確認してください。そうすれば、失敗したテストはすべてテストデータの時間移動によるものであると確信できます。

時間が問題になるもう1つの領域は、クロックからの他の動作に依存する場合です。かつて、クロック値に基づいてランダムキーを生成するシステムを見たことがあります。このシステムは、単一のクロックティック内で複数のIDを割り当てることができる高速マシンに移動されたときに障害が発生し始めました。[15]

システムクロックへの直接呼び出しが原因で発生する問題を数多く耳にしてきたため、コード分析を使用してシステムクロックへの直接呼び出しを検出し、そこでビルドを失敗させる方法を見つけることをお勧めします。単純な正規表現チェックでさえ、とんでもない時間に呼び出された後のイライラするデバッグセッションを回避できる可能性があります。

リソースリーク

アプリケーションに何らかのリソースリークがある場合、リソースリークが制限を超えたテストがどれであるかによってランダムなテストが失敗するため、ランダムなテストが失敗します。このケースは、この問題のためにどのテストでも断続的に失敗する可能性があるため、厄介です。1つのテストが非決定的な場合ではない場合、リソースリークは調査の良い候補です。

リソースリークとは、アプリケーションが取得と解放によって管理する必要があるすべてのリソースを意味します。メモリ管理されていない環境では、明らかな例はメモリです。メモリ管理はこの問題の解消に大きく貢献しましたが、データベース接続など、他のリソースを引き続き管理する必要があります。

通常、この種のリソースを処理する最良の方法は、リソースプールを使用することです。これを行う場合、プールをサイズ1に設定し、与えるリソースがない場合にリソースの要求があると例外をスローするように設定するのが良い戦術です。そうすれば、リーク後にリソースを最初に要求したテストが失敗します。これにより、問題のあるテストを見つけやすくなります。

リソースプールサイズを制限するというこの考え方は、テストでエラーが発生する可能性を高めるための制約を強化することです。これは、エラーがテストに表示されて、本番環境で発生する前に修正できるようにするため、優れています。この原則は他の方法でも使用できます。聞いた話の中に、ランダムに名前が付けられた一時ファイルを生成し、それらを適切にクリーンアップせず、衝突でクラッシュしたシステムがありました。この種のバグを見つけるのは非常に困難ですが、それを明らかにする1つの方法は、テスト用のランダマイザーをスタブして、常に同じ値を返すようにすることです。そうすれば、問題をより迅速に表面化させることができます。


脚注

1: はい、TDDの多くの支持者は、テストの主要なメリットは、要件と設計を推進する方法であると考えていることを知っています。これは大きなメリットであることに同意しますが、回帰スイートは、自動テストがもたらす最大のメリットであると考えています。TDDがなくても、テストはそのための費用をかける価値があります。

2: もちろん、テストの失敗は、コードが実行することになっている内容の変更が原因であり、テストが新しい動作を反映するように更新されていない場合があります。これは本質的にテストのバグですが、すぐに修正できれば同様に簡単に修正できます。

3: 非決定的なテストには有用な役割があります。ランダマイザーからシードされたテストは、エッジケースの発見に役立ちます。パフォーマンステストは常に異なる値を返します。しかし、これらの種類のテストは、ここで焦点を当てている自動回帰テストとはまったく異なります。

4: Mingleチームにとって、これはうまく機能します。彼らは非決定的なテストを迅速に見つけて修正するのに十分なスキルがあり、それを迅速に行うための規律も備えているからです。隔離されたテストが失敗したためにビルドが長時間壊れたままになっていると、継続的インテグレーションの価値が失われます。そのため、ほとんどのチームには、隔離されたテストをメインパイプラインから除外することをお勧めします。

5: 厳密な定義はありませんが、ここでは初期のエクストリームプログラミングの用語を使用しています。「単体テスト」とはきめ細かいものを、「機能テスト」とはエンドツーエンドで機能に関連するテストを意味します。

6: 1つのコツは、初期データベースを作成し、各テスト実行の前にファイルシステムコマンドを使用してコピーすることです。ファイルシステムのコピーは、多くの場合、データベースコマンドを使用してデータをロードするよりも高速です。

7: もちろん、この手法は、トランザクションをコミットせずにテストを実行できる場合にのみ有効です。

8: 応答が得られない場合に備えてタイムアウトを設定する必要がありますが、別の環境に移動すると、そのタイムアウトも同じ危険にさらされます。幸いなことに、そのタイムアウトはかなり高く設定できるため、問題が発生する可能性を最小限に抑えることができます。

9: ただし、その場合、テストの実行速度が非常に遅くなります。待機制限に達した場合は、テストスイート全体を中止することを検討してください。

10: 非同期動作がUIからトリガーされる場合、非同期操作が進行中であることを示すインジケーターをUIに配置することをお勧めします。これをUIの一部にすることで、このインジケーターを停止するために必要なフックが、テストロジックをいつ進行させるかを検出するためのフックと同じになるため、テストにも役立ちます。

11: リモートシステムが決定論的であっても、これらの状況でテストダブルを使用することには、他にも利点があります。多くの場合、応答時間が遅すぎてリモートシステムを使用できません。ライブシステムとのみ通信できる場合、テストはそのシステムに大きな、そして迷惑な負荷をかける可能性があります。

12: 現在の時刻に基づいて、各テストのデータストアを再シードできます。しかし、それは多くの作業であり、潜在的なタイミングエラーが発生しやすいです。

13: この場合、クロックスタブは分離を壊す一般的な方法であり、それを使用する各テストは、適切に再初期化されていることを確認する必要があります。

14: 私の同僚の1人は、現在の時刻を使用して1、2時間後には同じ日であると想定するテストを捕捉するために、真夜中の直前と直後にテスト実行を強制することを好みます。これは、月末など特に効果的です。

15: もちろん、これは必ずしも非決定性のバグではなく、環境の変化によるバグです。クロックティックがID割り当てにどれだけ近いかによって、非決定的な動作が発生する可能性があります。

謝辞

いつものように、この記事をまとめるための資料を提供してくれた多くのThoughtworksの同僚に感謝する必要があります。

Michael Dietz、Danilo Sato、Badrinath Janakiraman、Matt Savage、Krystan Vingrys、Brandon Byersは、この記事を読んでさらにフィードバックを提供してくれました。

Ed Sykesは、各テストの初期データベースを作成するために、データベースファイルのファイルシステムコピーを使用する方法を思い出させてくれました。

主な改訂

2011年4月14日: 初版

2011年3月24日: Thoughtworks内でレビューのためにドラフトを投稿

2011年2月16日: 記事の執筆開始