Jenkins PipelineのJenkinsfileでテスト並列実行を最適化する

 Jenkinsのpipelineを使うと、Jenkinsfileとしてジョブの定義・連携をGroovyで柔軟に記述できるようになります。そこでは例えば「変数を用いてノードやステージを横断する連携を実装する」「いろいろな外部スクリプトを実行して最適な実行条件を分析する」といった処理を容易に実装できます。

 今回はその具体例として、pytestのテスト実行時間の最適化を行うJenkinsfileを題材にします。

pytestのテスト並列実行の時間を最適化する

 対象は、Jenkins Pipelineで指定されたgitリポジトリから必要なファイルをチェックアウトし、その中のpytestのテストケースをノードサーバで実行して、結果をプッシュ& Jenkins Test Report出力する処理とします。実現にあたっては、(仮にテストケースが長大であるとして)ノードサーバを2つ用意し、テストを2分割してそれぞれのノードで並列実行させることで、テスト実行時間の短縮を狙います。

 このテストの2分割ですが、旧来の静的なジョブ定義では、テストケース数などで事前に静的分割せざるを得ない場合が多いです。一方でJenkins Pipelineを用いると、テスト実行時間が均等に分割されるように、テストケースを動的に2分割する処理を容易に実装できます。

Jenkinsfileの実装

 そのJenkinsfileの具体例を示します。ここでは以下を実装しています。

  • Prepareステージで、create_test_selection_file.py(実装例は後述)をシェル実行し、その標準出力をtest_selectionに格納します。
    • create_test_selection_file.pyは、テストケースファイルを、テスト実行時間が均等に2分割になるように2つにグルーピングし、結果をpytestの引数形式で標準出力するスクリプトです。過去のJUnit XML形式レポートからファイル名と実行時間のデータを抽出・集計することで実現します。
  • Testステージで、2つのノード:node1、node2にて、pytestのテストケースを並行実行します。pytestには、前ステージで得たtest_selectionを引数として指定します。
  • Reportステージで、pytestが生成したテストレポートファイルのプッシュと、Jenkins Test Reportへの出力を行います。

まとめると外に用意した集計スクリプトを呼び出して、ノードで実行するテストケースを動的に選択しています。

Jenkinsfileの実装例:

pipeline { 
    environment {
        test_selection = ""
    }
    agent any 
    stages {
        stage('Prepare') {
            steps {
                script {
                    def output = sh (
                        script: 'python create_test_selection_file.py',
                        returnStdout: true
                    ).trim()
                    test_selection = output.split(',')
                }
            }
        }
        stage('Test') { 
            steps {
                parallel 'automation test':{
                    node('node1') {
                        checkout scm
                        sh "pytest ${test_selection[0]} --junitxml=testresult/result1.xml"
                        sh "python push_test_report.py testresult"
                    }
                    node('node2') {
                        checkout scm
                        sh "pytest ${test_selection[1]} --junitxml=testresult/result2.xml"
                        sh "python push_test_report.py testresult"
                    }
                }
            }
        }
        stage('Report'){
            steps {
                git branch: 'main', url: 'git://hoge/integration-test.git'
            }
            post {
                always {
                    junit 'intagration_test/testresult/*.xml'
                }
            }
        }
    }
}
create_test_selection_file.pyの実装

 (主題ではないため簡易的なサンプルですが)例えば以下のようなものになります。

import glob
import xml.etree.ElementTree as ET
import numpy as np

NUMBER_OF_TESTNODE = 2

def select_test_by_time(test_files, resultfiles):
    test_result = {}
    all_time = 0.0
    for file in resultfiles:
        root = ET.parse(file).getroot()
        all_time += float(root.attrib['time'])
        for testcase in root.iter('testcase'):
            test_result[testcase.attrib['file']] = test_result.get(testcase.attrib['file'], 0) + float(testcase.attrib['time'])

    testsuite_time = 0.0
    node_no = 0
    output = ""
    for key, value in test_result.items():
        testsuite_time += value
        output += key + ' '
        if testsuite_time > all_time / NUMBER_OF_TESTNODE and node_no < NUMBER_OF_TESTNODE - 1:
            testsuite_time = 0
            output += ","
            node_no += 1
    output = output.replace('\\','/')
    print(output)

