自動テストを支えるテスト設計について講演

先日、JaSST'23 Tokaiというテストのイベントで「自動テストを活躍させるための基礎作りとテスト設計の工夫」と題して講演する機会を頂きました。

https://speakerdeck.com/goyoki/improvements-and-test-design-for-effective-test-automation

今回はシンポジウムの委員の一人の方から、テスト自動化に必要な基礎について話してほしい、特にテスト設計の必要性について話してほしいと要望いただいていたため、それをメインテーマに過去資料の内容をカスタマイズして話させていただきました。

なお自動テストを支えるテスト設計は、業界としてまだまだ改善の余地がある分野だと感じています。
ユニットテストをはじめとする開発者テストについては、どのようなテスト設計方針を示してテストを実現するか、すでに多数の実践や知見がたまっています。ただ一方で、システムテストや粒度の大きな結合テストについては、適切なテスト分析・設計をどう実現するかは迷っている現場が多いと感じています。そこでどうすべきかについて、今回の講演が何かしらの一助になれば幸いです。

SQuaRE、ISO/IEC 25010の製品品質モデルの改訂動向

SQuaRE、ISO/IEC 25010についてですが、標準規格の担当WGの方が、2022年の3月のタイミングで審議中の改訂情報に触れていました。

https://speakerdeck.com/washizaki/squareguan-lian-falsebiao-zhun-hua-falsequan-ti-dong-xiang-25010-25019gai-yao-ip-shan-jun-bo?slide=19

ISO/IEC 25010の製品品質モデルに限定して抜粋すると、主に以下のような改訂予定が説明されています。

  • 主特性にSafetyを新設。FailsafeやHazard warning、Safe integrationなどを副特性に配置
  • 移植性(Portability)をFlexibilityに変更。Scalabilityを副特性に追加
  • 使用性(Usability)をInteraction Capabilityに変更。Self-descriptivenessを副特性の追加
  • その他適宜の副特性の追加や名称変更を実施(Maturity→Faultlessnessの名称変更など)

このうち、移植性の改訂は、時代の変更に沿った良い改訂だと思います。

上記資料には10年スパンで改訂しているとありましたが、ここ10年で確実にソフトウェア開発を変えたのがインフラ関連の分野です。実行環境はAWSなど数が絞られたIaaSに構築することが当たり前になり、外部のSaaSをサービスの構成機能として活用することも普通になりました。Dockerやk8sといった仮想化技術が発達し、実行環境のコンテナ化も広く普及しています。例えば最近技術界隈で24時間以内にインフラ総取り換えをしてサービスを復旧させた事例が話題になっていましたが、これはインフラ分野が進化した現代ならではの出来事だと感じました。

振り返ると、現ISO/IEC 25010のインフラ関係の品質特性は、カスタマイズされた環境にカスタマイズしたプロダクトを設置する、旧来のスタイルに沿ったモデルになっていると感じます。仮想化で重視される冪等性や、IaaSやコンテナ・オーケストレーションで重要になるスケーラビリティといった、前述のインフラ環境の変遷を踏まえた品質観点では使いづらさがあります。上記で提示された改訂は、この使いずらさの是正に有効なものだと思います。
ただ、現在の実情を鑑みるとインフラ関連の改訂の程度がまだまだ甘い・不足しているようにも感じますので、この改訂ではさらに変更が大きくなる可能性もあると思います。

一方Safetyの追加は、日本人のWGらしいなあという印象です。存在意義は感じるものの、既存の信頼性と区別が曖昧な部分があり、それをどう整理してくるのかが気になりました。

10年スパンで改訂と資料中で説明されているため、改訂版は今年出てくるのではと思います。現場にとって使いやすいモデルになりそうなので、キャッチアップする価値があると感じます。

