次の方法で共有


Visual Studio 2015

Smart Unit Tests によるソフトウェア ビルド効率の向上

Pratap Lakshman

ソフトウェア開発のリリース サイクルはかつてないほど短くなってきています。ソフトウェア開発チームがウォーターフォール モデルに従って、仕様、実装、テストの各段階を厳密な順番で実行できたのは遠い昔のことです。現在のような慌ただしい環境では高い品質のソフトウェアを開発することは難しく、これまでの開発手法の再評価が求められています。

ソフトウェア製品のバグ数を減らすには、チーム メンバー全員がソフトウェア システムの目的について合意する必要がありますが、その合意自体が大きな課題となっています。仕様、実装、テストの各段階は、共通のコミュニケーション手段もなく、それぞれ独立して実行されます。各段階で異なる言語や成果物が使用されるため、ソフトウェアの実装作業を進めていく過程で、言語や成果物を一緒に進化させていくことは困難でした。仕様書がチーム メンバー全員の作業を結び付けるべきなのに、それが現実になることはめったにありません。本来の仕様と実際の実装が食い違ってしまうことがあり、最終仕様と、途中で下された設計上のさまざまな決定事項をすべてまとめて保持しているのはコードだけということになります。テストでは、はっきりとわかっているごくわずかなエンドツーエンドのシナリオをテストして、この食い違いを調整しようと試みます。

この状況は改善できる可能性があります。そのためには、ソフトウェア システムの動作目的を指定する共通媒体が必要です。それは、設計、実装、テストの各段階で共有できる媒体で、簡単に進化させていける媒体です。仕様とコードを直接関連付ける必要があるため、この媒体は包括的なテスト スイートとして成文化します。こうしたニーズを満たすのが、Smart Unit Tests によって実現されるツールベースの手法です。

Smart Unit Tests

Smart Unit Tests は Visual Studio 2015 Preview の機能 (図 1 参照) で、ソフトウェア開発をインテリジェントに支援し、開発チームがバグを早期発見できるようにして、テストでのメンテナンス コストを削減できるようにします。この機能は、Microsoft Research が以前に開発した "Pex" を基盤としています。そのエンジンはホワイトボックス コード分析と制約ソルバーを使用します。これにより、テスト対象のコード内のすべてのコード パスをテストできる正確なテスト入力値を合成し、これをカバレッジが高い従来の単体テストのコンパクトなスイートとして保存し、コードの進化に応じてテスト スイートも自動的に進化させます。

Visual Studio 2015 Preview に完全に統合された Smart Unit Tests
図 1 Visual Studio 2015 Preview に完全に統合された Smart Unit Tests

さらに、Smart Unit Tests では正確性のプロパティを使用することが強く推奨されています。正確性のプロパティは、コードではアサーションとして指定されます。このプロパティはその後テスト ケース生成の指針としても使用されます。

一連のコードに対して Smart Unit Tests を実行するだけであれば、既定では、合成入力値ごとに、テストするコードの監視対象の動作が、生成されるテスト ケースに取り込まれます。この段階では、実行時エラーを引き起こすテスト ケースを除き、残りのテスト ケースはテストに合格するものと見なされます。結局は、その残りのテスト ケースが監視対象の動作です。

さらに、テスト対象のコードの正確性のプロパティを指定するアサーションを記述すると、Smart Unit Tests はそのアサーションを失敗させるテスト入力値だけでなく、コード内のバグを明らかにするような各入力値と、その入力値によって失敗するテスト ケースも見つけ出します。Smart Unit Tests 自体はこのような正確性のプロパティを見つけることはできないため、正確性のプロパティは開発者が専門知識に基づいて記述することになります。

テスト ケースの生成

一般に、プログラム分析の手法は、次の 2 種類の両極端な手法の中間に位置します。

  • 静的分析手法。正確性のプロパティがすべての実行パスに対して true になることを検証します。目標はプログラムの検証なので、この手法は通常、過度に控えめになり、違反の可能性があればエラーとしてフラグを付けるため、誤検知につながります。
  • 動的分析手法。正確性のプロパティが実行パスの一部で true になることを検証します。テストではバグの検出を目的として動的分析アプローチを採用しますが、一般にエラーがないことは証明できません。そのため、この手法では、多くの場合、すべてのエラーを検出することはできません。