def select_test_by_number(test_files):
    test_group = list(np.array_split(test_files, NUMBER_OF_TESTNODE))
    output = ''
    for tests in test_group:
        output += ' '.join(tests) + ','
    output = output.replace('\\','/')
    print(output)

def create_test_selection_file():
    test_file = 'test/test*.py'
    input_file = 'testresult/*.xml'

    test_files = glob.glob(test_file)
    resultfiles = glob.glob(input_file)

    if resultfiles:
        select_test_by_time(test_files, resultfiles)
    else:
        select_test_by_number(glob.glob(test_file))

if __name__ == '__main__':
    create_test_selection_file()

atestcovでオールペア法での冗長なテストケースを見つける

ソフトウェアテスト #2 Advent Calendar 2018 - Qiitaの12/15の記事になります。

ATestCovの使い方の一例紹介です。

組み合わせテストツールATestCovを公開 - 千里霧中

atestcovを使って、テストケース削除時のnワイズカバレッジの変化を確認することで、nワイズテストにおける冗長なテストケースを検出できます。

pythonでのこの実装例を示します。
例は2ワイズカバレッジ(オールペア法)が対象です。テストケースを1つ削除した入力ファイルを全パターン生成し、atestcovで2ワイズカバレッジの変化を確認しています。
これにより、削除しても2ワイズカバレッジを下げないテストケースをピックアップします。

# check_2wise_testcase.py
'''指定されたテストケースのうち、削除しても2ワイズカバレッジを低下させないテストケースを検出する'''
import subprocess
import sys
import os
import re

def get_2wisecov(testcase_file, parameter_file):
    '''atestcovを実行し、2ワイズカバレッジ結果部分を抜き出す'''
    result = subprocess.check_output(f'atestcov.exe -t {testcase_file} -p {parameter_file}')
    return re.search('2wise coverage: ([0-9.]+)', result.decode('utf-8')).group(1)

def check_test(testcase_file, parameter_file):
    '''削除しても2ワイズカバレッジが変化しないテストケースをピックアップする'''
    origin_cov = get_2wisecov(testcase_file, parameter_file)
    print(f'2wiseカバレッジ:{origin_cov}%')

    with open(testcase_file) as f:
        lines = f.readlines()

    filename = 'temp.txt'
    for i in range(1, len(lines)):
        with open(filename, mode='w') as f:
            for index, line in enumerate(lines):
                if index == i:
                    remove_testcase = line.replace('\n', '')
                    continue
                f.write(lines[index])
        if get_2wisecov(filename, parameter_file) == origin_cov:
            print(f'-冗長なテストケース:{remove_testcase}({i+1}行目)')

        os.remove(filename)

if __name__ == '__main__':
    if len(sys.argv) != 3:
        print("実行引数:python check_2wise_testcase.py テストケースファイルパス パラメータファイルパス")
        sys.exit(1)
    check_test(sys.argv[1], sys.argv[2])

実行例として、次の入力ファイルを用意します。

テストケース定義ファイル:SampleTestCase.txt

麺,スープ
細麺,豚骨
太麺,豚骨
細麺,醤油
太麺,醤油
太麺,豚骨

パラメータ定義ファイル:SampleParameter.txt

麺:細麺,太麺
スープ:豚骨,醤油

実行コマンドは次の通りです。

python check_2wise_testcase.py SampleTestCase.txt SampleParameter.txt

すると次のような結果が得られます。

2wiseカバレッジ:100.00%
-冗長なテストケース:太麺,豚骨(3行目)
-冗長なテストケース:太麺,豚骨(6行目)

これにより、2wiseカバレッジを基準とした場合、テストケース定義ファイルの3行目あるいは6行目のテストケースが冗長なのが分かります。

ATestCovを使って、テストケースで網羅できていないパラメータ組み合わせを見つける

先日公開したATestCovの使い方の一例紹介です。

