diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index e51e96cf..accb36eb 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -22,6 +22,7 @@ jobs: - "qase-robotframework" - "qase-python-commons" - "qase-behave" + - "qase-tavern" name: Project ${{ matrix.changed-dir }} - Python ${{ matrix.python-version }} steps: - uses: actions/checkout@v3 @@ -60,7 +61,8 @@ jobs: "qase-pytest", "qase-robotframework", "qase-python-commons", - "qase-behave" + "qase-behave", + "qase-tavern" ] if: startsWith(github.event.ref, 'refs/tags') steps: diff --git a/examples/tavern/Readme.md b/examples/tavern/Readme.md new file mode 100644 index 00000000..1958ec02 --- /dev/null +++ b/examples/tavern/Readme.md @@ -0,0 +1,33 @@ +# How to run these examples + +1. Clone the repository + + ```bash + git clone https://github.com/qase-tms/qase-python.git + ``` + +2. Move to the directory with the examples + + ```bash + cd qase-python/examples/tavern + ``` + +3. Install the required packages + + ```bash + pip install -r requirements.txt + ``` + +4. Add the Qase token and project code to the ENV variables + + ```bash + export QASE_MODE=testops + export QASE_TESTOPS_API_TOKEN=your_token + export QASE_TESTOPS_PROJECT=your_project_code + ``` + +5. Run the tests + + ```bash + pytest + ``` diff --git a/examples/tavern/qase.config.json b/examples/tavern/qase.config.json new file mode 100644 index 00000000..d379879f --- /dev/null +++ b/examples/tavern/qase.config.json @@ -0,0 +1,30 @@ +{ + "mode": "testops", + "fallback": "off", + "debug": true, + "report": { + "driver": "local", + "connection": { + "local": { + "path": "./build/qase-report", + "format": "json" + } + } + }, + "testops": { + "api": { + "token": "", + "host": "qase.io" + }, + "run": { + "title": "Tavern run", + "description": "Tavern examples", + "complete": true + }, + "defect": false, + "project": "", + "batch": { + "size": 200 + } + } +} diff --git a/examples/tavern/requirements.txt b/examples/tavern/requirements.txt new file mode 100644 index 00000000..d011372f --- /dev/null +++ b/examples/tavern/requirements.txt @@ -0,0 +1,2 @@ +tavern==2.11.0 +qase-tavern~=1.0.0 diff --git a/examples/tavern/test_simple.tavern.yaml b/examples/tavern/test_simple.tavern.yaml new file mode 100644 index 00000000..daf81789 --- /dev/null +++ b/examples/tavern/test_simple.tavern.yaml @@ -0,0 +1,80 @@ +--- +test_name: Simple test success + +stages: + - name: Step 1 + request: + url: https://jsonplaceholder.typicode.com/posts/1 + method: GET + response: + status_code: 200 + json: + id: 1 + userId: 1 + title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + + - name: Step 2 + request: + url: https://jsonplaceholder.typicode.com/posts/1 + method: GET + response: + status_code: 200 + json: + id: 1 + userId: 1 + title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + + - name: Step 3 + request: + url: https://jsonplaceholder.typicode.com/posts/1 + method: GET + response: + status_code: 200 + json: + id: 1 + userId: 1 + title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + + +--- +test_name: Simple test failed + +stages: + - name: Step 1 + request: + url: https://jsonplaceholder.typicode.com/posts/1 + method: GET + response: + status_code: 200 + json: + id: 1 + userId: 1 + title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + + - name: Step 2 + request: + url: https://jsonplaceholder.typicode.com/posts/1 + method: GET + response: + status_code: 300 + json: + id: 1 + userId: 1 + title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + + - name: Step 3 + request: + url: https://jsonplaceholder.typicode.com/posts/1 + method: GET + response: + status_code: 200 + json: + id: 1 + userId: 1 + title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" diff --git a/examples/tavern/test_with_id.tavern.yaml b/examples/tavern/test_with_id.tavern.yaml new file mode 100644 index 00000000..4f6f97c4 --- /dev/null +++ b/examples/tavern/test_with_id.tavern.yaml @@ -0,0 +1,80 @@ +--- +test_name: QaseID=1 Test with QaseID success + +stages: + - name: Step 1 + request: + url: https://jsonplaceholder.typicode.com/posts/1 + method: GET + response: + status_code: 200 + json: + id: 1 + userId: 1 + title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + + - name: Step 2 + request: + url: https://jsonplaceholder.typicode.com/posts/1 + method: GET + response: + status_code: 200 + json: + id: 1 + userId: 1 + title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + + - name: Step 3 + request: + url: https://jsonplaceholder.typicode.com/posts/1 + method: GET + response: + status_code: 200 + json: + id: 1 + userId: 1 + title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + + +--- +test_name: QaseID=2 Test with QaseID failed + +stages: + - name: Step 1 + request: + url: https://jsonplaceholder.typicode.com/posts/1 + method: GET + response: + status_code: 200 + json: + id: 1 + userId: 1 + title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + + - name: Step 2 + request: + url: https://jsonplaceholder.typicode.com/posts/1 + method: GET + response: + status_code: 300 + json: + id: 1 + userId: 1 + title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" + + - name: Step 3 + request: + url: https://jsonplaceholder.typicode.com/posts/1 + method: GET + response: + status_code: 200 + json: + id: 1 + userId: 1 + title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" + body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" diff --git a/qase-behave/pyproject.toml b/qase-behave/pyproject.toml index 89fa314c..59c3fbda 100644 --- a/qase-behave/pyproject.toml +++ b/qase-behave/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ [project.urls] Homepage = "https://qase.io" -Repository = "https://github.com/qase-tms/qase-python/tree/master/qase-pytest" +Repository = "https://github.com/qase-tms/qase-python/tree/master/qase-behave" Documentation = "https://developers.qase.io" diff --git a/qase-tavern/LICENSE.txt b/qase-tavern/LICENSE.txt new file mode 100644 index 00000000..8dada3ed --- /dev/null +++ b/qase-tavern/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/qase-tavern/README.md b/qase-tavern/README.md new file mode 100644 index 00000000..c758fdcb --- /dev/null +++ b/qase-tavern/README.md @@ -0,0 +1,104 @@ +# [Qase TestOps](https://qase.io) Tavern Reporter + +[![License](https://lxgaming.github.io/badges/License-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) + +## Installation + +To install the latest version, run: + +```sh +pip install pre qase-tavern +``` + +## Getting started + +The Tavern reporter can auto-generate test cases +and suites from your test data. +Test results of subsequent test runs will match the same test cases +as long as their names and file paths don't change. + +You can also annotate the tests with the IDs of existing test cases +from Qase.io before executing tests. It's a more reliable way to bind +autotests to test cases, that persists when you rename, move, or +parameterize your tests. + +For example: + +```yaml +--- +test_name: QaseID=1 Test with QaseID success + +stages: + - name: Step 1 + request: + response: + + - name: Step 2 + request: + response: +``` + +To execute Tavern tests and report them to Qase.io, run the command: + +```bash +pytest +``` + +You can try it with the example project at [`examples/tavern`](../examples/tavern/). + +## Configuration + +Qase Tavern Reporter is configured in multiple ways: + +- using a config file `qase.config.json` +- using environment variables +- using command line options + +Environment variables override the values given in the config file, +and command line options override both other values. + +Configuration options are described in the +[configuration reference](docs/CONFIGURATION.md). + +### Example: qase.config.json + +```json +{ + "mode": "testops", + "fallback": "report", + "testops": { + "project": "YOUR_PROJECT_CODE", + "api": { + "token": "YOUR_API_TOKEN", + "host": "qase.io" + }, + "run": { + "title": "Test run title" + }, + "batch": { + "size": 100 + } + }, + "report": { + "driver": "local", + "connection": { + "local": { + "path": "./build/qase-report", + "format": "json" + } + } + }, + "environment": "local" +} +``` + +## Requirements + +We maintain the reporter on [LTS versions of Python](https://devguide.python.org/versions/). + +`python >= 3.7` +`tavern >= 2.11.0` + + + +[auth]: https://developers.qase.io/#authentication diff --git a/qase-tavern/changelog.md b/qase-tavern/changelog.md new file mode 100644 index 00000000..486a8003 --- /dev/null +++ b/qase-tavern/changelog.md @@ -0,0 +1,5 @@ +# qase-tavern 1.0.0 + +## What's new + +The first release in the 1.0.x series of the Tavern reporter. diff --git a/qase-tavern/docs/CONFIGURATION.md b/qase-tavern/docs/CONFIGURATION.md new file mode 100644 index 00000000..125c0487 --- /dev/null +++ b/qase-tavern/docs/CONFIGURATION.md @@ -0,0 +1,37 @@ +# Qase Tavern Plugin configuration + +Qase Tavern Reporter is configured in multiple ways: + +- using a config file `qase.config.json` +- using environment variables +- using command line options + +Environment variables override the values given in the config file, +and command line options override both other values. + +## Configuration options + +| Description | Config file | Environment variable | CLI option | Default value | Required | Possible values | +|-------------------------------------------|----------------------------|---------------------------------|-----------------------------------|-----------------------------------------|----------|----------------------------| +| **Common** | +| Main reporting mode | `mode` | `QASE_MODE` | `--qase-mode` | `testops` | No | `testops`, `report`, `off` | +| Fallback reporting mode | `fallback` | `QASE_FALLBACK` | `--qase-fallback` | `report` | No | `testops`, `report`, `off` | +| Execution plan path | `executionPlan.path` | `QASE_EXECUTION_PLAN_PATH` | `--qase-execution-plan-path` | `./build/qase-execution-plan.json` | No | Any string | +| Qase environment | `environment` | `QASE_ENVIRONMENT` | `--qase-environment` | `local` | No | Any string | +| Root suite | `rootSuite` | `QASE_ROOT_SUITE` | `--qase-root-suite` | | No | Any string | +| Debug logs | `debug` | `QASE_DEBUG` | `--qase-debug` | false | No | `true`, `false` | +| **Qase TestOps mode configuration** | +| Qase project code | `testops.project` | `QASE_TESTOPS_PROJECT` | `--qase-testops-project` | | Yes | Any string | +| Qase API token | `testops.api.token` | `QASE_TESTOPS_API_TOKEN` | `--qase-testops-api-token` | | Yes | Any string | +| Qase API host | `testops.api.host` | `QASE_TESTOPS_API_HOST` | `--qase-testops-api-host` | `qase.io` | No | Any string | +| Title of the Qase test run | `testops.run.title` | `QASE_TESTOPS_RUN_TITLE` | `--qase-testops-run-title` | `Automated Run {current date and time}` | No | Any string | +| Description of the Qase test run | `testops.run.description` | `QASE_TESTOPS_RUN_DESCRIPTION` | `--qase-testops-run-description` | None, leave empty | No | Any string | +| Create test run using a test plan | `testops.plan.id` | `QASE_TESTOPS_PLAN_ID` | `--qase-testops-plan-id` | None, don't use plans for the test run | No | Any integer | +| Complete test run after running tests | `testops.run.complete` | `QASE_TESTOPS_RUN_COMPLETE` | `--qase-testops-run-complete` | `True` | No | `true`, `false` | +| ID of the Qase test run to report results | `testops.run.id` | `QASE_TESTOPS_RUN_ID` | `--qase-testops-run-id` | None, create a new test run | No | Any integer | +| Batch size for uploading test results | `testops.batch.size` | `QASE_TESTOPS_BATCH_SIZE` | `--qase-testops-batch-size` | 200 | No | 1 to 2000 | +| Create defects in Qase | `testops.defect` | `QASE_TESTOPS_DEFECT` | `--qase-testops-defect` | `False`, don't create defects | No | `True`, `False` | +| **Qase Report mode configuration** | +| Local path to store report | `report.connection.path` | `QASE_REPORT_CONNECTION_PATH` | `--qase-report-connection-path` | `./build/qase-report` | No | Any string | +| Report format | `report.connection.format` | `QASE_REPORT_CONNECTION_FORMAT` | `--qase-report-connection-format` | `json` | No | `json`, `jsonp` | +| Driver used for report mode | `report.driver` | `QASE_REPORT_DRIVER` | `--qase-report-driver` | `local` | No | `local` | diff --git a/qase-tavern/pyproject.toml b/qase-tavern/pyproject.toml new file mode 100644 index 00000000..cd2e5e59 --- /dev/null +++ b/qase-tavern/pyproject.toml @@ -0,0 +1,102 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "qase-tavern" +version = "1.0.0" +description = "Qase Tavern Plugin for Qase TestOps and Qase Report" +readme = "README.md" +keywords = ["qase", "tavern", "plugin", "testops", "report", "qase reporting", "test observability"] +authors = [{ name = "Qase Team", email = "support@qase.io" }] +license = { file = "LICENSE" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python", + "Framework :: Tavern", + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Topic :: Software Development :: Quality Assurance', + 'Topic :: Software Development :: Testing', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', +] +requires-python = ">=3.7" +dependencies = [ + "qase-python-commons~=3.2.0", + "pytest>=7,<7.3", + "filelock~=3.12.2", + "more_itertools", +] + +[project.urls] +Homepage = "https://qase.io" +Repository = "https://github.com/qase-tms/qase-python/tree/master/qase-tavern" +Documentation = "https://developers.qase.io" + +[project.optional-dependencies] +testing = [ + "pytest", + "pytest-cov", +] + +[tool.tox] +legacy_tox_ini = """ +[tox] +minversion = 3.7 +envlist = py{37,38,39,310,311} + +[testenv] +deps = + pytest + pytest-cov +passenv = + HOME +commands = + pytest --cov-config=pyproject.toml {posargs} +extras = + all + testing +""" + +[tool.setuptools.packages.find] +where = ["src"] +exclude = ["tests"] + +[tool.pytest.ini_options] +addopts = "--cov-report=term-missing --verbose" +norecursedirs = ["dist", "build", ".tox"] +testpaths = ["tests"] + +[tool.flake8] +exclude = [".tox", "build", "dist", ".eggs"] + +[tool.coverage.run] +branch = true + +[tool.coverage.paths] +source = ["src/qase/tavern"] + +[tool.coverage.report] +# Regexes for lines to exclude from consideration +exclude_also = [ + # Don't complain about missing debug-only code: + "def __repr__", + "if self\\.debug", + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", + # Don't complain about abstract methods, they aren't run: + "@(abc\\.)?abstractmethod", +] + +ignore_errors = false diff --git a/qase-tavern/requirements.txt b/qase-tavern/requirements.txt new file mode 100644 index 00000000..587eb274 --- /dev/null +++ b/qase-tavern/requirements.txt @@ -0,0 +1,4 @@ +attrs==23.2.0 +pytest>=7,<7.3 +filelock~=3.13.1 +qase-python-commons~=3.2.0 diff --git a/qase-tavern/src/qase/tavern/__init__.py b/qase-tavern/src/qase/tavern/__init__.py new file mode 100644 index 00000000..f6779639 --- /dev/null +++ b/qase-tavern/src/qase/tavern/__init__.py @@ -0,0 +1,3 @@ +from .context_manager import ContextManager, contextdecorator + +__all__ = ["contextdecorator", "ContextManager"] diff --git a/qase-tavern/src/qase/tavern/conftest.py b/qase-tavern/src/qase/tavern/conftest.py new file mode 100644 index 00000000..19f86d4d --- /dev/null +++ b/qase-tavern/src/qase/tavern/conftest.py @@ -0,0 +1,128 @@ +from qase.commons import ConfigManager +from qase.commons.reporters import QaseCoreReporter + +from .plugin import QasePytestPluginSingleton +from .options import QasePytestOptions + + +def pytest_addoption(parser): + group = parser.getgroup("qase") + QasePytestOptions.addoptions(parser, group) + +def _add_markers(config): + config.addinivalue_line("markers", "qase_id: mark test to be associate with Qase TestOps test case") + config.addinivalue_line("markers", "qase_title: mark test with title") + config.addinivalue_line("markers", "qase_ignore: skip test from Qase TestOps and Qase Report") + config.addinivalue_line("markers", "qase_muted: mark test as muted so it will not affect test run status") + config.addinivalue_line("markers", "qase_author: mark test with author") + config.addinivalue_line("markers", "qase_fields: mark test with meta data") + config.addinivalue_line("markers", "qase_suite: mark test with suite") + + # Legacy markers | These markers are deprecated and will be removed in future versions + config.addinivalue_line("markers", "qase_description: mark test with description") + config.addinivalue_line("markers", "qase_preconditions: mark test with preconditions") + config.addinivalue_line("markers", "qase_postconditions: mark test with postconditions") + config.addinivalue_line("markers", "qase_layer: mark test with layer") + config.addinivalue_line("markers", "qase_severity: mark test with severity") + config.addinivalue_line("markers", "qase_priority: mark test with priority") + config.addinivalue_line("markers", "qase_tags: mark test with tags") + return config + + +def pytest_configure(config): + config = _add_markers(config) + + config_manager = setup_config_manager(config) + + QasePytestPluginSingleton.init(reporter=QaseCoreReporter(config_manager)) + config.qase = QasePytestPluginSingleton.get_instance() + config.pluginmanager.register( + config.qase, + name="qase-tavern", + ) + + +def pytest_unconfigure(config): + qase = getattr(config, "src", None) + if qase: + del config.qase + config.pluginmanager.unregister(qase) + + +def setup_config_manager(config): + config_manager = ConfigManager() + for option in config.option.__dict__: + if option == "output" and config.option.__dict__[option] is not None: + config_manager.config.framework.playwright.set_output_dir(config.option.__dict__[option]) + + if option == "video" and config.option.__dict__[option] is not None: + config_manager.config.framework.playwright.set_video(config.option.__dict__[option]) + + if option == "tracing" and config.option.__dict__[option] is not None: + config_manager.config.framework.playwright.set_trace(config.option.__dict__[option]) + + if option.startswith("qase_"): + if option == "qase_mode" and config.option.__dict__[option] is not None: + config_manager.config.set_mode(config.option.__dict__[option]) + + if option == "qase_fallback" and config.option.__dict__[option] is not None: + config_manager.config.set_fallback(config.option.__dict__[option]) + + if option == "qase_environment" and config.option.__dict__[option] is not None: + config_manager.config.set_environment(config.option.__dict__[option]) + + if option == "qase_profilers" and config.option.__dict__[option] is not None: + config_manager.config.set_profilers(config.option.__dict__[option].split(",")) + + if option == "qase_root_suite" and config.option.__dict__[option] is not None: + config_manager.config.set_root_suite(config.option.__dict__[option]) + + if option == "qase_debug" and config.option.__dict__[option] is not None: + config_manager.config.set_debug(config.option.__dict__[option]) + + if option == "qase_execution_plan_path" and config.option.__dict__[option] is not None: + config_manager.config.execution_plan.set_path(config.option.__dict__[option]) + + if option == "qase_testops_project" and config.option.__dict__[option] is not None: + config_manager.config.testops.set_project(config.option.__dict__[option]) + + if option == "qase_testops_api_token" and config.option.__dict__[option] is not None: + config_manager.config.testops.api.set_token(config.option.__dict__[option]) + + if option == "qase_testops_api_host" and config.option.__dict__[option] is not None: + config_manager.config.testops.api.set_host(config.option.__dict__[option]) + + if option == "qase_testops_plan_id" and config.option.__dict__[option] is not None: + config_manager.config.testops.plan.set_id(int(config.option.__dict__[option])) + + if option == "qase_testops_run_id" and config.option.__dict__[option] is not None: + config_manager.config.testops.run.set_id(int(config.option.__dict__[option])) + + if option == "qase_testops_run_title" and config.option.__dict__[option] is not None: + config_manager.config.testops.run.set_title(config.option.__dict__[option]) + + if option == "qase_testops_run_description" and config.option.__dict__[option] is not None: + config_manager.config.testops.run.set_description(config.option.__dict__[option]) + + if option == "qase_testops_run_complete" and config.option.__dict__[option] is not None: + config_manager.config.testops.run.set_complete(config.option.__dict__[option]) + + if option == "qase_testops_defect" and config.option.__dict__[option] is not None: + config_manager.config.testops.set_defect(config.option.__dict__[option]) + + if option == "qase_report_driver" and config.option.__dict__[option] is not None: + config_manager.config.report.set_driver(config.option.__dict__[option]) + + if option == "qase_report_connection_local_path" and config.option.__dict__[option] is not None: + config_manager.config.report.connection.set_path(config.option.__dict__[option]) + + if option == "qase_report_connection_local_format" and config.option.__dict__[option] is not None: + config_manager.config.report.connection.set_format(config.option.__dict__[option]) + + if option == "qase_testops_batch_size" and config.option.__dict__[option] is not None: + config_manager.config.testops.batch.set_size(int(config.option.__dict__[option])) + + if option == "qase_pytest_capture_logs" and config.option.__dict__[option] is not None: + config_manager.config.framework.pytest.set_capture_logs(config.option.__dict__[option]) + + return config_manager diff --git a/qase-tavern/src/qase/tavern/context_manager.py b/qase-tavern/src/qase/tavern/context_manager.py new file mode 100644 index 00000000..e9ce8e35 --- /dev/null +++ b/qase-tavern/src/qase/tavern/context_manager.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +import functools +from contextlib import ContextDecorator as PyContextDecorator +from contextlib import _GeneratorContextManager as GeneratorContextManager + + +class ContextManager(GeneratorContextManager, PyContextDecorator): + """Pass in a generator to the initializer and the resultant object + is both a decorator closure and context manager + """ + + def __init__(self, func, args=(), kwargs=None): + if kwargs is None: + kwargs = {} + + super().__init__(func, args, kwargs) + + +def contextdecorator(func): + """Similar to contextlib.contextmanager except the decorated generator + can be used as a decorator with optional arguments. + """ + + @functools.wraps(func) + def helper(*args, **kwargs): + is_decorating = len(args) == 1 and callable(args[0]) + + if is_decorating: + new_func = args[0] + + @functools.wraps(new_func) + def new_helper(*args, **kwargs): + instance = ContextManager(func) + return instance(new_func)(*args, **kwargs) + + return new_helper + return ContextManager(func, args, kwargs) + + return helper diff --git a/qase-tavern/src/qase/tavern/options.py b/qase-tavern/src/qase/tavern/options.py new file mode 100644 index 00000000..46e9b362 --- /dev/null +++ b/qase-tavern/src/qase/tavern/options.py @@ -0,0 +1,184 @@ +class QasePytestOptions: + + @staticmethod + def addoptions(parser, group): + QasePytestOptions.add_option( + parser, + group, + "--qase-mode", + dest="qase_mode", + help="Define Qase reporter mode: `off`, `report` or `testops`" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-fallback", + dest="qase_fallback", + help="Define Qase reporter fallback mode: `off`, `report` or `testops`" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-environment", + dest="qase_environment", + help="Define environment slug from TestOps" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-debug", + dest="qase_debug", + type="bool", + help="Enable debug mode" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-execution-plan-path", + dest="qase_execution_plan_path", + help="Path to file with execution plan" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-testops-project", + dest="qase_testops_project", + help="Project code in Qase TestOps" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-testops-api-token", + dest="qase_testops_api_token", + help="API token for Qase TestOps" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-testops-api-host", + dest="qase_testops_api_host", + help="API host for Qase TestOps" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-testops-plan-id", + dest="qase_testops_plan_id", + help="Test Plan ID in Qase TestOps" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-testops-run-id", + dest="qase_testops_run_id", + help="Test Run ID in Qase TestOps" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-testops-run-title", + "qase_testops_run_title", + help="Define title for autocreated Test Run. If not set, will be used autogenerated title" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-testops-run-description", + dest="qase_testops_run_description", + help="Define description for autocreated Test Run" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-testops-run-complete", + dest="qase_testops_run_complete", + type="bool", + help="Complete run after tests execution" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-testops-batch-size", + dest="qase_testops_batch_size", + type="int", + help="Define batch size of results. Default: 200." + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-testops-defect", + dest="qase_testops_defect", + type="bool", + help="Create defect if test failed" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-report-driver", + dest="qase_report_driver", + help="Define report driver: `local`. More options coming soon." + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-root-suite", + dest="qase_root_suite", + help="Define root suite for tests" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-report-connection-local-path", + dest="qase_report_connection_local_path", + help="Define local path for report directory" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-report-connection-local-format", + dest="qase_report_connection_local_format", + help="Define local format for report directory: `json` or `jsonp`" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-profilers", + dest="qase_profilers", + help="Profilers to use for tests. Available: `network`, `db`, `sleep`" + ) + + QasePytestOptions.add_option( + parser, + group, + "--qase-pytest-capture-logs", + dest="qase_pytest_capture_logs", + type="bool", + help="Capture logs from pytest" + ) + + @staticmethod + def add_option(parser, group, option, dest, default=None, type=None, **kwargs): + # We are going to add options that were not added before through the manager + try: + group.addoption(option, dest=dest, default=default, action="store", **kwargs) + except ValueError: + pass diff --git a/qase-tavern/src/qase/tavern/plugin.py b/qase-tavern/src/qase/tavern/plugin.py new file mode 100644 index 00000000..643b0e4c --- /dev/null +++ b/qase-tavern/src/qase/tavern/plugin.py @@ -0,0 +1,164 @@ +import json +import uuid +import re + +from qase.commons.models import Runtime, Result, Relation, Attachment +from qase.commons.models.relation import SuiteData +from qase.commons.models.step import Step, StepType, StepTextData + + +class QasePytestPlugin: + + def __init__( + self, + reporter + ): + self.runtime = Runtime() + self.reporter = reporter + self.config = reporter.config + self.run_id = None + self._current_item = None + self.ignore = None + + def pytest_sessionstart(self, session): + self.run_id = self.reporter.start_run() + + def pytest_sessionfinish(self, session, exitstatus): + self.reporter.complete_worker() + self.reporter.complete_run() + + def pytest_runtest_protocol(self, item): + self.start_pytest_item(item) + + def pytest_runtest_makereport(self, item, call): + if call.when == "call": + if call.excinfo: + self.runtime.result.execution.status = "failed" + if hasattr(call.excinfo, "value"): + self.runtime.result.execution.stacktrace = '\n'.join(call.excinfo.value.args) + if hasattr(call.excinfo.value, "failures"): + self.runtime.result.message = '\n'.join(call.excinfo.value.failures) + + if hasattr(call.excinfo.value, "stage"): + failed_step = call.excinfo.value.stage['name'] + + is_failed = False + for key, step in self.runtime.steps.items(): + step.execution.complete() + if step.data.action == failed_step: + step.execution.set_status("failed") + is_failed = True + continue + + if is_failed: + step.execution.set_status("skipped") + continue + + step.execution.set_status("passed") + return + + for key, step in self.runtime.steps.items(): + step.execution.complete() + step.execution.set_status("skipped") + + return + + self.runtime.result.execution.status = "passed" + for key, step in self.runtime.steps.items(): + step.execution.complete() + step.execution.set_status("passed") + + def pytest_runtest_logfinish(self): + self.runtime.result.execution.complete() + self.runtime.result.steps = [step for key, step in self.runtime.steps.items()] + self.reporter.add_result(self.runtime.result) + self.runtime.clear() + + def start_pytest_item(self, item): + qase_id, title = self.extract_qase_id(self._get_title(item)) + self.runtime.result = Result( + title=title, + signature='', + ) + if qase_id: + self.runtime.result.testops_id = qase_id + + self._set_relations(item) + self._set_steps(item) + self._get_signature(item) + + # self._set_testops_id(item) + + @staticmethod + def _get_title(item): + if hasattr(item, "spec"): + return item.spec["test_name"] + + def _set_relations(self, item): + if hasattr(item, "fspath") and hasattr(item.fspath, "basename"): + self.runtime.result.relations = self.__prepare_relations([item.fspath.basename]) + + @staticmethod + def __prepare_relations(suites: []): + relation = Relation() + + for suite in suites: + relation.add_suite(SuiteData(title=suite)) + + return relation + + def _set_steps(self, item): + if hasattr(item, "spec"): + for stage in item.spec['stages']: + self.runtime.add_step(self.__prepare_step(stage)) + + @staticmethod + def __prepare_step(stage) -> Step: + data = StepTextData(stage['name']) + step = Step(StepType.TEXT, str(uuid.uuid4()), data) + + step.attachments.append( + Attachment(file_name='request.json', content=json.dumps(stage['request']), mime_type='application/json', + temporary=True)) + step.attachments.append( + Attachment(file_name='response.json', content=json.dumps(stage['response']), mime_type='application/json', + temporary=True)) + + return step + + def _get_signature(self, item): + self.runtime.result.signature = item.nodeid.replace("/", "::") + if self.runtime.result.testops_id: + self.runtime.result.signature += f"::{self.runtime.result.testops_id}" + for key, val in self.runtime.result.params.items(): + self.runtime.result.signature += f"::{{{key}:{val}}}" + + @staticmethod + def extract_qase_id(text): + match = re.search(r'qaseid=\s*(\d+)', text, re.IGNORECASE) + if match: + qase_id = int(match.group(1)) + remaining_text = re.sub(r'qaseid=\s*\d+', '', text, flags=re.IGNORECASE).strip() + return qase_id, remaining_text + else: + return None, text + + +class QasePytestPluginSingleton: + _instance = None + + @staticmethod + def init(**kwargs): + if QasePytestPluginSingleton._instance is None: + QasePytestPluginSingleton._instance = QasePytestPlugin(**kwargs) + + @staticmethod + def get_instance() -> QasePytestPlugin: + """ Static access method""" + if QasePytestPluginSingleton._instance is None: + raise Exception("Init plugin first") + return QasePytestPluginSingleton._instance + + def __init__(self): + """ Virtually private constructor""" + raise Exception("Use get_instance()") diff --git a/qase-tavern/tests/test_extractor.py b/qase-tavern/tests/test_extractor.py new file mode 100644 index 00000000..dea36a9f --- /dev/null +++ b/qase-tavern/tests/test_extractor.py @@ -0,0 +1,50 @@ +from qase.tavern.plugin import QasePytestPlugin + + +def test_extract_qase_id_with_valid_id(): + text = "QaseID=123 Some text" + qase_id, remaining_text = QasePytestPlugin.extract_qase_id(text) + assert qase_id == 123 + assert remaining_text == "Some text" + + +def test_extract_qase_id_with_valid_id_lowercase(): + text = "qaseid=456 More text" + qase_id, remaining_text = QasePytestPlugin.extract_qase_id(text) + assert qase_id == 456 + assert remaining_text == "More text" + + +def test_extract_qase_id_with_no_qaseid(): + text = "No QaseID here" + qase_id, remaining_text = QasePytestPlugin.extract_qase_id(text) + assert qase_id is None + assert remaining_text == text + + +def test_extract_qase_id_with_spaces(): + text = " QaseID= 321 Text with spaces " + qase_id, remaining_text = QasePytestPlugin.extract_qase_id(text) + assert qase_id == 321 + assert remaining_text == "Text with spaces" + + +def test_extract_qase_id_with_only_qaseid(): + text = "QaseID=999" + qase_id, remaining_text = QasePytestPlugin.extract_qase_id(text) + assert qase_id == 999 + assert remaining_text == "" + + +def test_extract_qase_id_with_special_characters(): + text = "Some text QaseID=123!@#$" + qase_id, remaining_text = QasePytestPlugin.extract_qase_id(text) + assert qase_id == 123 + assert remaining_text == "Some text !@#$" + + +def test_extract_qase_id_with_empty_string(): + text = "" + qase_id, remaining_text = QasePytestPlugin.extract_qase_id(text) + assert qase_id is None + assert remaining_text == ""