はじめに
2023年9月22日、MagicPod主催の「テストケース管理ツール5社に聞く、おすすめテストケース管理術」に、TestRailベンダーとして登壇いたしました。
イベントでは、TestRailとMagicPod(自動テストツール)、Jenkins(CIツール)を連携させた環境を用いて、デモを実施しました。
本記事では、そのツール連携について、具体的な処理の流れやPythonを使用したコード例をご紹介します。
テスト管理ツール「TestRail」とは?
TestRail は、ソフトウェアテストのプロセスを効率化し、品質管理を向上させるために使用されるテスト管理ツールです。
テストケースの管理、テスト結果の記録、チームでの共有、バグの追跡など、テストケースのライフサイクル全体をカバーしています。
すでに世界で12,000社、250,000ユーザーにご利用いただいているツールです。
目次
テスト管理をテスト自動化に組み込む意図
TestRailのMagicPod連携
概要
処理の流れ
Jenkinsの「prepare」ステージ
Jenkinsの「test」ステージ
Pythonスクリプトの内容
TestRailの呼び出し
MagicPodの呼び出し
まとめ
参考:テスト管理ツール「TestRail」について
テスト管理をテスト自動化に組み込む意図
弊社テクマトリックスでは、かれこれ20年近くソフトウェア開発向けのテストツールを取り扱っています。コードを解析してバグを見つける静的解析ツールやUIテスト自動化ツール、ソフトウェアの構造を可視化する構造分析ツールなど、様々なアプローチでソフトウェアの品質向上を目的に活動してきました。ここ10年ほどはテスト自動化をテーマとして活動されているお客様も多く、Jenkinsを用いたクラウド上の開発環境の構築などもご提案する機会が増えました。
自動化されたテストの対極にあるのが手動テストです。手動テストはユーザビリティテストや探索的テストなどの観点も含まれるため、手動テストがなくなることはありません。自動化されたテストと手動テストは混在して開発チームに存在することになります。
テスト結果は自動/手動テスト、テスト内容ごとにばらばらに管理されているのが当たり前とされているように思います。しかし、リリースするソフトウェアは1つです。それであれば、そのソフトウェアのテスト結果はすべて1つにまとめられているのが正しい姿ではないかと感じます。
テスト管理をテスト自動化に組み込む意図は、自動化されたテストと手動テストの結果をまとめて管理/管理することで、開発ソフトウェアの状態を早く正しく把握できる仕組みを持つことです。
TestRailのMagicPod連携
動いている様子を動画として公開しています。各ツールやサービスの画面や動作は動画をご覧ください。
概要
連携の処理はPythonにて実装し、Jenkinsで実行しました。詳しい処理の流れや内容は後述します。
TestRailからはMagicPodの結果が以下のように確認できます。細かいことですが、MagicPodのリンクや画面ショット、テストにかかった時間が追加されています。
処理の流れ
処理はJenkinsのパイプラインで実行しています。作成したパイプラインに沿って、処理の流れを説明します。
Jenkinsのパイプライン
Jenkinsの「Prepare」ステージ
TestRailにはMagicPodのテストに対応するテストケースをあらかじめ登録しておきます。その上で、テスト結果を登録するためのテストラン(テスト計画)を作成します。
Prepareステージの処理
Jenkinsの「Test」ステージ
以下の処理を3種類のブラウザごとに並列に実施します。
- MagicPodの一括テスト実行を開始
- MagicPodのテストが終了したら、結果を取得し、TestRailにテスト結果を登録
Pythonスクリプトの内容
TestRailの呼び出し
TestRailはREST APIによる呼び出しが可能です。 今回の連携では、テストケースからテストランを作成する処理とテスト結果を登録する処理を利用します。
- TestRailのAPI呼び出し
- APIにアクセスするためのAPIバインディングのサンプルが用意されています。
https://github.com/gurock/testrail-api/ - 今回はPythonから呼び出しています。
- APIにアクセスするためのAPIバインディングのサンプルが用意されています。
- 「Prepare」ステージ:テスト計画の作成
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で実装しました。
- curl形式
- Invoke-RestMethod形式
- magicpod-api-client形式
-
「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製品窓口までお問い合わせください。
- Webフォームでのお問い合わせ:Webフォーム
- メールでのお問い合わせ:testrail-info@techmatrix.co.jp
参考:テスト管理ツール「TestRail」について
今回ツール連携を行ったTestRailについて、詳しく知りたい方はこちらをご覧ください。
テストケースの管理やテスト結果の記録、チームでの情報共有など、Excelを使ったテスト管理の業務に限界を感じていませんか?
TestRailはシンプルで使いやすいUIを提供し、テストにかかるさまざまな管理コストの削減に貢献します。
また、TestRailの開発元であるGurock社が提供するテスト管理のノウハウや、TestRailの機能と運用の秘訣、その他テストに関わる有益な情報を、TestRail Blogでも発信しています。
その他のツール連携もご紹介しています。
なぜTestRailが世界中で利用されているのか?知りたい方はこちらです。