組み合わせテストツールATestCovを公開 - 千里霧中

ATestCovでは、実行時引数「--info」(-i)の付与で、テストケースで網羅できていないパラメータ組み合わせを検出できます。
実行例として、以下のファイルを入力して実行します。

SampleParameter.txt(パラメータ入力ファイル)
#パラメータと値の一覧
麺:細麺,太麺
スープ:豚骨,醤油,味噌
SampleTestCase.txt(テストケース入力ファイル)
#テストケース一覧
麺,スープ
細麺,豚骨
太麺,醤油
細麺,醤油

今回、この入力ファイルを用いて、テストケースで網羅できていない、2つのパラメータの組み合わせを調べます。
調べ方は、2ワイズカバレッジ(2因子間網羅率)計測コマンドに、「--info」を付与します。実行コマンドは次のようになります。

./atestcov --testcase SampleTestCase.txt --param SampleParameter.txt --upper 2 --lower 2 --info

すると、2ワイズカバレッジに加えて、次のように「[info]」ログが出力されます。

atestcov(ver.0.04) ***
[info]measurering:2wise coverage
 [info]uncover:麺:細麺  スープ:味噌  
 [info]uncover:麺:太麺  スープ:豚骨  
 [info]uncover:麺:太麺  スープ:味噌  

[coverage report]
number of test case: 3
number of parameter: 2
2wise coverage: 50.00%(3/6)

このうち「[info]uncover:」の行で表示された組み合わせが、テストケースで網羅できていない組み合わせです。例えば「細麺の味噌スープ」を、テストケースが網羅できていないと読み取れます。

特定の組み合わせを調査対象から外す

網羅できていない組み合わせをピックアップする際、特定の組み合わせはピックアップから除外したい場合があります(例えば禁則の組み合わせなど)。その際は排他制約の文をパラメータに追加します。

例えば「太麺の豚骨スープ」は網羅しなくてよいならば、次のように@mutexで明記します。

SampleParameter.txt(パラメータ入力ファイル)
#パラメータと値の一覧
麺:細麺,太麺
スープ:豚骨,醤油,味噌
#禁則
@mutex 麺:太麺, スープ:豚骨


すると、出力で「[info]uncover:麺:太麺 スープ:豚骨 」が表示されなくなります。

組み合わせテストツールATestCovを公開

組み合わせテスト設計の補助用に、nワイズカバレッジを計測する簡易的なツールATestCovをリリースしました。

ATestCovユーザマニュアル
リリースページ

ツールの想定用途は、既存のテストケースの網羅度チェックや、組み合わせのばらつきの評価です。

計測対象のnワイズカバレッジは、テストケース中に、n個のパラメータ組み合わせがどれぐらい出現するかを、網羅率で示したものです。
具体的な公知のテスト技法との関係として、同値分割テストの網羅率では、1ワイズカバレッジを評価に用います。オールペア法(ペアワイズ法)や直交表技法の網羅率では、2ワイズカバレッジの網羅に重点を起きます。

使い方

使い方ですが、テストケース記述ファイルと、パラメータ記述ファイルを実行引数に指定して実行します。

パラメータ記述ファイルは、以下のようなテスト条件のパラメータと値を列記したファイルです(PICTの入力ファイルと同じです)。

#パラメータ記述ファイル.txt
麺:硬め,普通,柔らかめ
スープ:塩,醤油
あぶら:多め,普通,少なめ
飯:あり,なし

テストケース記述ファイルは、以下のようなテストケースを羅列したファイルです(PICTの出力結果と同じです)。

#テストケース記述ファイル.txt
麺	スープ	あぶら	飯
硬め	塩	あり	あり
普通	醤油	なし	なし
硬め	醤油	なし	なし

これを以下のように実行します。

atestcov.exe --param パラメータ記述ファイル.txt --testcase テストケース記述ファイル.txt


すると、以下のように、nワイズカバレッジを一覧表示します。

[coverage report]
number of test case: 3
number of parameter: 4
1wise coverage: 90.00%(9/10)
2wise coverage: 45.95%(17/37)
3wise coverage: 20.00%(12/60)
4wise coverage: 8.33%(3/36)

