Eradicating Non-Determinism in Tests

自動化されたリグレッションスイートは、ソフトウェアプロジェクトにおいて本番環境の欠陥を減らし、進化的設計を実現するうえで重要な役割を果たします。開発チームと話をする中で、私は非決定的なテストの問題についてよく耳にします。非決定的なテストとは成功したり失敗したりするようなテストのことです。制御されていないまま放置された非決定的テストは、自動化されたリグレッションスイートの価値を完全に破壊してしまう可能性があります。この記事では、非決定的テストに対処する方法の概要を説明します。手始めにはそういったテストを隔離することで他のテストへのダメージを減らすことができますが、それでもすぐに修正しなければなりません。そのため、非決定的テストのよくある原因である、分離の欠如、非同期的な挙動、リモートサービス、時間、リソースリークなどへの対処法について説明します。

私は、ThoughtWorksが多くの困難なエンタープライズアプリケーションに取り組み、これまでほとんど成功したことのない多くのクライアントに成功をもたらしているのを見て楽しんでいました。私たちの経験によって、10年前にマニフェストを書いたときには物議をかもしていたアジャイル手法がソフトウェア開発を成功に導くことを実証してきました。

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

自動テストは、書籍の中で読む限りは簡単に見えるかもしれません。実際、基本的な考え方は非常にシンプルです。しかし、納品プロジェクトのプレッシャーの中では、書籍ではあまり注目されていないような試練が降りかかります。私もよく知っていますが、書籍の著者は核心を突くために多くの詳細を省く傾向があります。私たちのデリバリチームとの会話の中で、私たちが繰り返し遭遇する問題の1つは、テストが信頼性を欠くようになり、信頼性が低すぎて人々がテストが成功するか失敗するかに注意を払わなくなるというものです。この信頼性の低さの主な原因は、いくつかのテストが非決定的になっていることです。

テストが非決定的であるというのは、コードやテスト、環境に変更がなくても、成功したり失敗したりする場合です。そのようなテストは、失敗した後再実行すると合格します。このようなテストの失敗は、一見ランダムに見えます。

非決定性はあらゆる種類のテストに害をもたらしますが、特に受け入れテストや機能テストのような広い範囲のテストに影響を与えやすいです。

なぜ非決定的なテストは問題なのか

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

まず非決定的なテストが役に立たない点について説明します。自動テストを持つことの主な利点は、リグレッションテストとして動作することでバグを検出するメカニズムを提供することです1。リグレッションテストが赤になると、すぐに問題が発生していることを知ることができます。多くの場合、これは気づかないうちにバグがシステムに忍び込んでいることを示しています。

このようなバグ検出器を持つことには大きなメリットがあります。最も明らかなことは、バグが発生した直後にそれを発見し、修正することができるということです。バグをすぐにつぶすことができるので開発が快適になるだけでなく、そのバグの原因が頭の中にフレッシュな状態で残っている最後の変更によって引き起こされたことが明確なので、取り除くのも簡単です。その結果、バグの原因を突き止めるためにどこを探せばいいのかがわかるようになります。これでバグとの闘いの半分以上は勝利したといってもよいでしょう。

第2の利点は、バグ検出器に自信を持てるようになると、へまをしたときにバグ検出器によってすぐにバグに気づくことができ、間違いをすぐに修正できることを知っているので、大きな変更を行う勇気が出てくるということです2。これがなければ、チームはコードをきれいに保つために必要な変更を行うことに怯えてしまい、コードベースが腐り、開発スピードが急落してしまいます。

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

実際、非決定的なテストは本当に捨てるべきなのです。そうしないと、それは他のテストに伝染するからです。もしあなたが100のテストスイートを持っていて、そのうちの10個が非決定的なテストだったとすると、そのテストスイートは頻繁に失敗します。最初は、人々は失敗レポートを見て、失敗が非決定的テストにあることに気づくでしょうが、すぐにそれも確認しなくなるでしょう。いったんその規律が失われると、健全な決定的テストでの失敗も無視されるようになります。その時点で、あなたはすでにゲームに敗北しているので、すべてのテストを取り除いたほうがいいかもしれません。

隔離

この記事での私の主な目的は、非決定的テストの一般的なケースと、非決定性を排除する方法を概説することです。しかしその前に、私は本質的なアドバイスを一つ提供します:非決定的テストを隔離しましょう。非決定的なテストがある場合は、健全なテストとは別のテストスイートに保管してください。そうすれば、健康的なテストの結果に注意を払い続け、そこから良いフィードバックを得ることができます。