探索的テストの力を引き出す段取り、そしてTEXの改善

 これまで色々な立場で、色々なテスト案件を経験してきましたが、その中で一番生産性が高いテストアプローチが、精鋭テストエンジニア達による探索的テストでした。
 適切なタイミングで、必要な環境と必要な人材が揃った探索的テスト部隊がテストを始めると、猛烈な勢いでバグが見つけられ、品質リスクが潰されていきます。
 ただ探索的テストは流動的な要素が大きく、段取りや準備によって、効果やコスト・労力が大きく変動するのにも注意が必要です。
 今回は、そうした探索的テストの効果を引き出すための段取りや準備の経験則をまとめます。

探索的テストの効果を引き出す段取り

テスト環境確保を工夫する/テスト環境に応じてテストアプローチを工夫する

 探索的テストのスコープは、テスト環境の制約で制限されることが経験的に多いです。例えば機材不足や本番環境との差異でテストできないといった状況です。探索的テストのスコープを広げ、その効果を確保するためには、そのテスト環境の制約の緩和が不可欠になります。
 この実現アプローチとしては、早期にテスト分析を行って必要な環境を洗い出し、その環境を適時に使用できるように計画を組むのが重要になります。その手段の一つとして、スクリプトテストと探索的テストを並走させ、前者のテスト分析を早期から実施する形をよく実践します。
 
 また事前に段取りや準備に力を入れても、テスト環境に制約が出る場合があります。その際は、環境制約に応じた探索的テストの段取りの工夫が有効になります。例えば「本番環境の確保が終盤まで難しく、試作環境に頼らなければならない」のような状況なら、各環境ごとにどのようなテストをすべきかの段取りを事前に工夫することで、探索的テストの活躍どころを確保できます。

探索的テストの活躍どころを計画で確保する

 探索的テストにとって適時性は重要です。自分の経験として、早すぎるタイミングで探索的テストを実施してバグを大量に見つけた結果「作りかけだからそのテストは無駄」「バグを報告しないでほしい」などとフィードバックされたことあります。まだ逆に遅すぎるタイミング(最終盤のデバッグフェーズの空き時間でテストするなど)で探索的テストを実施した結果、そこで見つけたバグのデバッグが間に合わず、プロジェクトが遅延するといったこともありました。

 探索的テストの適時性を確保するためには、開発と連携して、適切なタイミングで適切なスコープの探索的テストを実行する期間を確保し、それを明示的に計画に組み込むのが重要になります。

 そこで探索的テスト期間は開発スケジュール中で明示的を設けるのが有効です。スクリプトテストのみを工数確保し、探索的テストは合間時間にやる計画を組むと、探索的テストの工数が流動的になるほか、理解が深まっていない間は遅延時に探索的テストを無駄な作業とみなす圧力が外部からかけられる場面に遭遇しがちです。

必要な知識と能力をチームとして確保する/学習機会を確保しチームを育てる

 探索的テストの効果は属人的であり、テスト対象や、それを取り巻くドメイン、ビジネス、品質についての、属人的な知識や能力に依存します。例えば派生開発なら派生元のプロダクトの知識が重要になります。
 そのため探索的テストを活用しようとすると、相応の知識・能力を持った人材確保が必要です。必要なすべての能力と知識を持つ万能人材を獲得するのが難しい場合は、様々な人を集めて、探索的テストチーム総体として能力・知識を確保していくアプローチをとります。

 さらに、知識・能力ある人の獲得のほか、プロジェクト中に必要な知識・能力を高める機会を設けて、探索的テストチームを育てていく段取りも有効です。
 例えば上流仕様のレビュー機会を設けて仕様理解を深める、プロトタイピングや反復開発で早期からテストして必要な知識・能力のフィードバックを得る、職種をローテーションして設計知識を深める、といった段取りです。そうした段取りを開発計画に組み込むと、探索的テストチームの知識・能力が高まり、探索的テストの効果向上につながります。

