テスタビリティ(試験性)の拡張を実現する実装

前エントリ「テスタビリティ(試験性)を確保するための設計方針 - 千里霧中」の補足として、「テスタビリティ(試験性、Testability)を拡張可能にする」の実装について解説します。

拡張を実現する手段の一つに接合部(Seam)があります。今回は実装例として、接合部の一種である、メソッドインジェクションを使ってテスタビリティを拡張可能にする例を示します。

対象コード

次のtarget_method()をテストする場合について考えます。
target_method()は、device_controller_run()を呼び出して、外部デバイスを制御するメソッドです。

def device_controller_run(status):
    ...副作用を持つコード...

def target_method():
    ...
    device_controller_run(DeviceStatus.RUNNING)
    ...

テスタビリティを拡張可能にする

このtarget_method()をテスタビリティ拡張可能にします。その実現手段に、今回は次のようにメソッドインジェクションを導入します。

class DeviceController:
    def run(self, status):
    ...副作用を持つコード...

def target_method(device_controller):
    ...
    device_controller.run(DeviceStatus.RUNNING)
    ...

メソッドインジェクションは、対象の依存要素を、メソッド呼び出し時に注入するイディオムです。上記では、メソッドの引数としてDeviceControllerオブジェクトを渡し、それ経由で外部デバイス制御メソッドを呼び出すように変更しています。

メソッドインジェクションのような接合部を設けると、接合部を起点にして、後付けでテスタビリティを拡張できるようになります。

テスタビリティを拡張してユニットテストを実現する

ここで、target_method()に対するユニットテストを記述します。今回のユニットテストでは、target_method()が、適切な引数で外部デバイス制御メソッドを呼び出していることを確認します。

最初のtarget_method()の実装例では、このユニットテストを実装できるかは不明です。しかし、前述のようにメソッドインジェクションの導入でテスタビリティを拡張可能にしておくと、次のようなアプローチでテスタビリティを拡張して、ユニットテストを実現できるようになります。

class DeviceController:
    def run(self, status):
    ...副作用を持つコード...

class DeviceControllerWithTestability(DeviceController):
    def __init__(self):
        self._status = DeviceStatus.STOP

    def run(self, status):
        self._status = status

    def status_is_running(self):
        return self._status == DeviceStatus.RUNNING

def target_method(device_controller):
    '''テスト対象'''
    ...
    device_controller.run(DeviceStatus.RUNNING)
    ...
    
def test_target_method():
    '''ユニットテスト'''
    device_controller = DeviceControllerWithTestability()
    target_method(device_controller)
    assert device_controller.status_is_running()

上記では、テスト対象target_method()が依存するDeviceControllerを、DeviceControllerWithTestabilityに置換し、必要なテスタビリティを拡張しています。
テスタビリティの拡張ポイントは、DeviceControllerの副作用を解消する点、(やや過剰ですが)外部デバイス制御メソッドの引数をチェックするAssertionMethodを提供する点となります。

参考文献

接合部を含む、テスタビリティを拡張可能にするイディオムやパターンの解説としては、以下の書籍が充実しています。

レガシーコード改善ガイド | マイケル・C・フェザーズ, 平澤章, 越智典子, 稲葉信之, 田村友彦, 小堀真義, ウルシステムズ株式会社, ウルシステムズ株式会社 | コンピュータ・IT | Kindleストア | Amazon

テスタビリティ(試験性)を確保するための設計方針

テスタビリティ(試験性、テスト容易性)は「どれだけ容易にテストできるか」「どれだけテストを実現できるか」の度合いを示す品質特性です。
実践ソフトウェア・エンジニアリングの解説から引用すると、テスタビリティは次の特性から構成されます。

  • 実行円滑性(Operability)
    • テストの実行しやすさ。例えば、テスト実施をブロックする要因が少ないか。
  • 観測容易性(Observability)
    • テスト対象の観測のしやすさ。
  • 制御容易性(Controllability)
    • テスト対象の操作のしやすさ。
  • 分解容易性(Decomposability)
    • テスト対象の分離や分割のしやすさ。例えばテストのためにテスト対象を切り出しやすいか。
  • 単純性(Simplicity)
    • テスト対象の単純性。例えば機能がシンプルで必要なテストが少ないか。
  • 安定性(Stability)
    • テストに影響を与える変更の少なさ。
  • 理解容易性(Understandability)
    • テスト対象の理解しやすさ。

