G-Evalを使用してLLM-as-a-judgeを行う

 生成AIを組み込んだソフトウェアの評価では、出力の不確実性や柔軟性が大きいことから、対象をどう評価するかが定番の課題となります。その有望な対応手段にLLM-as-a-judgeがあります。
 LLM-as-a-judgeは、生成AIの出力結果の評価・採点を、精度の高いLLMを使って実施する手法です。LLMの柔軟性を活用して、多数かつ幅広い評価の自動化が行えるようになります。

 今回はそのLLM-as-a-judgeの実装例を解説します。評価の実装および実行環境は「生成AIアプリケーション評価入門」の解説を参考にしています。

LLM-as-a-judgeの評価者に使用するLLM

 評価にはLLM-as-a-judgeのフレームワークであるG-Evalを使用します。
 またG-Evalが利用するLLMとして、OpenAIのGPTを使用します(そのため以降のコード実行においては、本ブログ執筆時点ではOpen AIに課金し、API Keyを有効にする必要があります。有効化したKeyは実行環境の環境変数OPENAI_API_KEYに登録しておきます)。

評価対象のLLM

 評価対象は、ローカルで実行できるLLM、ollamaを使用します。テスト自動生成でのテスト観点の生成を想定して、「プロダクト品質モデルの主特性の名前を列挙してください」のような課題を指定し、その正確性を評価します。

LLM-as-a-judgeの実装

 LLM-as-a-judgeの実装を以下に示します。

from deepeval.metrics import GEval
from deepeval.test_case import LLMTestCase, SingleTurnParams
from openai import OpenAI
import ollama

# テスト対象
input="ISO/IEC 25010におけるプロダクト品質モデルの主特性の名前を列挙してください"
response = ollama.chat(model='llama2', messages=[{'role': 'user', 'content': input,}], options={'temperature': 0})

# 評価内容(生成AIアプリケーション評価入門より)
correctness_metric_ja = GEval(
	name="正確性",
	evaluation_steps=[
	"実際の出力に含まれる事実が、期待される出力に含まれる事実と矛盾していないかを確認してください。",
	"重要な情報が省略されている場合は大きく減点してください。",
	"曖昧な表現や意見の相違は許容されます。",
	"評価理由は日本語で記述してください。"
	],
	evaluation_params=[
		SingleTurnParams.INPUT,
		SingleTurnParams.ACTUAL_OUTPUT,
		SingleTurnParams.EXPECTED_OUTPUT,
	]
)

# 評価
test_case = LLMTestCase(
	input=input,
	actual_output=response['message']['content'],
	expected_output="Functional Suitability, Performance Efficiency, Compatibility, Interaction Capability, Reliability, Security, Maintainability, Flexibility, Safety"
)

correctness_metric_ja.measure(test_case)
print("score:", correctness_metric_ja.score)
print("reason:", correctness_metric_ja.reason)

 ollama.chatで、評価対象に対し、課題を提示し回答を取得しています。
 そして評価対象の正確性についてG-Evalで評価しています。このうちevaluation_stepsが正確性をスコア評価するための評価指針になります。このコードでは前述の「生成AIアプリケーション評価入門」のサンプルをそのまま参考にして使用しています。

評価結果

 前述の評価コードの実行結果は以下に様になります:

score: 0.13775406687981456
reason: 期待される主特性は Functional Suitability、Performance Efficiency、Compatibility、Interaction Capability、Reliability、Security、Maintainability、Flexibility、Safety の列挙だが、実際の出力は Reliability、Security、Maintainability 以外に Durability、Customizability、Scalability、Accessibility、Environmental impact、Social responsibility など期待にない項目を多数含み、Compatibility・Interaction Capability・Flexibility・Safety・Functional Suitability が欠落しています。Performance も Performance Efficiency と厳密には一致せず、重要情報の省略と不正確な追加が大きいため低評価です。

 正確性は、事前に提示した評価指針に基づいて、0~1でスコア付けして提示しています。また、reasonとしてその理由を提示し、正確性の評価が妥当か確認できるようにしています。
 表示されている結果の通り、Judge側のLLMが、テスト対象の情報の古さや誤りを見つけ出し、減点していることが読み取れます。

副作用の局所化

 副作用の局所化はソフトウェアの設計アプローチです。対象のコンポーネントの副作用の発生可能性の軽減と、副作用の影響範囲の局所化を行うアプローチを指します。副作用の局所化は疎結合設計を支え、バグが少なく保守しやすいコードを生むために普遍的に重要な設計アプローチと言えます。

