Skip to content

Plugin

QasePlugin

Pytest plugin for reporting tests result to Qase.

Source code in pytest_qaseio/plugin.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
class QasePlugin:
    """Pytest plugin for reporting tests result to Qase."""

    # We use .pytest-qaseio.lock to lock process, in other words make other
    # workers wait, until worker that locked file will create run and store
    # run's id in .pytest-qaseio. After other
    # workers load run by id from .pytest-qaseio.
    __run_file = pathlib.Path(".pytest-qaseio")
    __run_file_lock = pathlib.Path(".pytest-qaseio.lock")

    def __init__(
        self,
        browser: str,
        file_storage: storage.FileStorage | None,
        config: pytest.Config,
    ) -> None:
        """Save used browser for run's name and folder name."""
        self._config = config
        self._client = api_client.QaseClient(
            token=os.environ["QASE_TOKEN"],
            project_code=os.environ["QASE_PROJECT_CODE"],
            retries=config.getoption("--qase-api-retries"),
        )
        self._cases_ids_from_api: list[int] = self._client.load_cases_ids()
        self._current_run: Run | None = None
        self._converter = converter.QaseConverter(
            browser=browser,
            env=os.environ["ENVIRONMENT"],
            project_code=os.environ["QASE_PROJECT_CODE"],
            file_storage=file_storage,
            config=self._config,
        )

        # Mapping of pytest items ids and case id
        self._tests: dict[str, int | None] = {}
        # Mapping of case ids and result hash from qase with status
        self._qase_results: dict[int, tuple[str, ResultCreate]] = {}

    def pytest_sessionstart(self, session: pytest.Session) -> None:
        """Clear previously saved run, prepare lock file."""
        if hasattr(session.config, "workerinput"):
            # Do nothing if it is not master thread
            return
        self.__run_file.unlink(missing_ok=True)
        self.__run_file_lock.touch(exist_ok=True)

    @pytest.hookimpl(trylast=True)
    def pytest_collection_modifyitems(
        self,
        items: list[pytest.Function],
    ) -> None:
        """Create test run in qase."""
        with filelock.FileLock(self.__run_file_lock):
            try:
                run_data, self._tests = self._converter.prepare_run_data(
                    cases_ids_from_api=self._cases_ids_from_api,
                    items=items,
                )

                # Specifying plan allows to create run "from template".
                # New run will contain all cases from plan + cases that
                # specified in tests
                if plan_id := os.getenv("QASE_PLAN_ID"):
                    run_data.plan_id = int(plan_id)

                if environment_id := os.getenv("QASE_ENVIRONMENT_ID"):
                    run_data.environment_id = int(environment_id)

                if qase_url_custom_field_id := os.getenv(
                    "QASE_URL_CUSTOM_FIELD_ID",
                ):
                    run_data.custom_field = {
                        # This should be provided from script that runs test,
                        # f.e jenkins script
                        qase_url_custom_field_id: os.getenv("RUN_SOURCE_URL")
                        or "",
                    }

                self._current_run = self._load_run_from_file()
                if self._current_run:
                    return

                self._current_run = self._client.create_run(
                    run_data=run_data,
                )
                with pathlib.Path(self.__run_file).open(mode="w") as lock_file:
                    lock_file.write(str(self._current_run.id))
            except plugin_exceptions.BaseQasePluginException as e:
                pytest.exit(e.message)

    @pytest.hookimpl(tryfirst=True, hookwrapper=True)
    def pytest_runtest_makereport(self, item: pytest.Function):  # noqa: ANN201
        """Represent standard pytest hook on test completion.

        At this hook we will report passed, skipped and failed tests.

        """
        provided_report = yield
        report: pytest.TestReport = provided_report.get_result()
        should_report = not (
            # Passed tests should be reported only on call
            report.passed and report.when in ("setup", "teardown")
        )
        if not should_report:
            return
        case_id = self._tests[item.nodeid]

        # No need to report same passed status,
        # while skipped and failed should be always reported
        if not case_id or (case_id in self._qase_results and report.passed):
            return
        if not self._current_run:
            raise plugin_exceptions.RunNotConfigured()
        try:
            self._qase_results[case_id] = self._client.report_test_results(
                run=self._current_run,
                report_data=self._converter.prepare_report_data(
                    run_id=typing.cast(int, self._current_run.id),
                    case_id=case_id,
                    item=item,
                    report=report,
                ),
            )

        except ApiException as error:
            if report.passed:
                return
            # Qase closes runs, once every case got result.
            # So if try to report any other result,
            # we'll get an error `Test run is not active`.
            terminal_reporter: pytest.TerminalReporter = (
                item.config.pluginmanager.get_plugin(
                    "terminalreporter",  # type: ignore
                )
            )
            terminal_reporter.ensure_newline()
            terminal_reporter.section(
                f"{error}. "
                f"Seems that Qase closed run, "
                f"and we are unable to report failed {item.name}",
                sep="=",
            )

    def _load_run_from_file(
        self,
    ) -> Run | None:
        """Load run id and then load it from qase."""
        if not self.__run_file.exists():
            return None
        with pathlib.Path(self.__run_file).open() as lock_file:
            run_id = int(lock_file.read())
            return self._client.get_run(
                run_id=run_id,
            )

