テストオラクルに依存しないテスト実装テクニック:ラウンドトリップテスト(Roundtrip Test)

ファジングテスト、コンコリックテストなど、テスト入力を自動生成・大量使用するテストで問題になるのが、テストオラクル(テストの期待値を提供するもの)をどう実装するかです。このテストオラクル問題への対策の代表例については、次のようなアプローチがあります。

  • 期待値を生成できるテストオラクルを別に用意する(モデル駆動開発、実行可能な仕様など)
  • テスト対象から期待値を予測できる条件でテストする(メタモルフィックテスティングなど)
  • テスト入力後、動作不能でないこと・エラーが記録されていないことを大まかに確認する(ストレステストなど)
  • テスト結果の変化の監視にフォーカスする(ビジュアルリグレッションテストなど)

このうち「テスト対象から期待値を予測できる条件でテストする」の実装例の一つにラウンドトリップテスト(Roundtrip Test)があります。

※注意事項として、ラウンドトリップテストは複数の意味で用いられる用語です。例えばxUnit Test PatternsではEnd to Endの外部インターフェース経由でSUTとやり取りするテストをラウンドトリップテストと呼称しています。現状では、どの定義で使われているかは文脈で判断する必要があります。

ラウンドトリップテストとは

ラウンドトリップテストとは、入力データに処理をかけ、その結果に逆処理をかけた結果が、元の入力データと一致するか確認するテストを指します。

具体例として以下があります。

  • エンコードした後、デコードして、元のデータに戻ることをテスト(例えば暗号化処理のテスト)
  • 圧縮した後、解凍して、元のデータに戻ることをテスト(例えば可逆画像圧縮のテスト)
  • 出力し、それを読み取って、出力データと一致することをテスト(例えばデータ記録のテスト)

ラウンドトリップテストでは、入力データと実行結果を比較するのみのため、別にテストオラクルを実装する必要がなくなります。また「システムに異常が発生しない」「ログにエラーが記録されない」のような大雑把な確認だけでなく、ある程度詳細に動作を確認できます。
そのため、ファジングやストレステストのような、テスト入力を大量に自動生成するアプローチで、テストオラクル問題の改善に寄与します。

ファジングでのラウンドトリップテストの実装例として以下があります。

gif圧縮のファジング
json処理のファジング

ラウンドトリップテストを使うときの戦略立て

ラウンドトリップテストは、「処理をかけ、逆処理をかけたら元に戻る関係」のみを確認するため、それだけではテストはまったく不十分です。用意した期待値と比較する通常のテストの代替にはなりません。
ラウンドトリップテストは、それら通常のテストを補強するための、ファズデータに対する耐性や堅牢性を確認するための追加確認として活用するのが現実的な戦略になります。

ミューテーションテストの概要と類似手法との違い

ミューテーションテストとは、テストの有効性を評価するための手法です。テスト対象を変更し(バグを埋め込み)、それによってテスト結果が変化するかを調べることで、テストがバグを見つけられるかを評価します。
ユニットテストなどホワイトボックステストを対象した手法ですが、派生的にDSLやモデルのテストに適用したり、コンテナといった環境の条件設定に適用したりする事例もあります。

ミューテーションテストのアイデア自体は1970年代からあり、1980年代の"Hints on Test Data Selection: Help for the Practicing Programmer"でミューテーションの命名が行われ、手法として整理されました。

ソフトウェア開発では主に学術サイドで活発な研究や実践が続けられています。ICSTといったソフトウェアテストの学会では発表のそれなりの割合を占める活発なテーマとなっていて、専用のカテゴリや特設イベントが設けられているのをよく見ます。

ミューテーションテストの基本的な進め方

1. テストを実行しテスト結果を得る
2. ミューテーション操作(Mutation Operator。テスト対象を変更しバグを埋め込むやり方)に基づいてミュータント(ミューテーション操作によって変更されたテスト対象)を生成し、テストを実行してテスト結果を得る

ミューテーション操作は、網羅的なミューテーションテストを効率的に行うために、自動処理で実行できるものが一般的に選ばれます。
代表的なミューテーション操作として以下があります:

  • 判定処理の変更
    • >=、<=といった比較演算子の置換、ブール値あるいはブール演算子の反転、境界値の変更など
  • ステートメントを挿入(例えばコードを複製する)したり、削除したりする
  • 演算の変更
