TestRailとMagicPodを連携してみた

2023年9月22日に開催された株式会社MagicPod主催のテストケース管理ツール5社に聞く、おすすめテストケース管理術にTestRailベンダーとして登壇いたしました。

催しの中でTestRailとMagicPod(自動テスト), Jenkins(CIツール)を連携させた環境を用いてデモを行いましたので、今回はこの連携の中身についてご紹介します。

テスト管理をテスト自動化に組み込む意図

弊社テクマトリックスでは、かれこれ20年近くソフトウェア開発向けのテストツールを取り扱っています。コードを解析してバグを見つける静的解析ツールUIテスト自動化ツールソフトウェアの構造を可視化する構造分析ツールなど、様々なアプローチでソフトウェアの品質向上を目的に活動してきました。ここ10年ほどはテスト自動化をテーマとして活動されているお客様も多く、Jenkinsを用いたクラウド上の開発環境の構築などもご提案する機会が増えました。

自動化されたテストの対極にあるのが手動テストです。手動テストはユーザビリティテストや探索的テストなどの観点も含まれるため、手動テストがなくなることはありません。自動化されたテストと手動テストは混在して開発チームに存在することになります。

テスト結果は自動/手動テスト、テスト内容ごとにばらばらに管理されているのが当たり前とされているように思います。しかし、リリースするソフトウェアは1つです。それであれば、そのソフトウェアのテスト結果はすべて1つにまとめられているのが正しい姿ではないかと感じます。

テスト管理をテスト自動化に組み込む意図は、自動化されたテストと手動テストの結果をまとめて管理/管理することで、開発ソフトウェアの状態を早く正しく把握できる仕組みを持つことです。

TestRailのMagicPod連携

動いている様子を動画として公開しています。各ツールやサービスの画面や動作は動画をご覧ください。

概要

連携の処理はPythonにて実装し、Jenkinsで実行しました。詳しい処理の流れや内容は後述します。

TestRailからはMagicPodの結果が以下のように確認できます。細かいことですが、MagicPodのリンクや画面ショット、テストにかかった時間が追加されています。

TestRailにMagicPodの結果が登録された様子

処理の流れ

処理はJenkinsのパイプラインで実行しています。作成したパイプラインに沿って、処理の流れを説明します。

Jenkinsのパイプライン

Jenkinsの「Prepare」ステージ

TestRailにはMagicPodのテストに対応するテストケースをあらかじめ登録しておきます。その上で、テスト結果を登録するためのテストラン(テスト計画)を作成します。

Prepareステージの処理

Jenkinsの「Test」ステージ

以下の処理を3種類のブラウザごとに並列に実施します。

  • MagicPodの一括テスト実行を開始
  • MagicPodのテストが終了したら、結果を取得し、TestRailにテスト結果を登録
Testステージの処理

Pythonスクリプトの内容

TestRailの呼び出し

TestRailはREST APIによる呼び出しが可能です。 今回の連携では、テストケースからテストランを作成する処理とテスト結果を登録する処理を利用します。

  • TestRailのAPI呼び出し

  • 「Prepare」ステージ:テスト計画の作成
    • 今回のデモでは、3種類のブラウザのテストを行うため、3つのテストランを作成します。この3つのテストランを1つのテスト計画としてまとめています。
      以下はTestRailの用語を図でまとめたものです。
    • 今回はあらかじめ作成しておいたTestRailのテストケース番号とブラウザの設定を固定値として指定しています。
    • テストランは対象となるテストケースを選択し、テストランを作成します。名称は「作成時の日付+MagicPod Test」としています。
import os
import sys
import base64
import json
import requests
import argparse
from datetime import datetime
from testrail import *

class TestRailAPIWrapper:

    def __init__(self, base_url, user, password):
        self._client = APIClient(base_url)
        self._client.user = user
        self._client.password = password

    def add_plan(self, project_id, entries):
        # https://docs.testrail.techmatrix.jp/testrail/docs/702/api/reference/plans/
        # POST index.php?/api/v2/add_plan/:project_id
        entries["name"] = datetime.now().strftime("%Y-%m-%d-%H-%M") + " MagicPod Test"
        response = self._client.send_post(
            'add_plan/'+str(project_id),entries)
        return response