副作用とは

 ソフトウェアの設計やプログラミングにおける副作用は、意図した直接の出力とは別におこる作用を指します。例えば特定の値を返すことを目的とした関数を対象とした場合、値を返す以外に、内部でグローバルな状態・静的な状態を更新していれば、その状態更新が副作用になります。

 副作用を起こすものに以下があります。

  • グローバルな変数の更新
  • 静的な変数の更新
  • 共有するリソースやデータの更新
  • I/Oの実行
  • バッファオバーフローといった異常状態の発生
  • 上記を発生させる関数の呼び出し

 副作用は状態更新以外のものもあります。意図せず他の処理のタイミングや時間、順序を不適切に変えてしまう作用(例えばRTOSで動くソフトウェアにおいて、プロセッサリソースを占有して、他タスクの処理シーケンスを乱してしまうなど)も副作用です。ただし、副作用の局所化で語られる参照透過性や不変性などのコンテキストの中では、もっぱら副作用は状態更新に絞られます。

副作用の局所化

 副作用の局所化とは、副作用が発生する領域をなるべく小さな領域に閉じ込めること、副作用の発生可能性を軽減することを推進する設計アプローチです。
 状態更新の副作用に対しては、以下のような設計アプローチが、副作用の局所化に該当します。

  • 変数やリソースなどの状態のスコープを小さくする。グローバル変数を避ける
  • 静的な状態を少なくする
  • 横断的に共有される状態、静的な状態は、操作処理や、ガードといった防御的設計をまとめたオブジェクトとしてカプセル化する。状態更新についての防御的設計を複数に散在させない
  • 妥当な範囲で、参照透過性や不変性を確保する。例えば純粋関数(入力だけで出力を決め、外部に他の影響を発生させない関数)や、不変オブジェクト(初期化以外で状態を更新しない)として処理を実装する
  • バッファオーバーフローといった異常状態については、開発言語機能、型システム、コーディングルール、静的解析といった多重の手段で解消を目指す

 この副作用の局所化は保守性向上を中心に、さまざまな恩恵を提供します。主要なものを以下に示します。

  • デバッグが容易になる。バグ原因の発生可能性の範囲を狭く絞り込める
  • テストが容易になる。テストの入力・出力が明確化され、小さく絞り込まれる。ユニットテストなどで対象を切り出すのも容易になる
  • 再利用性を高める。再利用の際の前提条件や注意事項が小さくなる

不変オブジェクト

 副作用の局所化の実装手段として不変オブジェクトがあります。不変オブジェクトは、オブジェクト生成時の初期化を除いて、オブジェクトの状態を変更できないオブジェクトを指します。不変オブジェクトとして実装すれば副作用をなくせるわけではありませんが、副作用の発生可能性を下げ、副作用の範囲を削減する手段として有効です。

 例えばC++で以下のクラスUserがあるとします。

class User {
public:
    std::string name;
    int age;

    User(const std::string& name_str, int age_val) : name(name_str), age(age_val) {}
    
    ...さまざまなメソッド...
};

 このUserクラスはnameとageのパラメータを持ちます。この実装ではインスタンス化した後もnameとageを更新できるため、これは可変オブジェクトになります。外部から自由に更新できるため、Userのインスタンスは副作用の影響を受けやすいと言えます。

 このUserクラスを不変オブジェクトとして実装する場合、次のようになります

class User {
private:
    const std::string name_;
    const int age_;

public:
    User(const std::string& name, int age)
        : name_(name), age_(age) {}

    std::string getName() const { return name_; }
    int getAge() const { return age_; }

    ...さまざまなメソッド...
};

 すべてのパラメータについてconstを付与し、不変性を担保しています。Getterを定義する場合は、const宣言で内部のパラメータを更新しないことを担保します。

ドメインプリミティブな設計

 副作用の局所化と組み合わせるのが効果的な設計アプローチに、ドメインプリミティブという設計指針があります。これはドメイン駆動設計での用語で、プリミティブ型(開発言語の標準の基本型。intやstringなど)を使用せず、ドメインのコンテキストに合わせた独自型を使用すべきというものです。ドメインプリミティブの設計では、型にバリデーションを組み込み、オブジェクトが常に正しい状態の実現も目指します。ドメインプリミティブを遵守することで、不正な状態更新を抑止し、有害な副作用の影響を抑えることができます。

 例えば前述のUserクラスをドメインプリミティブの設計に基づいて改善した例を以下に示します。

class User {
private:
    const std::string name_;
    const int age_;

    static std::string validateName(const std::string& name) {
        if (name.length() < 1 || name.length() > 20) {
            throw std::invalid_argument("name must be between 1 and 20 characters");
        }
        return name;
    }

