diff --git a/examples/single/pytest-bdd/README.md b/examples/single/pytest-bdd/README.md new file mode 100644 index 00000000..7fb8cf4f --- /dev/null +++ b/examples/single/pytest-bdd/README.md @@ -0,0 +1,60 @@ +# qase-pytest + pytest-bdd example + +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. + +## 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 +``` + +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 +``` + +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/features/login.feature b/examples/single/pytest-bdd/features/login.feature new file mode 100644 index 00000000..fa19a402 --- /dev/null +++ b/examples/single/pytest-bdd/features/login.feature @@ -0,0 +1,7 @@ +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 + 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_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 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 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 7bc671f6..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"] @@ -46,6 +46,7 @@ qase_pytest = "qase.pytest.conftest" testing = [ "pytest", "pytest-cov", + "pytest-bdd>=7.0,<9.0", ] [tool.tox] diff --git a/qase-pytest/src/qase/pytest/bdd.py b/qase-pytest/src/qase/pytest/bdd.py new file mode 100644 index 00000000..6a300f2d --- /dev/null +++ b/qase-pytest/src/qase/pytest/bdd.py @@ -0,0 +1,400 @@ +"""Native pytest-bdd integration for qase-pytest. + +Loaded conditionally from conftest.py only when pytest_bdd is installed. +""" + +import ast as _ast +import re +import warnings as _warnings +from typing import Iterable, Optional + +from qase.commons.models.relation import Relation, SuiteData +from qase.commons.models.step import Step, StepGherkinData, StepType + + +class QasePytestBddPlugin: + """Bridge between pytest-bdd hooks and the main QasePytestPlugin runtime.""" + + 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) + 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, + } + + 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") + + 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 + # 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: + 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"} + + +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 + + +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_markdown_table_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_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 + + +def format_docstring(text) -> str: + """Render a Gherkin step docstring as a fenced markdown code block. + + 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 "" + + 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 + + +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. 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) or getattr( + bdd_step, "datatable", 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, + ), + ) + + +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): + 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"] + + expand_pytest_bdd_example_params(result) 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/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..3f4e9622 --- /dev/null +++ b/qase-pytest/tests/tests_qase_pytest/integration/test_bdd_scenarios.py @@ -0,0 +1,462 @@ +"""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"] + + +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" + + +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 + + +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"} + + +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] + # 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): + _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 + + +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 == [] + + +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 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..7aeff8ae --- /dev/null +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_helpers.py @@ -0,0 +1,473 @@ +"""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, + expand_pytest_bdd_example_params, + format_data_table, + format_docstring, + 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: + def test_empty_returns_empty_dict(self): + result = parse_scenario_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"]) + 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_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( + [ + "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_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 + + 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 + + +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 + + 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 + + +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```" + + 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 + + +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_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". + 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 + + +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] + + 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"} 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..e6f8d451 --- /dev/null +++ b/qase-pytest/tests/tests_qase_pytest/test_bdd_plugin.py @@ -0,0 +1,427 @@ +"""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 + ) + + +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={}, + ) + + +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 + + 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. + 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 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 + + +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"