その他の使い方

排他制約にも対応しています。排他制約は以下のように「@mutex」を付与して記述します。

#パラメータ記述ファイル.txt
麺:硬め,普通,柔らかめ
スープ:塩,醤油
あぶら:多め,普通,少なめ
飯:あり,なし

@mutex 麺:硬め, あぶら:多め

排他制約で指定された組み合わせは、カバレッジ計測から除外されます。

その他、追加の実行時引数で、網羅できていない組み合わせの表示や、組み合わせテストのメトリクスの表示を追加できるようにしています。

ISO/IEC/IEEE 42010での「観点」関連の用語の定義・用例

テストでは観点という言葉が時々使われています。ただ結構曖昧な用語なので、議論すると話が発散しがちな印象を持っています。
そこでは体系だった標準を土台にすると発散を軽減できる場合があるのですが、テストの観点を語る上で使えそうな標準として、ISO/IEC/IEEE 42010があります。

ISO 42010は、アーキテクチャ設計を対象にConcern(関心事)、Viewpoint(観点)、View(側面)の定義や関係性を規定するものです。
この規格は書いてある通りに従えば良いというものではないものの、Viewpoint、Viewなどの言葉の定義の共有手段として使えます。
またアーキテクチャ設計についての文献では、ISO 42010に則った解説や、ISO42010との関係性を明記した解説が結構あります。そのためアーキテクチャ設計の観点を学ぶ際の前提知識としても有用です。
テスト観点についての議論の助けになると思いますので、今回「観点」の用語に絞ってISO 42010について触れたいと思います。

用語

まずISO 42010で扱う用語を簡単に説明します。
留意点として、用語の対象はほとんどアーキテクチャについてです。本来「Architecture Viewpoint」「Architecture View」などと前後にArchitectureを明記しますが、自明であるとして規格では「Architecture」を省略しています。ここでもその省略表記に従います。

  • Concern(関心事)
    • システムに対するステークホルダ(システムに関わる個人や組織)の関心事。
    • 要求、制約、シーズ、製品リスクなどが該当する。
  • Model Kind
  • Model
    • Model Kindに従って表現したモデル成果物。
    • クラス図で構造を表現した成果物など。Viewの一種。
  • Perspective
    • 明示的に定義を行っているわけではないが、構造を横断する横断的Viewpointのような意味で使われている用語。
    • なお規格が引用している文献「ソフトウェアシステムアーキテクチャ構築の原理」では、ViewpointとPerspectiveを別の概念として区別している(前者は構造的に分けて考えられる観点、後者は構造を横断する横断的関心事、のように分けている)。

各用語の関係性

ISO 42010では前述の用語の関係性を「Conceptual model of an architecture description」という図でまとめています。そのうち、今回の解説に関係するものを抜粋すると、以下のようになります。

f:id:goyoki:20180403005221p:plain

関係性を大まかに説明すると次のようになります:

  • ステークホルダのConcernに対応するために、アーキテクチャを分析・設計します。
  • アーキテクチャの分析・設計を整理だてて行うために、整理・体系化されたViewpointを利用します。そこではViewpointごとにViewを分析し、得られたViewをArchitecture Descriptionとします。
  • ViewpointはModel Kindに関連付けられています。Viewの分析は、その関連付けされたModel Kindのモデルをモデリングする活動となります。

各用語の具体例

例えば給湯ポットの場合、具体例は次のような感じになります。

Concernの例

  • ポットの使用者のConcern
    • どれぐらい早く沸騰させられるか
    • 子供が誤って給湯できないようにするロック機能はあるか
  • 開発者のConcern
    • 開発をどのように分業できるか

Viewpointの例

  • 効率性についてのViewpoint
    • ハードウェアの資源効率性、加熱の時間効率性、・・・
  • 内部構造についてのViewpoint
    • ハードウェアとソフトウェアの責務分掌、ソフトウェアの構造、ソフトウェアの状態、・・・