    static int validateAge(int age) {
        if (age < 0 || age > 200) {
            throw std::invalid_argument("Age must be between 0 and 200");
        }
        return age;
    }

public:
    User(const std::string& name, int age)
        : name_(validateName(name)), age_(validateAge(age)) {}

    std::string getName() const { 
        return name_; }
    int getAge() const { return age_; }

    ...さまざまなメソッド...
};

 ドメインプリミティブの設計では、ユーザの名前や年齢をstring型やint型そのままで定義するのではなく、上記のUserクラスで定義するように統一します。
 このUserクラスでは、値の初期化時に入力バリデーションを行い、不正な状態が指定された場合は例外で弾くように実装しています。こうした事前条件の評価・防御的処理を組み込むことで、常に正しい状態のオブジェクトしか存在しないように実装できます。これにより、有害な影響を発生させる副作用を抑止できます。

Androidをとりまくテストスイート(xTS:CTS、VTS、STS、GTS)の概要

 Android開発に関しては、仕様に準拠しているか、互換性を満たしているか、セキュリティといった特定の品質を実現しているか確認するためのテストスイートが複数提示されています。これらはAndroid xTSとも呼称されます。今回はその主要なxTSをまとめます。

CTS(Compatibility Test Suite)

 CTSは、対象のデバイスのAndroidのAPIが標準仕様を実現しており、デバイスが、サードパーティのAndroidアプリーケーションを動作させられるか確認するテストスイートです。

 CTSでは、端末メーカーのカスタマイズで標準のAndroid APIが壊れていないか、妥当な性能を確保しているかといった確認を行います。CTS合格の保証によりアプリケーション開発者に対するWORA(Write once, run anywhere:一回コードを書けばどのAndroid端末でも動作する)の実現を支えます。
 CTSはホスト環境から端末をリモートで操作して実行します。

 テストの概要やソースコードは公開されています。
互換性テストスイート(CTS)の概要  |  Android Open Source Project

 CTSのテストベースは以下のCDD(Compatibility Definition Document)になります。
Android 互換性定義ドキュメント  |  Android Open Source Project

 注意点として、テストコードを確認すれば分かりますが、CDDに対してCTSは基本的な確認しか実施していません。CDDの実現保証には、手動テストやレビュー、網羅性を高めたテストも必要です。

VTS(Vendor Test Suite)

 VTSは、Androidのハードウェアベンダーインターフェースが要件を実現しているか確認するテストスイートです。

 VTSは複数のテストで構成されます。構成要素の一つに、Google TestでHALを切り出してテストするコンポーネントテストがあります。またLinux Kernelを接続した統合テストやAPIテスト、LTP(Linux Test Project)準拠のテストによる、OSとハードウェアの疎通確認も含まれます。そこではGSI(Generic System Image)を使って汎用的な確認を実施します。
 VTSでハードウェアベンダーインターフェースの実現性保証を行うことで、ハードウェアバリエーションが増加・爆発した状態でも、円滑にAndroidフレームワークをアップデートしたり、Android APIの標準性を担保したりできるようにする状態を実現します。

 VTSのテストベースは、VSR(Vendor Software Requirements)と呼ばれるベンダーに対する一連の要件とHALの設定になります。VSRは前述のCDD中のハードウェアベンダーに対する要件や、ベンダーAPI仕様で構成されます。

 VTSとCTSの関係を以下に示します。


GTS(Google Mobile Services Test Suite)

 Google提供のサービス(Play Services等)やアプリ(Playストア等)の動作確認を行うテストスイートです。Google自身のサービスの品質確認を行うほか、端末メーカーがGoogleサービスを組み込んで出荷する際に正しく組み込まれているかの確認で使われます。一般公開されていません。

STS(Security Test Suite)

 Androidの脆弱性対策、セキュリティ確保が適切か確認するテストスイートです。テストを実行する仕組みは公開されており、関係開発者が実行可能になっています。ただテスト内容の詳細は、脆弱性情報や攻撃手段を含むため一般公開されていません。

GitHub Copilotでユニットテストを楽に構築・運用する

 十分かつ有効なユニットテストが配備されるとさまざまな恩恵が提供されます。リグレッションテストとして使用可能です。またリファクタリングや追加変更もより安全に進められるようになります。
 ただユニットテストは数が多くプロダクトコードと結合しているため、その構築・保守には手間がかかりがちです。特にプロダクトコードのテスト容易性が低いとその手間が大きくなり、ユニットテストの費用対効果が悪化します。
 こうしたユニットテストを取り巻く煩雑な手間についてですが、生成AIのサポートでかなり手間を削減できます。今回は生成AIサービスのGitHub Copilotを例に、ユニットテストの構築・運用を楽にする簡単なテクニックをまとめます。