TESTRAIL_TESTPLAN_ENTRY = {
    "name": "testplan_name",
    "entries": [
        {
            "suite_id": 9,
            "include_all": True,
            "config_ids": [6,7,8], # 3種類のブラウザを利用するよう指定
            "runs": [
                {
                    "include_all": True,
                    "case_ids": [2312, 2313], # MagicPodのテストに対応したテストケースを指定
                    "assignedto_id": 1,
                    "config_ids": [6] # Chrome
                },
                {
                    "include_all": True,
                    "case_ids": [2312, 2313],
                    "assignedto_id": 1,
                    "config_ids": [7] # Firefox
                },
                {
                    "include_all": True,
                    "case_ids": [2312, 2313],
                    "assignedto_id": 1,
                    "config_ids": [8] # Edge
                }
            ]
        },
    ]
}


def prepare_testplan():
    print("Preparing test plan")
    client = TestRailAPIWrapper(TESTRAIL_URL, TESTRAIL_USER, TESTRAIL_PASSWORD)
    response = client.add_plan(TESTRAIL_PROJECT_ID, TESTRAIL_TESTPLAN_ENTRY)
    print(json.dumps(response, indent=4))
    with open(TESTRAIL_TESTPLAN_JSON_FILENAME, "w", encoding='utf-8') as file:
        file.write(json.dumps(response))

  • 「Test」ステージ:テスト結果の登録
    • 「Prepare」ステージで作成したテスト計画にテストに結果を登録します。
    • 登録するテスト結果はMagicPodの結果と画面ショットのパスが含まれるJSON形式のファイルを読み込みます。
    • テスト結果を追加した後、テストに画面ショットを添付ファイルとして追加します。
import os
import sys
import base64
import json
import requests
import argparse
from datetime import datetime
from testrail import *

class TestRailAPIWrapper:

    def __init__(self, base_url, user, password):
        self._client = APIClient(base_url)
        self._client.user = user
        self._client.password = password

    def get_tests(self, run_id):
        # https://docs.testrail.techmatrix.jp/testrail/docs/702/api/reference/tests/
        # GET index.php?/api/v2/get_tests/:run_id
        response = self._client.send_get(
            'get_tests/'+str(run_id))
        return response

    def add_result(self, test_id, entries):
        # https://docs.testrail.techmatrix.jp/testrail/docs/702/api/reference/results/
        response = self._client.send_post(
            'add_result/'+str(test_id),entries)
        return response

    def add_attachment(self, result_id, filename):
        # https://docs.testrail.techmatrix.jp/testrail/docs/702/api/reference/attachments/
        # POST index.php?/api/v2/add_attachment_to_result/:result_id
        response = self._client.send_post(
            'add_attachment_to_result/'+str(result_id),filename)
        return response

        return


def add_result(json_filename):
    print(f"Adding result using JSON file: {json_filename}")

    if not os.path.exists(json_filename):
        print("Error: json file not found.")
        sys.exit(1)

    if not os.path.exists(TESTRAIL_TESTPLAN_JSON_FILENAME):
        print("Error: testplan file not found.")
        sys.exit(1)
    
    with open(TESTRAIL_TESTPLAN_JSON_FILENAME, "r") as f:
        testplan_data = json.load(f)
        print(json.dumps(testplan_data, indent=4))
    
    with open(json_filename, "r") as f:
        magicpod_result_data = json.load(f)
        print(json.dumps(magicpod_result_data, indent=4))

    client = TestRailAPIWrapper(TESTRAIL_URL, TESTRAIL_USER, TESTRAIL_PASSWORD)

    # testrun
    is_succeed = 0
    testruns = testplan_data['entries'][0]['runs']
    magicpod_type = magicpod_result_data['test_setting_name'] # Browser
    magicpod_results = magicpod_result_data['test_cases']['details'][0]['results']
    for testrun in testruns:
        # testrunとmagicpodの結果をマッピング(ブラウザ名で特定)し、テストランIDを特定
        if testrun['config'] == magicpod_type:
            testrun_id = testrun['id']
            # テストランIDからテストを取得
            tests = client.get_tests(testrun_id)
            # test
            for test in tests:
                for magicpod_result in magicpod_results:
                    # magicpodの結果(name)とテストの名前を比較, 一致した場合、テスト結果を登録
                    if test['title'] == magicpod_result['test_case']['name']:
                        # 登録用のデータ整形
                        if magicpod_result['status'] == "succeeded":
                            status = 1
                        elif magicpod_result['status'] == "failed":
                            status = 5
                            is_succeed = 1
                        
                        started_at = datetime.fromisoformat(magicpod_result['started_at'][:-1])
                        if magicpod_result['finished_at'] == "":
                            finished_at = datetime.fromisoformat(datetime.now().strftime("%Y-%m-%dT%H:%M:%S"))
                        else:
                            finished_at = datetime.fromisoformat(magicpod_result['finished_at'][:-1])
                        elapsed_seconds = str((finished_at - started_at).total_seconds()) + "s"

                        comment = f"MagicPod URL:{magicpod_result_data['url']}"

                        result_data = {
                            "status_id": status,
                            "comment": comment,
                            "elapsed": elapsed_seconds,
                        }

                        # 登録
                        add_result_response = client.add_result(test['id'], result_data)
                        print(json.dumps(add_result_response, indent=4))

                        add_attachment_to_result_response = client.add_attachment(add_result_response['id'], magicpod_result['screenshot'])
                        print(json.dumps(add_attachment_to_result_response, indent=4))
    return is_succeed