3. ミューテーション操作によってテスト結果が変化したか評価する

前提用として、ミューテーション操作によりテスト結果が変化することをテストが補足できたら、ミュータントをキルできたとみなします。

包括的な評価アプローチとして、ミューテーションスコア(あるいはキルスコア)の評価があります。これは、ミュータントの総数で、キルできたミュータントの数を割った割合値です。テストカバレッジと複合して評価し、ざっくりテストが妥当か、ミューテーション操作の選択が問題ないか判断します。
等価ミュータントの問題などに起因して、ミューテーションスコアが厳密にテストの品質を表現できない場合もあるため、あくまで俯瞰的な評価に用いられます。

厳密に評価するには、キルできなかったミュータントを調べ、その場合に対するテストが妥当か確認していくアプローチを取ります。

ミューテーションテストの用途

ミューテーションテストの主要な用途として以下があります:

  • バグを見つけられないテストの識別。キルできなかったミュータントを調べ、それをテストで見つけるべきだったか評価します。
    • 全くミュータントを検出できないテストを調べることで、無駄なテストの特定にも活用できます。
  • テストの網羅性の不足の識別。ミューテーションテストでのテストカバレッジを調べ、テストが網羅できていないテスト対象の領域を特定します。
  • ミューテーション操作の妥当性の評価。ミューテーションテストにおけるテストカバレッジやミューテーションスコアを評価して、ミューテーションテストのやり方がそもそも有効なのかを評価します。

類似手法との違い

テスト対象にバグを埋め込む手法としては、今回のミューテーションテストのほかに、フォールトインジェクションやエラーシーディングがあります。

ミューテーションテストは、ミューテーション操作でテスト対象を改変しミューテーションスコアで評価する、というように具体的にアプローチが規定されています。
一方で、フォールトインジェクションやエラーシーティングは障害や不具合を埋め込むアプローチを大まかに指す言葉です。そのため、この2手法とミューテーションテストは包含関係にあるとも言えなくもありません(筆者もこれまでテストの評価アプローチとして、ミューテーションテストを含むものとしてフォールトインジェクションの用語を使っていました)。が、微妙に目的や適用範囲が異なります。

※参考情報として、ミューテーションテスト、フォールトインジェクション、エラーシーディングといった、不具合を埋め込む手法を包括した呼び名として、エラー指向テスト、エラー指向アプローチという言葉があります。

ミューテーションテストとフォールトインジェクションの違い

ミューテーションテストはテスト対象に変異を埋め込み、テストを評価します。
一方で、フォールトインジェクションは、テスト対象に障害を埋め込むだけでなく、テスト対象外に障害を埋め込むやり方も一般的です。後者は、主にテスト対象の障害許容性や障害対応処理を評価したり、テスト対象に組み込まれたサニティテストが有効か評価したりする用途で活用されます。

ミューテーションテストとエラーシーティングの違い

ミューテーションテストはホワイトボックステストの評価に使用します。
エラーシーティングは、主にテストプロセスやテスト工程の評価に使用します。目的はテストの有効性評価だけでなく、残留不具合の推定や、将来的な流出不具合の見積もりを行うために実施されます。

Go Fuzzingによるファジングテスト/ランダムテスト

Goは標準のテストフレームワークでファジングをサポートしています。今回はそのファジング機能について、テスト条件の網羅をどうするかを中心に使い方をメモします。

基本的なテストコードの書き方

Fuzzingを使ったテストコードの簡単な例を以下に示します。
テスト対象Targetを実行し、エラーが発生しないことを確認しています。Seed Corpus(ユーザ指定の入力のセット)は未指定です。

import (
	"testing"
)

func FuzzTarget(f *testing.F) {
	f.Fuzz(func(t *testing.T, i int8) {
		_, err1 := Target(i)
		if err1 != nil {
			return
		}
	})
}