※この他にも、文献によってはプロジェクトの制約度合い(e.g.テストに十分なコストを確保しているか)、テストエンジニアのスキルといったテスト対象の外の特性もテスタビリティに含めているものがあります。

今回は、このテスタビリティを確保するための代表的な設計方針を解説したいと思います。

前提:様々なテストそれぞれにとってのテスタビリティがある

本題に入る前の前提の話です。テスタビリティは、あらゆるソフトウェアテストに関わる品質特性です。手動テスト、自動テスト、あるいはGUIを通したテスト、APIを通したテスト、コードレベルのテストなど種類を問わず、各々のテストにとって、それぞれのテスタビリティがあります。

例えば観測容易性を例をとります。テスタビリティの実装は、テストの種類ごとに次のように変化します。

  • 手動のシステムテストでの優れた観測容易性の実装例
    • 必要なエラーや実行情報を得るためのUIが提供されている
    • テストの前提条件(構成管理情報)を確認するためのUIが提供されている
  • APIテストでの優れた観測容易性の実装例
    • 必要な内部情報を得るためのAPIが揃っている
  • ユニットテストでの優れた観測用意性の実装例
    • 間接的な出力がなく、テストコードから必要な出力を直接簡単に参照できる

以降で解説する設計方針についても、具体的な実装は対象のテストの種類によって異なります。

テスタビリティを確保するための設計方針

テスタビリティを低下させる要因の影響範囲を小さくし、分離・置換できるようにする

テスタビリティを低下させる要因として、以下のような存在があります。

テスタビリティ確保の点では、こうしたテストの支障となるコンポーネントへの依存箇所を最小化すると、テスト可能な範囲が広がります。例えば以下のような対策が有効です。

  • 実行円滑性に劣るコンポーネント群は、Facadeパターンで簡易化したインターフェースを通して制御可能にする。
  • 安定性に劣るコンポーネントは、コンポーネントをなるべく局所化した上で、ラッピングして他との依存性を削減する。例えばUIデザインが頻繁に変更されるなら、UI層を最小化・分離するなど。

さらに、上記のテスタビリティを低下させる要素は、後述する接合部を設けて分離・置換できるようにすると、テスタビリティ確保の助けになります。例えば観測容易性に劣るコンポーネントを、観測手段を埋め込んだTest Doubleに置換できるようにするといった工夫です。

結合度を低く、凝集度を高く

一般的な設計の方針として、コンポーネント間の結合度を低くし、凝集度を高くすることが推奨されていますが、この方針はテスタビリティの改善にも繋がります。
結合度を低くするように設計・実装すると、テスタビリティのうち分解容易性が向上します。凝集度を高くするように設計・実装すると、テスタビリティのうちの単純性や理解容易性が高まります。

特に結合度を低く保つ設計については、設計の要所に接合部(Seam)を設けることが重要です。接合部は、特定のコンポーネントを切り離して他に置換できるようにするための仕組みです。依存性の逆転(Dependency Inversion)、Link Seam(ビルドを分割しリンク時に切り替えられるようにする)などがあります。接合部があると、テスト対象を切り離したり、テストの障害となるコンポーネントをTest Doubleに置換したりすることができるようになり、テストにとって十分な結合性の低さを確保できます。

テストにとって十分な観測点、制御点を設ける

観測点(Observation Point)はテストの出力を得るための手段です。その充実度が観測容易性に直結します。制御点(Control Point)はテスト対象を操作するための手段です。その充実度が制御容易性に直結します。