そして、問題は隔離されたテストスイートをどうするかということです。それらはリグレッションテストとしては役に立ちませんが、修正の必要がある作業一覧として利用することができます。隔離されたテストがリグレッションテストの役に立たないからと言って、こういったテストを廃棄するべきではありません。

ここでの危険な点は、テストが隔離されたまま忘れ去られてしまうことです。これではバグ検出システムが徐々に壊れていってしまいます。そのため、テストがあまりにも長く隔離されたままにならないようにする仕組みを持つことには価値があります。私はこれを行うためのさまざまな方法を見てきました。1つは単純に隔離するテスト数に上限を設けるやり方です。例えば、隔離できるのは8つのテストまでとする、などです。一度上限に達したら、すべてのテストをクリアするために時間を費やすのです。これは、テストを修正する作業をまとめて行うというやり方で、もしこの進め方があなたの好みに合っていれば優れたやり方でしょう。もう一つの方法は、例えば1週間以上は隔離しないというように、テストが隔離される期間に時間制限を設けることです。

隔離の一般的なアプローチはメインのデプロイメントパイプラインから隔離されたテストを取り除くやり方で、そうすることで通常のビルドプロセスを継続することができます。しかし、優れたチームはより積極的に取り組みます。私たちのMingle(訳注: ThoughtWorks社がかつて開発・運営していたプロジェクトマネジメントツール)チームは、デプロイメントパイプラインの中の健全なテストの一つ後の段階に隔離テストを配置しています。そうすることで、健全なテストからフィードバックを得ることができますが、また同時に隔離されたテストを迅速に選び出すことを強制するのです4

分離の欠如

テストを信頼性高く実行するためには、テストが実行される環境を明確に制御しなければなりません。そしてテストの開始時点での状態を把握できるようにするのです。あるテストがデータベースにデータを作成して、それをそのままにしておくと、異なるデータベースの状態に依存している可能性のある別のテストの実行を破損させてしまいます。

そのため、私はテストを分離しておくことは非常に重要だと考えています。適切に分離されたテストは、どのような順序でも実行できます。機能テストの運用範囲が大きくなればなるほど、テストを分離しておくことはますます難しくなります。非決定性の調査をしているとき、分離の欠如はよくある原因でイライラさせされます。

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

空白の状態から始めることはユニットテストでは通常簡単ですが、機能テストではかなり難しくなります5 - 特に事前にデータベースに準備しておく必要があるデータが多くある場合です。毎回データベースを再構築することは、テスト実行に多くの時間を追加することになります。こういった理由からクリーンアップ戦略に切り替えたいという議論が生じることがあります6

データベースを使用しているときに便利なトリックの1つは、トランザクションの中でテストを行い、テストの最後にトランザクションをロールバックすることです。そうすれば、トランザクションマネージャがあなたの代わりにクリーンアップしてくれるので、エラーの可能性を減らすことができます7

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

分離の欠如問題のよくある原因はデータベースですが、メモリで問題が発生することも多くあります。特に、スタティックデータやシングルトンには注意してください。この種の問題の良い例は、現在ログインしているユーザのようなコンテキスト環境です。

テストで明示的なティアダウンがある場合、ティアダウン中に例外が発生しないように注意してください。このような例外が発生した場合でもテストは成功しますが、その後のテストで分離の欠如による失敗を引き起こす可能性があります。そのため、ティアダウンで問題が発生した場合には、すぐに気づけるようにしましょう。

分離をあまり重視せず、明確な依存関係を定義してテストを指定した順番で実行させることを好む人もいます。しかし、適切に分離を行うことでテストの一部だけを実行したりテストを並列化する事ができるようになるので、私は分離を重視した方が良いと思います。

非同期的な挙動

非同期処理により、長時間かかるタスクを処理しながらソフトウェアの応答性を維持することができます。Ajaxの呼び出しによりブラウザはより多くのデータを得るためにサーバにからのレスポンスを待っている間も応答性を維持することができ、非同期メッセージによりサーバプロセスは他のシステムの遅延に縛られることなく他のシステムと通信することができます。

しかし、テストでは非同期処理は災いとなりえます。ここでのよくある間違いは、スリープすることです。

// 疑似コード
makeAsyncCall;
sleep(aWhile);
readResponse;