ユニットテストを生成する

 GitHub Copilotを使うとテストコードのスケルトンやたたき台を容易に生成できます。例えば次のようなコードがあるとします。

target.cpp

#include <utility>
#include <vector>
#include "target.h"

int partition(std::vector<int>& arr, const int low, const int high) {
    const int pivot = arr[high];
    int i = low - 1;
    for (int j = low; j < high; j++) {
        if (arr[j] < pivot) {
            i++;
            std::swap(arr[i], arr[j]);
        }
    }
    std::swap(arr[i + 1], arr[high]);
    return i + 1;
}

void quickSort(std::vector<int>& arr, const int low, const int high) {
    if (low < high) {
        const int pi = partition(arr, low, high);
        quickSort(arr, low, pi - 1);
        quickSort(arr, pi + 1, high);
    }
}

target.h

#pragma once

#include <vector>

void quickSort(std::vector<int>& arr, const int low, const int high); 

 こうしたファイルに対しては、GitHub Copilotのチャットビューで「test.cppに対するユニットテストを作成してください。別ファイルで作ってください」と指示を打ち込みます(今回は簡単な例なのでこれで済みますが、複雑なプロジェクトに対しては追加のプロンプトが求められます)。
 するとGitHub CopilotはGoogle TestやCMakeなど必要なツールのインストールを許可を取りながら進めます。そして以下のユニットテストファイルを生成します。

test_target.h

#include <gtest/gtest.h>
#include <vector>
#include "target.h"

TEST(QuickSortTest, SortsAlreadySortedVector) {
    std::vector<int> arr = {1, 2, 3, 4, 5};
    quickSort(arr, 0, static_cast<int>(arr.size()) - 1);
    EXPECT_EQ(arr, std::vector<int>({1, 2, 3, 4, 5}));
}

TEST(QuickSortTest, SortsReverseSortedVector) {
    std::vector<int> arr = {5, 4, 3, 2, 1};
    quickSort(arr, 0, static_cast<int>(arr.size()) - 1);
    EXPECT_EQ(arr, std::vector<int>({1, 2, 3, 4, 5}));
}

TEST(QuickSortTest, SortsRandomOrderVector) {
    std::vector<int> arr = {3, 1, 4, 1, 5};
    quickSort(arr, 0, static_cast<int>(arr.size()) - 1);
    EXPECT_EQ(arr, std::vector<int>({1, 1, 3, 4, 5}));
}

TEST(QuickSortTest, SortsSingleElementVector) {
    std::vector<int> arr = {42};
    quickSort(arr, 0, static_cast<int>(arr.size()) - 1);
    EXPECT_EQ(arr, std::vector<int>({42}));
}

TEST(QuickSortTest, EmptyVectorDoesNothing) {
    std::vector<int> arr;
    EXPECT_NO_THROW(quickSort(arr, 0, static_cast<int>(arr.size()) - 1));
    EXPECT_TRUE(arr.empty());
}

 またGitHub Copilotは同時にCMakeLists.txtといったビルド・実行のための設定ファイルも自動生成・更新し、生成したユニットテストをビルド・実行可能にします。
 実行結果です:

[       OK ] QuickSortTest.SortsRandomOrderVector (0 ms)
[ RUN      ] QuickSortTest.SortsSingleElementVector
[       OK ] QuickSortTest.SortsSingleElementVector (0 ms)
[ RUN      ] QuickSortTest.EmptyVectorDoesNothing
[       OK ] QuickSortTest.EmptyVectorDoesNothing (0 ms)
[----------] 5 tests from QuickSortTest (0 ms total)

[----------] Global test environment tear-down
[==========] 5 tests from 1 test suite ran. (0 ms total)
[  PASSED  ] 5 tests.

 注意点として、生成されたユニットテストのテストケースは通常そのまま信用できるのではなく、修正や補強が必要です。ただたたき台を自動生成させれば、ゼロからテストコードを手打ちするよりはるかに楽にユニットテストを確保できます。

テストコードの実装を補完させる

 GitHub Copilotでは、テストコードのプログラミングの中で細部のスニペット生成や実装候補の提案も高度に実施してくれます。
 例えば前項で生成したユニットテストのファイルで「//巨大な要素数で動作することを確認 」とコメントを入力してコード提案を実行させると、コメントに沿ったテストメソッドが提案されます。

 また高度なコード補完・コードスニペット生成手段としても有効です。テストメソッド中に「E」を入力すると、EXPECT_EQのアサーションメソッドの記述が提案されます。


