TestRailとMagicPodを連携する方法：Pythonで実装した処理の流れをご紹介

# はじめに
2023年9月22日、MagicPod主催の「[テストケース管理ツール５社に聞く、おすすめテストケース管理術](https://trident-qa.connpass.com/event/288308/)」に、TestRailベンダーとして登壇いたしました。
イベントでは、TestRailとMagicPod（自動テストツール）、Jenkins（CIツール）を連携させた環境を用いて、デモを実施しました。

本記事では、そのツール連携について、具体的な処理の流れやPythonを使用したコード例をご紹介します。

:::note
テスト管理ツール「TestRail」とは？
TestRail は、ソフトウェアテストのプロセスを効率化し、品質管理を向上させるために使用されるテスト管理ツールです。
テストケースの管理、テスト結果の記録、チームでの共有、バグの追跡など、テストケースのライフサイクル全体をカバーしています。
すでに世界で12,000社、250,000ユーザーにご利用いただいているツールです。
:::

# 目次
[テスト管理をテスト自動化に組み込む意図](#テスト管理をテスト自動化に組み込む意図)
[TestRailのMagicPod連携](#testrailのmagicpod連携)
　[概要](#概要)
　[処理の流れ](#処理の流れ) 
　　 [Jenkinsの「prepare」ステージ](#jenkinsのprepareステージ)
　　 [Jenkinsの「test」ステージ](#jenkinsのtestステージ)
　[Pythonスクリプトの内容](#pythonスクリプトの内容)
　　[TestRailの呼び出し](#testrailの呼び出し)
　　[MagicPodの呼び出し](#magicpodの呼び出し)
[まとめ](#まとめ)
[参考：テスト管理ツール「TestRail」について](#参考テスト管理ツールtestrailについて)

# テスト管理をテスト自動化に組み込む意図
弊社テクマトリックスでは、かれこれ20年近くソフトウェア開発向けのテストツールを取り扱っています。[コードを解析してバグを見つける静的解析ツール](https://www.techmatrix.co.jp/product/jtest/index.html)や[UIテスト自動化ツール](https://www.techmatrix.co.jp/product/ranorex/index.html)、[ソフトウェアの構造を可視化する構造分析ツール](https://www.techmatrix.co.jp/product/understand/index.html)など、様々なアプローチでソフトウェアの品質向上を目的に活動してきました。ここ10年ほどはテスト自動化をテーマとして活動されているお客様も多く、[Jenkinsを用いたクラウド上の開発環境の構築](https://www.techmatrix.co.jp/product/software-dev/index.html)などもご提案する機会が増えました。

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

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

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

# TestRailのMagicPod連携
動いている様子を動画として公開しています。各ツールやサービスの画面や動作は動画をご覧ください。
<iframe width="560" height="315" src="https://www.youtube.com/embed/BdERy9Y7cTw?si=g1YgPe3neob8hpIm" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

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

![MagicPodとTestRailの連携デモ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3678684/2b0c291b-46c8-ae8b-9147-e600b6667e6f.png)
TestRailからはMagicPodの結果が以下のように確認できます。細かいことですが、MagicPodのリンクや画面ショット、テストにかかった時間が追加されています。

![TestRailにMagicPodの結果が登録された様子.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3678684/64a2eaba-abe2-66d6-f4ec-36d82e084899.png)
<font color="Gray">　　　　　　　　　　　　TestRailにMagicPodの結果が登録された様子
</font>

## 処理の流れ
処理はJenkinsのパイプラインで実行しています。作成したパイプラインに沿って、処理の流れを説明します。
![Jenkinsのパイプライン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3678684/4c2c1ae9-a1c1-2f01-a156-9552071abb91.png)
<font color="Gray">　　　　　　　　　　　　　  　　　Jenkinsのパイプライン</font>

### Jenkinsの「Prepare」ステージ
TestRailにはMagicPodのテストに対応するテストケースをあらかじめ登録しておきます。その上で、テスト結果を登録するためのテストラン（テスト計画）を作成します。
![Prepareステージの処理.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3678684/6435ca1a-cb01-9fb5-4077-f155ce0ad2d3.png)
<font color="Gray">　　　　　　　　　　　　　  　　　Prepareステージの処理</font>

### Jenkinsの「Test」ステージ
以下の処理を3種類のブラウザごとに並列に実施します。
* MagicPodの一括テスト実行を開始
* MagicPodのテストが終了したら、結果を取得し、TestRailにテスト結果を登録
 
![Testステージの処理.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3678684/24152f3b-acb3-1e76-5ad1-b052e48474db.png)
<font color="Gray">　　　　　　　　　　　　　　　　　Testステージの処理</font>

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

* TestRailのAPI呼び出し
    * APIにアクセスするためのAPIバインディングのサンプルが用意されています。
    [https://github.com/gurock/testrail-api/](https://github.com/gurock/testrail-api/)
    * 今回はPythonから呼び出しています。
***
* <b>「Prepare」ステージ：テスト計画の作成</b>
    * 今回のデモでは、3種類のブラウザのテストを行うため、3つのテストランを作成します。この3つのテストランを1つのテスト計画としてまとめています。
以下はTestRailの用語を図でまとめたものです。
![3つのテストランを1つのテスト計画としてまとめる.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3678684/1eef783a-b6da-8d3f-b800-d4a9639628ab.png)
    * 今回はあらかじめ作成しておいたTestRailのテストケース番号とブラウザの設定を固定値として指定しています。
    * テストランは対象となるテストケースを選択し、テストランを作成します。名称は「作成時の日付＋MagicPod Test」としています。
~~~python:
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))
~~~

* <b>「Test」ステージ：テスト結果の登録</b>
    * 「Prepare」ステージで作成したテスト計画にテストに結果を登録します。
    * 登録するテスト結果はMagicPodの結果と画面ショットのパスが含まれるJSON形式のファイルを読み込みます。
    * テスト結果を追加した後、テストに画面ショットを添付ファイルとして追加します。

~~~python:
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形式](https://github.com/Magic-Pod/magicpod-api-client)
<br>

* <b>「Test」ステージ：テストの一括実行、結果と画面ショットの取得</b>
    * magicpod-api-client形式で一括実行した際は同期処理で呼び出しを行い、テストの終了を待ちます。
    * テスト終了後にMagicPodのテスト結果を取得するために、magicpod-api-client get-batch-run や magicpod-api-client get-screenshots を実行します。
    * 画面ショットを取得する際はすべての画面ショットがzipファイルとして取得されるため、解凍と最後の画面ショットのファイルパスのみをテスト結果のJSONに追加しています。
    * 最終的に、テスト結果＋最後の画面ショットのパスが入ったJSON形式のデータをファイルに書き出します。このファイルをTestRailへの結果登録で利用しています。

~~~python:
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”として最後の画面ショットのパスを追加しています。

~~~python:
{
    "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フォーム](https://s.techmatrix.co.jp/form/TRcontactTMX?k3ad=Qiita)
* メールでのお問い合わせ：[testrail-info@techmatrix.co.jp](testrail-info@techmatrix.co.jp)

# 参考：テスト管理ツール「TestRail」について
今回ツール連携を行ったTestRailについて、詳しく知りたい方はこちらをご覧ください。

https://www.techmatrix.co.jp/product/testrail/index.html?utm_source=qiita&utm_medium=social&utm_campaign=20240311TR_qiita

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

また、TestRailの開発元であるGurock社が提供するテスト管理のノウハウや、TestRailの機能と運用の秘訣、その他テストに関わる有益な情報を、[TestRail Blog](https://blog.testrail.techmatrix.jp/?utm_source=qiita&utm_medium=social&utm_campaign=20240311TR_qiita)でも発信しています。


:link:その他のツール連携もご紹介しています。
* [TestRailとmabl, backlog, slackを連携させてみた](https://blog.testrail.techmatrix.jp/testrail-mabl-backlog-slack/?utm_source=qiita&utm_medium=social&utm_campaign=20240311TR_qiita)
* [SeleniumとTestRailを統合する方法](https://blog.testrail.techmatrix.jp/integrate-selenium-with-testrail/?utm_source=qiita&utm_medium=social&utm_campaign=20240311TR_qiita
)

:link:なぜTestRailが世界中で利用されているのか？知りたい方はこちらです。
 * [Webベースのテスト管理ツール TestRail](https://blog.testrail.techmatrix.jp/testrail-cloud-pre-open/?utm_source=qiita&utm_medium=social&utm_campaign=20240311TR_qiita)