これは2つの意味で問題となります。まず、スリープ時間を十分に長く設定して、応答を得るための時間を十分に確保したいでしょう。しかし、これでは反応を待つために多くの時間を無為に過ごすことになり、結果としてテストが遅くなってしまいます。もう一つは、どんなに長くスリープしても、それだけでは十分ではないことがあるということです。環境の変化によってスリープ時間を超えて処理に時間がかかってしまうこともありえます。結果として、このように直接スリープすることは絶対に行わないよう強くお勧めします。

非同期レスポンスをテストするには基本的に2つの方法があります。1つ目は、非同期サービスが終了したときにコールバックを取ることです。これは必要以上に待たされることがないという意味で最も良い方法です8。この方法の最大の問題は、これが可能な環境を使う必要があり、サービスプロバイダもコールバックに対応している必要があるということです。これは、開発チームがテストの実装も行うことによって得られる利点の1つです - コールバックが必要であれば自分たちで実装すればよいだけだからです。

2つ目の選択肢は ポーリングです これは一度だけ結果を確認するのではなく、以下のように定期的に確認するやり方です

// 擬似コード
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 パターンでは、テストが困難な環境にロジックがある場合は、テストが必要なロジックをその環境から分離すべきだと述べています。この場合、テストが必要なロジックの大部分を同期的にテストできる場所に置くということです。非同期動作は可能な限り最小限に(謙虚に)すべきです。

リモートサービス

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

このようなリモートシステムとのテストには多くの問題がありますが、その中でも非決定性はそのリストの上位にあります。多くの場合、リモートシステムには利用可能な可能なテスト用のシステムがありません。テストシステムがある場合であっても、それは十分に安定していないこともあります。

このような状況では、決定性を確保することが重要なのでテストダブルの利用を検討するべきです。テストダブルはリモートサービスのように見えますが、実際にはリモートシステムの動作を模倣しているだけのコンポーネントです。テストダブルは、私たちのシステムとのやり取りで適切なレスポンスを提供するように設定する必要がありますが、それは私たちがコントロールできます。このようにして、我々は決定性を保証することができます。

テストダブルにもデメリットはあります。特に広い範囲でテストを行う場合です。どのようにしてテストダブルがリモートシステムと同じように動作することを確認できるでしょうか?私たちはここで、私が契約テストと呼ぶテストを使用してこれに取り組むことができます。これはリモートシステムとテストダブルの両方に同じインタラクションを実行し、両者が一致するかどうかをチェックします。この場合の「一致」とは、(非決定性のために)同じ結果が得られるという意味ではなく、同じ本質的な構造を共有する結果を意味する場合があります。統合契約テストは頻繁に実行する必要がありますが、私たちのシステムのデプロイメントパイプラインの一部である必要はありません。リモートシステムの変更の頻度に基づいて定期的に実行するのが通常は最適です。

この種のテストダブルを書くために、私は自己初期化フェイクを好んで使います - なぜならこれらは管理しやすいからです。

一部の人は、機能テストでテストダブルを使用することに固く反対し、エンドツーエンドの挙動を保証するために本当にリモートサービスに接続する必要があると信じています。私は彼らの議論に共感しますが、自動化されたテストが非決定的である場合は、役に立ちません。ですから、実際のシステムと接続することによって得られるどんな利点と比べても、非決定性を打破する必要性の方が上回ります11

時間

システムクロックの呼び出しほど非決定的なものはありません。呼び出すたびに新しい結果が返り、それに依存するテストはすべて変化します。今後1時間以内に締め切りのタスクを取得すると、定期的に異なる答えが返ってくるでしょう12

ここで最も重要なことは、テストのために指定された値に置き換えることができるルーチンでシステムクロックを常にラップすることです。クロックのスタブを特定の時間に設定し、その時間に凍結することで、テストがその動きを完全に制御できるようにします。このようにして、テストデータを埋め込まれたクロックの値に同期させることができます1314

この方法で注意しなければならないことの一つは、テストデータが古すぎて問題を起こし始め、アプリケーション内の他の時間ベースの要因と衝突してしまうことがあるかもしれないということです。この場合、データとクロックシードを新しい値に変更することができます。これを行う際には、その変更だけを行うようにしてください。そうすれば、テストに失敗した場合でも、テストデータの時間の変更が原因であると確認することができます。

時間が問題となるもう一つの場面は、クロックからの他の動作に依存している場合です。クロック値に基づいてランダムなキーを生成するシステムを見たことがあります。このシステムがより速いマシンに移行した際に、1回のクロックティック中に複数のIDを割り当てることができるようになったときに、テストが失敗し始めました15