静的分析のみを採用したり、コードの構造を認識しないテスト手法を採用すると、バグを正確に検出できない可能性があります。たとえば、次のコードを考えてみます。

int Complicated(int x, int y)
{
  if (x == Obfuscate(y))
    throw new RareException();
  return 0;
}
int Obfuscate(int y)
{
  return (100 + y) * 567 % 2347;
}

静的分析手法は控えめになる傾向があるため、Obfuscate 関数で行われる非線形の整数演算が原因で、ほとんどの静的分析手法では、Complicated 関数でエラーが発生する可能性があるという警告を発します。また、ランダム テスト手法では、例外が発生する原因となる x 値と y 値のペアを検出するチャンスはほとんどありません。

Smart Unit Tests では、これら 2 種類の両極端な手法の中間に位置する分析手法が実装されます。静的分析手法と同様に、正確性のプロパティがもっとも現実的なパスに対して true になることが証明されます。また動的分析手法と同様に、実際のエラーのみが報告され、誤検知は報告されません。

テスト ケースの生成には、以下のものが関与します。

  • テスト対象のコードに含まれるすべての分岐 (明示および暗黙) の動的な検出。
  • 検出した分岐をテストする正確なテスト入力値の合成。
  • 合成入力値に対するテスト対象コードの出力の記録。
  • 高いカバレッジを備えたコンパクトなテスト スイートとしての保存。

図 2 は、ランタイム インストルメンテーションの使用と監視のしくみを示しています。この図には以下の手順が関係しています。

  1. 最初にテスト対象のコードがインストルメント化され、テスト エンジンが実行を監視できるようにコールバックが埋め込まれます。次に、最も単純で具体的な関連入力値 (パラメーターの型に基づく) を使用してコードが実行されます。これが最初のテスト ケースを表します。
  2. テスト エンジンが実行を監視し、テスト ケースごとにカバレッジを計算して、入力値がコード内をどのように流れるかを追跡します。すべてのパスがカバーされると、プロセスは停止します。例外動作はすべて分岐と見なされ、コード内の明示的な分岐とまったく同様に扱われます。カバーされていないパスが残っている場合、テスト エンジンはカバーされていない分岐の起点となるプログラム ポイントに達するテスト ケースを選んで、分岐条件が入力値にどのように依存するかを判断します。
  3. エンジンは、そのプログラム ポイントへの到達を制御する条件を表す制約システムを構築し、それまでにカバーされていない分岐に沿ってテストを続けます。次に、制約ソルバーにクエリを行い、この制約に基づいて新しい具体的な入力値を合成します。
  4. 制約ソルバーによって制約に対する具体的な入力値が特定されれば、テスト対象のコードが新たな入力値で実行されます。
  5. カバレッジが増加する場合は、テスト ケースが生成されます。

テスト ケースの生成のしくみ
図 2 テスト ケースの生成のしくみ

手順 2. ~ 5. は、すべての分岐がカバーされるまで、または事前に構成した探索の制限を超えるまで繰り返されます。

手順 2. ~ 5. のプロセスを "探索" と呼びます。探索の実行中に、テスト対象のコードは複数回 "実行" されることになります。この数回の実行によってカバレッジが増加し、カバレッジが増加する実行のみを対象にテスト ケースが生成されます。したがって、生成されるすべてのテストによって、現実のパスがテストされます。

制限付きの探索

