CucumberやBehave等のテスト結果はAllureを使うと、
エビデンスの保存もでき、レポートも綺麗に確認ができます。

ただHTML形式のレポートではHostingする環境が必要が必要になります。
GitHubを使っている場合、Markdown形式であれば、
PullRequestのコメント・ActionsのJob Summaries等でお手軽に表示できるので、
このエントリでは、Markdown形式に変換する事を考えてみます。

結論からになりますが、
次に示すファイル構成でリポジトリを作って、GitHubにpushすれば実現できます。

ファイル構成

  • .behaverc … Behaveの設定ファイル
  • .github
    • workflows
      • feature-test.yaml … GitHub Actionsのワークフロー定義
  • bin
    • allure2markdown.py … AllureレポートからMarkdownに変換するスクリプト
  • requirements.txt … Pythonの依存ライブラリ指定
  • tests
    • features
      • steps
        • sample.py … テストの定義(Python)
      • sample.feature … テストの定義(Gherkin)

各ファイルの内容

.behaverc … Behaveの設定ファイル

[behave]
format=allure_behave.formatter:AllureFormatter
outfiles=allure-results

.github/workflows/feature-test.yaml … GitHub Actionsのワークフロー定義

name: features-test
on: [push]

jobs:
  features_test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
      - run: pip install -r requirements.txt
      - run: behave tests/features
      - run: python bin/allure2markdown.py allure-results/ >> $GITHUB_STEP_SUMMARY
        if: always()

bin/allure2markdown.py … AllureレポートからMarkdownに変換するスクリプト

import os
import datetime
import csv
import glob
import json
import jinja2
import textwrap
import sys