私はこれまでシステムクロックへの直接呼び出しによる問題を多く聞いてきたので、コード解析を使ってシステムクロックへの直接呼び出しを検出し、その場でビルドを失敗させるべきだと考えています。単純な正規表現を使ったチェックであっても、おかしな時間帯に呼び出しがあった後のイライラするデバッグセッションをなくすことができるかもしれません。

リソースリーク

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

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

通常、これらのリソースを処理するための最良の方法はリソースプールを利用することです。もしこれを行うのであれば、プールのサイズを1に設定して、リソースのリクエストを受けた場合に返せるリソースがない場合に例外をスローするようにするのが良い戦術です。そうすることで、リーク後にリソースを要求する最初のテストは失敗します - これによって問題のあるテストを見つけるのがとても簡単になります。

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

謝辞

いつものように、この記事をまとめるための材料を提供してくれた多くのThoughtWorksの同僚に感謝します。

Michael Dietz氏、Danilo Sato氏、Badrinath Janakiraman氏、Matt Savage氏、Krystan Vingrys氏、Brandon Byers氏には記事を読んでいただき、さらにいくつかのフィードバックをいただきました。

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

  1. TDDの支持者の多くがテストの主なメリットは要件と設計を推進する点にあると考えることは知っています。これが大きな利点であることには同意しますが、私はリグレッションスイートが自動化されたテストが私たちに与える最大の利点であると考えています。TDDがなくても、テストはそのためのコストを払う価値があります。 

  2. テストの失敗は、もちろんコードが行うべきことが変更されたことに起因しますが、テストが新しい動作を反映していないことに起因することも時折あります。これは本質的にはテストのバグですが、すぐに捕まえられれば同じように簡単に修正できます。 

  3. 非決定的テストにも有用な役割があります。ランダムな値を使用するテストは、エッジケースを探し出すのに役立ちます。パフォーマンステストは常に異なる値を返してきます。しかし、この種のテストは自動化されたリグレッションテストとは全く異なります。 

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

  5. 堅苦しい定義はありませんが、私は初期のエクストリームプログラミングの用語である「ユニットテスト」をより細かいものを意味するものとして、「機能テスト」をよりエンドツーエンドで機能に関連したテストを指すものとして使っています。 

  6. 1つのコツは、テスト実行のたびにデータベースを開く前に、初期データベースを作成しファイルシステムコマンドを使ってコピーすることです。ファイルシステムのコピーは、データベースコマンドを使ってデータをロードするよりも高速であることが多いです。 

  7. もちろん、このやり方はトランザクションをコミットせずにテストを実行できる場合にのみ機能します。 

  8. しかし、レスポンスが返ってこないケースに対処するためにタイムアウトは引き続き必要になります - そして、そのタイムアウトは別の環境に移行したときに同じ危険を孕んでいます。幸いなことに、問題が起きる可能性を最小化するためにタイムアウト時間をかなり長く設定することができます。 

  9. しかし、その場合テストの実行は非常に遅くなります。待ち時間の制限に達した場合は、テストスイート全体の中止を検討した方が良いでしょう。 

  10. 非同期処理がUIからトリガーされる場合、非同期動作が進行中であることを示すインジケータをUIの一部として用意しておくと良いでしょう。これをUIで実装しておくと、このインジケータを停止させるために必要なフックは、テストロジックの進行状況を検出するためのフックと同じものになるので、テストにも役立ちます。 

  11. リモートシステムが決定的であっても、このような状況でテストダブルを使用する利点は他にもあります。多くの場合、リモートシステムを使用すると、レスポンスタイムが遅すぎることがあります。ライブシステムとしか通信ができない場合、テストはそのシステムにかなりの望ましくない負荷を発生させることになります。 

  12. 現在の時刻に基づいてテストごとにデータストアを再生成することはできます。しかし、これは多くの作業が必要で、タイミングエラーが発生する可能性があります。 

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

  14. 私の同僚の一人は真夜中の直前と直後にテストを実行することを好んでいます。こうすることで、現在時刻を使って1時間か2時間後に同じ日だと仮定しているテストをキャッチする事ができます。これは月の最終日のような時に特に有効です。 

  15. もちろん、これは必ずしも非決定性バグではなく、環境の変化によるものです。クロックティックとIDの生成にかかる時間がどのくらい近いかによっては、非決定的な振る舞いになる可能性があります。