プロダクトコードの変更に追従させる

 GithubCopilotではプロダクトコードの変更対応も高度に自動化できます。
 例えばプロダクトコードに次の関数を追加したとします。

target.cpp

void BubbleSort(std::vector<int>& arr) {
    const int n = static_cast<int>(arr.size());
    for (int i = 0; i < n - 1; i++) {
        for (int j = 0; j < n - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                std::swap(arr[j], arr[j + 1]);
            }
        }
    }
}

 こうしたプロダクトコード変更に対しては、チャットビューで「target.cppを網羅するユニットテストを作成してください。既存のユニットテストtest_target.cppを修正して対応してください」といったプロンプトを入力すると、プロダクトコードの変更に追従したテストコード修正が実施されます。今回の例では、追加関数に対する追加テストコードが作成されます。

 また例えば追加した「BubbleSort」を「bubbleSort」に置換した場合に対するセルフヒーリングも可能です。例えば「target.cppの変更に対応して、ユニットテストを修正してください」とプロンプトを実行すると、置換に対応したヘッダファイルとテストコード修正が実施され、実行可能確認が行われます。

テスト駆動開発のサポートを行う

 テスト駆動開発は、生成AIとかなり親和性が高いプログラミングスタイルだと前々からよく言われています。それは事実なのですが、注意点として生成AIを前提とした場合、テスト駆動開発の進め方やサイクルサイズが変化します。
 通常テスト駆動開発は次のステップで進めます。

1. 仕様をテストリストとして明文化する
2. テストリストの1項目について、テストを書いて失敗させる
3. テストの失敗を成功に変えるようにプロダクトコードを変更する

※このうち2、3をテストリスト全てに対応するまで繰り返す
※また一定のプロダクトコードがたまったら、適宜リファクタリングを実施してプロダクトコードを綺麗にする

 これを生成AI活用を前提とすると、次のような流れになります。

1. 仕様をテストリストとして明文化する
2. テストリストについて、プロダクトコードとテストコードを一緒に生成する
3. テストコードのテスト設計・テスト実装を適切なものに修正する
4. テスト成功状態を維持しながら、プロダクトコードを適切なものに修正する

 通常のテスト駆動開発では、テスト失敗→テスト成功のテストファーストのサイクルは数十秒から数分の短期サイクルです。しかし生成AIを使った場合、一気にすべてを生成できるため、サイクルが大きくなります。さらにテストを失敗するステップの恩恵が小さくなり、多くの場合スキップすることになります。

テスト工程・レビュー工程の全体の建付けを考える際に留意が必要な効果・法則

 テスト工程やレビュー工程の建付け(どのようなテスト工程をプロセス上に設けるか等)を考える際に、注意すべき効果や法則があります。今回はそのいくつかを紹介します。

多重チェックの落とし穴

 問題を見逃さないように、チェックを何重にも多重化すると、問題検出率がむしろ悪化するという傾向を多重チェックの落とし穴と呼びます。
 例えばレビューでのミス検出率について、レビューを1重化(一人が一回だけレビュー)すると検出率は65%になり、2重化(二人が別のタイミングでチェック)すると80%に改善する一方で、3重化すると65%、4重化すると55%と、多重化を増やすほど検出率が下がる調査結果が存在します。

 これは、レビューの多重化を増やすほど、個々の問題検出率も、全体の問題検出率も下がっていく傾向を示します。この傾向は多重化により自分が注力しなくても他が見つけてくれるという期待が発生することで生まれると考えられています。
 より難しい問題を見つけて高品質を実現したいという目的に対しては、逆行する傾向と言えます。

 この傾向は品質保証の体系や仕組みを構築する際にも注意が必要です。高品質の実現のため同じようなテスト工程・レビュー工程を何重にも設けても、工数やコストを悪化させる一方で品質を悪化させる可能性があるためです。
 なおこれは最初から「他者が見るから手を抜こう」という悪意を持った担当を避けても、発生しえる落とし穴です。というのも現場の開発ではスケジュールやリソースの不足に追われる場面は少なくありません。そこで他に同じようなテスト工程があれば、やむを得ず自分達はテストより他の作業を優先する、という判断はしばしば発生します。

 この多重チェックの落とし穴を回避して高品質を実現するためには、次のような工夫が求められます。

  • レビューやテストを多重化させる場合は、それぞれの視座や観点に違いを設け、各レビュー・テストのバリエーションを増やす
  • 各レビューやテストの責任を明確化する。例えば特定のバグはどこで防ぐべきか具体化する
  • レビューやテスト、あるいは品質保証についてのリーダーとその責任を明確にし、リーダーシップを発揮させる 