大雑把に基本ルールのみに絞って説明すると、Go Testingのテストコードの記法や命名(*_test.goのファイル名にするなど)を踏襲した上で、以下のような記述を行います。

  • テストメソッドは「Fuzz」から始める
  • 実行時はgo tesetに「-fuzz」オプションを付与する
  • (*testing.F).Fuzzでテストメソッドをラップする

既に以下のような明快な解説が複数存在するため、詳細な説明は割愛します。

Go Fuzzing - The Go Programming Language
Tutorial: Getting started with fuzzing - The Go Programming Language
Go1.18から追加されたFuzzingとは | フューチャー技術ブログ

デフォルトでFuzzingが生成する入力値データ

試しに上記のコード(int8を入力)を対象に、Seed Corpus未指定のまま、2秒間Fuzzingを実行(1491回テスト実行)して得られた値の出現数を示します。

この試行を行った場合では、入力値の全パターンに対する網羅率は98%でした。割と広く網羅していますが、今回は一般的に品質リスクの高いと見なされる下限の「-128」の境界値を網羅できていませんでした。

ファジングツールはランダムデータに加えて、一般的に品質リスクの高いパターンを反映したファズデータを入力に用いることが多いです。
一方、Go Fuzzingは、テスト対象のASTを網羅するように探索するものの、ブラックボックス観点や外部の知見を使用しません。その点でいうと、Seed Corpus未指定では、Go FuzzingはHaskellのQuickCheckと同種の、ランダムテストのツールと見なしても良いと思います。

なおSeed Corpusを指定すると、Go Fuzzingはその入力セットとそれを変異させたデータを入力として生成するようになります。すなわち、ブラックボックス観点での品質リスクを使ってファジングテストとしての有効性をより高めるためには、テストコード実装者がテスト条件を分析して追加指定する必要があります。

Fuzzingで特定のテスト条件を生成させる

Fuzzingの入力値生成エンジンに特定のテスト条件を生成させるためには、そのテスト条件をデータファイルあるいは(*testing.F).Addの手段を使って、Seed Corpusとして指定します。
たとえばファジングでint8の境界値を網羅するように指定する場合、次のように記述します。

func FuzzTarget(f *testing.F) {
	condisions := []int8{127, -128}
	for _, cd := range condisions {
		f.Add(cd)
	}
	f.Fuzz(func(t *testing.T, i int8) {
		_, err1 := Target(i)
		if err1 != nil {
			return
		}
	})
}

上記のようにSeed Corpusでテスト条件を指定すると、そのテスト条件と、その一部を変異させたテスト条件を網羅するようにFuzzingが動作します。サンプルコードの場合、127、-128とその周辺値をテスト条件に用いるようになります。

テスト失敗時のテスト条件に基づいたテスト条件の生成

ファジングでテストが失敗した場合、実行ディレクトリの「testdata/fuzz/テスト名/自動生成したファイル名」に失敗時のテスト条件をSeed Corpusとして記録します。
例えば以下のテスト対象を対象にファジングを実行し、前述のファジングのテストコードでエラーを発生させてテスト失敗させてみます。

func IntTarget(i1 int, i2 int) (int, error) {
	if i2 == 1 {
		return 1, errors.New("")
	}
	return 0, nil
}

すると前述のディレクトリパスに以下のSeed Corpusファイルを生成します。

go test fuzz v1
int(-65)
int(1)

※-65はテスト失敗時のi1の入力値

以降、同じファジングテストを実行する際は、Fuzzingは記録されたテスト条件i1=-65、i2=1を網羅・頻出させるように動作します。

コードカバレッジに基づいたテスト条件の生成

Fuzzingはテスト対象のコードカバレッジが変化するテスト条件をinteresting pointとして抽出し、そのデータを用いてよりコードカバレッジを網羅するように入力値を生成させます。
例えば以下のようなテスト対象・テストコードでファジングを実行します。

//テスト対象
func IntTarget(i1 int, i2 int) (int, error) {
	if i1 == 100 && i2 == -100 {
		fmt.Print("hogefuga")
	}
	if i1 == 200 {
		fmt.Print("piyo")
	}
	return 0, nil
}
//テストコード
func FuzzIntTarget(f *testing.F) {
	f.Fuzz(func(t *testing.T, i1 int, i2 int) {
		_, err1 := IntTarget(i1, i2)
		if err1 != nil {
			t.Error("error")
			return
		}
	})
}