def transform_allure_report2markdown(allure_results_dir):
    result_files = glob.glob(os.path.join(allure_results_dir, "*.json"))

    # 最新の結果を集める
    results = {"features_map": {}}
    for f in result_files:
        with open(f) as fp:
            result_body = json.loads(fp.read())
        feature = {e["name"]: e["value"] for e in result_body.get("labels")}.get("feature", "null")
        if feature not in results["features_map"]:
            results["features_map"][feature] = {"scenarios_map": {}}
        start_already = results["features_map"][feature]["scenarios_map"].get(result_body.get("name"), {}).get("start")
        if start_already is None or int(result_body.get("start", 0)) >= start_already:
            results["features_map"][feature]["scenarios_map"][result_body.get("name")] = result_body

    # 結果を実施時間順にソート
    for kf in results["features_map"]:
        results["features_map"][kf]["name"] = kf
        results["features_map"][kf]["scenarios"] = sorted(results["features_map"][kf]["scenarios_map"].values(), key=lambda e: e.get("start"))
        del results["features_map"][kf]["scenarios_map"]
    results["features"] = sorted(results["features_map"].values(), key=lambda e: e["scenarios"][0].get("start"))
    del results["features_map"]

    # テスト結果の集計
    stats_status = lambda s: s if s in ["passed", "skipped"] else "failed"
    stats = {
        "features": {"passed":0, "failed":0, "skipped":0},
        "scenarios": {"passed":0, "failed":0, "skipped":0},
        "steps": {"passed":0, "failed":0, "skipped":0},
    }
    results["stats"] = {"passed":0, "failed":0, "skipped":0}
    for feature in results["features"]:
        feature["stats"] = {"passed":0, "failed":0, "skipped":0}
        for scenario in feature.get("scenarios"):
            feature["stats"][stats_status(scenario.get("status"))] += 1
            stats["scenarios"][stats_status(scenario.get("status"))] += 1
            scenario["stats"] = {"passed":0, "failed":0, "skipped":0}
            for step in scenario.get("steps"):
                scenario["stats"][stats_status(step.get("status"))] += 1
                stats["steps"][stats_status(step.get("status"))] += 1
        if feature["stats"]["failed"] == 0:
            if feature["stats"]["passed"] == 0:
                feature_status = "skipped"
            else:
                feature_status = "passed"
        else:
            feature_status = "failed"
        results["stats"][feature_status] += 1
        stats["features"][feature_status] += 1
    for k in ["features", "scenarios", "steps"]:
        stats[k]["tests"] = sum(stats[k].values())

    # 添付ファイルのロード (テキスト形式のみ)・補助情報の追加
    status_icon_map = {"passed": ":white_check_mark:", "failed": ":x:", "broken": ":x:"}
    for feature in results["features"]:
        feature["start"] = feature["scenarios"][0].get("start")
        feature["start_time"] = datetime.datetime.fromtimestamp(int(feature.get("start"))/1000).astimezone().strftime("%Y/%m/%d %H:%M:%S %Z")
        feature["stop"] = feature["scenarios"][-1].get("stop")
        feature["stop_time"] = datetime.datetime.fromtimestamp(int(feature.get("stop"))/1000).astimezone().strftime("%Y/%m/%d %H:%M:%S %Z")
        for scenario in feature["scenarios"]:
            scenario["start_time"] = datetime.datetime.fromtimestamp(int(scenario.get("start"))/1000).astimezone().strftime("%Y/%m/%d %H:%M:%S %Z")
            scenario["stop_time"] = datetime.datetime.fromtimestamp(int(scenario.get("stop"))/1000).astimezone().strftime("%Y/%m/%d %H:%M:%S %Z")
            scenario["status_icon"] = status_icon_map.get(scenario["status"], f'[{scenario["status"]}]')
            scenario["tags"] = [e["value"] for e in scenario.get("labels") if e["name"] == "tag"]
            for step in scenario.get("steps"):
                step["start_time"] = datetime.datetime.fromtimestamp(int(step.get("start"))/1000).astimezone().strftime("%Y/%m/%d %H:%M:%S %Z")
                step["stop_time"] = datetime.datetime.fromtimestamp(int(step.get("stop"))/1000).astimezone().strftime("%Y/%m/%d %H:%M:%S %Z")
                step["status_icon"] = status_icon_map.get(step["status"], f'[{step["status"]}]')
                for attach in step.get("attachments", []):
                    if attach["type"].startswith("text/"):
                        with open(os.path.join(allure_results_dir, attach["source"])) as fp:
                            if attach["type"] == "text/csv":
                                # attach["body"] = csv.reader(fp)
                                attach["body"] = ""
                                for idx, row in enumerate(csv.reader(fp)):
                                    attach["body"] += "| " + " | ".join(row) + " |\n"
                                    if idx == 0:
                                        attach["body"] += "| " + " | ".join(["----" for e in range(len(row))]) + " |\n"
                            else:
                                attach["body"] = fp.read()
    results["start"] = min([e["start"] for e in results["features"]])
    results["start_time"] = datetime.datetime.fromtimestamp(int(results.get("start"))/1000).astimezone().strftime("%Y/%m/%d %H:%M:%S %Z")
    results["stop"] = max([e["stop"] for e in results["features"]])
    results["stop_time"] = datetime.datetime.fromtimestamp(int(results.get("stop"))/1000).astimezone().strftime("%Y/%m/%d %H:%M:%S %Z")

    tpl = """
    Test Result Report
    ---
    
    Date: {{ results["start_time"] }} - {{ results["stop_time"] }}
        
    - {{ stats["features"]["passed"] }} features passed, {{ stats["features"]["failed"] }} failed, {{ stats["features"]["skipped"] }} skipped
    - {{ stats["scenarios"]["passed"] }} scenarios passed, {{ stats["scenarios"]["failed"] }} failed, {{ stats["scenarios"]["skipped"] }} skipped
    - {{ stats["steps"]["passed"] }} steps passed, {{ stats["steps"]["failed"] }} failed, {{ stats["steps"]["skipped"] }} skipped
    
    {% for feature in results["features"] %}
    ## {{ feature["name"] }}  - {{ feature["stats"]["passed"] }} passed, {{ feature["stats"]["failed"] }} failed, {{ feature["stats"]["skipped"] }} skipped

    {% for scenario in feature["scenarios"] %}
    <details>
    <summary>
    {{ scenario["status_icon"] }}
    #{{ loop.index }} {% if scenario["tags"] %}[{% for t in scenario["tags"] %}{{ t }}{% endfor %}]{% endif %}
    {{ scenario["name"] }}
     - {{ scenario["stop"] - scenario["start"] }}ms
    </summary><blockquote>
    
    - Tags: {{ ", ".join(scenario["tags"]) }}
    - Status: {{ scenario["status"] }}
    - Duration: {{ scenario["stop"] - scenario["start"] }}ms ({{ scenario["start_time"] }} - {{ scenario["stop_time"] }})

    {% if scenario["statusDetails"] %}
    Message
    ```
    {{ scenario["statusDetails"]["message"] }}
    ```
    {% if scenario["statusDetails"]["trace"] %}
    Trace 
    ```
    {{ scenario["statusDetails"]["trace"] }}
    ```
    {% endif %}
    {% endif %}
    

    {% for step in scenario["steps"] %}
    <details><summary>{{ step["status_icon"] }} {{ step["name"] }}  - {{ step["stop"] - step["start"] }}ms</summary><blockquote>
    
    - Status: {{ step["status"] }}
    - Duration: {{ step["stop"] - step["start"] }}ms ({{ step["start_time"] }} - {{ step["stop_time"] }})
    
    {% for attach in step["attachments"] %}
    {{ attach["name"] }}
    {% if attach["type"] == "text/csv" %}
    {{ attach["body"] }}
    {% else %}
    ```
    {{ attach["body"] }}
    ```
    {% endif %}
    {% endfor %}
    {% if step["statusDetails"] %}
    Message
    ```
    {{ step["statusDetails"]["message"] }}
    ```
    {% if step["statusDetails"]["trace"] %}
    Trace 
    ```
    {{ step["statusDetails"]["trace"] }}
    ```
    {% endif %}
    {% endif %}
    
    </blockquote></details>
    {% endfor %}
    </blockquote></details>
    {% endfor %}
    
    {% endfor %}
    """
    template = jinja2.Template(source=textwrap.dedent(tpl))
    print(template.render(results=results, stats=stats))