Viewの例

  • Viewpoint「ソフトウェアの構造」に対するView
  • Viewpoint 「ハードウェアの資源効率性」に対するView
    • 「ROMサイズは128MByte以下である」
    • 「シングルコアである」

活用

ViewpointやViewをうまく体系化すると、次のような活用が可能になります。

  • 分析や設計の進め方についてのコミュニケーション(共有、レビュー、教授など)を容易にします。
  • View ModelとしてViewpointを整理しておくと、分析漏れに気づいたり、分析結果を整理したりするのに有用な事があります。
  • Viewpointを分けることで、複雑で大規模な分析を、分けて進められるようにします。

こうした活用を行っているものとしては、例えば開発プロセスのRUPやUPが有名です。

FreeMindからテストケースを自動生成するテストツールFMPictをリリース

最近、FMPictというテスト設計自動化ツールを作りました。

https://github.com/hiro-iseri/fmpict

FMPictは、FreeMindのモデルからテストケースを生成するテスト設計自動化ツールです(PICTを制御して実現しています)。nワイズカバレッジ(n:1~3)を100%網羅するテストケースを生成できます。オールペア法ツール、クラシフィケーションツリー法ツールとして利用可能です。

ツールは以下のメリットを持ちます。

  • マインドマップでテスト条件をモデリングできます。それにより、テスト条件の抽象構造やグルーピング構造をわかりやすく表現できます。
  • ツリーモデルの記法的制約(*)の回避手段として、リンク記法とタグ記法という機能を加えています。これによりツリーの重複をなくしたり、複数のツリーを一つのツリーにまとめて描いたりすることが可能です。
  • PICTをマインドマップで操作できます。

環境設定とインストール

環境設定とインストールの手順は以下に記載しています。

https://github.com/hiro-iseri/fmpict/blob/master/doc/howto_setup.md

PICT、FreemindPython(2.7 or 3。pip必要)が必要です。Win、Macで動作確認しています。
FMPictのインストールは「pip install fmpict」を実行ください。

機能の概要

記法ルールは以下にまとめています。

https://github.com/hiro-iseri/fmpict/blob/master/doc/manual.md

大まかに、プレフィックスかフォルダアイコンでノードの種類を表現します。テスト条件(因子orクラシフィケーション)にはフォルダアイコンか先頭文字「@」を付与します。値(水準orクラス)は何も付けません。

例えば次のようなモデルをFreeMindで描きます。

f:id:goyoki:20180204230440p:plain

そして以下のコマンドを実行します。

fmpict FreeMindファイルのファイルパス

すると2ワイズカバレッジ100%網羅のテストケースが出力されます。

ライス  味      ほうれんそう    スープ  味玉    麺      チャーシュー
なし    普通    あり    こってり        なし    ふつう  なし
中      普通    なし    ふつう  あり    硬め    あり
小      こいめ  あり    ふつう  なし    硬め    なし
小      薄め    なし    こってり        あり    ふつう  あり
なし    薄め    なし    ふつう  あり    硬め    なし
なし    こいめ  あり    ふつう  あり    ふつう  あり
小      普通    なし    こってり        なし    硬め    あり
中      薄め    あり    こってり        なし    ふつう  なし
中      こいめ  なし    こってり        あり    ふつう  あり

テストケースの生成では、色々な網羅基準を選択可能です。詳しい手順を以下に記載しました。

https://github.com/hiro-iseri/fmpict/blob/master/doc/howto_select_coverage_criteria.md

FreeMindを使ってテスト設計技法クラシフィケーションツリー法のツールを作る

ソフトウェアテストの小ネタ Advent Calendar 2017 - Qiitaの7日目の記事です。