__init__(browser, file_storage, config)

Save used browser for run's name and folder name.

Source code in pytest_qaseio/plugin.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def __init__(
    self,
    browser: str,
    file_storage: storage.FileStorage | None,
    config: pytest.Config,
) -> None:
    """Save used browser for run's name and folder name."""
    self._config = config
    self._client = api_client.QaseClient(
        token=os.environ["QASE_TOKEN"],
        project_code=os.environ["QASE_PROJECT_CODE"],
        retries=config.getoption("--qase-api-retries"),
    )
    self._cases_ids_from_api: list[int] = self._client.load_cases_ids()
    self._current_run: Run | None = None
    self._converter = converter.QaseConverter(
        browser=browser,
        env=os.environ["ENVIRONMENT"],
        project_code=os.environ["QASE_PROJECT_CODE"],
        file_storage=file_storage,
        config=self._config,
    )

    # Mapping of pytest items ids and case id
    self._tests: dict[str, int | None] = {}
    # Mapping of case ids and result hash from qase with status
    self._qase_results: dict[int, tuple[str, ResultCreate]] = {}

pytest_collection_modifyitems(items)

Create test run in qase.

Source code in pytest_qaseio/plugin.py
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
@pytest.hookimpl(trylast=True)
def pytest_collection_modifyitems(
    self,
    items: list[pytest.Function],
) -> None:
    """Create test run in qase."""
    with filelock.FileLock(self.__run_file_lock):
        try:
            run_data, self._tests = self._converter.prepare_run_data(
                cases_ids_from_api=self._cases_ids_from_api,
                items=items,
            )

            # Specifying plan allows to create run "from template".
            # New run will contain all cases from plan + cases that
            # specified in tests
            if plan_id := os.getenv("QASE_PLAN_ID"):
                run_data.plan_id = int(plan_id)

            if environment_id := os.getenv("QASE_ENVIRONMENT_ID"):
                run_data.environment_id = int(environment_id)

            if qase_url_custom_field_id := os.getenv(
                "QASE_URL_CUSTOM_FIELD_ID",
            ):
                run_data.custom_field = {
                    # This should be provided from script that runs test,
                    # f.e jenkins script
                    qase_url_custom_field_id: os.getenv("RUN_SOURCE_URL")
                    or "",
                }

            self._current_run = self._load_run_from_file()
            if self._current_run:
                return

            self._current_run = self._client.create_run(
                run_data=run_data,
            )
            with pathlib.Path(self.__run_file).open(mode="w") as lock_file:
                lock_file.write(str(self._current_run.id))
        except plugin_exceptions.BaseQasePluginException as e:
            pytest.exit(e.message)

pytest_runtest_makereport(item)

Represent standard pytest hook on test completion.

At this hook we will report passed, skipped and failed tests.

Source code in pytest_qaseio/plugin.py
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(self, item: pytest.Function):  # noqa: ANN201
    """Represent standard pytest hook on test completion.

    At this hook we will report passed, skipped and failed tests.

    """
    provided_report = yield
    report: pytest.TestReport = provided_report.get_result()
    should_report = not (
        # Passed tests should be reported only on call
        report.passed and report.when in ("setup", "teardown")
    )
    if not should_report:
        return
    case_id = self._tests[item.nodeid]

    # No need to report same passed status,
    # while skipped and failed should be always reported
    if not case_id or (case_id in self._qase_results and report.passed):
        return
    if not self._current_run:
        raise plugin_exceptions.RunNotConfigured()
    try:
        self._qase_results[case_id] = self._client.report_test_results(
            run=self._current_run,
            report_data=self._converter.prepare_report_data(
                run_id=typing.cast(int, self._current_run.id),
                case_id=case_id,
                item=item,
                report=report,
            ),
        )

    except ApiException as error:
        if report.passed:
            return
        # Qase closes runs, once every case got result.
        # So if try to report any other result,
        # we'll get an error `Test run is not active`.
        terminal_reporter: pytest.TerminalReporter = (
            item.config.pluginmanager.get_plugin(
                "terminalreporter",  # type: ignore
            )
        )
        terminal_reporter.ensure_newline()
        terminal_reporter.section(
            f"{error}. "
            f"Seems that Qase closed run, "
            f"and we are unable to report failed {item.name}",
            sep="=",
        )