リンゲルマン効果・社会的手抜き

 共同作業をする際に、分担する人数が増えるほど、個々の個人の貢献やリソースパワーが減っていく効果を、リンゲルマン効果や社会的手抜きと呼びます(元の文献は、綱引きで人数を増やすほど個々人の力の入れ具合が弱まる傾向を示す)。これは、人数が増えるほど、個人の貢献や責任が希薄になるほか、連携のために効率が低下することで発生すると考えられています。

 このリンゲルマン効果・社会的手抜きも、多重チェックの落とし穴と同じように、テスト工程・レビュー工程の体系や建付けの構築において注意が必要です。例えばレビューを強化するにしても単純にレビューアを増やすだけだと、個々のレビューアの力が活かされにくくなる可能性があるためです。

 高品質の実現のためには、個人の能力の発揮を引き出すことが求められます。そこでこのリンゲルマン効果・社会的手抜きを回避して高品質を実現するためには、前述の多重チェックの落とし穴と同様のアプローチが求められます。すなわち各人に異なった視座や観点を割り当てる、各人の責任を明確化する、責任あるリーダーにリーダーシップを発揮してもらう、といった工夫が求められます。

今年の登壇・執筆活動まとめ

 登壇は基本的に受け身のみで、お声がけいただいたら対応しています。ただそれでも、今年は6月にソフトウェアテスト徹底指南書を出版してからありがたいことに多数の登壇依頼をいただき、かなりの講演・登壇をすることになりました。自分は講演上手でないと自覚しているので、ありがたい一方で、大変恐縮しています。振り返りとして、今年の登壇活動・執筆活動のうち、オープンにできるものをまとめたいと思います。

登壇系

■講師「テスト分析入門」

CADDi様社内勉強会

https://speakerdeck.com/goyoki/test-analysis-tutorial speakerdeck.com

■講演「チームのテスト力を総合的に鍛えて品質、スピード、レジリエンスを共立させる」

「チームのテスト力を総合的に鍛えてソフトウェア開発の高品質と高スピードを両立させる実践技法」勉強会

https://speakerdeck.com/goyoki/testing-approach-that-improves-quality-speed-and-resilience speakerdeck.com

■講演・パネルディスカッション「書籍著者が語る!高品質と高スピードを両立させる!ソフトウェアテスト徹底指南書のご紹介」

コニカミノルタ様社内勉強会

■講師「プロダクト開発を成功させるためのソフトウェア品質保証のアプローチと技術」

DXPO東京25夏

https://speakerdeck.com/goyoki/software-qa-approach-for-puduct-success speakerdeck.com

■LT・パネルディスカッション「著者・訳者が徹底指南!2冊のフルスタックな書籍に学ぶ『テスト』の極意」

https://speakerdeck.com/goyoki/introduction-for-software-test-tettei-shinansho speakerdeck.com

■講演「チームのテスト力を鍛える」

「『ソフトウェアテスト徹底指南書』に学ぶ、品質とスピードを高める実践アプローチ」勉強会

https://speakerdeck.com/goyoki/develop-teams-testing-capabilities speakerdeck.com

■招待講演「チームのテスト力を総合的に鍛えてシフトレフトを推進する」

Veriserve Mobility Initiative 2025

https://speakerdeck.com/goyoki/shifting-left-with-software-testing-improvements speakerdeck.com

■壇上コンサルティング「テストデータの準備・保守の終わらない戦い…適切な方法は?」「AIが品質保証をする未来はどうすれば創れる?」

JaSST Online Gerbera
https://jasst.jp/online/gerbera-about/

■招待講演「自動テストを活かすためのテスト分析・テスト設計の進め方」

JaSST’25 Shikoku

https://speakerdeck.com/goyoki/jasst25-shikoku speakerdeck.com

■招待講演「 開発に寄りそう自動テストの実現」

ソフトウェアテスト自動化カンファレンス2025

https://speakerdeck.com/goyoki/automated-testing-integrated-with-development
speakerdeck.com

執筆系

■書籍「ソフトウェアテスト徹底指南書 〜開発の高品質と高スピードを両立させる実践アプローチ」

技術評論社
ソフトウェアテスト徹底指南書 | 技術評論社

■研究論文「Componentwise Automata Learning for System Integration」(共著)

ATVA 2025
https://arxiv.org/pdf/2508.04458