テスタビリティの確保においては、プロジェクトのそれぞれのテストが求める観測容易性、制御容易性の要求を識別して、それを満たす観測点・制御点を設けることが重要になります。
例えばテストに必要な観測・制御ができるようにテスト用インターフェースを設ける、テストで観測すべき情報がすべて盛り込まれるようにログ設計する、テストにとって必要な情報が得られるようにエラーなど情報通知UIを設ける、といった工夫が有効になります。

テスタビリティを拡張可能にする

後からテスタビリティを拡張可能にすると、テスタビリティを確保できなくても、テストを実施するタイミングで必要なテスタビリティを後から確保できるようになります。
また、開発成果物が将来レガシーコード化する備えの点でも、各所にテスタビリティが注入可能なポイントを実装しておくのが有効です。その備えを行っておくと、将来レガシーコード化しても、テスタビリティを注入して必要なリグレッションテストを構築し、そのテスト使って脱レガシーコードのためのリファクタリングを行うといった対策が打てるようになります。

テスタビリティを拡張可能にする手段としては、例えば前述した接合部があります。接合部を設けると、テストが依存するコンポーネントを、観測・制御手段付きに改造したコンポーネントに置換できるようになります。

テストでとり得る条件を制限する

テストの入出力のパラメータや値が少ないと、テスタビリティのうちの単純性が向上するほか、観測や制御が容易になります。
このパラメータや値の削減には、次のような工夫が有効になります。

  • 変数やメソッドなどのスコープを狭め、グローバルな依存関係を削減する
  • 冗長な引数や戻り値をなくす
  • 内部状態をシンプルに保つ
  • 副作用の発生可能性をなくす
  • 型でとり得る値を制限する

また、値の組み合わせなど、テストでとり得る条件を削減すると、同じく単純性が向上します。この実現のためには、前述のパラメータや値の削減のほか、次のような工夫が有効になります。

  • 制御フローをシンプルに保つ。例えばエラーチェックを冒頭で行ってネストを浅くするなど。これによりデシジョンテーブルの圧縮やCFD法の適用などが可能になる。
  • ロックなどの排他処理や割り込み保護を適切に行い、タイミングの組み合わせを削減する
  • 禁則の組み合わせを、実装上の工夫で実現不能にする
  • DRYを推進する

品質特性のバランスを取る

テスタビリティは、一部の品質特性とトレードオフの関係を持ちます。代表例は、性能効率性やセキュリティです。
セキュリティを例に取ると、例えばテスト用に設けたAPIセキュリティホールになるといった問題が、トレードオフの関係例です。

テスタビリティの確保にあたっては、テスタビリティとトレードオフになる品質を特定して、両者を両立する設計を見つけ出す必要があります。
例えばテスタビリティとセキュリティのトレードオフの場合ですと、次のようなアプローチが設計として有効になります。

  • トラストバウンダリを設け、その内部にテストインターフェースを設ける
  • テスト用とデプロイ用を安全に切り替えられるよう、コードやビルドシステムを工夫する

設計やコードをリーダブルに保つ

理解しやすい命名をするといった、リーダブルにするための設計やコードの原則は、単純性や理解容易性を改善し、テスタビリティを底上げします。

Flutter Integration Testingでは、testパッケージではなくSDKのintegration_testパッケージを使う

 前のエントリ「テストコードのデザインパターン:Robotパターン」を書いた際に情報提供いただいたネタです。
 現在、FlutterのIntegration Testでは、flutter_driverパッケージとtestパッケージの組み合わせではなく、SDKのintegration_testパッケージでテストコードを記述することが公式で推奨されています。

An introduction to integration testing - Flutter

Note: The integration_test package is now the recommended way to write integration 
tests. See the Integration testing page for details.

 SDKのintegration_testパッケージは、先月stable版がリリースされたFlutter2から利用可能になっているものです。

 integration_testパッケージは旧来のパッケージと比べて、次のようなメリットがあります。

  • Firebase Test Labでの実行をサポート
  • flutter_test APIを使って、Widget Testと同等の構文でIntegration Testを記述できる
  • 旧来のFlutter Driveコマンドに対応していて、物理デバイスでもエミュレータでもIntegration Testを実行できる

 今回はこのintegration_testパッケージを使った簡単なテストコードを示します。