テスト対象のコードにループが含まれていない場合、および無制限の繰り返しが含まれていない場合は、分析対象の実行パスの数が有限 (少ない数) になるため、通常探索はすぐに停止します。ただし、大半のプログラムにはループや制限のない繰り返しが含まれています。そのような場合、実行パスの数は (事実上) 無限になるため、通常、あるステートメントに到達できるかどうかを決定できません。つまり、プログラムの実行パスをすべて分析する探索が永久に続きます。テスト ケースの生成ではテスト対象のコードを実際に実行する必要があるため、そのように手に負えない探索から保護する方法が必要です。そこで、制限付きの探索が重要になります。制限付きの探索では、妥当な時間が経過したら探索が停止するようにします。次に示すように、複数の層から成る構成可能な探索の制限が使用されます。

  • 制約ソルバーの制限。ソルバーが次回の具体的な入力値の検索に使用できる時間とメモリの量を制限します。
  • 探索パスの制限。分析対象の実行パスの複雑さ (分岐数)、チェックする必要がある入力に対する条件の数、および実行パスの深さ (スタック フレーム数) を制限します。
  • 探索の制限。テスト ケースを生成しない "実行" の回数、許容合計実行回数、および探索を停止するまでの時間を制限します。

ツールベースのテスト アプローチの効果を上げるには、迅速なフィードバックが重要です。そのため、これらすべての制限は迅速な対話型の使用を実現できるように事前に構成されています。

さらに、テスト エンジンはヒューリスティックを使用して、解決が難しい制約システムを延期することで、高いコード カバレッジをすばやく実現します。作業中のコード用のテストをすばやく生成するようエンジンに指示することができます。ただし、残りの解決が難しいテスト入力が生成される問題に対処するために、しきい値を変えて複雑な制約システムをテスト エンジンに細分化させることができます。

パラメーター化した単体テスト

すべてのプログラム分析手法は、特定のプログラムのある特性 (プロパティ) を指定して、そのプロパティの検証または反証を試みます。こうしたプログラムのプロパティは、次のようなさまざまな手法で指定できます。

  • API コントラクト。実装の観点から API の個別のアクションの動作を指定します。目標は堅牢性を保証することです。つまり、操作がクラッシュすることなく、データの不変性が保持されることを保証します。API コントラクトの一般的な問題点は、API の個別のアクションという狭い範囲を対象にすることです。そのため、システム全体の手順を記述することが難しくなります。
  • 単体テスト。API のクライアントという観点から模範となる使用シナリオを具体化します。目標は機能の正確性を保証することです。つまり、複数の操作の相互作用が意図したとおりに動作することを保証します。単体テストの一般的な問題点は、テストが API の実装の詳細から切り離されることです。

Smart Unit Tests は、この両方の手法を兼ね備えた、パラメーター化した単体テスト (PUT: Parameterized Unit Testing) を可能にします。テスト入力生成エンジンのサポートの下、この手法はクライアントと実装の両者の観点を組み合わせます。機能の正確性のプロパティ (PUT) が、実装 (テスト入力の生成) のほとんどのケースに対してチェックされます。

PUT は、パラメーターを使用することによって、単体テストをわかりやすく汎用化します。PUT を見れば、1 つの模範的な入力値に対してではなく、可能性のある一連の入力値全体に対するコードの動作が分かります。PUT はテスト入力の前提を表現し、アクションのシーケンスを実行して、最終状態で保持すべきプロパティのアサーションを行います。つまり、PUT が仕様書の役割を果たします。このような仕様書であれば、新しい言語や成果物を必要とすることも、導入することもありません。PUT は、ソフトウェア製品が実装する実際の API のレベルで、ソフトウェア製品のプログラミング言語を使って記述します。設計者はソフトウェア API の動作目的の表現に PUT を使用できます。開発者は PUT を使って自動テストを自身で実行できます。テスト担当者は詳細自動テストの生成に PUT を利用できます。たとえば、以下の PUT は、null 以外のリストに要素を追加後、その要素が実際にリストに含められることをアサートします。

void TestAdd(ArrayList list, object element)
{
  PexAssume.IsNotNull(list);
  list.Add(element);
  PexAssert.IsTrue(list.Contains(element));
}

PUT は、次の 2 つの懸案事項を分離します。

  1. 可能性のあるすべてのテスト引数に対するテスト対象のコードの正確性のプロパティの指定。
  2. 具体的な引数を使用した実際の "閉じられた" テスト ケース。

エンジンは、最初の懸案事項についてスタブを生成します。開発者は自身の専門知識に基づいてこのスタブに肉付けします。その後の Smart Unit Tests の呼び出しによって、個別の閉じられたテスト ケースが自動的に生成および更新されます。