正しい方向にテストを方向付けする/フィードバックサイクルを回して方向性を正す

 探索的テストは流動的で、様々な要因で効果が変動します。悪い方向に倒れれば、モンキーテストと変わらなくなり、時間やコストを浪費する場合もあり得ます。
 そのため探索的テストの実施では、適切なテストへの方向付けが求められます。この方向付けには、次の2つがあります:

  • テスト分析を実施してテストチャータを作成する、セッションベースを取り入るといった形で、テスト実施の方向性を明示化する。
  • 実施したテストを評価して、それに基づいて方向を正しい方に改善する改善フィードバックサイクルを回す。テストを繰り返し実施したり、エラーシーディングを行ったりして、欠陥流出や生産性を評価し、自分たちのテストが妥当だったか確認して、問題があれば是正していく。

テストへのニーズ・シーズを継続的に把握する

 探索的テストは様々なテストのニーズやシーズに柔軟に対応できます。テストのニーズ・シーズを的確に把握すると、探索的テストを活躍させるチャンスを増やせます。

 テストのニーズやシーズは、例えば「開発中に特定のコンポーネントの品質が悪いことがわかり、それ起因のトラブルに困っている」「ユーザ要求を正しく認識できなかった可能性が出てきた」「特定のコードから不吉な臭いを感じた」などと、プロジェクト進展中にどんどん生まれ、移り変わっていきます。
 そのため、開発ライフサイクルを通じて、テストのニーズ・シーズを継続的に監視し、逐次探索的テストを投入していくアプローチが有効になります。

効率化のためのテスト技術を蓄積する

 探索的テストの適用可能な領域は広く、それに付随して様々な効率化手段を活用できる余地があります。
 その主な手段に自動化があります。煩雑な作業を自動化して、テストエンジニアの作業をより重要なテストに集中させる、手動で困難な作業を自動化で実現して探索的テストで活用する、といったアプローチは、探索的テストの過程で数多く活用できます。
 そのため、探索的テストを効率化する技術や手段を蓄積し、探索的テストで活用していくアプローチを組むと、技術蓄積に応じて、探索的テストのスコープをより広く、生産性をより高く改善できるようになります。

補足:TEX(Test Engineer eXperience)と探索的テスト

 上記で上げた段取りは、いうなればTEX(テストエンジニア体験:Test Engineer eXperience)を改善するための施策ともいえます。
 探索的テストの効果は、テストエンジニアの能力を発揮させることで生まれます。そのため、テストエンジニアの能力の発揮しやすさ、すなわちTEXは、探索的テストの効果や生産性に直結すると言ってよいと思います。
 そのため上記で上げた段取りの工夫に限らず、TEXを向上させることが、探索的テストの効果向上の基本と言えます。

テスト自動化の事後(影響、評価、ネクストステップ)について講演

先日、QuesというソフトウェアQAをテーマにした勉強会に「テスト自動化の成果をどう評価し、どう次につなげるか」と題して登壇させていただきました。

docs.google.com

「テスト自動化をした後」をテーマにしてほしいとの要望を頂いていたため、今回は以下の3パート構成で話させていただきました。

  • テスト自動化によってどうなるのか
  • テスト自動化をどう評価するか
  • テスト自動化をどう次につなげるのか

参加者として日頃から楽しんでいたイベントに、登壇側として参加できたのは感慨深かったです。運営者の方、参加者の方、大変ありがとうございました。

GoogleTestとSanitizerを組み合わせて動的解析

Calendar for ソフトウェアテストの小ネタ | Advent Calendar 2022 - Qiita」の記事です。

C++のメジャーなテスティングフレームワークGoogleTestは、gccやclangに組み込まれたSanitizerと連動することで、不正なメモリ操作や不適切なスレッド間データ共有、リスクある未規定処理の実行などの異常を、テスト上で検出できるようになります。今回はそのGoogleTestとSanitizerの連携で、コードレベルの動的解析の環境を構築する例を解説します。

Sanitizerとの連携