pubspec.yaml

dev_dependencies:
  ...
  integration_test:
    sdk: flutter
  flutter_test:
    sdk: flutter
  ...

テストコード

 旧来のIntegration Testのテストコードは次のようになります

import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';

void main() {
  FlutterDriver driver;

  setUpAll(() async {
    driver = await FlutterDriver.connect();
  });

  tearDownAll(() async {
    await driver.close();
  });

  group('E2E Test for Login', () {
    test('authenticate a user', () async {
      await driver.tap(find.byValueKey('usernameTextField'));
      await driver.enterText('hoge hoge');

      await driver.tap(find.byValueKey('passwordTextField'));
      await driver.enterText('sample password');

      await driver.tap(find.byValueKey('login'));

      final indexMenuFinder = find.text('Execute');
      await driver.waitFor(indexMenuFinder);
      expect(await driver.getText(indexMenuFinder), 'Execute');
    });
  });
}

 integration_testパッケージを使用すると、上記のテストコードは次のように記述できます。Widget Testと同じようなスタイルになります。

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import 'package:flutter_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('E2E Test for Login', () {
    testWidgets('authenticate a user', (WidgetTester tester) async {
      app.main();
      await tester.pumpAndSettle();

      await tester.enterText(find.byKey(Key('usernameTextField')), 'hoge hoge');
      await tester.enterText(find.byKey(Key('passwordTextField')), 'sample password');

      await tester.tap(find.byKey(Key('login')));

      await tester.pumpAndSettle();

      expect(find.text('Execute'), findsOneWidget);
    });
  });
}

テストコードのデザインパターン:Robotパターン

 アプリケーションなどのテストコードのデザインパターンの一つに、Robotパターン(Testing Robotパターン、Robot Testingパターン)という汎用的なパターンがあります。

Presentation: Testing Robots - Jake Wharton
Robot Pattern Testing for XCUITest | by Rob Whitaker | Capital One Tech | Medium

 Robotパターンは、テスト対象のセグメンテーション(例えば画面)ごとにRobotオブジェクトを用意し、テストコード上にテストのWhat(テストしたい振る舞いや仕様)を、RobotオブジェクトにテストのHow(テスト対象の操作や期待値比較の詳細な実装や、テスト対象への直接の依存部分)を実装するものです。これによりテストコードの可読性や再利用性を向上させることを目的とします。

 このパターンは、テストコード上にWhatを、PageオブジェクトにHowを実装する、一般的なPage Objectパターンと目的やアプローチが類似しています(ただRobotパターンは、Robotオブジェクト内にAssertion機能を実装するのを許容するという違いがあります)。

 今回はFlutterアプリを対象に、このRobotパターンの実装例を示します。

テスト対象のサンプル

 HOME画面に次のようなログイン画面を表示する、単純なログイン機能を有したFlutterアプリを対象にします。今回は、これに対し、ログインして、Executeという内部機能実行ボタンが表示されることを確認するIntegration Testを実装します。

f:id:goyoki:20210422003746p:plain

Robotパターンを使用しないテストコード

 Robotパターンを使用せずにIntegration Testを実装した例を次に示します。

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import 'package:flutter_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('E2E Test for Login', () {
    testWidgets('authenticate a user', (WidgetTester tester) async {
      app.main();
      await tester.pumpAndSettle();

      await tester.enterText(find.byKey(Key('usernameTextField')), 'hoge hoge');
      await tester.enterText(find.byKey(Key('passwordTextField')), 'sample password');

      await tester.tap(find.byKey(Key('login')));

      await tester.pumpAndSettle();

      expect(find.text('Execute'), findsOneWidget);
    });
  });
}

 テストコードにはusernameTextFieldといったキー名など、テスト対象の詳細な操作がハードコーディングされています。

Robotパターンを使用したテストコード

 次にRobotパターンを使用してIntegration Testを実装した例を次に示します。

 まず画面ごとにRobotオブジェクトを用意します。Robotオブジェクトでは、テスト対象の操作や期待値比較についての詳細な実装(How)を、わかりやすい名前(What)のメソッドにラッピングしていきます。