Bridgeパターン、Type Erasureパターンによる関心の分離の推進

 ソフトウェア設計で普遍的に重要な設計アプローチ:関心の分離(SoC)は、高凝集設計、疎結合設計両方を要求します。具体的に、高凝集設計の推進で、コンポーネントの責務が関心ごとに集中している設計を実現します。疎結合設計の推進で、コンポーネントに割り当てられた関心ごとが構造的に分離されている設計を実現します。

 この関心の分離は、抽象的な責務設計から詳細なコード実装まで、抽象・具体を横断的に工夫しながら推進する必要があります。コード実装なしに考えることができません。高凝集設計は契約による設計といった責務設計で推進できる一方で、疎結合設計は具体的なインターフェースやバウンダリの実装に依存しているためです。
 今回はこのコード実装の工夫による疎結合設計の推進で、関心の分離を支えるアプローチを解説します。

対象の題材

 今回は以下の仕様をC++で実装する場合を考えます。

  • ある制御システムの通信処理が対象。サンプルコードのoperate()はその一つ。operate()含め通信処理は共通化して実装する。
  • 通信処理の詳細はプラットフォームごとに異なる。プラットフォームはModel A、Model Bがある。今後も増える。プラットフォームごとに依存ライブラリが大きく異なる
  • 通信処理の詳細は通信仕様バージョンごとにも差異がある。仕様バージョンはcom_v1、com_v2があり、今後も増える。

 上記に対して、プラットフォーム、通信規格、それらを利用する通信処理実行者を関心事として関心の分離を実施し、疎結合にする方針をとります。

関心の分離が破綻した駄目なコード

 関心の分離の点で悪い実装として、グローバルなフラグなどを観てDependency Lookupで処理を呼び出すパターンがあります。