GoogleTestのテストコード上で以下の関数を定義することで、Sanitizerがエラーを検出したときの処理をHookできるようになります。

  • address sanitizer :void __asan_on_error()
    • 不正なメモリ操作を検出
  • behavior sanitizer : void __ubsan_on_report()
    • 致命的なエラーや例外を発生させる不正な動作を検出
  • thread sanitizer : void __tsan_on_report()
    • スレッド間の不適切なデータ共有を検出

例えばGoogleTestのテスト実行中、Address Sanitizerが異常なメモリ操作を検出した際に、特定の文字列を出力するとともにテストをFailさせたいならば、以下をテストコード上で定義します。

extern "C" {
    void __asan_on_error() {
      FAIL() << "address sanitizer error!!!!";
    }
} // extern "C"

注意点として、Sanitizerが異常を検出したタイミングでテストコードは強制終了されます。その際、--gtest_outputによる結果ファイル出力は動作しません。そのため、CIなどで自動実行する際は、XMLなどの出力ファイルでなく、コンソールログ解析でテスト結果を判定する必要があります。

実装例

以下のテスト対象をテストするとします。範囲外への不正なメモリアクセスを行っています。

int target() {
    int a[5];
    return a[5];
}

通常のユニットテストでは、上記を実行した時のふるまいは不定です。ただ特に何もなくテストが終了する場合もあります。
ここで上記のような不正なメモリアクセスをSanitizerで見逃しなく検出させる場合、テストコードを次のように記述します。

//test_hoge.cpp
#include <gtest/gtest.h>

...

extern "C" {
    void __asan_on_error() {
        FAIL() << "Encountered an address sanitizer error";
    }
}  // extern "C"

TEST(TestCase, dummy) {
    EXPECT_EQ(0, target());//サンプル例示のための適当な確認
}

このテストコードをビルドする際に、Address Sanitizerを有効化します。例えば次のようなオプションで記述します。

g++ test_hoge.cpp -lgtest_main -lgtest -lpthread -fsanitize=address -o test_hoge

これを実行すると、a[5]にアクセスしたタイミングで、「Encountered an address sanitizer error」が出力され、テストがFailします。具体的には以下のようなメッセージが出力されます。

[==========] Running 1 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 tests from TestCase
[ RUN      ] TestCase.dummy
=================================================================
test_hoge.cpp:8: Failure
Failed
Encountered an address sanitizer error
==468==ERROR: AddressSanitizer: stack-buffer-overflow on address
... 以下、不正メモリアクセスを検出した旨のSanitizerの出力メッセージ ...

上記のようなアプローチで、Sanitizerの機能を使った動的解析を、GoogleTestのテストコードとして記述できるようになります。

Cap'n Protoのシリアライズによるツール間のデータやりとり実装例

最近 Cap'n Proto の導入例を割と見るようになっています。Cap'n ProtoはRPCのフレームワークおよびフォーマット仕様のシステムで、gRPCに対抗して作られたものです。

今回はCap'n Protoのスキーマ言語の定義と、その処理の簡単な例として、Serializationの最小限のサンプルを実装・提示します。

題材

パイプでのコマンド渡しでデータをやり取りする場合を例にします。
言語はCap'n Protoのデフォルト言語となっているC++にしました。

扱うデータのスキーマ定義

やり取りするデータの定義ファイルとして、demo.capnpのファイル名で次を用意します。
任意個数のタグ情報を持つテキストのリストをやり取りします。

#demo.capnp
@0x9eb32e19f86ee174;

using Cxx = import "/capnp/c++.capnp";
$Cxx.namespace("demo");

struct DemoData {
  name @0 :Text;
  tags @1 :List(Tag);
  
  struct Tag {
    name @0 :Text;
    param @1 :Text;
  }
}

struct DemoDataList {
  demodata @0 :List(DemoData);
}

C++コードから参照する際に都合の良いように、namespace demoでコードを生成するよう冒頭で指定しています。