// login_robot.dart(ログイン画面のRobotクラス)
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

class LoginRobot {
  const LoginRobot(this.tester);

  final WidgetTester tester;

  Future<void> enterUserName(String username) async {
    await tester.enterText(find.byKey(Key('usernameTextField')), 'hoge hoge');
  }

  Future<void> enterPassword(String password) async {
    await tester.enterText(find.byKey(Key('passwordTextField')), 'sample password');
  }

  Future<void> tapLoginButton() async {
    await tester.tap(find.byKey(Key('login')));
    await tester.pumpAndSettle();
  }
}
// index_robot.dart(ログイン後画面のRobotクラス)
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

class IndexRobot {
  const IndexRobot(this.tester);
  final WidgetTester tester;

  Future<void> checkUserIsOnTheIndexScreen() async {
    expect(find.text('Execute'), findsOneWidget);
  }
}

 次にテストコードです。Robotオブジェクトを経由してテスト対象を操作することにより、テストコードは次のように実装できます。usernameTextFieldといった内部のキーへの直接の依存がなくなり、テストしたい振る舞いや仕様が端的に表現されたコードになります。

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import 'robots/login_robot.dart';
import 'robots/index_robot.dart';

import 'package:flutter_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
  group('E2E Test for Login', () {
    testWidgets('authenticate a user', (WidgetTester tester) async {
      final loginRobot = LoginRobot(tester);
      final indexRobot = IndexRobot(tester);
      app.main();
      await tester.pumpAndSettle();

      await loginRobot.enterUserName('hoge hoge');
      await loginRobot.enterPassword('sample password');
      await loginRobot.tapLoginButton();
      await indexRobot.checkUserIsOnTheIndexScreen();
    });
  });
}

Flutterの自動テストをGithub Actionsで実行する

Flutterのユニットテストウィジェットテスト、インテグレーションテスト(エミュレータを使ったテスト)をGithub Actionsで実行する方法についてです。

対象のディレクトリ構成

ユニットテストウィジェットテストのワークフロー

上記ディレクトリファイルにて、testディレクトリのユニットテストウィジェットテストをGithub Actionsで実行する場合、次のようなワークフロー定義ファイルflutter_test.yamlを作成します。
push、プルリクをトリガに、単にflutter実行用のアクション上で、テスト実行コマンドを実行しています。

name: flutter component test

on: [push, pull_request]

jobs:
  component_test:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v2
      - uses: subosito/flutter-action@v1
        with:
          channel: 'stable'
      - run: flutter pub get
      - run: flutter test

インテグレーションテストのワークフロー

次にtest_driverディレクトリのインテグレーションテストをGithub Actionsで実行する場合、次のようなワークフロー定義ファイルflutter_integration_test.yamlを作成します。
この例では「iPhone 12 Pro Max (14.2)」「iPhone 12 (14.2)」のシミュレータでテストを実行します。処理としては、xcrunで利用可能なシミュレータ一覧を取得し、awkでそこから対象のUDIDを取得してエミュレータを起動します。そしてflutter driveコマンドでテスト対象・テストコードを実行します。

name: flutter integration test

on: [push, pull_request]

jobs:
  integration_test:
    strategy:
      matrix:
        device:
          - "iPhone 12 Pro Max (14.2)"
          - "iPhone 12 (14.2)"
      fail-fast: false
    runs-on: macos-latest
    steps:
      - name: "start simulator"
        timeout-minutes: 10
        run: |
          UDID=$(
            xcrun instruments -s |
            awk \
              -F ' *[][]' \
              -v 'device=${{ matrix.device }}' \
              '$1 == device { print $2 }'
          )
          xcrun simctl boot "${UDID:?No Simulator with this name found}"
      - uses: actions/checkout@v2
      - uses: subosito/flutter-action@v1
        with:
          channel: 'stable'
      - run: "flutter drive --target=test_driver/app.dart"