pytest_sessionstart(session)

Clear previously saved run, prepare lock file.

Source code in pytest_qaseio/plugin.py
184
185
186
187
188
189
190
def pytest_sessionstart(self, session: pytest.Session) -> None:
    """Clear previously saved run, prepare lock file."""
    if hasattr(session.config, "workerinput"):
        # Do nothing if it is not master thread
        return
    self.__run_file.unlink(missing_ok=True)
    self.__run_file_lock.touch(exist_ok=True)

pytest_addhooks(pluginmanager)

Add hooks for plugin.

Source code in pytest_qaseio/plugin.py
43
44
45
46
47
def pytest_addhooks(pluginmanager: pytest.PytestPluginManager) -> None:
    """Add hooks for plugin."""
    from . import hooks

    pluginmanager.add_hookspecs(hooks)

pytest_addoption(parser)

Add custom args to command line.

Source code in pytest_qaseio/plugin.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def pytest_addoption(parser: pytest.Parser) -> None:
    """Add custom args to command line."""
    parser.addoption(
        "--qase-enabled",
        action="store_true",
        default=False,
        help="Store run result in qase",
    )
    parser.addoption(
        "--qase-file-storage",
        default="qase",
        help="Choose file storage to upload debug files",
    )
    parser.addoption(
        "--qase-run-name",
        default="",
        help="Specify run title to use in Qase",
    )
    parser.addoption(
        "--qase-api-retries",
        default=3,
        help="Specify number of retries for Qase API requests",
    )

pytest_configure(config)

Configure pytest-qaseio plugin.

Add qase marker for pytest. If qase enabled, register qase plugin. Get file storage for qase plugin.

Source code in pytest_qaseio/plugin.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
@pytest.hookimpl(trylast=True)
def pytest_configure(config: pytest.Config) -> None:
    """Configure pytest-qaseio plugin.

    Add `qase` marker for pytest.
    If qase enabled, register qase plugin.
    Get file storage for qase plugin.

    """
    config.addinivalue_line(
        "markers",
        "qase(test_case_url): Mark test for qase",
    )
    qase_enabled = config.getoption("--qase-enabled")
    if not qase_enabled:
        return

    browser_name: str = config.hook.pytest_qase_browser_name(config=config)

    config.pluginmanager.register(
        plugin=QasePlugin(
            browser=browser_name,
            file_storage=_get_file_storage(config),
            config=config,
        ),
        name="qase_plugin",
    )

pytest_get_debug_info(item)

Try to get selenium debug info object.

Source code in pytest_qaseio/plugin.py
87
88
89
90
91
92
93
94
@pytest.hookimpl(trylast=True)
def pytest_get_debug_info(item: pytest.Function) -> DebugInfo | None:
    """Try to get selenium debug info object."""
    return (
        SeleniumDebugInfo(item._webdriver)  # type: ignore
        if hasattr(item, "_webdriver")
        else None
    )

pytest_get_run_name(config, env, browser)

Return name for test run to use in Qase.

Default option if --qase-run-name not specified: (Dev) Automated Test Run Edge 07/23/2025 18:06:37

Source code in pytest_qaseio/plugin.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
@pytest.hookimpl(trylast=True)
def pytest_get_run_name(config: pytest.Config, env: str, browser: str) -> str:
    """Return name for test run to use in Qase.

    Default option if `--qase-run-name` not specified:
        (Dev) Automated Test Run Edge 07/23/2025 18:06:37

    """
    qase_run_name: str = config.getoption("--qase-run-name")
    if not qase_run_name or qase_run_name == "none":
        return constants.RUN_NAME_TEMPLATE.format(
            env=env.capitalize(),
            browser=browser.capitalize(),
            date=datetime.datetime.now(tz=datetime.UTC).strftime(
                "%m/%d/%Y %H:%M:%S",
            ),
        )
    return qase_run_name

pytest_qase_browser_name(config)

Try to get browser name from webdriver pytest option.

Source code in pytest_qaseio/plugin.py
81
82
83
84
@pytest.hookimpl(trylast=True)
def pytest_qase_browser_name(config: pytest.Config) -> str:
    """Try to get browser name from `webdriver` pytest option."""
    return config.getoption("--webdriver")

pytest_qase_file_storages()

Provide mapping of available file storages for qase debug files.

Source code in pytest_qaseio/plugin.py
70
71
72
73
74
75
76
77
78
@pytest.hookimpl(trylast=True)
def pytest_qase_file_storages() -> dict[str, storage.FileStorage]:
    """Provide mapping of available file storages for qase debug files."""
    return {
        "qase": storage.QaseFileStorage(
            qase_token=os.environ["QASE_TOKEN"],
            qase_project_code=os.environ["QASE_PROJECT_CODE"],
        ),
    }