From 77207b972a30b0c9ea8b8697ffa28f50066b8be9 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 13:59:45 +0300 Subject: [PATCH 01/30] chore: add pytest-bdd to qase-pytest testing extras --- qase-pytest/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/qase-pytest/pyproject.toml b/qase-pytest/pyproject.toml index 7bc671f6..ea1350df 100644 --- a/qase-pytest/pyproject.toml +++ b/qase-pytest/pyproject.toml @@ -46,6 +46,7 @@ qase_pytest = "qase.pytest.conftest" testing = [ "pytest", "pytest-cov", + "pytest-bdd>=7.0,<9.0", ] [tool.tox] From 14de659454f3f9074834b306ffaf06d44fdb5391 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 14:06:18 +0300 Subject: [PATCH 02/30] feat: bootstrap pytest-bdd bridge plugin in qase-pytest --- qase-pytest/src/qase/pytest/bdd.py | 11 +++++++++++ qase-pytest/src/qase/pytest/conftest.py | 10 ++++++++++ .../tests/tests_qase_pytest/test_bdd_plugin.py | 17 +++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 qase-pytest/src/qase/pytest/bdd.py create mode 100644 qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py new file mode 100644 index 00000000..5ae41e1e --- /dev/null +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -0,0 +1,11 @@ +"""Native pytest-bdd integration for qase-pytest. + +Loaded conditionally from conftest.py only when pytest_bdd is installed. +""" + + +class QasePytestBddPlugin: + """Bridge between pytest-bdd hooks and the main QasePytestPlugin runtime.""" + + def __init__(self, pytest_plugin): + self._pytest_plugin = pytest_plugin diff --git a/qase-pytest/src/qase/pytest/conftest.py b/qase-pytest/src/qase/pytest/conftest.py index b2805bfa..039e4b4f 100644 --- a/qase-pytest/src/qase/pytest/conftest.py +++ b/qase-pytest/src/qase/pytest/conftest.py @@ -32,6 +32,16 @@ def pytest_configure(config): name="qase-pytest", ) + try: + import pytest_bdd # noqa: F401 + from .bdd import QasePytestBddPlugin + config.pluginmanager.register( + QasePytestBddPlugin(config.qase), + name="qase-pytest-bdd", + ) + except ImportError: + pass + def _add_markers(config): config.addinivalue_line( diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py new file mode 100644 index 00000000..75336082 --- /dev/null +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py @@ -0,0 +1,17 @@ +"""Tests for the pytest-bdd bridge plugin (QasePytestBddPlugin).""" + +import pytest + + +def test_bdd_module_importable(): + """The bdd module exists and can be imported without pytest_bdd present at runtime.""" + from qase.pytest import bdd as bdd_module + assert hasattr(bdd_module, "QasePytestBddPlugin") + + +def test_bdd_plugin_constructs_with_pytest_plugin(): + """QasePytestBddPlugin can be instantiated by passing a pytest plugin instance.""" + from qase.pytest.bdd import QasePytestBddPlugin + fake_pytest_plugin = object() + plugin = QasePytestBddPlugin(fake_pytest_plugin) + assert plugin is not None From b5571fc067e37457de6013c06a5df12075bf2b4c Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 14:11:15 +0300 Subject: [PATCH 03/30] style: drop unused import and tighten docstring in bdd plugin smoke test --- qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py index 75336082..405aab81 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py @@ -1,10 +1,8 @@ """Tests for the pytest-bdd bridge plugin (QasePytestBddPlugin).""" -import pytest - def test_bdd_module_importable(): - """The bdd module exists and can be imported without pytest_bdd present at runtime.""" + """The bdd module is importable and exposes QasePytestBddPlugin.""" from qase.pytest import bdd as bdd_module assert hasattr(bdd_module, "QasePytestBddPlugin") From e130ad2d5cd6ee4ae8bb17bbf5b1df4b87798255 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 14:13:40 +0300 Subject: [PATCH 04/30] feat: add parse_scenario_tags helper for pytest-bdd tag parsing --- qase-pytest/src/qase/pytest/bdd.py | 88 ++++++++++++++++++ .../tests_qase_pytest/test_bdd_helpers.py | 91 +++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index 5ae41e1e..ab186b36 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -3,9 +3,97 @@ Loaded conditionally from conftest.py only when pytest_bdd is installed. """ +import re +from typing import Iterable, Optional + class QasePytestBddPlugin: """Bridge between pytest-bdd hooks and the main QasePytestPlugin runtime.""" def __init__(self, pytest_plugin): self._pytest_plugin = pytest_plugin + + +_KNOWN_FIELD_KEYS = {"severity", "priority", "layer", "description"} + + +def parse_scenario_tags(tags: Iterable[str]) -> dict: + """Parse pytest-bdd scenario tags into Qase metadata. + + Recognized forms (single separator: '='): + qase.id=123 -> testops_ids + qase.id=123,124 -> testops_ids + qase.project_id.CODE=1,2 -> testops_project_mapping + qase.ignore -> ignore flag + qase.muted -> muted flag + qase.suite=A.B -> nested suites + qase.severity= / priority= / layer= -> fields + Any other tag (with or without '@' prefix) is appended to tags. + """ + out = { + "testops_ids": None, + "testops_project_mapping": None, + "ignore": False, + "muted": False, + "suite": None, + "fields": {}, + "tags": [], + } + + for raw in tags: + tag = raw[1:] if raw.startswith("@") else raw + lowered = tag.lower() + + if lowered == "qase.ignore": + out["ignore"] = True + continue + if lowered == "qase.muted": + out["muted"] = True + continue + + if lowered.startswith("qase.id="): + values = tag.split("=", 1)[1] + ids = _parse_id_list(values) + if ids: + out["testops_ids"] = ids + continue + + if lowered.startswith("qase.project_id."): + # qase.project_id.CODE=1,2 + head, _, values = tag.partition("=") + code = head.split(".", 2)[2] if head.count(".") >= 2 else None + ids = _parse_id_list(values) + if code and ids: + if out["testops_project_mapping"] is None: + out["testops_project_mapping"] = {} + out["testops_project_mapping"][code] = ids + continue + + if lowered.startswith("qase.suite="): + value = tag.split("=", 1)[1] + out["suite"] = [s.strip() for s in value.split(".") if s.strip()] + continue + + # qase.=value + if lowered.startswith("qase.") and "=" in lowered: + key = lowered.split("=", 1)[0].split(".", 1)[1] + if key in _KNOWN_FIELD_KEYS: + out["fields"][key] = tag.split("=", 1)[1] + continue + + # Unknown — treat as a free tag. + out["tags"].append(tag) + + return out + + +def _parse_id_list(values: str) -> Optional[list]: + parsed = [] + for chunk in re.split(r"\s*,\s*", values.strip()): + if not chunk: + continue + try: + parsed.append(int(chunk)) + except ValueError: + return None + return parsed or None diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py new file mode 100644 index 00000000..5e66e9d5 --- /dev/null +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py @@ -0,0 +1,91 @@ +"""Unit tests for pure helpers in qase.pytest.bdd.""" + +from qase.pytest.bdd import parse_scenario_tags + + +class TestParseScenarioTags: + def test_empty_returns_empty_dict(self): + result = parse_scenario_tags([]) + assert result == { + "testops_ids": None, + "testops_project_mapping": None, + "ignore": False, + "muted": False, + "suite": None, + "fields": {}, + "tags": [], + } + + def test_qase_id_single(self): + result = parse_scenario_tags(["qase.id=42"]) + assert result["testops_ids"] == [42] + + def test_qase_id_multiple(self): + result = parse_scenario_tags(["qase.id=42,43,44"]) + assert result["testops_ids"] == [42, 43, 44] + + def test_qase_id_multiple_with_spaces(self): + result = parse_scenario_tags(["qase.id=42, 43 ,44"]) + assert result["testops_ids"] == [42, 43, 44] + + def test_qase_project_id_multi_project(self): + result = parse_scenario_tags( + [ + "qase.project_id.PROJ_A=1,2", + "qase.project_id.PROJ_B=3", + ] + ) + assert result["testops_project_mapping"] == { + "PROJ_A": [1, 2], + "PROJ_B": [3], + } + + def test_ignore_flag(self): + result = parse_scenario_tags(["qase.ignore"]) + assert result["ignore"] is True + + def test_muted_flag(self): + result = parse_scenario_tags(["qase.muted"]) + assert result["muted"] is True + + def test_suite_simple(self): + result = parse_scenario_tags(["qase.suite=Login"]) + assert result["suite"] == ["Login"] + + def test_suite_nested_dot_notation(self): + result = parse_scenario_tags(["qase.suite=Login.Smoke.Critical"]) + assert result["suite"] == ["Login", "Smoke", "Critical"] + + def test_known_fields(self): + result = parse_scenario_tags( + [ + "qase.severity=critical", + "qase.priority=high", + "qase.layer=e2e", + ] + ) + assert result["fields"] == { + "severity": "critical", + "priority": "high", + "layer": "e2e", + } + + def test_free_tags_passthrough(self): + result = parse_scenario_tags(["smoke", "regression"]) + assert result["tags"] == ["smoke", "regression"] + + def test_at_prefix_is_stripped(self): + # pytest-bdd usually exposes tags without "@", but be defensive. + result = parse_scenario_tags(["@qase.id=99", "@smoke"]) + assert result["testops_ids"] == [99] + assert result["tags"] == ["smoke"] + + def test_unknown_qase_tag_falls_through_to_tags(self): + # qase.foo is not recognized; treat as a free tag, not silent loss. + result = parse_scenario_tags(["qase.unknown=bar"]) + assert "qase.unknown=bar" in result["tags"] + + def test_invalid_id_value_is_ignored(self): + # Non-int values must not crash the parser. + result = parse_scenario_tags(["qase.id=abc"]) + assert result["testops_ids"] is None From c020bbad540d17a855fa11378c82eec1b7095a9f Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 14:41:31 +0300 Subject: [PATCH 05/30] test: tighten parse_scenario_tags coverage and remove brittle dict assertion --- .../tests_qase_pytest/test_bdd_helpers.py | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py index 5e66e9d5..f7f8df7a 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py @@ -6,15 +6,13 @@ class TestParseScenarioTags: def test_empty_returns_empty_dict(self): result = parse_scenario_tags([]) - assert result == { - "testops_ids": None, - "testops_project_mapping": None, - "ignore": False, - "muted": False, - "suite": None, - "fields": {}, - "tags": [], - } + assert result["testops_ids"] is None + assert result["testops_project_mapping"] is None + assert result["ignore"] is False + assert result["muted"] is False + assert result["suite"] is None + assert result["fields"] == {} + assert result["tags"] == [] def test_qase_id_single(self): result = parse_scenario_tags(["qase.id=42"]) @@ -28,6 +26,12 @@ def test_qase_id_multiple_with_spaces(self): result = parse_scenario_tags(["qase.id=42, 43 ,44"]) assert result["testops_ids"] == [42, 43, 44] + def test_repeated_qase_id_keeps_last(self): + # Duplicated tags are user error in the .feature file, but the parser + # behaves deterministically: the last one wins. Lock that contract. + result = parse_scenario_tags(["qase.id=42", "qase.id=99"]) + assert result["testops_ids"] == [99] + def test_qase_project_id_multi_project(self): result = parse_scenario_tags( [ @@ -40,6 +44,12 @@ def test_qase_project_id_multi_project(self): "PROJ_B": [3], } + def test_qase_project_id_with_dotted_code_is_accepted(self): + # Mirrors qase-behave parser: extra dots inside the project code are + # passed through verbatim. This is intentional consistency. + result = parse_scenario_tags(["qase.project_id.A.B=1"]) + assert result["testops_project_mapping"] == {"A.B": [1]} + def test_ignore_flag(self): result = parse_scenario_tags(["qase.ignore"]) assert result["ignore"] is True From c3286ddccab32df2b6518c1c88893d7288b6f569 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 14:44:24 +0300 Subject: [PATCH 06/30] feat: add format_data_table helper for Gherkin step data tables --- qase-pytest/src/qase/pytest/bdd.py | 31 +++++++++++ .../tests_qase_pytest/test_bdd_helpers.py | 52 ++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index ab186b36..3a864275 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -97,3 +97,34 @@ def _parse_id_list(values: str) -> Optional[list]: except ValueError: return None return parsed or None + + +def format_data_table(table) -> str: + """Render a pytest-bdd DataTable as a GitHub-flavored markdown table. + + Accepts a duck-typed object exposing `.rows[].cells[].value`. Returns "" if + table is None or empty. + """ + if table is None: + return "" + + rows = getattr(table, "rows", None) or [] + if not rows: + return "" + + def _row_values(row): + return [_escape_cell(cell.value) for cell in row.cells] + + header_values = _row_values(rows[0]) + lines = [ + "| " + " | ".join(header_values) + " |", + "| " + " | ".join(["---"] * len(header_values)) + " |", + ] + for row in rows[1:]: + lines.append("| " + " | ".join(_row_values(row)) + " |") + + return "\n".join(lines) + + +def _escape_cell(value) -> str: + return str(value).replace("|", "\\|") diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py index f7f8df7a..2993d77e 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py @@ -1,6 +1,21 @@ """Unit tests for pure helpers in qase.pytest.bdd.""" -from qase.pytest.bdd import parse_scenario_tags +from qase.pytest.bdd import format_data_table, parse_scenario_tags + + +class _FakeCell: + def __init__(self, value): + self.value = value + + +class _FakeRow: + def __init__(self, values): + self.cells = [_FakeCell(v) for v in values] + + +class _FakeDataTable: + def __init__(self, rows): + self.rows = [_FakeRow(r) for r in rows] class TestParseScenarioTags: @@ -99,3 +114,38 @@ def test_invalid_id_value_is_ignored(self): # Non-int values must not crash the parser. result = parse_scenario_tags(["qase.id=abc"]) assert result["testops_ids"] is None + + +class TestFormatDataTable: + def test_none_returns_empty_string(self): + assert format_data_table(None) == "" + + def test_simple_table(self): + table = _FakeDataTable( + [ + ["name", "email"], + ["Alice", "alice@example.com"], + ["Bob", "bob@example.com"], + ] + ) + result = format_data_table(table) + assert result == ( + "| name | email |\n" + "| --- | --- |\n" + "| Alice | alice@example.com |\n" + "| Bob | bob@example.com |" + ) + + def test_single_row_header_only(self): + table = _FakeDataTable([["col1", "col2"]]) + result = format_data_table(table) + assert result == "| col1 | col2 |\n| --- | --- |" + + def test_empty_table_returns_empty_string(self): + table = _FakeDataTable([]) + assert format_data_table(table) == "" + + def test_escapes_pipes_in_values(self): + table = _FakeDataTable([["a", "b"], ["x|y", "z"]]) + result = format_data_table(table) + assert "x\\|y" in result From 245a093680f9aa443f8477fbea5f0fdfbeee1162 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 14:47:58 +0300 Subject: [PATCH 07/30] fix: escape backslashes and newlines in markdown table cells --- qase-pytest/src/qase/pytest/bdd.py | 14 +++++++++++--- .../tests/tests_qase_pytest/test_bdd_helpers.py | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index 3a864275..10b67af4 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -113,7 +113,7 @@ def format_data_table(table) -> str: return "" def _row_values(row): - return [_escape_cell(cell.value) for cell in row.cells] + return [_escape_markdown_table_cell(cell.value) for cell in row.cells] header_values = _row_values(rows[0]) lines = [ @@ -126,5 +126,13 @@ def _row_values(row): return "\n".join(lines) -def _escape_cell(value) -> str: - return str(value).replace("|", "\\|") +def _escape_markdown_table_cell(value) -> str: + """Escape characters that would break a markdown table row.""" + text = str(value) + # Order matters: escape backslashes first so subsequently inserted + # backslashes (from pipe escaping) are not re-doubled. + text = text.replace("\\", "\\\\") + text = text.replace("|", "\\|") + # Newlines split table rows; map them to
which renders inside cells. + text = text.replace("\r\n", "
").replace("\n", "
").replace("\r", "
") + return text diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py index 2993d77e..1342efc0 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py @@ -149,3 +149,17 @@ def test_escapes_pipes_in_values(self): table = _FakeDataTable([["a", "b"], ["x|y", "z"]]) result = format_data_table(table) assert "x\\|y" in result + + def test_escapes_backslashes_before_pipes(self): + table = _FakeDataTable([["a"], ["C:\\Users"]]) + result = format_data_table(table) + # Backslash must be escaped as \\, not left bare. + assert "C:\\\\Users" in result + + def test_replaces_newlines_with_br_inside_cells(self): + table = _FakeDataTable([["a"], ["line1\nline2"]]) + result = format_data_table(table) + # Newline must not split the row; rendered as
. + assert "line1
line2" in result + # The cell stays on a single output line. + assert "line1\nline2" not in result From 14751baa81a4359a65fd611ccbb0eadb2b0a124e Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 14:50:10 +0300 Subject: [PATCH 08/30] feat: add format_docstring helper for Gherkin step docstrings --- qase-pytest/src/qase/pytest/bdd.py | 14 +++++++++++ .../tests_qase_pytest/test_bdd_helpers.py | 24 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index 10b67af4..19e9c595 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -136,3 +136,17 @@ def _escape_markdown_table_cell(value) -> str: # Newlines split table rows; map them to
which renders inside cells. text = text.replace("\r\n", "
").replace("\n", "
").replace("\r", "
") return text + + +def format_docstring(text) -> str: + """Render a Gherkin step docstring as a fenced markdown code block. + + Returns "" for None/empty input. Outer blank lines from triple-quote + indentation are stripped; internal newlines are preserved. + """ + if not text: + return "" + stripped = text.strip("\n").rstrip() + if not stripped: + return "" + return "```\n" + stripped + "\n```" diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py index 1342efc0..78cd652a 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py @@ -163,3 +163,27 @@ def test_replaces_newlines_with_br_inside_cells(self): assert "line1
line2" in result # The cell stays on a single output line. assert "line1\nline2" not in result + + +from qase.pytest.bdd import format_docstring + + +class TestFormatDocstring: + def test_none_returns_empty_string(self): + assert format_docstring(None) == "" + + def test_empty_string_returns_empty_string(self): + assert format_docstring("") == "" + + def test_single_line(self): + assert format_docstring("hello") == "```\nhello\n```" + + def test_multiline_preserved(self): + text = "line1\nline2\nline3" + assert format_docstring(text) == "```\nline1\nline2\nline3\n```" + + def test_strips_only_outer_blank_lines(self): + # pytest-bdd sometimes includes leading/trailing blank lines from + # triple-quote indentation. + text = "\n\nline\n\n" + assert format_docstring(text) == "```\nline\n```" From e71ce75ac7e5526627be50a02fd83fa168a7d073 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 14:53:17 +0300 Subject: [PATCH 09/30] fix: consolidate imports and handle backticks inside docstrings --- qase-pytest/src/qase/pytest/bdd.py | 18 ++++++++++++--- .../tests_qase_pytest/test_bdd_helpers.py | 22 +++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index 19e9c595..b2afa23d 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -141,12 +141,24 @@ def _escape_markdown_table_cell(value) -> str: def format_docstring(text) -> str: """Render a Gherkin step docstring as a fenced markdown code block. - Returns "" for None/empty input. Outer blank lines from triple-quote - indentation are stripped; internal newlines are preserved. + Returns "" for None/empty input. Outer leading/trailing newlines and + trailing whitespace are stripped; internal indentation and internal + trailing whitespace are preserved verbatim (Gherkin docstrings may + contain semantically significant indentation, e.g. code samples). + + If the input contains a run of backticks, the wrapping fence is made + one longer than the longest run found — preventing premature fence + closure (standard CommonMark behavior). """ if not text: return "" stripped = text.strip("\n").rstrip() if not stripped: return "" - return "```\n" + stripped + "\n```" + + longest_run = 0 + for match in re.finditer(r"`+", stripped): + longest_run = max(longest_run, len(match.group(0))) + fence = "`" * max(3, longest_run + 1) + + return fence + "\n" + stripped + "\n" + fence diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py index 78cd652a..121d7a62 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py @@ -1,6 +1,10 @@ """Unit tests for pure helpers in qase.pytest.bdd.""" -from qase.pytest.bdd import format_data_table, parse_scenario_tags +from qase.pytest.bdd import ( + format_data_table, + format_docstring, + parse_scenario_tags, +) class _FakeCell: @@ -165,9 +169,6 @@ def test_replaces_newlines_with_br_inside_cells(self): assert "line1\nline2" not in result -from qase.pytest.bdd import format_docstring - - class TestFormatDocstring: def test_none_returns_empty_string(self): assert format_docstring(None) == "" @@ -187,3 +188,16 @@ def test_strips_only_outer_blank_lines(self): # triple-quote indentation. text = "\n\nline\n\n" assert format_docstring(text) == "```\nline\n```" + + def test_whitespace_only_returns_empty_string(self): + # Whitespace-only input collapses to empty after stripping. + assert format_docstring("\n\n \n") == "" + + def test_triple_backticks_inside_uses_longer_fence(self): + text = "before\n```\ninner\n```\nafter" + result = format_docstring(text) + # Fence must be 4+ backticks to safely wrap content with a `` ``` `` run. + assert result.startswith("````\n") + assert result.endswith("\n````") + # The original triple-backtick content is preserved unchanged. + assert "```\ninner\n```" in result From 004b24eb38c2a46628bf13b40b8260cbe7373b00 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 14:56:05 +0300 Subject: [PATCH 10/30] feat: add build_step helper that converts pytest-bdd Step to Qase Step --- qase-pytest/src/qase/pytest/bdd.py | 34 +++++++++++ .../tests_qase_pytest/test_bdd_helpers.py | 57 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index b2afa23d..b8cad2c8 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -6,6 +6,8 @@ import re from typing import Iterable, Optional +from qase.commons.models.step import Step, StepGherkinData, StepType + class QasePytestBddPlugin: """Bridge between pytest-bdd hooks and the main QasePytestPlugin runtime.""" @@ -162,3 +164,35 @@ def format_docstring(text) -> str: fence = "`" * max(3, longest_run + 1) return fence + "\n" + stripped + "\n" + fence + + +def build_step(bdd_step) -> Step: + """Build a Qase Step(GHERKIN) from a pytest-bdd Step object. + + Reads keyword, name, line_number, data_table, docstring defensively so the + helper survives minor API drifts between pytest-bdd versions. + """ + keyword = getattr(bdd_step, "keyword", "") + name = getattr(bdd_step, "name", "") + line = getattr(bdd_step, "line_number", 0) or 0 + + data_table = getattr(bdd_step, "data_table", None) + docstring = getattr(bdd_step, "docstring", None) + + payload = None + table_md = format_data_table(data_table) + if table_md: + payload = table_md + elif docstring: + payload = format_docstring(docstring) + + return Step( + step_type=StepType.GHERKIN, + id=None, # let Step generate uuid + data=StepGherkinData( + keyword=keyword, + name=name, + line=line, + data=payload, + ), + ) diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py index 121d7a62..9f253309 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py @@ -1,6 +1,9 @@ """Unit tests for pure helpers in qase.pytest.bdd.""" +from qase.commons.models.step import StepType + from qase.pytest.bdd import ( + build_step, format_data_table, format_docstring, parse_scenario_tags, @@ -201,3 +204,57 @@ def test_triple_backticks_inside_uses_longer_fence(self): assert result.endswith("\n````") # The original triple-backtick content is preserved unchanged. assert "```\ninner\n```" in result + + +class _FakeBddStep: + def __init__( + self, + keyword="Given", + name="something happens", + line_number=3, + data_table=None, + docstring=None, + ): + self.keyword = keyword + self.name = name + self.line_number = line_number + self.data_table = data_table + self.docstring = docstring + + +class TestBuildStep: + def test_basic_step(self): + step = build_step(_FakeBddStep("Given", "a user", 5)) + assert step.step_type == StepType.GHERKIN + assert step.data.keyword == "Given" + assert step.data.name == "a user" + assert step.data.line == 5 + assert step.data.data is None + + def test_when_keyword(self): + step = build_step(_FakeBddStep("When", "they click", 7)) + assert step.data.keyword == "When" + + def test_with_data_table(self): + # _FakeDataTable is defined module-level in this test file + # (see TestFormatDataTable section above). + table = _FakeDataTable([["a", "b"], ["1", "2"]]) + step = build_step(_FakeBddStep("Given", "table", 5, data_table=table)) + assert "| a | b |" in step.data.data + assert "| 1 | 2 |" in step.data.data + + def test_with_docstring(self): + step = build_step(_FakeBddStep("When", "send body", 5, docstring="payload")) + # Default fence is 3 backticks because there are no backticks in "payload". + assert step.data.data == "```\npayload\n```" + + def test_default_line_when_missing(self): + s = _FakeBddStep("Given", "x", 0) + del s.line_number # simulate missing attribute on older pytest-bdd + step = build_step(s) + assert step.data.line == 0 + + def test_each_call_returns_unique_id(self): + s1 = build_step(_FakeBddStep()) + s2 = build_step(_FakeBddStep()) + assert s1.id != s2.id From 2d872ecabf4a8513455817281d0fee760d25d4ed Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 15:04:29 +0300 Subject: [PATCH 11/30] feat: add enrich_result_from_scenario helper for scenario metadata --- qase-pytest/src/qase/pytest/bdd.py | 43 ++++++ .../tests_qase_pytest/test_bdd_helpers.py | 142 ++++++++++++++++++ 2 files changed, 185 insertions(+) diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index b8cad2c8..07b59ff1 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -6,6 +6,7 @@ import re from typing import Iterable, Optional +from qase.commons.models.relation import Relation, SuiteData from qase.commons.models.step import Step, StepGherkinData, StepType @@ -196,3 +197,45 @@ def build_step(bdd_step) -> Step: data=payload, ), ) + + +def enrich_result_from_scenario(result, feature, scenario) -> None: + """Mutate an existing Result with metadata extracted from a pytest-bdd scenario.""" + if getattr(scenario, "name", None): + result.title = scenario.name + + parsed = parse_scenario_tags(getattr(scenario, "tags", []) or []) + + feature_desc = (getattr(feature, "description", "") or "").strip() + scenario_desc = (getattr(scenario, "description", "") or "").strip() + description_parts = [p for p in (feature_desc, scenario_desc) if p] + if description_parts: + result.fields["description"] = "\n\n".join(description_parts) + + if parsed["suite"]: + new_relation = Relation() + for s in parsed["suite"]: + new_relation.add_suite(SuiteData(title=s)) + result.relations = new_relation + elif getattr(feature, "name", None): + relation = result.relations or Relation() + existing = list(getattr(relation.suite, "data", []) or []) + new_relation = Relation() + new_relation.add_suite(SuiteData(title=feature.name)) + for s in existing: + new_relation.add_suite(s) + result.relations = new_relation + + if parsed["testops_project_mapping"]: + for code, ids in parsed["testops_project_mapping"].items(): + result.set_testops_project_mapping(code, ids) + elif parsed["testops_ids"]: + result.testops_ids = parsed["testops_ids"] + + for key, value in parsed["fields"].items(): + result.fields[key] = value + + if parsed["tags"]: + result.add_tags(parsed["tags"]) + result.muted = parsed["muted"] + result.ignore = parsed["ignore"] diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py index 9f253309..b3720e10 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py @@ -1,9 +1,12 @@ """Unit tests for pure helpers in qase.pytest.bdd.""" +from qase.commons.models.relation import Relation, SuiteData +from qase.commons.models.result import Result from qase.commons.models.step import StepType from qase.pytest.bdd import ( build_step, + enrich_result_from_scenario, format_data_table, format_docstring, parse_scenario_tags, @@ -258,3 +261,142 @@ def test_each_call_returns_unique_id(self): s1 = build_step(_FakeBddStep()) s2 = build_step(_FakeBddStep()) assert s1.id != s2.id + + +class _FakeFeature: + def __init__( + self, + name="My Feature", + description="Feature desc", + filename="features/x.feature", + ): + self.name = name + self.description = description + self.filename = filename + + +class _FakeScenario: + def __init__( + self, + name="My Scenario", + description="", + tags=None, + feature=None, + ): + self.name = name + self.description = description + self.tags = tags or set() + self.feature = feature or _FakeFeature() + + +class TestEnrichResultFromScenario: + def _new_result(self): + r = Result(title="placeholder", signature="") + rel = Relation() + rel.add_suite(SuiteData(title="placeholder_suite")) + r.relations = rel + return r + + def test_title_replaced_by_scenario_name(self): + r = self._new_result() + enrich_result_from_scenario(r, _FakeFeature(), _FakeScenario(name="Login OK")) + assert r.title == "Login OK" + + def test_feature_prepended_to_suite_chain(self): + r = self._new_result() + feature = _FakeFeature(name="Auth") + enrich_result_from_scenario(r, feature, _FakeScenario()) + suites = [s.title for s in r.relations.suite.data] + assert suites[0] == "Auth" + + def test_description_combines_feature_and_scenario(self): + r = self._new_result() + feature = _FakeFeature(description="Big feature description") + scenario = _FakeScenario(description="Specific scenario context") + enrich_result_from_scenario(r, feature, scenario) + assert "Big feature description" in r.fields["description"] + assert "Specific scenario context" in r.fields["description"] + + def test_only_feature_description(self): + r = self._new_result() + feature = _FakeFeature(description="Just feature") + scenario = _FakeScenario(description="") + enrich_result_from_scenario(r, feature, scenario) + assert r.fields["description"].strip() == "Just feature" + + def test_no_description_field_when_both_empty(self): + r = self._new_result() + feature = _FakeFeature(description="") + scenario = _FakeScenario(description="") + enrich_result_from_scenario(r, feature, scenario) + assert "description" not in r.fields + + def test_testops_ids_from_tag(self): + r = self._new_result() + enrich_result_from_scenario( + r, _FakeFeature(), _FakeScenario(tags={"qase.id=42"}) + ) + assert r.testops_ids == [42] + + def test_suite_override_replaces_chain(self): + r = self._new_result() + enrich_result_from_scenario( + r, + _FakeFeature(name="ShouldBeIgnored"), + _FakeScenario(tags={"qase.suite=Login.Smoke"}), + ) + suites = [s.title for s in r.relations.suite.data] + assert suites == ["Login", "Smoke"] + + def test_severity_priority_layer_fields(self): + r = self._new_result() + enrich_result_from_scenario( + r, + _FakeFeature(), + _FakeScenario( + tags={ + "qase.severity=critical", + "qase.priority=high", + "qase.layer=e2e", + } + ), + ) + assert r.fields["severity"] == "critical" + assert r.fields["priority"] == "high" + assert r.fields["layer"] == "e2e" + + def test_muted_flag(self): + r = self._new_result() + enrich_result_from_scenario( + r, _FakeFeature(), _FakeScenario(tags={"qase.muted"}) + ) + assert r.muted is True + + def test_ignore_flag(self): + r = self._new_result() + enrich_result_from_scenario( + r, _FakeFeature(), _FakeScenario(tags={"qase.ignore"}) + ) + assert getattr(r, "ignore", False) is True + + def test_free_tags_added_to_result(self): + r = self._new_result() + enrich_result_from_scenario( + r, + _FakeFeature(), + _FakeScenario(tags={"smoke", "regression"}), + ) + assert "smoke" in r.tags + assert "regression" in r.tags + + def test_project_id_mapping(self): + r = self._new_result() + enrich_result_from_scenario( + r, + _FakeFeature(), + _FakeScenario( + tags={"qase.project_id.PROJ_A=1,2", "qase.project_id.PROJ_B=3"} + ), + ) + assert r.get_testops_ids_for_project("PROJ_A") == [1, 2] + assert r.get_testops_ids_for_project("PROJ_B") == [3] From edb2b21f8bc2bdf7f92ecbc8e0bed09e4e283973 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 15:11:49 +0300 Subject: [PATCH 12/30] feat: implement pytest_bdd_before_scenario hook --- qase-pytest/src/qase/pytest/bdd.py | 31 ++++++ .../tests_qase_pytest/test_bdd_plugin.py | 105 ++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index 07b59ff1..cdaa84ce 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -15,6 +15,37 @@ class QasePytestBddPlugin: def __init__(self, pytest_plugin): self._pytest_plugin = pytest_plugin + self._current = None # per-scenario state dict + + def pytest_bdd_before_scenario(self, request, feature, scenario): + runtime = getattr(self._pytest_plugin, "runtime", None) + if runtime is None or runtime.result is None: + return + + enrich_result_from_scenario(runtime.result, feature, scenario) + + # Build the ordered cache: background steps first, then scenario steps, + # de-duplicated by identity in case pytest-bdd already merges them. + background = getattr(feature, "background", None) or getattr( + scenario, "background", None + ) + bg_steps = list(getattr(background, "steps", []) or []) if background else [] + sc_steps = list(getattr(scenario, "steps", []) or []) + seen = set() + remaining = [] + for s in bg_steps + sc_steps: + sid = id(s) + if sid in seen: + continue + seen.add(sid) + remaining.append(s) + + self._current = { + "remaining_steps": remaining, + "next_step_idx": 0, + "bdd_step_to_id": {}, # id(bdd_step) -> qase Step.id + "scenario_failed": False, + } _KNOWN_FIELD_KEYS = {"severity", "priority", "layer", "description"} diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py index 405aab81..3910bb68 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py @@ -1,15 +1,120 @@ """Tests for the pytest-bdd bridge plugin (QasePytestBddPlugin).""" +from unittest.mock import MagicMock + +from qase.commons.models.relation import Relation, SuiteData +from qase.commons.models.result import Result + def test_bdd_module_importable(): """The bdd module is importable and exposes QasePytestBddPlugin.""" from qase.pytest import bdd as bdd_module + assert hasattr(bdd_module, "QasePytestBddPlugin") def test_bdd_plugin_constructs_with_pytest_plugin(): """QasePytestBddPlugin can be instantiated by passing a pytest plugin instance.""" from qase.pytest.bdd import QasePytestBddPlugin + fake_pytest_plugin = object() plugin = QasePytestBddPlugin(fake_pytest_plugin) assert plugin is not None + + +def _runtime_with_result(): + """Build a Runtime-like object that exposes `.result` mutably.""" + runtime = MagicMock() + result = Result(title="placeholder", signature="") + rel = Relation() + rel.add_suite(SuiteData(title="placeholder")) + result.relations = rel + runtime.result = result + runtime.steps = {} + runtime.step_id = None + return runtime + + +def _fake_scenario( + name="My Scenario", + tags=None, + steps=None, + description="", + feature_name="Feat", + feature_desc="", +): + feature = MagicMock() + feature.name = feature_name + feature.description = feature_desc + feature.filename = "x.feature" + + scenario = MagicMock() + scenario.name = name + scenario.description = description + scenario.tags = tags or set() + scenario.steps = steps or [] + scenario.feature = feature + # Some pytest-bdd versions expose `background` here. + scenario.background = None + return feature, scenario + + +class TestBeforeScenarioHook: + def test_enriches_result(self): + from qase.pytest.bdd import QasePytestBddPlugin + + pytest_plugin = MagicMock() + pytest_plugin.runtime = _runtime_with_result() + + bdd = QasePytestBddPlugin(pytest_plugin) + feature, scenario = _fake_scenario( + name="Login", tags={"qase.id=42"}, feature_name="Auth" + ) + + bdd.pytest_bdd_before_scenario( + request=MagicMock(), feature=feature, scenario=scenario + ) + + assert pytest_plugin.runtime.result.title == "Login" + assert pytest_plugin.runtime.result.testops_ids == [42] + suites = [s.title for s in pytest_plugin.runtime.result.relations.suite.data] + assert suites[0] == "Auth" + + def test_caches_steps_for_skipped_finalization(self): + from qase.pytest.bdd import QasePytestBddPlugin + + pytest_plugin = MagicMock() + pytest_plugin.runtime = _runtime_with_result() + + bdd = QasePytestBddPlugin(pytest_plugin) + step_a = MagicMock( + keyword="Given", name="a", line_number=1, data_table=None, docstring=None + ) + step_b = MagicMock( + keyword="When", name="b", line_number=2, data_table=None, docstring=None + ) + feature, scenario = _fake_scenario(steps=[step_a, step_b]) + + bdd.pytest_bdd_before_scenario( + request=MagicMock(), feature=feature, scenario=scenario + ) + + state = bdd._current + assert state is not None + assert state["remaining_steps"] == [step_a, step_b] + assert state["next_step_idx"] == 0 + + def test_no_runtime_result_is_safe(self): + """If runtime.result is somehow None, the hook must not crash.""" + from qase.pytest.bdd import QasePytestBddPlugin + + pytest_plugin = MagicMock() + pytest_plugin.runtime = MagicMock(result=None) + + bdd = QasePytestBddPlugin(pytest_plugin) + feature, scenario = _fake_scenario() + + # Must not raise. + bdd.pytest_bdd_before_scenario( + request=MagicMock(), feature=feature, scenario=scenario + ) From f4cf1347294da7e345ffe949d5a283ce0876ac98 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 15:14:58 +0300 Subject: [PATCH 13/30] feat: implement pytest_bdd before/after_step happy-path hooks --- qase-pytest/src/qase/pytest/bdd.py | 20 ++++ .../tests_qase_pytest/test_bdd_plugin.py | 96 +++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index cdaa84ce..acf31bde 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -47,6 +47,26 @@ def pytest_bdd_before_scenario(self, request, feature, scenario): "scenario_failed": False, } + def pytest_bdd_before_step(self, request, feature, scenario, step, step_func): + runtime = getattr(self._pytest_plugin, "runtime", None) + if runtime is None or self._current is None: + return + qase_step = build_step(step) + runtime.add_step(qase_step) + self._current["bdd_step_to_id"][id(step)] = qase_step.id + self._current["next_step_idx"] += 1 + + def pytest_bdd_after_step( + self, request, feature, scenario, step, step_func, step_func_args + ): + runtime = getattr(self._pytest_plugin, "runtime", None) + if runtime is None or self._current is None: + return + qase_step_id = self._current["bdd_step_to_id"].get(id(step)) + if qase_step_id is None: + return + runtime.finish_step(qase_step_id, status="passed") + _KNOWN_FIELD_KEYS = {"severity", "priority", "layer", "description"} diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py index 3910bb68..d60e49f9 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py @@ -118,3 +118,99 @@ def test_no_runtime_result_is_safe(self): bdd.pytest_bdd_before_scenario( request=MagicMock(), feature=feature, scenario=scenario ) + + +class TestBeforeAfterStepHooks: + def _setup(self): + from qase.pytest.bdd import QasePytestBddPlugin + + pytest_plugin = MagicMock() + runtime = _runtime_with_result() + pytest_plugin.runtime = runtime + bdd = QasePytestBddPlugin(pytest_plugin) + step_a = MagicMock( + keyword="Given", line_number=1, data_table=None, docstring=None + ) + step_a.name = "a" + step_b = MagicMock( + keyword="When", line_number=2, data_table=None, docstring=None + ) + step_b.name = "b" + feature, scenario = _fake_scenario(steps=[step_a, step_b]) + bdd.pytest_bdd_before_scenario( + request=MagicMock(), feature=feature, scenario=scenario + ) + return bdd, pytest_plugin, feature, scenario, step_a, step_b + + def test_before_step_adds_step_to_runtime(self): + bdd, pytest_plugin, feature, scenario, step_a, _ = self._setup() + + bdd.pytest_bdd_before_step( + request=MagicMock(), + feature=feature, + scenario=scenario, + step=step_a, + step_func=MagicMock(), + ) + + # Runtime.add_step was called once with a Step(GHERKIN). + assert pytest_plugin.runtime.add_step.call_count == 1 + added_step = pytest_plugin.runtime.add_step.call_args.args[0] + assert added_step.data.name == "a" + assert added_step.data.keyword == "Given" + # ID mapping recorded. + assert id(step_a) in bdd._current["bdd_step_to_id"] + # Next index advanced past this step. + assert bdd._current["next_step_idx"] == 1 + + def test_after_step_marks_passed(self): + bdd, pytest_plugin, feature, scenario, step_a, _ = self._setup() + bdd.pytest_bdd_before_step( + request=MagicMock(), + feature=feature, + scenario=scenario, + step=step_a, + step_func=MagicMock(), + ) + + bdd.pytest_bdd_after_step( + request=MagicMock(), + feature=feature, + scenario=scenario, + step=step_a, + step_func=MagicMock(), + step_func_args={"foo": "bar"}, + ) + + qase_step_id = bdd._current["bdd_step_to_id"][id(step_a)] + pytest_plugin.runtime.finish_step.assert_called_once_with( + qase_step_id, status="passed" + ) + + def test_no_state_is_safe(self): + """If before_scenario was never called, hooks must not crash.""" + from qase.pytest.bdd import QasePytestBddPlugin + + pytest_plugin = MagicMock() + pytest_plugin.runtime = _runtime_with_result() + bdd = QasePytestBddPlugin(pytest_plugin) + step = MagicMock( + keyword="Given", name="x", line_number=0, data_table=None, docstring=None + ) + + # Must not raise. + bdd.pytest_bdd_before_step( + request=MagicMock(), + feature=MagicMock(), + scenario=MagicMock(), + step=step, + step_func=MagicMock(), + ) + bdd.pytest_bdd_after_step( + request=MagicMock(), + feature=MagicMock(), + scenario=MagicMock(), + step=step, + step_func=MagicMock(), + step_func_args={}, + ) From f8fb5d0ba7059e47da65c799c73fdfe76bb5ed58 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 15:19:18 +0300 Subject: [PATCH 14/30] feat: implement step_error, step_func_lookup_error, after_scenario hooks --- qase-pytest/src/qase/pytest/bdd.py | 37 ++++++ .../tests_qase_pytest/test_bdd_plugin.py | 108 ++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index acf31bde..b236d0ee 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -67,6 +67,43 @@ def pytest_bdd_after_step( return runtime.finish_step(qase_step_id, status="passed") + def pytest_bdd_step_error( + self, request, feature, scenario, step, step_func, step_func_args, exception + ): + runtime = getattr(self._pytest_plugin, "runtime", None) + if runtime is None or self._current is None: + return + qase_step_id = self._current["bdd_step_to_id"].get(id(step)) + if qase_step_id is not None: + runtime.finish_step(qase_step_id, status="failed") + self._current["scenario_failed"] = True + + def pytest_bdd_step_func_lookup_error( + self, request, feature, scenario, step, exception + ): + runtime = getattr(self._pytest_plugin, "runtime", None) + if runtime is None or self._current is None: + return + # No before_step fired — create the Step directly with status='invalid'. + qase_step = build_step(step) + qase_step.execution.set_status("invalid") + qase_step.execution.complete() + runtime.steps[qase_step.id] = qase_step + self._current["scenario_failed"] = True + + def pytest_bdd_after_scenario(self, request, feature, scenario): + runtime = getattr(self._pytest_plugin, "runtime", None) + if runtime is None or self._current is None: + return + # Steps after the last reached one were skipped because of a prior failure. + remaining = self._current["remaining_steps"][self._current["next_step_idx"] :] + for s in remaining: + qase_step = build_step(s) + qase_step.execution.set_status("skipped") + qase_step.execution.complete() + runtime.steps[qase_step.id] = qase_step + self._current = None + _KNOWN_FIELD_KEYS = {"severity", "priority", "layer", "description"} diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py index d60e49f9..b3145c1c 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py @@ -214,3 +214,111 @@ def test_no_state_is_safe(self): step_func=MagicMock(), step_func_args={}, ) + + +class TestStepErrorAndAfterScenario: + def _setup_two_steps(self): + from qase.pytest.bdd import QasePytestBddPlugin + + pytest_plugin = MagicMock() + pytest_plugin.runtime = _runtime_with_result() + bdd = QasePytestBddPlugin(pytest_plugin) + step_a = MagicMock( + keyword="Given", line_number=1, data_table=None, docstring=None + ) + step_a.name = "a" + step_b = MagicMock( + keyword="When", line_number=2, data_table=None, docstring=None + ) + step_b.name = "b" + feature, scenario = _fake_scenario(steps=[step_a, step_b]) + bdd.pytest_bdd_before_scenario( + request=MagicMock(), feature=feature, scenario=scenario + ) + bdd.pytest_bdd_before_step( + request=MagicMock(), + feature=feature, + scenario=scenario, + step=step_a, + step_func=MagicMock(), + ) + return bdd, pytest_plugin, feature, scenario, step_a, step_b + + def test_step_error_marks_failed_and_flags_scenario(self): + bdd, pytest_plugin, feature, scenario, step_a, _ = self._setup_two_steps() + + bdd.pytest_bdd_step_error( + request=MagicMock(), + feature=feature, + scenario=scenario, + step=step_a, + step_func=MagicMock(), + step_func_args={}, + exception=AssertionError("boom"), + ) + + qase_id = bdd._current["bdd_step_to_id"][id(step_a)] + pytest_plugin.runtime.finish_step.assert_called_once_with( + qase_id, status="failed" + ) + assert bdd._current["scenario_failed"] is True + + def test_after_scenario_skips_unreached_steps(self): + bdd, pytest_plugin, feature, scenario, step_a, step_b = self._setup_two_steps() + bdd.pytest_bdd_step_error( + request=MagicMock(), + feature=feature, + scenario=scenario, + step=step_a, + step_func=MagicMock(), + step_func_args={}, + exception=AssertionError("boom"), + ) + + bdd.pytest_bdd_after_scenario( + request=MagicMock(), feature=feature, scenario=scenario + ) + + # step_b was never started; must be added directly with status='skipped'. + added = pytest_plugin.runtime.steps + skipped_steps = [s for s in added.values() if s.execution.status == "skipped"] + assert len(skipped_steps) == 1 + assert skipped_steps[0].data.name == "b" + + def test_after_scenario_clears_state(self): + bdd, pytest_plugin, feature, scenario, *_ = self._setup_two_steps() + bdd.pytest_bdd_after_scenario( + request=MagicMock(), feature=feature, scenario=scenario + ) + assert bdd._current is None + + +class TestStepLookupError: + def test_lookup_error_records_invalid_step(self): + from qase.pytest.bdd import QasePytestBddPlugin + + pytest_plugin = MagicMock() + pytest_plugin.runtime = _runtime_with_result() + bdd = QasePytestBddPlugin(pytest_plugin) + step = MagicMock( + keyword="Given", line_number=3, data_table=None, docstring=None + ) + step.name = "missing impl" + feature, scenario = _fake_scenario(steps=[step]) + bdd.pytest_bdd_before_scenario( + request=MagicMock(), feature=feature, scenario=scenario + ) + + bdd.pytest_bdd_step_func_lookup_error( + request=MagicMock(), + feature=feature, + scenario=scenario, + step=step, + exception=Exception("no def for step"), + ) + + added = pytest_plugin.runtime.steps + invalid = [s for s in added.values() if s.execution.status == "invalid"] + assert len(invalid) == 1 + assert invalid[0].data.name == "missing impl" + assert bdd._current["scenario_failed"] is True From 661e77c60919d534aab8c841ee31a1402f596aba Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 15:22:39 +0300 Subject: [PATCH 15/30] test: cover happy-path after_scenario with no skipped tail --- .../tests_qase_pytest/test_bdd_plugin.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py index b3145c1c..86a30dfd 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py @@ -292,6 +292,42 @@ def test_after_scenario_clears_state(self): ) assert bdd._current is None + def test_after_scenario_no_skipped_when_all_passed(self): + bdd, pytest_plugin, feature, scenario, step_a, step_b = self._setup_two_steps() + # Run step_a all the way, then step_b all the way. + bdd.pytest_bdd_after_step( + request=MagicMock(), + feature=feature, + scenario=scenario, + step=step_a, + step_func=MagicMock(), + step_func_args={}, + ) + bdd.pytest_bdd_before_step( + request=MagicMock(), + feature=feature, + scenario=scenario, + step=step_b, + step_func=MagicMock(), + ) + bdd.pytest_bdd_after_step( + request=MagicMock(), + feature=feature, + scenario=scenario, + step=step_b, + step_func=MagicMock(), + step_func_args={}, + ) + + bdd.pytest_bdd_after_scenario( + request=MagicMock(), feature=feature, scenario=scenario + ) + + # No "skipped" steps should appear when all steps ran. + added = pytest_plugin.runtime.steps + skipped = [s for s in added.values() if s.execution.status == "skipped"] + assert skipped == [] + class TestStepLookupError: def test_lookup_error_records_invalid_step(self): From 8a5d500ee66827335348b6d7ef6dcac5f1dc325f Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 15:55:09 +0300 Subject: [PATCH 16/30] test: add pytester-based integration test for basic pytest-bdd scenario --- .../tests_qase_pytest/integration/__init__.py | 0 .../integration/test_bdd_scenarios.py | 110 ++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 qase-pytest/tests/tests_qase_pytest/integration/__init__.py create mode 100644 qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py diff --git a/qase-pytest/tests/tests_qase_pytest/integration/__init__.py b/qase-pytest/tests/tests_qase_pytest/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py new file mode 100644 index 00000000..ab2a0839 --- /dev/null +++ b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py @@ -0,0 +1,110 @@ +"""End-to-end pytest-bdd scenarios run via pytester. + +Each test spins up a temporary pytest project that uses pytest-bdd and +qase-pytest in `mode=report` so we can inspect the produced JSON file +and assert the Gherkin step structure. +""" + +import json + +import pytest + + +pytest_bdd = pytest.importorskip("pytest_bdd") + + +REPORT_DIR_NAME = "qase_report" + + +def _read_results(pytester) -> list: + """Read every result JSON file from the temporary project's report dir.""" + results_dir = pytester.path / REPORT_DIR_NAME / "results" + if not results_dir.exists(): + return [] + out = [] + for path in sorted(results_dir.glob("*.json")): + with open(path) as fh: + out.append(json.load(fh)) + return out + + +def _write_config(pytester): + """Write qase.config.json enabling report mode at pytester.path.""" + config = { + "mode": "report", + "fallback": "off", + "debug": False, + "report": { + "driver": "local", + "connection": { + "path": str(pytester.path / REPORT_DIR_NAME), + "format": "json", + }, + }, + } + (pytester.path / "qase.config.json").write_text(json.dumps(config)) + + +def _write_feature(pytester, name, body): + """Write a .feature file under a `features/` directory inside the project.""" + features_dir = pytester.path / "features" + features_dir.mkdir(exist_ok=True) + (features_dir / name).write_text(body) + + +def test_basic_scenario_captures_gherkin_steps(pytester): + _write_config(pytester) + _write_feature( + pytester, + "login.feature", + """ +Feature: Login + Scenario: Successful login + Given the user is on the login page + When the user enters valid credentials + Then the user should see the dashboard +""", + ) + pytester.makepyfile( + test_login=""" +from pytest_bdd import scenarios, given, when, then + +scenarios("features/login.feature") + +@given("the user is on the login page") +def login_page(): + pass + +@when("the user enters valid credentials") +def valid_credentials(): + pass + +@then("the user should see the dashboard") +def dashboard_visible(): + pass +""" + ) + + result = pytester.runpytest_subprocess("-v") + result.assert_outcomes(passed=1) + + results = _read_results(pytester) + assert len(results) == 1 + r = results[0] + assert r["title"] == "Successful login" + assert r["execution"]["status"] == "passed" + + steps = r["steps"] + assert len(steps) == 3 + # The report driver flattens Gherkin steps to TEXT steps when persisting + # to JSON: keyword + name are joined into `data.action`. + for step in steps: + assert step["step_type"] == "text" + assert step["execution"]["status"] == "passed" + + assert steps[0]["data"]["action"].startswith("Given") + assert "login page" in steps[0]["data"]["action"] + assert steps[1]["data"]["action"].startswith("When") + assert "valid credentials" in steps[1]["data"]["action"] + assert steps[2]["data"]["action"].startswith("Then") + assert "dashboard" in steps[2]["data"]["action"] From b129af165463109bb708afa1fae1b802f66eab0a Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 15:59:14 +0300 Subject: [PATCH 17/30] test: integration coverage for failing step and skipped tail --- .../integration/test_bdd_scenarios.py | 92 ++++++++++++++++++- 1 file changed, 87 insertions(+), 5 deletions(-) diff --git a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py index ab2a0839..2047e9a8 100644 --- a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py +++ b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py @@ -9,7 +9,6 @@ import pytest - pytest_bdd = pytest.importorskip("pytest_bdd") @@ -65,8 +64,7 @@ def test_basic_scenario_captures_gherkin_steps(pytester): Then the user should see the dashboard """, ) - pytester.makepyfile( - test_login=""" + pytester.makepyfile(test_login=""" from pytest_bdd import scenarios, given, when, then scenarios("features/login.feature") @@ -82,8 +80,7 @@ def valid_credentials(): @then("the user should see the dashboard") def dashboard_visible(): pass -""" - ) +""") result = pytester.runpytest_subprocess("-v") result.assert_outcomes(passed=1) @@ -108,3 +105,88 @@ def dashboard_visible(): assert "valid credentials" in steps[1]["data"]["action"] assert steps[2]["data"]["action"].startswith("Then") assert "dashboard" in steps[2]["data"]["action"] + + +def test_failing_step_skips_remaining(pytester): + _write_config(pytester) + _write_feature( + pytester, + "math.feature", + """ +Feature: Math + Scenario: Bad math + Given a calculator + When I add 2 and 2 + Then the result is 5 +""", + ) + pytester.makepyfile(test_math=""" +from pytest_bdd import scenarios, given, when, then + +scenarios("features/math.feature") + +@given("a calculator") +def a_calc(): + pass + +@when("I add 2 and 2") +def add(): + pass + +@then("the result is 5") +def assert_five(): + assert 2 + 2 == 5 +""") + + result = pytester.runpytest_subprocess("-v") + result.assert_outcomes(failed=1) + + results = _read_results(pytester) + assert len(results) == 1 + steps = results[0]["steps"] + assert len(steps) == 3 + assert steps[0]["execution"]["status"] == "passed" + assert steps[1]["execution"]["status"] == "passed" + assert steps[2]["execution"]["status"] == "failed" + + +def test_assert_in_first_step_skips_rest(pytester): + _write_config(pytester) + _write_feature( + pytester, + "fail_first.feature", + """ +Feature: Fail early + Scenario: Boom + Given an impossible precondition + When something happens + Then we observe an outcome +""", + ) + pytester.makepyfile(test_fail=""" +from pytest_bdd import scenarios, given, when, then + +scenarios("features/fail_first.feature") + +@given("an impossible precondition") +def precond(): + assert False, "nope" + +@when("something happens") +def happens(): + pass + +@then("we observe an outcome") +def outcome(): + pass +""") + + result = pytester.runpytest_subprocess("-v") + result.assert_outcomes(failed=1) + + results = _read_results(pytester) + steps = results[0]["steps"] + statuses = [s["execution"]["status"] for s in steps] + assert statuses[0] == "failed" + assert statuses[1] == "skipped" + assert statuses[2] == "skipped" From f03fc991dd6f5c13cfa8ed6eedec77f74e74497c Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 16:01:25 +0300 Subject: [PATCH 18/30] test: integration coverage for step lookup error -> invalid status --- .../integration/test_bdd_scenarios.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py index 2047e9a8..45a9de4f 100644 --- a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py +++ b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py @@ -190,3 +190,37 @@ def outcome(): assert statuses[0] == "failed" assert statuses[1] == "skipped" assert statuses[2] == "skipped" + + +def test_step_lookup_error_marks_step_invalid(pytester): + _write_config(pytester) + _write_feature( + pytester, + "missing.feature", + """ +Feature: Missing impl + Scenario: Step not implemented + Given a step that has an implementation + When a step that nobody has implemented +""", + ) + pytester.makepyfile(test_missing=""" +from pytest_bdd import scenarios, given + +scenarios("features/missing.feature") + +@given("a step that has an implementation") +def implemented(): + pass +""") + + pytester.runpytest_subprocess("-v") + + results = _read_results(pytester) + assert len(results) == 1 + steps = results[0]["steps"] + statuses = [s["execution"]["status"] for s in steps] + # First step passes (it has an implementation). + assert statuses[0] == "passed" + # The missing one should be marked invalid by our plugin. + assert "invalid" in statuses From 903f2d2f01ffe0a8311a82b9023b837809c1f2aa Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 16:03:41 +0300 Subject: [PATCH 19/30] test: integration coverage for nested qase.step() under Gherkin step --- .../integration/test_bdd_scenarios.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py index 45a9de4f..a9c8b8f1 100644 --- a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py +++ b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py @@ -224,3 +224,41 @@ def implemented(): assert statuses[0] == "passed" # The missing one should be marked invalid by our plugin. assert "invalid" in statuses + + +def test_nested_qase_step_inherits_gherkin_parent(pytester): + _write_config(pytester) + _write_feature( + pytester, + "nested.feature", + """ +Feature: Nested + Scenario: User flow + Given the user opens the page +""", + ) + pytester.makepyfile(test_nested=""" +from pytest_bdd import scenarios, given +from qase.pytest import qase + +scenarios("features/nested.feature") + +@given("the user opens the page") +def opens(): + with qase.step("Navigate"): + pass + with qase.step("Wait for load"): + pass +""") + + result = pytester.runpytest_subprocess("-v") + result.assert_outcomes(passed=1) + + results = _read_results(pytester) + steps = results[0]["steps"] + # One top-level Gherkin step (flattened to text in the report) with two children. + assert len(steps) == 1 + assert len(steps[0]["steps"]) == 2 + # Child step actions are the qase.step() titles. + child_actions = {child["data"]["action"] for child in steps[0]["steps"]} + assert child_actions == {"Navigate", "Wait for load"} From dc6c14ebdf5543914625e150d283b8e55f53bb3b Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 16:08:39 +0300 Subject: [PATCH 20/30] test: integration coverage for Scenario Outline parameterization --- .../integration/test_bdd_scenarios.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py index a9c8b8f1..500623be 100644 --- a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py +++ b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py @@ -262,3 +262,49 @@ def opens(): # Child step actions are the qase.step() titles. child_actions = {child["data"]["action"] for child in steps[0]["steps"]} assert child_actions == {"Navigate", "Wait for load"} + + +def test_scenario_outline_produces_multiple_parameterized_results(pytester): + _write_config(pytester) + _write_feature( + pytester, + "outline.feature", + """ +Feature: Outline + Scenario Outline: Adding numbers + Given I have and + Then their sum is + + Examples: + | a | b | c | + | 1 | 2 | 3 | + | 5 | 7 | 12 | +""", + ) + pytester.makepyfile(test_outline=""" +from pytest_bdd import scenarios, given, then, parsers + +scenarios("features/outline.feature") + +@given(parsers.parse("I have {a:d} and {b:d}"), target_fixture="numbers") +def numbers(a, b): + return a, b + +@then(parsers.parse("their sum is {c:d}")) +def check_sum(numbers, c): + assert sum(numbers) == c +""") + + result = pytester.runpytest_subprocess("-v") + result.assert_outcomes(passed=2) + + results = _read_results(pytester) + assert len(results) == 2 + # Each result should have its own params populated (from pytest-bdd's + # Examples-to-parametrize conversion captured by the existing _set_params). + all_params = [r.get("params") or {} for r in results] + # At least one result must have a non-empty params dict — the Examples row + # provided real parameter values, so this is a strict expectation. + assert any( + p for p in all_params + ), "expected at least one result with params populated" From 06daa53d49459a3de6dddb54ae5c1b188de68b3b Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 16:13:00 +0300 Subject: [PATCH 21/30] test: integration coverage for DataTable and DocString rendering --- qase-pytest/src/qase/pytest/bdd.py | 8 +++- .../integration/test_bdd_scenarios.py | 46 +++++++++++++++++++ .../tests_qase_pytest/test_bdd_helpers.py | 15 ++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index b236d0ee..661460fb 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -259,13 +259,17 @@ def build_step(bdd_step) -> Step: """Build a Qase Step(GHERKIN) from a pytest-bdd Step object. Reads keyword, name, line_number, data_table, docstring defensively so the - helper survives minor API drifts between pytest-bdd versions. + helper survives minor API drifts between pytest-bdd versions. pytest-bdd + >= 7 exposes the DataTable on ``datatable`` (no underscore); older or + alternative API shapes may use ``data_table`` — accept either. """ keyword = getattr(bdd_step, "keyword", "") name = getattr(bdd_step, "name", "") line = getattr(bdd_step, "line_number", 0) or 0 - data_table = getattr(bdd_step, "data_table", None) + data_table = getattr(bdd_step, "data_table", None) or getattr( + bdd_step, "datatable", None + ) docstring = getattr(bdd_step, "docstring", None) payload = None diff --git a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py index 500623be..3f505016 100644 --- a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py +++ b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py @@ -308,3 +308,49 @@ def check_sum(numbers, c): assert any( p for p in all_params ), "expected at least one result with params populated" + + +def test_data_table_and_docstring_preserved(pytester): + _write_config(pytester) + _write_feature( + pytester, + "data.feature", + ''' +Feature: Data carriers + Scenario: With table and docstring + Given the following users: + | name | role | + | Alice | admin | + | Bob | user | + When I send the payload: + """ + {"key": "value"} + """ +''', + ) + pytester.makepyfile(test_data=""" +from pytest_bdd import scenarios, given, when + +scenarios("features/data.feature") + +@given("the following users:") +def users(): + pass + +@when("I send the payload:") +def payload(): + pass +""") + + pytester.runpytest_subprocess("-v") + results = _read_results(pytester) + assert len(results) == 1 + steps = results[0]["steps"] + assert len(steps) == 2 + # DataTable formatted as markdown by format_data_table() lands in input_data. + table_payload = steps[0]["data"]["input_data"] or "" + assert "| name | role |" in table_payload + # The DocString is wrapped as a fenced code block by format_docstring(). + docstring_payload = steps[1]["data"]["input_data"] or "" + assert docstring_payload.startswith("```") + assert '"key": "value"' in docstring_payload diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py index b3720e10..e6835abd 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py @@ -246,6 +246,21 @@ def test_with_data_table(self): assert "| a | b |" in step.data.data assert "| 1 | 2 |" in step.data.data + def test_with_datatable_attribute(self): + # pytest-bdd >= 7 exposes the table on `datatable` (no underscore). + class _StepWithDatatable: + keyword = "Given" + name = "users" + line_number = 5 + data_table = None + docstring = None + + s = _StepWithDatatable() + s.datatable = _FakeDataTable([["name"], ["Alice"]]) + step = build_step(s) + assert "| name |" in step.data.data + assert "| Alice |" in step.data.data + def test_with_docstring(self): step = build_step(_FakeBddStep("When", "send body", 5, docstring="payload")) # Default fence is 3 backticks because there are no backticks in "payload". From 44d474f78aac639ff2f3006a3ebdca9635194915 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 16:16:55 +0300 Subject: [PATCH 22/30] test: integration coverage for scenario tags and ignore flag --- qase-pytest/src/qase/pytest/bdd.py | 6 ++ .../integration/test_bdd_scenarios.py | 61 +++++++++++++++++++ .../tests_qase_pytest/test_bdd_plugin.py | 9 +++ 3 files changed, 76 insertions(+) diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index 661460fb..63317483 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -95,6 +95,12 @@ def pytest_bdd_after_scenario(self, request, feature, scenario): runtime = getattr(self._pytest_plugin, "runtime", None) if runtime is None or self._current is None: return + # Honor @qase.ignore: when set, drop the result so the reporter + # doesn't emit it. + if getattr(runtime.result, "ignore", False): + runtime.result = None + self._current = None + return # Steps after the last reached one were skipped because of a prior failure. remaining = self._current["remaining_steps"][self._current["next_step_idx"] :] for s in remaining: diff --git a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py index 3f505016..518b3dac 100644 --- a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py +++ b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py @@ -354,3 +354,64 @@ def payload(): docstring_payload = steps[1]["data"]["input_data"] or "" assert docstring_payload.startswith("```") assert '"key": "value"' in docstring_payload + + +def test_scenario_tags_map_to_qase_fields(pytester): + _write_config(pytester) + _write_feature( + pytester, + "tagged.feature", + """ +Feature: Tagged + @qase.id=42 @qase.suite=Login.Smoke @qase.severity=critical @smoke + Scenario: Tagged scenario + Given a step +""", + ) + pytester.makepyfile(test_tagged=""" +from pytest_bdd import scenarios, given + +scenarios("features/tagged.feature") + +@given("a step") +def step_impl(): + pass +""") + + pytester.runpytest_subprocess("-v") + results = _read_results(pytester) + assert len(results) == 1 + r = results[0] + assert r["testops_ids"] == [42] + suites = [s["title"] for s in r["relations"]["suite"]["data"]] + assert suites == ["Login", "Smoke"] + assert r["fields"]["severity"] == "critical" + assert "smoke" in r["tags"] + + +def test_scenario_with_qase_ignore_is_skipped(pytester): + _write_config(pytester) + _write_feature( + pytester, + "ignored.feature", + """ +Feature: Ignored + @qase.ignore + Scenario: Should not be reported + Given a step +""", + ) + pytester.makepyfile(test_ignored=""" +from pytest_bdd import scenarios, given + +scenarios("features/ignored.feature") + +@given("a step") +def step_impl(): + pass +""") + + pytester.runpytest_subprocess("-v") + results = _read_results(pytester) + # The scenario is marked @qase.ignore — it must not appear in the report. + assert results == [] diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py index 86a30dfd..09360d49 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py @@ -292,6 +292,15 @@ def test_after_scenario_clears_state(self): ) assert bdd._current is None + def test_after_scenario_drops_result_when_ignore(self): + bdd, pytest_plugin, feature, scenario, *_ = self._setup_two_steps() + # Simulate @qase.ignore flag. + pytest_plugin.runtime.result.ignore = True + bdd.pytest_bdd_after_scenario( + request=MagicMock(), feature=feature, scenario=scenario + ) + assert pytest_plugin.runtime.result is None + def test_after_scenario_no_skipped_when_all_passed(self): bdd, pytest_plugin, feature, scenario, step_a, step_b = self._setup_two_steps() # Run step_a all the way, then step_b all the way. From cef4330c79eb1ccd56c9c2dd59d47ec95d028fe5 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 16:18:56 +0300 Subject: [PATCH 23/30] test: integration coverage for BDD and plain pytest coexistence --- .../integration/test_bdd_scenarios.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py index 518b3dac..845094e4 100644 --- a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py +++ b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py @@ -415,3 +415,42 @@ def step_impl(): results = _read_results(pytester) # The scenario is marked @qase.ignore — it must not appear in the report. assert results == [] + + +def test_bdd_and_plain_pytest_coexist(pytester): + _write_config(pytester) + _write_feature( + pytester, + "coexist.feature", + """ +Feature: Coexist + Scenario: BDD path + Given a bdd step +""", + ) + pytester.makepyfile( + test_bdd=""" +from pytest_bdd import scenarios, given + +scenarios("features/coexist.feature") + +@given("a bdd step") +def bdd_step(): + pass +""", + test_plain=""" +def test_plain_passes(): + assert 1 + 1 == 2 +""", + ) + + result = pytester.runpytest_subprocess("-v") + result.assert_outcomes(passed=2) + + results = _read_results(pytester) + assert len(results) == 2 + titles = {r["title"] for r in results} + # The BDD test should expose its scenario name as title; the plain test + # keeps its function name. + assert "BDD path" in titles + assert "test_plain_passes" in titles From 0c41d98572a52da5797714f1e0828d2bbd54abe4 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 16:20:53 +0300 Subject: [PATCH 24/30] docs: add runnable pytest-bdd example reproducing customer scenario --- examples/single/pytest-bdd/README.md | 20 ++++++++++++++ .../single/pytest-bdd/features/login.feature | 7 +++++ examples/single/pytest-bdd/qase.config.json | 12 +++++++++ examples/single/pytest-bdd/requirements.txt | 3 +++ .../single/pytest-bdd/tests/test_login.py | 26 +++++++++++++++++++ 5 files changed, 68 insertions(+) create mode 100644 examples/single/pytest-bdd/README.md create mode 100644 examples/single/pytest-bdd/features/login.feature create mode 100644 examples/single/pytest-bdd/qase.config.json create mode 100644 examples/single/pytest-bdd/requirements.txt create mode 100644 examples/single/pytest-bdd/tests/test_login.py diff --git a/examples/single/pytest-bdd/README.md b/examples/single/pytest-bdd/README.md new file mode 100644 index 00000000..6dd68104 --- /dev/null +++ b/examples/single/pytest-bdd/README.md @@ -0,0 +1,20 @@ +# qase-pytest + pytest-bdd example + +Reproduces the Allure-vs-Qase scenario from the customer feedback: a +single Gherkin login scenario with three steps, each containing a +nested manual step via `qase.step(...)`. + +## Run + +```bash +pip install -r requirements.txt +pytest -v +``` + +Inspect the produced JSON under `build/qase-report/results/` — the +result has the scenario name as title, the feature/suite hierarchy from +the tag, three top-level Gherkin steps, and one sub-step under each. + +To send to Qase TestOps instead, set `mode` to `testops` in +`qase.config.json` and provide `QASE_TESTOPS_API_TOKEN` and +`QASE_TESTOPS_PROJECT` via env. diff --git a/examples/single/pytest-bdd/features/login.feature b/examples/single/pytest-bdd/features/login.feature new file mode 100644 index 00000000..a746c454 --- /dev/null +++ b/examples/single/pytest-bdd/features/login.feature @@ -0,0 +1,7 @@ +@qase.id=1 @qase.suite=Login.Smoke @qase.severity=critical +Feature: Login + + Scenario: Successful login + Given the user is on the login page + When the user enters valid credentials + Then the user should see the dashboard diff --git a/examples/single/pytest-bdd/qase.config.json b/examples/single/pytest-bdd/qase.config.json new file mode 100644 index 00000000..64163aa8 --- /dev/null +++ b/examples/single/pytest-bdd/qase.config.json @@ -0,0 +1,12 @@ +{ + "mode": "report", + "fallback": "off", + "debug": true, + "report": { + "driver": "local", + "connection": { + "path": "./build/qase-report", + "format": "json" + } + } +} diff --git a/examples/single/pytest-bdd/requirements.txt b/examples/single/pytest-bdd/requirements.txt new file mode 100644 index 00000000..72ba9668 --- /dev/null +++ b/examples/single/pytest-bdd/requirements.txt @@ -0,0 +1,3 @@ +pytest>=7.4 +pytest-bdd>=7.0,<9.0 +qase-pytest diff --git a/examples/single/pytest-bdd/tests/test_login.py b/examples/single/pytest-bdd/tests/test_login.py new file mode 100644 index 00000000..2bbb758b --- /dev/null +++ b/examples/single/pytest-bdd/tests/test_login.py @@ -0,0 +1,26 @@ +from pytest_bdd import scenarios, given, when, then + +from qase.pytest import qase + +scenarios("../features/login.feature") + + +@given("the user is on the login page") +def login_page(): + with qase.step("Open login page"): + assert True + + +@when("the user enters valid credentials") +def valid_credentials(): + with qase.step("Enter username and password"): + username = "admin" + password = "password123" + assert username == "admin" + assert password == "password123" + + +@then("the user should see the dashboard") +def dashboard_visible(): + with qase.step("Verify dashboard is visible"): + assert True From a48d0180bfb5933c2ad2301fd9e5582776e09e66 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 16:23:01 +0300 Subject: [PATCH 25/30] docs: move qase tags from Feature to Scenario in pytest-bdd example --- examples/single/pytest-bdd/features/login.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/single/pytest-bdd/features/login.feature b/examples/single/pytest-bdd/features/login.feature index a746c454..fa19a402 100644 --- a/examples/single/pytest-bdd/features/login.feature +++ b/examples/single/pytest-bdd/features/login.feature @@ -1,6 +1,6 @@ -@qase.id=1 @qase.suite=Login.Smoke @qase.severity=critical Feature: Login + @qase.id=1 @qase.suite=Login.Smoke @qase.severity=critical Scenario: Successful login Given the user is on the login page When the user enters valid credentials From e9710baaf9e76f4ca2f6ff0f77c9b458532b498f Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 16:24:53 +0300 Subject: [PATCH 26/30] docs: document pytest-bdd integration and bump version to 8.2.0 --- qase-pytest/README.md | 54 ++++++++++++++++++++++++++++++++++++++ qase-pytest/changelog.md | 15 +++++++++++ qase-pytest/pyproject.toml | 2 +- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/qase-pytest/README.md b/qase-pytest/README.md index 8b57e4de..17beba0d 100644 --- a/qase-pytest/README.md +++ b/qase-pytest/README.md @@ -280,3 +280,57 @@ See the [examples directory](../examples/) for complete working examples. ## License Apache License 2.0. See [LICENSE](../LICENSE) for details. + +## Using with pytest-bdd + +If `pytest-bdd` is installed alongside `qase-pytest`, Gherkin scenarios +are reported with full step hierarchy automatically — no manual +`qase.step()` instrumentation required. + +### Feature file + +```gherkin +Feature: Login + + @qase.id=42 @qase.suite=Login.Smoke + Scenario: Successful login + Given the user is on the login page + When the user enters valid credentials + Then the user should see the dashboard +``` + +### Test module + +```python +from pytest_bdd import scenarios + +scenarios("features/login.feature") +``` + +Scenario name becomes the test title, the Feature becomes the parent suite, +each step is captured with its Given/When/Then keyword. You can still +use `with qase.step("..."):` inside a step function to add nested +sub-steps — they will appear as children of the Gherkin step. + +### Recognized scenario tags + +Place tags on the `Scenario` line so they reach the plugin via +`scenario.tags`: + +| Tag | Effect | +| --- | --- | +| `@qase.id=123` | Link to test case 123 | +| `@qase.id=123,124` | Link to multiple test cases | +| `@qase.project_id.CODE=1,2` | Multi-project link | +| `@qase.ignore` | Skip the scenario from reporting | +| `@qase.muted` | Do not let this scenario fail the run | +| `@qase.suite=A.B.C` | Override suite (dot for nesting) | +| `@qase.severity=critical` | Set severity field | +| `@qase.priority=high` | Set priority field | +| `@qase.layer=e2e` | Set layer field | +| Any other tag | Stored as a free tag on the result | + +### Versions + +Tested with `pytest-bdd >= 7.0, < 9.0`. `pytest-bdd-ng` is expected to +work but is not officially tested. diff --git a/qase-pytest/changelog.md b/qase-pytest/changelog.md index 79f6ea58..39b1521f 100644 --- a/qase-pytest/changelog.md +++ b/qase-pytest/changelog.md @@ -1,3 +1,18 @@ +# qase-pytest 8.2.0 + +## What's new + +- Added native pytest-bdd integration. When `pytest-bdd` is installed, + Gherkin scenarios are reported automatically with full step + hierarchy: scenario name becomes the test title, the feature becomes + the parent suite, Given/When/Then keywords are preserved on each + step, DataTable and DocString contents are captured, Scenario Outline + rows become parameterized results, and a `qase.step()` inside a step + function appears as a sub-step of the Gherkin step. +- Tags on scenarios drive Qase metadata: `@qase.id=`, `@qase.suite=`, + `@qase.severity=`, `@qase.priority=`, `@qase.layer=`, `@qase.ignore`, + `@qase.muted`, `@qase.project_id.CODE=`. + # qase-pytest 8.1.0 ## What's new diff --git a/qase-pytest/pyproject.toml b/qase-pytest/pyproject.toml index ea1350df..046d123e 100644 --- a/qase-pytest/pyproject.toml +++ b/qase-pytest/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "qase-pytest" -version = "8.1.0" +version = "8.2.0" description = "Qase Pytest Plugin for Qase TestOps and Qase Report" readme = "README.md" keywords = ["qase", "pytest", "plugin", "testops", "report", "qase reporting", "test observability"] From 19b69934524f4e6fc944ae835f389ac522503565 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 16:41:10 +0300 Subject: [PATCH 27/30] docs: remove Allure mention from pytest-bdd example README --- examples/single/pytest-bdd/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/single/pytest-bdd/README.md b/examples/single/pytest-bdd/README.md index 6dd68104..dba9ad03 100644 --- a/examples/single/pytest-bdd/README.md +++ b/examples/single/pytest-bdd/README.md @@ -1,8 +1,8 @@ # qase-pytest + pytest-bdd example -Reproduces the Allure-vs-Qase scenario from the customer feedback: a -single Gherkin login scenario with three steps, each containing a -nested manual step via `qase.step(...)`. +Reproduces the customer feedback scenario: a single Gherkin login +scenario with three steps, each containing a nested manual step via +`qase.step(...)`. ## Run From 97392310b472dfeec21430786c807f9fd6645dad Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 17:15:13 +0300 Subject: [PATCH 28/30] docs: expand pytest-bdd example with failing, outline, data carriers, checkout scenarios --- examples/single/pytest-bdd/README.md | 60 +++++++++++++++---- .../pytest-bdd/features/calculator.feature | 12 ++++ .../pytest-bdd/features/checkout.feature | 16 +++++ .../pytest-bdd/features/data_carriers.feature | 13 ++++ .../pytest-bdd/features/failing.feature | 7 +++ .../pytest-bdd/tests/test_calculator.py | 13 ++++ .../single/pytest-bdd/tests/test_checkout.py | 33 ++++++++++ .../pytest-bdd/tests/test_data_carriers.py | 21 +++++++ .../single/pytest-bdd/tests/test_failing.py | 18 ++++++ 9 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 examples/single/pytest-bdd/features/calculator.feature create mode 100644 examples/single/pytest-bdd/features/checkout.feature create mode 100644 examples/single/pytest-bdd/features/data_carriers.feature create mode 100644 examples/single/pytest-bdd/features/failing.feature create mode 100644 examples/single/pytest-bdd/tests/test_calculator.py create mode 100644 examples/single/pytest-bdd/tests/test_checkout.py create mode 100644 examples/single/pytest-bdd/tests/test_data_carriers.py create mode 100644 examples/single/pytest-bdd/tests/test_failing.py diff --git a/examples/single/pytest-bdd/README.md b/examples/single/pytest-bdd/README.md index dba9ad03..7fb8cf4f 100644 --- a/examples/single/pytest-bdd/README.md +++ b/examples/single/pytest-bdd/README.md @@ -1,20 +1,60 @@ # qase-pytest + pytest-bdd example -Reproduces the customer feedback scenario: a single Gherkin login -scenario with three steps, each containing a nested manual step via -`qase.step(...)`. +A runnable demo of the native pytest-bdd integration in qase-pytest. +Exercises every feature of the integration so you can see how each kind +of Gherkin construct is reported in Qase. -## Run +## What the example covers + +| Feature file | Scenario(s) | Demonstrates | +| --- | --- | --- | +| `login.feature` | Successful login | Basic Given/When/Then with nested `qase.step()` calls — sub-steps appear as children of the Gherkin step. | +| `failing.feature` | A failing assertion in the middle step | A failing Then step. The failed step is marked `failed`; pytest-bdd does not run later steps, but the integration records them as `skipped`. | +| `calculator.feature` | Scenario Outline with 3 example rows | Scenario Outline / Examples — produces three parameterized Qase results for the same scenario. | +| `data_carriers.feature` | Step with a data table and a docstring | DataTable rendered as a markdown table, DocString rendered as a fenced code block — both end up in the step `data` payload. | +| `checkout.feature` | Two scenarios sharing a Background | Background steps run before each scenario and are reported on every scenario's result. Multi-scenario feature with distinct `@qase.id=`, `@qase.suite=` tags per scenario. | + +## Recognized scenario tags + +Tags must be placed on the `Scenario` line (not the `Feature` line) so +they reach `scenario.tags`: + +- `@qase.id=NN` — link to a test case +- `@qase.suite=A.B.C` — override suite chain (dot for nesting) +- `@qase.severity=critical` / `@qase.priority=high` / `@qase.layer=e2e` +- `@qase.ignore` — drop the scenario from the report +- `@qase.muted` — don't let the scenario fail the run + +## Run locally (report mode) ```bash pip install -r requirements.txt pytest -v ``` -Inspect the produced JSON under `build/qase-report/results/` — the -result has the scenario name as title, the feature/suite hierarchy from -the tag, three top-level Gherkin steps, and one sub-step under each. +5 scenarios execute (one failing on purpose). Inspect the produced JSON +under `build/qase-report/results/`: + +- Each result has the scenario name as `title` +- Each result has a suite chain matching the `@qase.suite` tag +- Each result has a list of `steps` mirroring the Gherkin steps in order +- Nested `qase.step(...)` calls (inside step functions) appear as + children of the corresponding Gherkin step +- Failed step has `execution.status: "failed"`; trailing unrun steps + are marked `"skipped"` +- Scenario Outline produces one Qase result per Examples row + +## Run against Qase TestOps + +Set `mode` to `testops` in `qase.config.json`, and provide credentials +via env: + +```bash +export QASE_TESTOPS_API_TOKEN=... +export QASE_TESTOPS_PROJECT=PROJ_CODE +pytest -v +``` -To send to Qase TestOps instead, set `mode` to `testops` in -`qase.config.json` and provide `QASE_TESTOPS_API_TOKEN` and -`QASE_TESTOPS_PROJECT` via env. +The TestOps API mode preserves native Gherkin step structure (keyword ++ name + data) and renders steps with their Given/When/Then keywords +in the Qase UI. diff --git a/examples/single/pytest-bdd/features/calculator.feature b/examples/single/pytest-bdd/features/calculator.feature new file mode 100644 index 00000000..e91e3b97 --- /dev/null +++ b/examples/single/pytest-bdd/features/calculator.feature @@ -0,0 +1,12 @@ +Feature: Adding numbers + + @qase.id=11 @qase.suite=Math.Outline + Scenario Outline: Adding two numbers + Given I have and + Then their sum is + + Examples: + | a | b | c | + | 1 | 2 | 3 | + | 5 | 7 | 12 | + | 0 | 0 | 0 | diff --git a/examples/single/pytest-bdd/features/checkout.feature b/examples/single/pytest-bdd/features/checkout.feature new file mode 100644 index 00000000..2a60454e --- /dev/null +++ b/examples/single/pytest-bdd/features/checkout.feature @@ -0,0 +1,16 @@ +Feature: Checkout + + Background: + Given the user is signed in + And the cart contains 2 items + + @qase.id=13 @qase.suite=Checkout.Success + Scenario: Successful checkout + When the user clicks "Place order" + Then the order is created + + @qase.id=14 @qase.suite=Checkout.PaymentDeclined + Scenario: Declined payment + Given the saved card is expired + When the user clicks "Place order" + Then the payment fails with "card_expired" diff --git a/examples/single/pytest-bdd/features/data_carriers.feature b/examples/single/pytest-bdd/features/data_carriers.feature new file mode 100644 index 00000000..b9f47708 --- /dev/null +++ b/examples/single/pytest-bdd/features/data_carriers.feature @@ -0,0 +1,13 @@ +Feature: Data carriers + + @qase.id=12 @qase.suite=API.Payloads + Scenario: Step with a data table and a docstring + Given the following users: + | name | role | + | Alice | admin | + | Bob | user | + When I send the payload: + """ + {"username": "alice", "active": true} + """ + Then the request succeeds diff --git a/examples/single/pytest-bdd/features/failing.feature b/examples/single/pytest-bdd/features/failing.feature new file mode 100644 index 00000000..4e7051f0 --- /dev/null +++ b/examples/single/pytest-bdd/features/failing.feature @@ -0,0 +1,7 @@ +Feature: Math + + @qase.id=10 @qase.suite=Math.Failure @qase.severity=major + Scenario: A failing assertion in the middle step + Given a calculator + When I add 2 and 2 + Then the result should be 5 diff --git a/examples/single/pytest-bdd/tests/test_calculator.py b/examples/single/pytest-bdd/tests/test_calculator.py new file mode 100644 index 00000000..82fb1b3b --- /dev/null +++ b/examples/single/pytest-bdd/tests/test_calculator.py @@ -0,0 +1,13 @@ +from pytest_bdd import scenarios, given, then, parsers + +scenarios("../features/calculator.feature") + + +@given(parsers.parse("I have {a:d} and {b:d}"), target_fixture="numbers") +def numbers(a, b): + return a, b + + +@then(parsers.parse("their sum is {c:d}")) +def check_sum(numbers, c): + assert sum(numbers) == c diff --git a/examples/single/pytest-bdd/tests/test_checkout.py b/examples/single/pytest-bdd/tests/test_checkout.py new file mode 100644 index 00000000..bcc1b6dc --- /dev/null +++ b/examples/single/pytest-bdd/tests/test_checkout.py @@ -0,0 +1,33 @@ +from pytest_bdd import scenarios, given, when, then, parsers + +scenarios("../features/checkout.feature") + + +@given("the user is signed in") +def signed_in(): + pass + + +@given("the cart contains 2 items") +def cart_two_items(): + pass + + +@given("the saved card is expired") +def expired_card(): + pass + + +@when(parsers.parse('the user clicks "{label}"')) +def click(label): + assert label == "Place order" + + +@then("the order is created") +def order_created(): + assert True + + +@then(parsers.parse('the payment fails with "{reason}"')) +def payment_failure(reason): + assert reason == "card_expired" diff --git a/examples/single/pytest-bdd/tests/test_data_carriers.py b/examples/single/pytest-bdd/tests/test_data_carriers.py new file mode 100644 index 00000000..7202fbb9 --- /dev/null +++ b/examples/single/pytest-bdd/tests/test_data_carriers.py @@ -0,0 +1,21 @@ +from pytest_bdd import scenarios, given, when, then + +scenarios("../features/data_carriers.feature") + + +@given("the following users:") +def users(datatable=None): + # pytest-bdd injects the data table into the step function when a + # parameter named `datatable` is present; ignore it here — the + # purpose of this example is to see the table land in the Qase report. + pass + + +@when("I send the payload:") +def payload(docstring=None): + pass + + +@then("the request succeeds") +def request_ok(): + assert True diff --git a/examples/single/pytest-bdd/tests/test_failing.py b/examples/single/pytest-bdd/tests/test_failing.py new file mode 100644 index 00000000..1329113c --- /dev/null +++ b/examples/single/pytest-bdd/tests/test_failing.py @@ -0,0 +1,18 @@ +from pytest_bdd import scenarios, given, when, then + +scenarios("../features/failing.feature") + + +@given("a calculator") +def a_calc(): + pass + + +@when("I add 2 and 2") +def add(): + pass + + +@then("the result should be 5") +def assert_five(): + assert 2 + 2 == 5 From 50230f77f79cc182702f5b2a2f1baca4e627549b Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 17:28:19 +0300 Subject: [PATCH 29/30] feat: expand pytest-bdd Examples row into individual params --- qase-pytest/src/qase/pytest/bdd.py | 34 +++++++++++ .../integration/test_bdd_scenarios.py | 16 ++++-- .../tests_qase_pytest/test_bdd_helpers.py | 56 +++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index 63317483..60957e6f 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -3,6 +3,7 @@ Loaded conditionally from conftest.py only when pytest_bdd is installed. """ +import ast as _ast import re from typing import Iterable, Optional @@ -297,6 +298,37 @@ def build_step(bdd_step) -> Step: ) +def expand_pytest_bdd_example_params(result) -> None: + """Explode pytest-bdd's `_pytest_bdd_example` param into individual keys. + + pytest-bdd 8.x converts a Scenario Outline row into a single parametrize + argument `_pytest_bdd_example` whose value is a dict (sometimes already + a dict, sometimes a repr string). We unpack it into one param per + column for readable rendering in Qase, and drop the original key. + + No-op if the key is absent or its value cannot be parsed as a dict. + """ + params = getattr(result, "params", None) + if not params or "_pytest_bdd_example" not in params: + return + + raw = params["_pytest_bdd_example"] + parsed = raw + if isinstance(parsed, str): + try: + parsed = _ast.literal_eval(parsed) + except (ValueError, SyntaxError): + return # keep the original key — can't parse safely + + if not isinstance(parsed, dict): + return + + # Remove the wrapper key only after we know the unpack will succeed. + params.pop("_pytest_bdd_example", None) + for key, value in parsed.items(): + params[str(key)] = str(value) + + def enrich_result_from_scenario(result, feature, scenario) -> None: """Mutate an existing Result with metadata extracted from a pytest-bdd scenario.""" if getattr(scenario, "name", None): @@ -337,3 +369,5 @@ def enrich_result_from_scenario(result, feature, scenario) -> None: result.add_tags(parsed["tags"]) result.muted = parsed["muted"] result.ignore = parsed["ignore"] + + expand_pytest_bdd_example_params(result) diff --git a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py index 845094e4..3f4e9622 100644 --- a/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py +++ b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py @@ -303,11 +303,17 @@ def check_sum(numbers, c): # Each result should have its own params populated (from pytest-bdd's # Examples-to-parametrize conversion captured by the existing _set_params). all_params = [r.get("params") or {} for r in results] - # At least one result must have a non-empty params dict — the Examples row - # provided real parameter values, so this is a strict expectation. - assert any( - p for p in all_params - ), "expected at least one result with params populated" + # Each result must have the Examples row exploded into individual params, + # not a single ugly _pytest_bdd_example key. + all_keys = set() + for p in all_params: + assert ( + "_pytest_bdd_example" not in p + ), "Scenario Outline params should be exploded, not kept as a single key" + all_keys.update(p.keys()) + assert {"a", "b", "c"}.issubset( + all_keys + ), f"expected a/b/c in exploded params, got keys: {sorted(all_keys)}" def test_data_table_and_docstring_preserved(pytester): diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py index e6835abd..7aeff8ae 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py @@ -7,6 +7,7 @@ from qase.pytest.bdd import ( build_step, enrich_result_from_scenario, + expand_pytest_bdd_example_params, format_data_table, format_docstring, parse_scenario_tags, @@ -415,3 +416,58 @@ def test_project_id_mapping(self): ) assert r.get_testops_ids_for_project("PROJ_A") == [1, 2] assert r.get_testops_ids_for_project("PROJ_B") == [3] + + def test_pytest_bdd_example_param_is_expanded(self): + r = self._new_result() + r.params = {"_pytest_bdd_example": "{'a': '5', 'b': '7'}"} + enrich_result_from_scenario(r, _FakeFeature(), _FakeScenario()) + assert r.params == {"a": "5", "b": "7"} + + +class TestExpandPytestBddExampleParams: + def _result_with_params(self, params): + r = Result(title="placeholder", signature="") + r.params = dict(params) + return r + + def test_noop_when_key_absent(self): + r = self._result_with_params({"foo": "bar"}) + expand_pytest_bdd_example_params(r) + assert r.params == {"foo": "bar"} + + def test_expands_str_dict(self): + r = self._result_with_params( + {"_pytest_bdd_example": "{'a': '1', 'b': '2', 'c': '3'}"} + ) + expand_pytest_bdd_example_params(r) + assert r.params == {"a": "1", "b": "2", "c": "3"} + assert "_pytest_bdd_example" not in r.params + + def test_expands_real_dict(self): + # Some pytest-bdd versions may pass a dict directly (defensive). + r = self._result_with_params({"_pytest_bdd_example": {"x": 10, "y": 20}}) + expand_pytest_bdd_example_params(r) + assert r.params == {"x": "10", "y": "20"} + + def test_keeps_original_when_unparseable(self): + r = self._result_with_params({"_pytest_bdd_example": "not a dict repr"}) + expand_pytest_bdd_example_params(r) + # Cannot parse — preserve the key untouched. + assert r.params == {"_pytest_bdd_example": "not a dict repr"} + + def test_keeps_original_when_parsed_is_not_dict(self): + r = self._result_with_params( + {"_pytest_bdd_example": "[1, 2, 3]"} # parses to list, not dict + ) + expand_pytest_bdd_example_params(r) + assert r.params == {"_pytest_bdd_example": "[1, 2, 3]"} + + def test_preserves_other_params(self): + r = self._result_with_params( + { + "_pytest_bdd_example": "{'a': '1'}", + "browser": "chrome", + } + ) + expand_pytest_bdd_example_params(r) + assert r.params == {"a": "1", "browser": "chrome"} From 6297d7fab289980ae975f8b8ec35296e0204e431 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 12 May 2026 17:35:17 +0300 Subject: [PATCH 30/30] feat: silence PytestUnknownMarkWarning for qase.* gherkin tags --- qase-pytest/src/qase/pytest/bdd.py | 27 +++++++++ .../tests_qase_pytest/test_bdd_plugin.py | 58 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py index 60957e6f..6a300f2d 100644 --- a/qase-pytest/src/qase/pytest/bdd.py +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -5,6 +5,7 @@ import ast as _ast import re +import warnings as _warnings from typing import Iterable, Optional from qase.commons.models.relation import Relation, SuiteData @@ -17,6 +18,32 @@ class QasePytestBddPlugin: def __init__(self, pytest_plugin): self._pytest_plugin = pytest_plugin self._current = None # per-scenario state dict + self._install_warning_filter() + + @staticmethod + def _install_warning_filter(): + """Silence pytest-bdd-forwarded Gherkin tags showing up as unknown marks. + + pytest-bdd 7+/8 turns every Gherkin scenario tag into a pytest marker + whose name is the raw tag string (e.g. ``qase.id=42``). pytest emits + ``PytestUnknownMarkWarning`` for each unique one. The warnings have + no diagnostic value for the user (it's not a typo — the tag is + intentional), so we silence ONLY the qase.* family. Other unknown + marks still warn normally. + """ + try: + # local import — pytest is always installed when this plugin loads + import pytest as _pytest + + category = getattr(_pytest, "PytestUnknownMarkWarning", Warning) + except Exception: + category = Warning + + _warnings.filterwarnings( + "ignore", + message=r"Unknown pytest\.mark\.qase\.", + category=category, + ) def pytest_bdd_before_scenario(self, request, feature, scenario): runtime = getattr(self._pytest_plugin, "runtime", None) diff --git a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py index 09360d49..e6f8d451 100644 --- a/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py @@ -367,3 +367,61 @@ def test_lookup_error_records_invalid_step(self): assert len(invalid) == 1 assert invalid[0].data.name == "missing impl" assert bdd._current["scenario_failed"] is True + + +class TestWarningFilter: + def test_init_installs_qase_warning_filter(self): + from qase.pytest.bdd import QasePytestBddPlugin + import warnings as _warnings + + # Reset filters to a known state, then construct the plugin. + with _warnings.catch_warnings(): + _warnings.resetwarnings() + QasePytestBddPlugin(MagicMock()) + # At least one filter targeting qase.* unknown-mark warnings + # must be registered. + qase_filters = [ + f + for f in _warnings.filters + if f[0] == "ignore" + and f[2] is not None + and "qase" in (getattr(f[2], "__name__", "") or "") + or (f[1] is not None and "qase" in getattr(f[1], "pattern", str(f[1]))) + ] + assert ( + qase_filters + ), f"expected a 'qase' warning filter, got: {_warnings.filters}" + + def test_filter_silences_qase_unknown_mark(self): + from qase.pytest.bdd import QasePytestBddPlugin + import pytest as _pytest + import warnings as _warnings + + with _warnings.catch_warnings(record=True) as caught: + _warnings.simplefilter("default") + QasePytestBddPlugin(MagicMock()) + # Emit the warning text pytest would emit. + _warnings.warn( + "Unknown pytest.mark.qase.id=42 - is this a typo?", + getattr(_pytest, "PytestUnknownMarkWarning", UserWarning), + ) + + relevant = [w for w in caught if "qase.id=42" in str(w.message)] + assert relevant == [], f"qase.* unknown-mark warning leaked through: {relevant}" + + def test_filter_does_not_silence_unrelated_unknown_marks(self): + from qase.pytest.bdd import QasePytestBddPlugin + import pytest as _pytest + import warnings as _warnings + + with _warnings.catch_warnings(record=True) as caught: + _warnings.simplefilter("default") + QasePytestBddPlugin(MagicMock()) + _warnings.warn( + "Unknown pytest.mark.somethingelse - is this a typo?", + getattr(_pytest, "PytestUnknownMarkWarning", UserWarning), + ) + + # The non-qase warning should still surface. + relevant = [w for w in caught if "somethingelse" in str(w.message)] + assert relevant, "unrelated unknown-mark warning should not be silenced"