アジャイルテストでの計画・管理のツール:Test MatrixとTest Mindmap

ソフトウェアテストの小ネタ Advent Calendar 2020」の記事です。

Agile Testing CondensedMore Agile Testingでは、リリーステスト(システム全体を対象とするテストレベル)の計画のやり方として、「リリース全体を俯瞰する視点で作成する。計画の作成・運用ではテストマインドマップ(Test Mindmap)やテストマトリクス(Test Matrix)を活用できる」のような解説がされています。そして下図でその流れを紹介しています。

f:id:goyoki:20201216031526p:plain

執筆時点で日本語の解説がなく気になったので、今回はここで言及されている、計画と管理のツールであるテストマインドマップとテストマトリクスの内容についてメモしたいと思います。

共通する特徴

テストマインドマップ、テストマトリクスいずれも、マネジメントとチームのコラボレーションを支えるためのモデルとして活用します。具体的には、話し合いながら、チームでアイデアを出し、チームでアクティビティを共有し、チームで進行状態を共有するための手段として、テストマインドマップ、テストマトリクスを活用しています。

テストマインドマップ

モデルの形式

テストマインドマップは、テスト実行のアクティビティをマインドマップのノードで表現したモデルです。

次のようなモデルになります。

f:id:goyoki:20201216031541p:plain

モデルの目的

次の目的で作成します。

  • チーム全体のテストのアクティビティ抽出の助けにする。例えばマインドマップでアイデアを発散させながら、チームメンバーでアクティビティを抽出する作業に用いる。
  • チームのテストのアクティビティの全体像を高位レベルで可視化する。
  • チームのテストのアクティビティの進捗情報を高位レベルで可視化する。具体的にはマインドマップのノードにアクティビティ完了のマーカーを付与して、完了したアクティビティを可視化する。

テストマトリクス

モデルの形式

テストマトリクスは、縦軸にテスト対象項目(アジャイル開発ならフィーチャ等。フィーチャを縦軸にしたものはフィーチャテストマトリクスと呼称)、横軸に抽象的なテスト条件あるいはテストケイパビリティ(テストチームが実施可能なテストタイプ)を配置したマトリクスを用います。
マトリクス縦軸・横軸の形式は、ゆもつよメソッドのテスト分析マトリクスや、QUINTEEのテストマップと類似しています。ただテストマトリクスでは、マトリクス中の項目に、さらに以下の情報を表現します。

  • テスト実行の対象か、対象外かの情報
  • テスト実行の対象ならば、その優先度と進行状態の情報

テストマトリクスは例えば次のようなモデルとなります。今回はセルの背景色で上記の情報を表現しています(More Agile Testingでは背景色と背景パターンの2つを使って表現を使い分けています)。

f:id:goyoki:20201216031531p:plain

モデルの目的

マトリクスの横軸・縦軸はテスト分析マトリクスやテストマップと類似していますが、目的・用途は異なります。
このテストマトリクスは次の目的で作成します。

  • チームのテストのアクティビティの全体像を高位レベルで可視化する。
  • チームのテストのアクティビティの優先度と進行状態を可視化する。具体的には、次に優先すべきテストの明示化、未着手or残作業ありor完了の進行状態の明示化を行う。

機械学習による決定木分析でクラシフィケーションツリーを洗練させる

機械学習の手法の一つである決定木分析を使うと、入出力データから、対象の内部ロジックをある程度推測できるようになります。これはデバッグやテストの洗練に活用できる余地があります。

今回はその一例として、決定木分析の主要なアルゴリズムであるCART法を使って、クラシフィケーションツリー法でのクラシフィケーションツリーを洗練させるアプローチを説明します。

題材

イメージとしては、特定条件でログにワーニングやエラーが記録されるかの確認を行うような、ログ機能のテストを想定します。
テストはクラシフィケーションツリー法を使って作成します。作成したクラシフィケーションツリーは以下の通りです(処理をシンプルにするため簡略化した例を用います)。

f:id:goyoki:20200705230642p:plain