C++定義ファイルの生成

Cap'n Protoのスキーマ定義を、C++コードが参照可能なフォーマットに変換するため、以下コマンドを実行します。

capnp compile -o c++ demo.capnp

これにより「demo.capnp.c++」「demo.capnp.h++」が生成されます。
これをコンパイルしリンクすることで、C++コード上から、前述のスキーマ定義を用いたシリアライズやRPCが可能になります。

シリアライズの実装

データをスキーマ定義に基づいてシリアライズし、パイプで他ツールに渡すコードは次のようになります。
hoge、fugaのデータを、適宜のタグ情報を付与してシリアライズしています。

#include "addressbook.capnp.h"
#include <capnp/message.h>
#include <capnp/serialize-packed.h>
#include <iostream>

using demo::DemoData;
using demo::DemoDataList;

...

void output() {
  ::capnp::MallocMessageBuilder message;

  DemoDataList::Builder demoDataList = message.initRoot<DemoDataList>();
  ::capnp::List<DemoData>::Builder demoData = demoDataList.initDemodata(2);

  DemoData::Builder hoge = demoData[0];
  hoge.setName("hoge");
  ::capnp::List<DemoData::Tag>::Builder hogeTags = hoge.initTags(2);
  hogeTags[0].setName("param1");
  hogeTags[0].setParam("123");
  hogeTags[1].setName("param2");
  hogeTags[1].setParam("456");

  DemoData::Builder fuga = demoData[1];
  fuga.setName("fuga");
  ::capnp::List<DemoData::Tag>::Builder fugaTags = fuga.initTags(1);
  fugaTags[0].setName("param1");
  fugaTags[0].setParam("abc");

  writePackedMessageToFd(1, message);
}

...

シリアライズの実装

前述のツールからパイプで送られてきた情報をデシリアライズし、表示するコードは次のようになります。

#include "addressbook.capnp.h"
#include <capnp/message.h>
#include <capnp/serialize-packed.h>
#include <iostream>

using demo::DemoData;
using demo::DemoDataList;

...

void read() {
  ::capnp::PackedFdMessageReader message(0);

  DemoDataList::Reader demoDataList = message.getRoot<DemoDataList>();

  for (DemoData::Reader demoData : demoDataList.getDemodata()) {
    std::cout << demoData.getName().cStr() << std::endl;
    for (DemoData::Tag::Reader tag: demoData.getTags()) {
      std::cout << " " << tag.getName().cStr() << ":"
                << tag.getParam().cStr() << std::endl;
    }
  }
}

...

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

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

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

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

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

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

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

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

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

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

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

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

実装例

前述のGIFのラウンドトリップテストのコードを転載します。GIF変換処理について、エンコードし、デコードしたデータが元に戻ることを大まかに確認しています。この確認を、Go Fuzzのファジングライブラリで自動生成したテスト条件で実行することで、ラウンドトリップテストを実現しています。

  f.Fuzz(func(t *testing.T, b []byte) {
    cfg, _, err := image.DecodeConfig(bytes.NewReader(b))
    if err != nil {
      return
    }
    if cfg.Width*cfg.Height > 1e6 {
      return
    }
    img, typ, err := image.Decode(bytes.NewReader(b))
    if err != nil || typ != "gif" {
      return
    }
    for q := 1; q <= 256; q++ {
      var w bytes.Buffer
      err := Encode(&w, img, &Options{NumColors: q})
      if err != nil {
        t.Fatalf("failed to encode valid image: %s", err)
      }
      img1, err := Decode(&w)
      if err != nil {
        t.Fatalf("failed to decode roundtripped image: %s", err)
      }
      got := img1.Bounds()
      want := img.Bounds()
      if !got.Eq(want) {
        t.Fatalf("roundtripped image bounds have changed, got: %v, want: %v", got, want)
      }
    }
  })

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

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