するとFuzzingは、i1については100と200およびその周辺値、i2については-100およびその周辺値を頻出させるように入力値を生成します。

C++ コンセプト(concepts)の型制約のテスト

C++20から導入されたコンセプト(concepts)は、テンプレート型のパラメータの制約を実現する、型制約の言語機能です。
コンセプトを使うと、従来のSFINAEなどのテクニックと比べて、簡潔に型の制約を記述できるようになるほか、制約違反時のエラーメッセージを格段に読みやすくできます。
ただコンセプトによって、型の制約をより複雑に書けるようになる側面があります。そこで需要が増えるのが、型制約のテストです。複雑なコンセプトを書けばプログラミング中に確認を行いたくなりますし、また例えばライブラリなどの汎用的なAPIでは型制約は重要な品質保証の対象になります。

コンセプトのテストの記述

コンセプトはコンパイル時に評価され、それ単体では制約を満たせばbool型でtrueを、制約に違反すればfalseを返す挙動をとります。そのためコンセプトのテストは、コンパイル時評価手段のstatic_assertを使って簡単に実現できます。
サンプルコードを示します。

#include <concepts>

template <class T, class U>
concept Comparable = requires (T t, U u)
{
  {t == u} -> std::convertible_to<bool>;
};

class Dummy
{//Comparableの制約を満たさないダミー
};

int main()
{
  //コンセプトのテスト
  static_assert(Comparable<int, double>);
  static_assert(Comparable<int, bool>);
  static_assert(not Comparable<int, Dummy>);//等価比較不能
}

以下がテスト対象のコンセプト記述です。等価比較演算できることを制約で保証します。このコンセプトが適用されたテンプレートのパラメータが制約違反すると、コンパイル不能になります。

template <class T, class U>
concept Comparable = requires (T t, U u)
{
  {t == u} -> std::convertible_to<bool>;
};

以下がコンセプトのテストの記述になります。

  static_assert(Comparable<int, double>);
  static_assert(Comparable<int, bool>);
  static_assert(not Comparable<int, Dummy>);//等価比較不能

static_assertでコンパイル時テストとして処理します。コンセプトを満たすことを確認するテストはコンセプトをそのまま、コンセプトに違反することを確認するテストはnot演算子を付与して記述します。
今回のコンセプトはこのテストに合格するため、コンパイルに成功します。

コンセプトのテストに失敗する場合

仮に、Dummyで次のように等価比較をオーバーロードして、Comparableが成立するように改変します。

class Dummy
{
public:
	bool operator == (const bool obj) const
	{
		return true == obj;
	}
};

これを前述のコンパイル時テストで実行すると、コンパイルが次のエラーで失敗するようになります。

sample.cpp:22:17: error: static assertion failed
   22 |   static_assert(not Comparable<int, Dummy>);
      |                 ^~~~~~~~~~~~~~~~~~~~~~~~~~

JaSST'22東北でテスト自動化について登壇

先日、JaSST’22東北にて、「テスト自動化の成功を支えるチームと仕組み」と題して招待講演を行う機会をいただきました。

内容は、テスト自動化を支える活動や、テスト自動化の基礎作りについて、長年の経験で得た経験や知見を整理したものになります。

docs.google.com

他の講演の内容と相乗効果もあり、今回はかなり好評だったようで安堵しています。
運営者・参加者の方々、貴重な機会をいただきありがとうございました。