テスト業務でFreeMindを使っている現場をちらほら見ます。このFreeMindについてですが、中身はテキストベースのXMLフォーマットなので、容易に読み出しや変更を行えます。XMLパーサでスクリプトを組めば、自動で他の成果物と連携させたり、ツリーの整合性を維持したりできるようになります。
今回は実践例として、このFreeMindを使ってテスト設計技法であるクラシフィケーションツリー法の簡易的なツールを作ります(クラシフィケーションツリー法の詳細は「クラシフィケーション・ツリー法入門」を参照ください。題材の完成形は「https://github.com/hiro-iseri/fm_ctm」)。

1. FreeMindクラシフィケーションツリーを表現する。

まずFreeMindクラシフィケーションツリーを描きます。
クラシフィケーションツリーの表現では、最低限クラスとクラシフィケーションの区別が必要です。その区別は一般的には枠線の形で行いますが、今回は区別に操作容易なアイコンを用います。具体的には、テストの入力に指定する末端のクラシフィケーションに、フォルダアイコンを付与する表記ルールを取ります。
例題として、ラーメン二郎のツリーを描いてみました。


2. Freemindのツリーを読み込む

次にFreeMindから、テスト条件となるクラシフィケーションとクラスの抽出を行います。
今回はPythonXMLパーサを用います。処理としては、マインドマップのノードを再帰的に巡回し、フォルダーアイコンが付与されたノード(属性BUILTINに「"folder"」格納)と、その子ノードのテキストを抽出していきます。
コードは例えば以下のようになります。ファイルパスinput_fileのファイルを解析し、クラシフィケーション名をキー、クラス名のリストを値とするディクショナリを_clsf_dictに格納しています。

import xml.etree.ElementTree as ET

_clsf_dict = {}

def _get_testcon_from_node(parent):
    """FreeMindのノードを再帰的に巡回。クラシフィケーションとクラスの組を_clsf_dictへ格納"""

    if [x for x in parent if x.attrib == {'BUILTIN': 'folder'}]:
        class_list = []
        for node in list(parent):
            if 'TEXT' in node.attrib:
                class_list.append(node.attrib['TEXT'].encode(sys.stdout.encoding))
        cf_text = parent.attrib['TEXT'].encode(sys.stdout.encoding)
        _clsf_dict[cf_text] = class_list
    else:
        for node in list(parent):
            _get_testcon_from_node(node)
    return _clsf_dict

...
cls_tree = ET.parse(input_file)
_get_testcon_from_node(cls_tree.getroot())
# _clsf_dictに結果格納
...

3. ツリー結果をPICTに入力し、テスト条件リストを生成する。

最後にテスト条件一覧を出力します。
今回はFreeMindから抽出したクラシフィケーションとクラスの組を、組み合わせテストツールPICTに入力して、2ワイズカバレッジ100%のテスト条件組み合わせを出力させます。
コードは例えば以下のようになります。
前述の「2.」のディクショナリclsf_dictを入力にして、結果を標準出力に出力しています。

def _print_testcondition(clsf_dict):
    """クラシフィケーションとクラスの組をインプットにpictを実行。その結果を標準出力に出力"""
    # クラスとクラシフィケーションの組をpict形式ファイルtemp.csvとして保存
    with open("temp.csv", "w") as pict_input_file:
        for key, classlist in clsf_dict.iteritems():
            line = key + ':' + ",".join(classlist) + '\n'
            pict_input_file.write(line)
    subprocess.Popen("pict temp.csv", shell=True)

表示例は以下の通りです。

豚        アブラ  野菜    ニンニク        辛め    麺      サイズ
豚W     なし    増し    増し    なし    普通    小
普通    増し    あり    なし    あり    硬め    大
豚入り  あり    増し    あり    増し    硬め    大
豚入り  あり    なし    なし    あり    普通    小
普通    なし    なし    増し    増し    普通    大
豚W     なし    あり    なし    増し    硬め    小
豚W     あり    なし    あり    なし    硬め    大
豚入り  あり    あり    増し    あり    硬め    小
豚入り  増し    あり    あり    なし    普通    小
豚W     増し    増し    あり    あり    普通    大
普通    なし    増し    あり    あり    硬め    小
豚入り  なし    増し    なし    なし    硬め    小
豚W     増し    なし    増し    増し    硬め    小
普通    あり    なし    あり    なし    普通    大

コード完成形

題材を実行可能にしたコードを以下に置いています。

https://github.com/hiro-iseri/fm_ctm

python fmctm.py マインドマップファイル」で実行します。