導入方法

既にさまざまな手法を導入しているソフトウェア開発チームもあるため、一夜にして新しい手法に切り替えるというのは現実的ではありません。実際には、Smart Unit Tests をテスト実行チームの代わりになるものと考えるのではなく、どちらかと言えば、既存の手法を拡張するものと考えます。導入は段階的に始めます。まず、既定の自動テスト生成とメンテナンス機能を使用してみて、次にコードでの仕様書の記述に進みます。

監視対象の動作のテスト: テスト カバレッジが用意されていない状態でコードの本文に変更を加える必要があるとします。変更に着手する前に、単体テスト スイートの観点からコードの動作を把握しておきたいと考えますが、次のようなことから、口で言うほど簡単なことではありません。

  • コード (運用コード) は、単体テストが可能になるように考えて記述されているわけではありません。外部環境との密接な依存関係があるかもしれません。このような依存関係は切り離す必要がありますが、依存関係を特定できなければ、どこから手をつけてよいかわからない場合もあります。
  • テストの品質が問題になることもあります。そのため、多くの品質評価基準が存在します。たとえば、カバレッジの評価基準では、運用コードに含まれる多くの分岐、コード パス、またはそれ以外のプログラム成果物をどのように調べてテストするかが指定されます。アサーションの評価基準では、コードが適切に実行されるかどうかが表現されます。しかし、どのような評価基準でも単独では十分なものとは言えません。そこで推奨されるのが、広い範囲のコード カバレッジで、細かいレベルで多数実行されるアサーションを検証する方法です。しかし、テストを記述するときに、頭の中でこのような品質分析を実行するのは容易なことではありません。結果として、同じコード パスを繰り返し実行するテストになってしまうことがあります。おそらく、「うまくいくパス」をテストするだけになってしまい、運用コードがすべてのエッジ ケースに対処できるかどうかは決してわかりません。
  • そのうえ腹立たしいことに、どのようなアサーションを用意すべきかわからないことさえあります。中身がわからないコード ベースに変更を加えるよう求められることを想像してみてください。

このような状況に特に役立つのが、Smart Unit Tests の自動テスト生成機能です。回帰テスト スイートとして使用するための一連のテストとして、現時点でのコードの監視対象の動作をベースラインに指定できます。

仕様書ベースのテスト: ソフトウェア チームは、PUT を仕様書にして、包括的なテスト ケースの生成を推進し、テストのアサーションに対する違反を明らかにすることができます。高いコード カバレッジを実現するテスト ケースの作成に必要な手作業のほとんどから解放されるため、チームは関心の高いシナリオを PUT として記述することや、PUT の対象範囲を超える統合テストを開発することなど、Smart Unit Tests で自動化できないタスクに専念することができます。

自動バグ検出: 正確性のプロパティを表現するアサーションは、assert ステートメントやコード コントラクトなど、複数の方法で指定できます。アサーションが優れているのは、すべてを分岐としてまとめられる点です。つまり、アサートされる述語の結果が then 分岐と else 分岐を備えた if ステートメントとして表現されることです。Smart Unit Tests はすべての分岐をテストする入力を導き出すため、アサーションが効率的なバグ検出ツールになります。つまり、アサーションの else 分岐に進むすべての入力は、テスト対象のコード内のバグを表します。そのため、報告されるすべてのバグが実際のバグになります。

テスト ケースのメンテナンスの削減: PUT を用意すると、メンテナンスが必要なテスト ケースの数が大幅に少なくなります。閉じられたテスト ケースを手動で記述していた時代は、テスト対象のコードが進化すると大変なことになりました。すべてのテストのコードをコードの進化に合わせて個別に適合させる必要が生じ、膨大なコストがかかっていました。しかし、PUT を記述しておけば、メンテナンスが必要になるのは PUT だけです。PUT をメンテナンスしたら、Smart Unit Tests によって個別のテスト ケースが自動的に再生成されます。

課題