MagicPodの呼び出し

  • MagicPodのテストを外部から一括実行する場合、以下の方法があります。今回はmagicpod-api-client形式での実行をラップする処理をPythonで実装しました。
  • 「Test」ステージ:テストの一括実行、結果と画面ショットの取得
    • magicpod-api-client形式で一括実行した際は同期処理で呼び出しを行い、テストの終了を待ちます。
    • テスト終了後にMagicPodのテスト結果を取得するために、magicpod-api-client get-batch-run や magicpod-api-client get-screenshots を実行します。
    • 画面ショットを取得する際はすべての画面ショットがzipファイルとして取得されるため、解凍と最後の画面ショットのファイルパスのみをテスト結果のJSONに追加しています。
    • 最終的に、テスト結果+最後の画面ショットのパスが入ったJSON形式のデータをファイルに書き出します。このファイルをTestRailへの結果登録で利用しています。
import os
import re
import requests
import sys
import json
import inspect
import subprocess
import zipfile
import shutil


class MagicpodApiClientWrapper:

    def __init__(self, secret_api_token, org_name, project_name, cmd_path, tmp_dir):
        self._secret_api_token = secret_api_token
        self._org_name = org_name
        self._project_name = project_name
        self._cmd_path = cmd_path
        self._tmp_dir = tmp_dir

    def _run_command(self, command):
        try:
            result = subprocess.run(
                command,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,  # テキストモードで出力を取得
                check=True  # エラーコードが非ゼロの場合に例外を発生させる
            )
            return result.stdout, result.stderr
        except subprocess.CalledProcessError as e:
            return None, f"エラー: コマンドがエラーコード {e.returncode} で終了しました。"
        except FileNotFoundError:
            return None, "エラー: コマンドが見つかりませんでした。"


    def batch_run(self, setting):
        command = [
            self._cmd_path,
            "batch-run",
            "-t", self._secret_api_token,
            "-o", self._org_name,
            "-p", self._project_name,
            "-S", str(setting)
        ]
        stdout, stderr = self._run_command(command)
        return stdout


    def get_latest_batch_number(self, test_setting_name):
        latest_number = 0
        url = f"https://magic-pod.com/api/v1.0/{self._org_name}/{self._project_name}/batch-runs/"
        headers = {
            "Authorization": f"Token {self._secret_api_token}"
        }
        response = requests.get(url, headers=headers)
        if response.status_code == 200:
            result = response.json()

        for run in result['batch_runs']:
            if run['test_setting_name'] == test_setting_name:
                latest_number = run['batch_run_number']
                break
        return latest_number


    def get_batch_run(self, batch_run_number):
        url = f"https://magic-pod.com/api/v1.0/{self._org_name}/{self._project_name}/batch-run/{batch_run_number}"
        headers = {
            "Authorization": f"Token {self._secret_api_token}"
        }
        response = requests.get(url, headers=headers)
        if response.status_code == 200:
            result = response.json()
            return result


    def get_screenshots(self, batch_run_number):
        temp_directory = self._tmp_dir + ORGANIZATION_NAME + "_" + PROJECT_NAME + "_" + str(batch_run_number)
        temp_zipfile = temp_directory + "/screenshots_" + str(batch_run_number) + ".zip"

        if not os.path.exists(temp_directory):
            os.makedirs(temp_directory)

        command = [
            self._cmd_path,
            "get-screenshots",
            "-t", self._secret_api_token,
            "-o", self._org_name,
            "-p", self._project_name,
            "-b", str(batch_run_number),
            "-d", temp_zipfile
        ]
        stdout, stderr = self._run_command(command)

        filelist = []
        with zipfile.ZipFile(temp_zipfile) as zf:
            filelist = zf.namelist()
            zf.extractall(temp_directory)
        return filelist


    def get_max_numbered_files(self, file_paths):
        # 各親フォルダごとに最も数字が大きいファイルのパスを格納するリスト
        max_numbered_file_paths = []

        # 親フォルダごとにファイルをグループ化
        grouped_files = {}
        for file_path in file_paths:
            folder_name = os.path.dirname(file_path)
            base_name = os.path.basename(file_path)
            # パスを正規化
            folder_name = os.path.normpath(folder_name)
            if folder_name not in grouped_files:
                grouped_files[folder_name] = []
            grouped_files[folder_name].append(base_name)

        # 各親フォルダ内のファイルで最も数字が大きいファイルを選択
        for folder_name, file_names in grouped_files.items():
            max_file = max(file_names, key=lambda x: int(os.path.splitext(x)[0]))
            max_numbered_file_path = os.path.join(folder_name, max_file)
            max_numbered_file_paths.append(max_numbered_file_path)

        return max_numbered_file_paths


    def update_testresults(self, json_data, file_paths):
        # ファイルパスからテストケースの情報を抽出し、辞書に格納
        screenshot_array = []
        for file_path in file_paths:
            # 正規表現を使用して数字と名前を抽出
            match = re.search(r'(\d+)_(.+?)[/\\]', file_path)  # / または \ にマッチ
            if match:
                screenshot = {}
                screenshot['number'] = match.group(1)
                screenshot['name'] = match.group(2)
                # パスを正規化
                base_dir = self._tmp_dir + ORGANIZATION_NAME + "_" + PROJECT_NAME + "_" + str(json_data['batch_run_number'])
                screenshot['screenshot'] = os.path.normpath(base_dir + "/" + file_path)
                screenshot_array.append(screenshot)

        # 2つ目のJSONデータを更新
        details = json_data.get("test_cases", {}).get("details", [])
        for result in details[0]['results']:
            number = result['test_case']['number']
            name   = result['test_case']['name'].replace(' ', '_')

            for screenshot in screenshot_array:
                if screenshot['number'] == str(number) and screenshot['name'] == name:
                    result["screenshot"] = screenshot['screenshot']

        return json_data