void operate(int param) 
{
    if (comm_ver_ == PF_MODEL_A) {
        CommModelA::operate(param);
    } else if (comm_ver_ == PF_MODEL_B) {
        ...何かの特殊処理...
        CommMldelB::operate(param);
    } else if {
    ..フラグを使った分岐が続く..
}

 こうした実装は疎結合設計が破綻しており、関心の分離に失敗しています。インターフェース共通化を行えず、プラットフォーム・通信規格の関心ごとがぐちゃぐちゃにまとめられていて、関心ごとに分けて考えられなくなっています。またフラグの分岐構造が複雑化するほか、この分岐構造のコピーが散在する形となり、プラットフォームや通信仕様の増加に対応する保守性が崩壊しています。

ダメなコード:継承を使った関心の分離

 オブジェクト指向言語では、今回のプラットフォーム、通信仕様といった横断的なバリエーションがあるコードに対応する手段として、継承が使われる場合があります。
 継承を使う場合、次のように抽象クラスでインターフェースを共通化します。

// 通信処理の共通IF
class CommunicationBase {
public:
    virtual ~CommunicationBase() = default;
    virtual void operate() const = 0;
};

 そしてバリエーションごとに派生クラスを定義し、処理部を共通化します。

class PFModelAComm : public CommunicationBase {
public:
    void operate() const override {
       ...
    }
    ...
};

class PFModelBComm : public CommunicationBase {
public:
    void operate() const override {
       ...
    }
    ...
};

...

 継承の導入により、通信処理の実行者視点では、バリエーションを共通記述で扱えるようになります。

std::unique_ptr<CommunicationBase> comm;

...

//PF Model Aの制御を行う場合
comm = std::make_unique<PFModelAComm>(); 
...
comm->operate();
...

 ただこの継承依存の対応は問題が大きいです。例えば今回の例のようにプラットフォームに通信規格と、関心ごとの種類が複数ある場合、派生クラスが次のように爆発するか、保守性に問題ある多重継承に頼る形になります。

class PFModelA_V1_Comm : public CommunicationBase {
    ...
};

class PFModelA_V2_Comm : public CommunicationBase {
    ...
};

class PFModelB_V1_Comm : public CommunicationBase {
    ...
};

class PFModelB_V2_Comm : public CommunicationBase {
    ...
};

...

 これはバリエーションの増加に対する保守性が破綻しているほか、プラットフォーム、通信仕様と関心ごとの分離ができておらず、組み合わせが一体化しています。これは関心の分離が失敗している状態です。
 疎結合設計、関心の分離に継承だけで対応するのは多くの場合でベストではありません。また抽象インターフェースの仮想関数は処理にオーバーヘッドがかかり、効率の点でもよくない手段と言えます。

良い例:BridgeパターンやStrategyバターンを使った関心の分離

 今回の例のような、プラットフォーム、通信仕様と複数の関心がある場合で、関心の分離を行いつつ、呼び出し部を共通化して保守性を確保するのに有用なのが、BridgeパターンやStrategyパターンです。これらはバリエーションの組み合わせ対応をすべて継承で対応するのではなく、合成で対応します(いわゆる「継承でなくコンポジションの原則」)。
 今回のサンプルの場合、次のような抽象化層を定義します。

class Communication {
protected:
    std::unique_ptr<CommunicationImpl> pimpl_;
    
public:
    Communication(std::unique_ptr<CommunicationImpl> impl) : pimpl_(std::move(impl)) {}
    virtual ~Communication() = default;

    void operate() const {
        if (pimpl_) {
            this->send_operation();
        }
    }
    
protected:
    virtual void send_operation() const = 0;
};

 バリエーションをすべて継承で対応せず、プラットフォーム対応はCommunicationImplクラスのメンバオブジェクトで対応します。プラットフォーム対応の実装は次ようなコードになります。

class CommunicationImpl {
public:
    virtual ~CommunicationImpl() = default;
    virtual void send_data(const std::string& version) const = 0;
};

class ModelAPlatform : public CommunicationImpl {
public:
    void send_data(const std::string& version) const override {
       ...プラットフォームModel A固有の処理
    }
    ...
};

class ModelBPlatform : public CommunicationImpl {
public:
    void send_data(const std::string& version) const override {
       ...プラットフォームModel B固有の処理
    }
    ...
};

 通信仕様対応は次のような継承で対応します。

class ComV1 : public Communication {
public:
    using Communication::Communication;
    
protected:
    void send_operation() const override {
        ...V1固有処理
    }
};

class ComV2 : public Communication {
public:
    using Communication::Communication;
    
protected:
    void send_operation() const override {
        ...V2固有処理
    }
};

 すると、例えばoperate()呼び出し部は次のように共通化して記述できるようになります。

std::unique_ptr<Communication> comm;

...

// Model A、通信仕様V1の処理を行う場合
comm = std::make_unique<ComV1>(std::make_unique<ModelAPlatform>());    
...
comm->operate();
...

 こうしたBrigeパターンやStrategyパターンの導入で、関心ごとに実装を分けて疎結合にできます。特に今回のパターンでは、プラットフォーム依存のコードが完全に分離されるため、依存ライブラリの分離が促進されます。これにより、関心の分離を実現しつつ、さらにバリエーション増加に対応するための保守性も確保できます。

モダンなC++での良い例:Type Erasure(型消去)パターンによる関心の分離

 前述のBridgeパターンでは、保守性の確保と関心の分離の両立を実現できました。ただ関心ごとのバリエーション対応では、値セマンティクスに完全に対応し、例えばプラットフォームが異なるオブジェクトを一つのコンテナでまとめて管理、といった実装を行いたい場合があります。
 これに対する対応策として、モダンなC++ではType Erasureパターンの導入で、C++でありながら実行時多態を実現しつつ、参照セマンティクスから値セマンティクスに移行させるアプローチがあります。

 今回のサンプルでType Erasureパターンを導入する場合、次のType Erasureの抽象クラスを定義します。

class CommConcept {
public:
    virtual ~CommConcept() = default;
    virtual void operate() const = 0;
};

template <typename T>
class CommModel final : public CommConcept {
private:
    T object_;
public:
    template <typename... Args>
    CommModel(Args&&... args) : object_(std::forward<Args>(args)...) {}

    void operate() const override {
        object_.operate();
    }
};

class AnyCommunication {
private:
    std::unique_ptr<CommConcept> concept_; 
public:

    template <typename T>
    AnyCommunication(T object) 
        : concept_(std::make_unique<CommModel<T>>(std::move(object))) 
    {}
    
    AnyCommunication(const AnyCommunication& other) = delete;
    AnyCommunication& operator=(const AnyCommunication& other) = delete;
    
    AnyCommunication(AnyCommunication&&) = default;
    AnyCommunication& operator=(AnyCommunication&&) = default;

    void operate() const {
        if (concept_) {
            concept_->operate();
        }
    }
};

 こうしたType Erasureパターンを導入すると、operate()の呼び出しを次のように実装できるようになります。

//様々なバリエーションのオブジェクトを同一のvectorに格納してしまう
ComV1 comm_v1_a(std::make_unique<ModelAPlatform>());
ComV2 comm_v2_b(std::make_unique<ModelBPlatform>());
std::vector<AnyCommunication> any_comms;
any_comms.push_back(std::move(comm_v1_a));
any_comms.push_back(std::move(comm_v2_b));

...

//コンテナに対する一括実行が可能になる
for (const auto& comm : any_comms) {
    comm.operate();
}

 こうした実装を行うと、プラットフォーム依存部、通信仕様依存部と利用者の結合をより疎にでき、より関心の分離を推進できます。