ツールの制限: 制約ソルバーとホワイトボックス コード分析を使用する手法は、うまく分離された単体レベルのコードでは非常に適切に機能します。ただし、テスト エンジンには以下のような制限があります。

  • 言語: 原則として、テスト エンジンは .NET 言語で記述された任意の .NET プログラムを分析できますが、生成されるテスト コードは C# に限定されます。
  • 非決定ロジック: テスト エンジンは、テスト対象のコードが決定的であると仮定します。コードのロジックが非決定の場合、非決定となる実行パスを取り除くか、探索のサイクルを制限します。
  • 同時実行: テスト エンジンはマルチスレッド プログラムを処理しません。
  • ネイティブ コードまたはインストルメント化されない .NETコード: テスト エンジンはネイティブ コード (Microsoft .NET Framework の Platform Invoke (P/Invoke) 機能を使用して呼び出される x86 命令) を理解しません。テスト エンジンは、そのような呼び出しを、制約ソルバーが解決できる制約に変換する方法を認識しません。.NET コードであっても、エンジンが分析できるのはインストルメント化したコードのみです。
  • 浮動小数点数演算: テスト エンジンは自動制約ソルバーを使用して、テスト ケースとテスト対象のコードに関連する値を判断します。ただし、制約ソルバーの機能は限られています。特に、浮動小数点演算を正確に判別できません。

上記に該当する場合、テスト エンジンは警告を表示して開発者に通知します。このような制限があるエンジンの動作は、カスタム属性を使用して制御できます。

優れた PUT の記述: 優れた PUT を記述するのは、簡単なことではありません。次に示す 2 つの重要な疑問を解消する必要があります。

  • カバレッジ: テスト対象のコードをテストする優れたシナリオ (メソッドの呼び出しシーケンス) はどのようなものか。
  • 検証: アルゴリズムを再実装することなく簡単に指定できる優れたアサーションはどのようなものか。

PUT が効果的に機能するのは、この両方の疑問を解消できる場合のみです。

  • カバレッジが十分でない場合、つまり、テスト対象のすべてのコードに到達するにはシナリオが狭すぎる場合、PUT の範囲が制限されます。
  • 求められた結果の検証が不十分な場合、つまり、PUT に十分なアサーションが含まれていない場合、コードが正しく動作することをチェックできません。その場合に PUT でチェックできるのは、テスト対象のコードがクラッシュしないこと、またはそのコードで実行時エラーが発生しないことだけです。

従来の単体テストでは、もう 1 つ解消すべき疑問点があります。それは関連するテスト入力とは何か、というものです。PUT では、この疑問をツールが解消します。ただし、優れたアサーションを見つけ出すという問題は、これまでの単体テストの方が簡単に対処できます。従来の単体テストでは、特定のテスト入力に対してアサーションを記述することになるため、よりシンプルになる傾向があります。

まとめ

Visual Studio 2015 Preview の Smart Unit Tests 機能により、開発者はソフトウェアのソース コードの観点からソフトウェアの動作目的を指定できるようになります。Smart Unit Tests 機能では、自動化されたホワイトボックス コード分析と制約ソルバーが組み合わされ、.NET コードに対してカバレッジが高いコンパクトな関連テスト スイートが生成およびメンテナンスされます。そのメリットはさまざまなメンバーに恩恵をもたらします。設計者はソフトウェア API の動作目的を指定できます。開発者は自動テストを自身で実行できます。テスト担当者は詳細な自動テスト生成に利用できます。

ソフトウェア開発におけるかつてないほど短いリリース サイクルは、計画、仕様策定、実装、テストの各段階に関連するアクティビティのほとんどに影響を及ぼし、絶えず対応が迫られます。このように慌ただしい世界では、これらのアクティビティで行っている既存の手法を再評価するのは困難です。短期間にすばやく繰り返されるリリース サイクルでは、各段階を連携させて新しいレベルに引き上げることが求められます。Smart Unit Tests などの機能は、ソフトウェア開発チームを新たなレベルへと簡単に引き上げる手助けをします。


Pratap Lakshman は、マイクロソフトのデベロッパー部門に勤務し、現在は Visual Studio チームのシニア プログラム マネージャーとしてテスト ツールに取り組んでいます。

この記事のレビューに強力してくれたマイクロソフト技術スタックの Nikolai Tillmann に心より感謝いたします。