def run_magicpod(test_setting, output_filename, temp_dir):
    if not temp_dir.endswith('/'):
        temp_dir += '/'

    client = MagicpodApiClientWrapper(SECRET_API_TOKEN, ORGANIZATION_NAME, PROJECT_NAME, MAGICPOD_API_CLIENT_PATH, temp_dir)

    # MagicPodテスト実行
    client.batch_run(test_setting)

    # テスト結果取得
    test_setting_name = next((item['name'] for item in TEST_SETTING_LIST if item['id'] == test_setting), None)
    latest_batch_number = client.get_latest_batch_number(test_setting_name)
    test_results = client.get_batch_run(latest_batch_number)
    screenshots = client.get_screenshots(latest_batch_number)

    # テスト結果加工
    last_screenshots = client.get_max_numbered_files(screenshots)
    magicpod_result = client.update_testresults(test_results, last_screenshots)

    # 結果をファイルに保存
    with open(output_filename, "w", encoding='utf-8') as file:
        file.write(json.dumps(magicpod_result))
  • 出力されるJSON形式の内容の例です。MagicPodから取得したテスト結果に”screenshot”として最後の画面ショットのパスを追加しています。
{
    "organization_name": "ORGANIZATION",
    "project_name": "PROJECT",
    "batch_run_number": 26,
    "test_setting_name": "Edge",
    "status": "failed",
    "status_number": 2,
    "started_at": "2023-09-07T15:14:42Z",
    "finished_at": "2023-09-07T15:16:02Z",
    "test_cases": {
        "succeeded": 1,
        "failed": 1,
        "total": 2,
        "details": [
            {
                "pattern_name": "\u5b9f\u884c\u8a2d\u5b9a",
                "included_labels": [],
                "excluded_labels": [],
                "results": [
                    {
                        "order": 1,
                        "test_case": {
                            "number": 1,
                            "name": "TestRail\u30c7\u30e2\u30b5\u30a4\u30c8 \u30ed\u30b0\u30a4\u30f3",
                            "url": "https://app.magicpod.com/ORGANIZATION/PROJECT/1/"
                        },
                        "number": 1,
                        "status": "succeeded",
                        "started_at": "2023-09-07T15:14:46Z",
                        "finished_at": "2023-09-07T15:15:13Z",
                        "data_patterns": null,
                        "screenshot": "temp\\ORGANIZATION_PROJECT_26\\ORGANIZATION_PROJECT_batch_run_26\\1_TestRail\u30c7\u30e2\u30b5\u30a4\u30c8_\u30ed\u30b0\u30a4\u30f3\\17.jpg"
                    },
                    {
                        "order": 2,
                        "test_case": {
                            "number": 2,
                            "name": "TestRail\u30c7\u30e2\u30b5\u30a4\u30c8 \u30ed\u30b0\u30a4\u30f3\uff08\u5931\u6557\uff09",
                            "url": "https://app.magicpod.com/ORGANIZATION/PROJECT/2/"
                        },
                        "number": 2,
                        "status": "failed",
                        "started_at": "2023-09-07T15:15:13Z",
                        "finished_at": "2023-09-07T15:16:01Z",
                        "data_patterns": null,
                        "screenshot": "temp\\ORGANIZATION_PROJECT_26\\ORGANIZATION_PROJECT_batch_run_26\\2_TestRail\u30c7\u30e2\u30b5\u30a4\u30c8_\u30ed\u30b0\u30a4\u30f3\uff08\u5931\u6557\uff09\\17.png"
                    }
                ]
            }
        ]
    },
    "url": "https://app.magicpod.com/ORGANIZATION/PROJECT/batch-run/26/"
}