テストカバレッジを具体的に表現するためのアプローチ

 構造やコードに対するテストカバレッジは、テスト対象のモデルが比較的明快であるため、表現が容易ですし、既に様々なカバレッジが世の中で使用されています。
 例えばコードの場合、次のようなカバレッジでテストカバレッジを表現できます。

 一方、システムテストなどでの、ブラックボックスの要求や仕様に対するカバレッジについては、テスト対象のモデルが複雑・不明瞭であることもあり、カバレッジの表現が適切でない場面をよく見ます。
 例えば、そういった場面では、テスト設計の網羅目標として、次のようなカバレッジのみでテストカバレッジを表現する事例をよく見ます。

  • 仕様項目(ユーザストーリ、ユースケース、USDMの仕様など)に対する網羅率
    • 仕様項目一つ一つに対し、対応するテストを用意できているか
  • テストベースに対する網羅率
    • テストベース(例えば要求仕様書)の一文一文に対し、テストを漏れなく用意できているか

 あるいは、上記より少しだけ踏み込んだレベルで、「画面遷移を網羅できているか」「選択肢を網羅できているか」といった、特に目立つ側面のみのカバレッジで表現している場合もあります。

 このレベルのテストカバレッジは、大規模開発でテスト設計を俯瞰把握したり、組織に最低限のテストを求めたりするような、ざっくりとした用途では有効です。

 しかし、こうしたカバレッジは、テストの目標やテスト設計の十分性基準を表現するものとしては不足しています。
 例えば「仕様項目(例:ユーザストーリ)に対する網羅率が100%である」というテストカバレッジを守らせても、ユーザストーリ→テストのトレーサビリティの関連づけができる程度のことしか保証できません。ユーザストーリに対して適切な網羅ができているかは触れられていない状態です。

カバレッジが不明瞭な場面でのテストカバレッジの表現アプローチ:表現可能になるまでテスト観点を具体化する

 ではテスト設計の目標・十分性基準を具体的に明文化したり、指定したりするためにどうすればいいかについてですが、広く有効なアプローチとして「カバレッジを適切に表現可能になるまで、テスト観点を分解する」というものがあります。

 具体例として、特定のアプリの画面のテストのカバレッジを表現したい場合、次のようにテスト観点を分解します。

  • 文言
    • 各言語の正確さ
    • 文字切れ
  • アクセシビリティ基準の準拠
  • アニメーション
    • 滑らかさ
  • 実行デバイス
    • OS
      • 種類
      • バージョン
    • 画面レイアウト
  • ・・・

 このようにテスト観点を具体化しておくと、「一通りの画面・画面遷移を網羅できていること」といったざっくりとしたカバレッジ表現ではなく、「指定のOSのバリエーションを網羅していること」「アクセシビリティ基準を満たしていること」といった具体的なカバレッジ表現ができるようになります。

 なおこのテスト観点の抽出は、マインドマップやテスト観点分析ツリーといった、ズームアウト・ズームインを表現できるモデリングで行うと、カバレッジ表現の点で都合が良いです。標準的なカバレッジ表現をしたいならばテスト観点の抽象度を上げ、具体的な対象のカバレッジ表現をしたいならば抽象度を下げ、といったアプローチが取れるようになります。

テストタイプの設計アプローチ(標準規格の品質モデルで分割してはいけない)

 特定のテストレベル(例えばシステムテスト)で大規模で複雑なテストを設計する場合、目的や十分性基準、テスト設計方針を具体的に考えるために、関心事の分離を実施する必要があります。
 その手段の一つとして、テストタイプの設計があります。そこでは巨大なテストの活動をテストタイプに分割することによって、テストタイプごとに関心事を分けて活動を進められるようにします。

まずいアプローチ:品質モデルの標準規格そのままに分割する

 このテストタイプの設計手段ですが、ISO25010といった標準的な品質モデルをそのままテストタイプの切り分け方に適用するアプローチをしばしば見ます。
 例えば、ISO25010の主特性「機能適合性」「性能効率性」「互換性」「信頼性」「セキュリティ」「移植性」をそのまま使って、テストを「機能適合性テスト」「性能効率性テスト」「互換性テスト」「信頼性テスト」「セキュリティテスト」「移植性テスト」に分ける、というアプローチです。

 ただこれは不適切なアプローチです。
 標準規格の品質モデルは、テスト活動のやりやすさを考慮して分けられたものではありません。さらにあくまで標準的なモデルであり、実際のあるべき品質モデルはプロジェクトや開発物によって大きく異なります。そのため標準規格の品質モデルでテスト活動を区分けしようとすると、以降のテスト活動にて、分析作業の重複が多発するなどといった支障が発生します。