if __name__ == "__main__":
    if len(sys.argv) < 1:
        print("usage: python bin/allure2markdown.py [allure-results dir path]")
        exit(1)
    allure_results_dir = sys.argv[1]
    transform_allure_report2markdown(allure_results_dir)

requirements.txt … Pythonの依存ライブラリ指定

allure-behave==2.13.5
allure-python-commons==2.13.5
attrs==23.2.0
behave==1.2.6
Jinja2==3.1.4
MarkupSafe==2.1.5
parse==1.20.2
parse-type==0.6.2
pluggy==1.5.0
six==1.16.0

tests/features/steps/sample.py … テストの定義(Python)

from allure import *
import allure

@When("成功するステップ")
def ok_step(context):
    assert True

@When("失敗するステップ")
def ng_step(context):
    assert False

@When("添付するステップ")
def attach_step(context):
    allure.attach(
        "\n".join([f"{i}" for i in range(10)]),
        name="添付ファイル",
        attachment_type=allure.attachment_type.TEXT,
    )

tests/features/sample.feature … テストの定義(Gherkin)

Feature: 動作確認用フィーチャー

    @正常系
    Scenario: 成功するシナリオ
        When 成功するステップ
        When 成功するステップ

    @正常系
    Scenario: 添付するシナリオ
        When 添付するステップ

    @準正常系
    Scenario: 失敗するシナリオ
        When 失敗するステップ

    @異常系
    Scenario: 途中で失敗するシナリオ
        When 成功するステップ
        When 失敗するステップ
        When 成功するステップ

レポートイメージ

allure2md_01

allure2md_02

allure2md_03

GitHub Flavored Markdownの、
折り畳み・アイコンなどを使えば、
Markdownでもそこそこ見やすいテスト結果が作れそうです。

以上。