まとめ

ツール同士がオフィシャルにサポートされていたり、便利なプラグインが公開されていたりすれば、実装することなく連携ができます。しかし、各ツールのインターフェイスが公開されていれば、今回ご紹介したように簡単に連携させることができますし、自分のチームのニーズに合った情報を連携させたり、好きなタイミングで好きなツールで通知を行うことができ、柔軟性が増します。

自動テストの導入や改善を検討されている場合、テスト管理との連携を視野に入れて検討されることをおすすめします。テスト結果のフィードバック性能が大幅に改善します。

今回作成したスクリプトはデモ用のものですが、連携を試したい方がいらっしゃいましたら、TestRail製品窓口までお問い合わせください。

関連する製品

テスト管理ツール TestRail

テストケースの管理やテスト結果の記録、チームでの情報共有など、Excelを使ったテスト管理の業務に限界を感じていませんか?TestRailはシンプルで使いやすいUIを提供し、テストにかかるさまざまな管理コストの削減に貢献します。

■ TestRailの特長 ■

  • テストにさまざまな情報を関連づけて一元管理
  • Webブラウザー上でテストケースを簡単に入力や編集可能
  • テスト実施の準備と結果の共有が容易
  • 進捗や比較などのレポートを提供
  • 要件 / 課題管理ツールやテスト自動化ツールと連携

日本国内では、テスト管理にExcelを使っていたお客さまからの乗り換えが多く、Web上で完結するテスト管理を実現されています。

TestRail でテスト管理のお悩みを解決しませんか?

eBook 公開中

Paul Gerrard著 効果的なテスト管理12の秘密 (日本語)

テスト計画やテスト管理に役立つ12のトピックを解説します。

詳細はこちら