今回は、この作成したクラシフィケーションツリーが妥当かチェックするため、テスト実行時に決定木分析を使う場面を扱います。目的としては、例えばテスト設計の妥当性の確認や、リグレッションテストの洗練などを想定します。

データの取得

まず実際にテスト対象を動かして、なるべく多くの入出力データを取得します。

取得対象ですが、クラシフィケーションを取得対象データとします。また取得データはCART法を適用できるように、クラスを参考にして数値化します(例えばクラスが真偽なら1、0で記録します)。

データ取得範囲については、実行空間を全網羅する入力の全組み合わせを実現するのが理想です。ただ一般的に実現不可能なので、QuickCheckのように全体を大まかに網羅するランダムデータを生成してデータ取得します。

例えば以下のようなデータを取得します。

# インプットデータ:
input_name = ["input1", "input2", "input3", "input4"]
inputs = [
    [0, 0, 0, 88],
    [1, 1, 0, 2],
    [0, 1, 1, 26],
    [0, 0, 1, 55],
    [1, 0, 0, 29],
    [1, 1, 0, 12],
    ・・・
]
# 計測したアウトプットデータ:
# (0:"non-error", 1:"error1", 2:"error2", 3:"error3", 4:"warning1"):
output_name = ["non-error", "error1", "error2", "error3", "warning1"]
outputs = [
    0,
    4,
    0,
    4,
    4,
    3,
    ・・・
]

決定木の生成

次に取得したデータからCART法で決定木を生成します。そしてクラシフィケーションツリーの評価のため、決定木のイメージと正解率を求めます。
scikit-learnでの実装は次のようになります。

from io import StringIO
from sklearn import tree
import pydotplus
from sklearn.metrics import accuracy_score
 
def create_tree(inputs, input_name, outputs, output_name):
    clf = tree.DecisionTreeClassifier(max_depth=5) #仕様規模に応じてmax_depthを制限する。
    clf = clf.fit(inputs, outputs)

    dot_data = StringIO()
    tree.export_graphviz(clf, out_file=dot_data,
        feature_names=input_name, class_names=output_name,
        filled=True, rounded=True)
    graph = pydotplus.graph_from_dot_data(dot_data.getvalue())
    graph.write_png('./tree.png')

    predicted_outputs = clf.predict(inputs)
    accuracy = accuracy_score(outputs, predicted_outputs)
    print(f'accuracy:{accuracy}')

前述したデータで決定木を生成すると次が得られます。

f:id:goyoki:20200705230808p:plain

また得られた正解率は今回は0.73となりました。

クラシフィケーションツリーの評価と改善

そして次に、得られた決定木と正解率から、クラシフィケーションツリーを評価します。

以下に該当する場合、不具合の可能性(あくまでこの決定木は既存実装から機械学習で生成したものであり、正しい仕様に基づいたものではない点には注意が必要です)があるか、クラシフィケーション・クラスの抽出漏れの可能性があります。

  • 仕様と比べて決定木が複雑
  • 正解率が低い

 
また、以下に該当する場合、不具合あるいはデータ不足の可能性があるか、クラスの分け方に問題がある可能性があります。

  • 決定木の条件と、クラスの境界が異なる

 
また、以下に該当する場合、不具合あるいはデータ不足の可能性があるか、冗長なクラシフィケーションがある可能性があります(ただ不具合・データ不足の可能性の存在から、冗長であるとしてクラシフィケーションを削除するのは安易に行なえません)。

 

今回は決定木が複雑すぎるのと、正解率が低すぎる点が見受けられます。すなわちクラシフィケーションの抽出漏れの可能性があります。そこで、クラシフィケーションツリーを見直し、次のようにクラシフィケーションを追加します。

f:id:goyoki:20200705230933p:plain

再評価

このクラシフィケーションツリーに基づいて入出力データを再取得し、前述のコードで決定木を生成した結果が以下になります。

f:id:goyoki:20200705230953p:plain

また正解率は1.00となりました。
決定木がシンプルになったのと、正解率が高まった点から、クラシフィケーションの抽出漏れがあり、改善によりそれが是正されたと推測できます。