有効なアプローチ:テスト対象に適した品質モデルと、テスト活動のやりやすさを観点に設計する

 ではどうするかについてですが、テストタイプの導出は、テスト対象に適した品質モデルの観点を分析するのがメインアプローチになります。
 それに加えて、以降のテスト活動が実施しやすいか」の観点を使ってテストタイプを具体化・調整していくのが有効です。この「以降のテスト活動が実施しやすいか」のより具体的な観点をいくつか以下に記載します。

「テスト分析・テスト設計がしやすいか」

 例えば、テスト分析を行えるサイズまで、テスト観点あるいはテスト条件をテストタイプとしてグルーピングするという観点です。大規模で複雑なテストは、テスト観点あるいはテスト条件も膨大かつ複雑で、そのままではテスト分析が困難です。それを扱えるサイズまでパッケージングできれば、以降のテスト分析も容易になります。

 また、テスト分析・テスト設計の担当の能力に合わせて、テストタイプを分割するというのも、この観点の主要なアプローチです。
 例えばセキュリティテストだけ特別なスキルを持つある専門チームでしかテスト設計できないならば、セキュリティテストとそれ以外のテストをテストタイプとして分割することで、プロジェクトが以降の作業を効率的に進められるようになります。

 さらに、テスト分析・テスト設計を行うタイミングに合わせて、テストタイプを分割するのも、この観点に基づいた設計アプローチです。
 例えばテスト分析・テスト設計に必要なインプット情報の入手時期が大きく異なるならば、早期に扱うテストタイプ、後期に扱うテストタイプと、入手時期ごとに分割することで、時期に応じてテスト設計アプローチを変える対応が可能になります。

 設計・実装のアプローチに合わせて、テストタイプを配置するという観点もあります。
 例えばユーザストーリをベースに開発しているならば、ユーザストーリテストと、それを補完するテストにテストタイプを分割することで、ユーザストーリ単位の開発アプローチを支えつつ、補完的な品質保証を実施するアプローチを容易に遂行できるようになります。

「テスト実施がしやすいか」

 例えば、必要なテスト環境・テスト機材に応じて、テストタイプを分割するアプローチです。
 特殊なテストラボでしか実施できないテストがあるならば、特殊なテストラボで実施するテストタイプと、それ以外のテストタイプを分割することで、テスト実施環境に合わせたテスト活動を円滑に遂行できるようになります。

 テスト実施のサイクルやタイミングに合わせて、テストタイプを分割するのもこの観点の一種です。例えば1週間のスプリントごとに実施するユーザストーリテストと、1か月程度のリリースサイクルごとに実施するリリーステストがあれば、そのサイクルを基準にテストタイプを分割すると、サイクルに応じたテスト活動方針の最適化が容易になります。

テストタイプ間の連携をどうするか

 テストタイプの設計で関心事の分離を推進した場合で課題となるのが、テストタイプ間の連携です。
 
 テスト活動は全体として品質保証を達成する必要があり、テストタイプの組み合わせに漏れがあると問題が発生します。また無駄なリソースやコストを軽減するために、テストタイプ間の重複のムダは、少ないほうが良い場合が多いです。さらに困難なテストの課題に対応する際は、それぞれのテストタイプの強みを活かすように、テストの重ねわせやテストの連携協力が求められます。

 そのため、テストタイプを分割すれば、あとはバラバラに活動すれば良いというわけではなく、分割後もテストタイプ間で相互連携をしながらテスト活動を推進する必要があります。

 この連携の管理方法ですが、有効なものにテストタイプの分割アプローチのモデリングがあります。例えば「GSNでテストタイプを分析する - 千里霧中」で紹介したGSNによってテストタイプ設計のアプリーチをモデル化しておくと、テストタイプそれぞれが全体の中でどのような責務を持っているか明確になります。またあるテストをどのテストタイプで実施すべきかの判断フロートしてもモデルを活用できます。

 また課題やリスクのコントロールとして、テストタイプをどのように連携させるか管理するアプローチも有効です。例えば「テストを導くためのテストアーキテクチャの組み立て方/cetam - Speaker Deck」で紹介しているリスクコントロールとしてテストタイプをどのように組み合わせるかを管理しておくと、テストタイプがどのテストタイプとどのように連携すべきかを明示できるようになります。