├── .coveragerc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── codeql-analysis.yml │ ├── pytest.yml │ └── python-publish.yml ├── .gitignore ├── .idea └── .gitignore ├── .sourcelevel.yml ├── Dockerfile.template ├── LICENSE ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── SECURITY.md ├── coverage.sh ├── docs ├── CHANGELOG.md ├── CNAME ├── advanced_guide.md ├── building.md ├── extensions.md ├── index.md ├── lessons.md └── quickstart.md ├── examples ├── dummyapp-posting-forms.yaml ├── github_api_smoketest.yaml ├── github_api_test.yaml ├── miniapp-benchmark-overhead-generators.yaml ├── miniapp-benchmark-overhead.yaml ├── miniapp-benchmark.yaml ├── miniapp-binding.yaml ├── miniapp-extract-validate.yaml ├── miniapp-schema.json ├── miniapp-test.json ├── miniapp-test.yaml └── schema_test.yaml ├── render.sh ├── requirements.txt ├── resttest3 ├── __init__.py ├── binding.py ├── constants.py ├── contenthandling.py ├── exception.py ├── ext │ ├── __init__.py │ ├── extractor_jmespath.py │ └── validator_jsonschema.py ├── generators.py ├── reports │ ├── __init__.py │ ├── template │ │ └── report_template.html │ └── templite.py ├── runner.py ├── testcase.py ├── utils.py └── validators.py ├── setup.cfg ├── setup.py └── tests ├── content-test-include.yaml ├── content-test.yaml ├── extension_use_test.yaml ├── fun_test.yaml ├── jmespath-test-all ├── jmespath-test.yaml ├── lcov.info ├── miniapp-schema.json ├── person_body_notemplate.json ├── person_body_template.json ├── sample.yaml ├── test_binding.py ├── test_contenthandling.py ├── test_generators.py ├── test_jmespath_extractor.py ├── test_parse.py ├── test_parsing.py ├── test_schema_validation.py ├── test_templite.py ├── test_testcase.py ├── test_tests.py ├── test_utils.py ├── test_validators.py └── unicode-test.yaml /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | omit = resttest3/runner.py 5 | 6 | command_line = --source py3resttest -m pytest tests/test_*.py 7 | [paths] 8 | source = 9 | resttest3/ 10 | 11 | [report] 12 | # Regexes for lines to exclude from consideration 13 | exclude_lines = 14 | # Have to re-enable the standard pragma 15 | pragma: no cover 16 | 17 | # Don't complain about missing debug-only code: 18 | def __repr__ 19 | if self\.debug 20 | 21 | # Don't complain if tests don't hit defensive assertion code: 22 | raise AssertionError 23 | raise NotImplementedError 24 | 25 | # Don't complain if non-runnable code isn't run: 26 | if 0: 27 | if __name__ == .__main__.: 28 | 29 | ignore_errors = True 30 | 31 | [html] 32 | directory = coverage_html_report -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | pull_request: 10 | # The branches below must be a subset of the branches above 11 | branches: [master] 12 | 13 | jobs: 14 | analyze: 15 | name: Analyze 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | # Override automatic language detection by changing the below list 22 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 23 | language: ['python'] 24 | # Learn more... 25 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 26 | 27 | steps: 28 | - name: Install xmllint 29 | run: sudo apt-get install -y python-pycurl libssl-dev libcurl4-openssl-dev python-dev 30 | - name: Checkout repository 31 | uses: actions/checkout@v2 32 | 33 | # Initializes the CodeQL tools for scanning. 34 | - name: Initialize CodeQL 35 | uses: github/codeql-action/init@v1 36 | with: 37 | languages: ${{ matrix.language }} 38 | # If you wish to specify custom queries, you can do so here or in a config file. 39 | # By default, queries listed here will override any specified in a config file. 40 | # Prefix the list here with "+" to use these queries and those in the config file. 41 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 42 | 43 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 44 | # If this step fails, then you should remove it and run the build manually (see below) 45 | - name: Autobuild 46 | uses: github/codeql-action/autobuild@v1 47 | 48 | # ℹ️ Command-line programs to run using the OS shell. 49 | # 📚 https://git.io/JvXDl 50 | 51 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 52 | # and modify them (or add more) to build your code if your project 53 | # uses a compiled language 54 | 55 | #- run: | 56 | # make bootstrap 57 | # make release 58 | 59 | - name: Perform CodeQL Analysis 60 | uses: github/codeql-action/analyze@v1 61 | 62 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: "Testing and Code Coverage" 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [3.5, 3.6, 3.7, 3.8, 3.9] 15 | 16 | steps: 17 | - name: Install xmllint 18 | run: sudo apt-get install -y python-pycurl libssl-dev libcurl4-openssl-dev python-dev 19 | - uses: actions/checkout@v2 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install flake8 pytest coverage coveralls 28 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 29 | - name: Lint with flake8 30 | run: | 31 | # stop the build if there are Python syntax errors or undefined names 32 | flake8 resttest3 --count --select=E9,F63,F7,F82 --show-source --statistics 33 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 34 | flake8 . --count --exit-zero --max-complexity=30 --max-line-length=127 --statistics 35 | - name: Test with pytest 36 | run: | 37 | coverage run --source resttest3 -m pytest tests/test_*.py 38 | - name: Upload Coverage 39 | run: coveralls 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | COVERALLS_FLAG_NAME: ${{ matrix.python-version }} 43 | COVERALLS_PARALLEL: true 44 | coveralls: 45 | name: Finish Coveralls 46 | needs: build 47 | runs-on: ubuntu-latest 48 | container: python:3-slim 49 | steps: 50 | - name: Finished 51 | run: | 52 | pip3 install --upgrade coveralls 53 | coveralls --finish 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: "Upload Python Package" 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # docker file to test all pytho3 version 9 | docker-* 10 | Dockerfile* 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | bin/ 16 | .idea/ 17 | 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | 44 | # Translations 45 | *.mo 46 | 47 | # Mr Developer 48 | .mr.developer.cfg 49 | .project 50 | .pydevproject 51 | 52 | # Rope 53 | .ropeproject 54 | 55 | # Django stuff: 56 | *.log 57 | *.pot 58 | 59 | # Sphinx documentation 60 | docs/_build/ 61 | 62 | venv/include/ 63 | venv/lib64 64 | venv/pyvenv.cfg 65 | venv/share/ 66 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.sourcelevel.yml: -------------------------------------------------------------------------------- 1 | styleguide: sourcelevel/linters 2 | engines: 3 | pep8: 4 | enabled: true 5 | fixme: 6 | enabled: true 7 | radon: 8 | enabled: false 9 | bandit: 10 | enabled: true 11 | remark-lint: 12 | enabled: true 13 | duplication: 14 | enabled: true 15 | 16 | exclude_paths: 17 | - tests 18 | -------------------------------------------------------------------------------- /Dockerfile.template: -------------------------------------------------------------------------------- 1 | FROM python:%%PY_VERSION%%-alpine 2 | # Install packages 3 | RUN apk add --no-cache libcurl 4 | 5 | # Needed for pycurl 6 | ENV PYCURL_SSL_LIBRARY=openssl 7 | RUN apk add --no-cache --virtual .build-dependencies build-base curl-dev \ 8 | && pip install pycurl \ 9 | && apk del .build-dependencies 10 | RUN pip install --upgrade pip 11 | RUN pip install flake8 pytest coverage 12 | COPY requirements.txt /tmp/requirements.txt 13 | 14 | #RUN pip install --no-cache-dir -r /tmp/requirements.txt 15 | 16 | COPY . /resttest3 17 | WORKDIR /resttest3 18 | 19 | 20 | RUN python setup.py bdist_wheel 21 | RUN pip install -U dist/* 22 | RUN flake8 resttest3 --count --select=E9,F63,F7,F82 --show-source --statistics 23 | RUN flake8 resttest3 --count --exit-zero --max-complexity=30 --max-line-length=127 --statistics 24 | # RUN python -m pytest tests 25 | RUN coverage run --source resttest3 -m pytest tests/test_*.py 26 | RUN coverage report 27 | RUN resttest3 --url https://www.courtlistener.com --test tests/fun_test.yaml 28 | 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 Sam Van Oort 190 | Copyright 2020 Abhilash Joseph C 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019-2020 Sam Van Oort 190 | Copyright 2020 Abhilash Joseph C 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.yaml 2 | include resttest3/reports/template/*.html 3 | include LICENSE 4 | include requirements.txt 5 | global-exclude *.pyc 6 | global-exclude test_*.py 7 | include setup.py 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | RestTest3 2 | ========= 3 | 4 | ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/crazi-coder/resttest3/Python%20package) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/resttest3) 6 | ![PyPI - Wheel](https://img.shields.io/pypi/wheel/resttest3) 7 | ![PyPI](https://img.shields.io/pypi/v/resttest3) 8 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/resttest3) 9 | [![Coverage Status](https://coveralls.io/repos/github/crazi-coder/resttest3/badge.svg)](https://coveralls.io/github/crazi-coder/resttest3/) 10 | [![SourceLevel](https://app.sourcelevel.io/github/crazi-coder/resttest3.svg)](https://app.sourcelevel.io/github/crazi-coder/resttest3/) 11 | [![CodeFactor](https://www.codefactor.io/repository/github/crazi-coder/resttest3/badge)](https://www.codefactor.io/repository/github/crazi-coder/resttest3/) 12 | 13 | 14 | 15 | Please read [documentation](https://crazicoder.com/). We are doing active development for python3 and removed python2 support 16 | 17 | This project is a fork of [pyresttest](https://github.com/svanoort/pyresttest) 18 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | 4 | -------------------------------------------------------------------------------- /coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # pip install coverage 3 | coverage run --source resttest3 -m pytest tests/test_*.py 4 | coverage html 5 | coverage report 6 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 1.0.2 4 | Released 2020-10-31 5 | 6 | - Bug fixes & code optimization 7 | - Resolved URL binding 8 | - Resolved generator binding 9 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | softlinkweb.com 2 | -------------------------------------------------------------------------------- /docs/building.md: -------------------------------------------------------------------------------- 1 | # How to build 2 | 3 | There are two options for how to work with code 4 | 5 | ## Key scripts: 6 | - Unit + functional tests: run_tests.sh 7 | - Coverage Test: coverage.sh (result in htmlconv/index.html) 8 | 9 | ## Conventions 10 | - All non-functional unit tests (runnable without a server) start with 'test_' 11 | 12 | ## Environments 13 | 1. Local (native) python (Linux or Mac) 14 | - You'll need to pip install the following packages: 15 | + pycurl 16 | + pyyaml 17 | + mock 18 | + django==1.6.5 (for functional testing server) 19 | + django-tastypie==0.12.1 (for functional testing server) 20 | + discover (if on a python 2.6 system) 21 | - Avoid a virtualenv unless you *very carefully* set it up for pycurl use (it may not find libcurl) 22 | 23 | 2. Docker: see the docker folder, we have preconfigured images for a *stable, clean verified* dev/test environment 24 | 1. (sudo) 'build.sh' will build docker images and verify the environment 25 | - pyresttest-build-centos6 acts as the python 2.6 / RPM-based distro environment 26 | - pyresttest-build-ubuntu-14 acts as the python 2.7 and apt-based distro environment 27 | - pyresttest-build-python3 acts as a clean testbed for work on python3 compatibility 28 | 2. After building you can use them as dev environments in volumes: 29 | - (sudo) docker run -v `pwd`:/tmp/pyresttest -it --rm pyresttest-build-centos6 /bin/bash 30 | - (sudo) docker run -v `pwd`:/tmp/pyresttest -it --rm pyresttest-build-ubuntu-14 /bin/bash 31 | 3. OR just run the images and clone the repo from within them: 32 | 1. (sudo) docker run -it --rm pyresttest-build-ubuntu-14 /bin/bash 33 | 2. Inside container: cd /tmp && git clone https://github.com/svanoort/pyresttest.git 34 | 3. Do your coding and commit/push, etc 35 | 36 | ## Releasing 37 | Release tooling requires its own special goodies. The docker images have it all baked in, for convenience's sake. 38 | 39 | 1. Tar (for packaging distributions) 40 | 2. For CentOS 6, rpm-build -------------------------------------------------------------------------------- /docs/extensions.md: -------------------------------------------------------------------------------- 1 | # Extensions 2 | PyRestTest provides hooks for extending built-in components with your own Python code. 3 | 4 | # What Can An Extension Do? 5 | 6 | - In general: use more advanced dependencies while not making them required for installation 7 | - Generators: generate data for templating URL/request body/tests/etc 8 | - Extractors: get data from HTTP response body/headers and use it in future tests 9 | - Validators: write custom tests using headers & response bodies 10 | - Test Functions: for the ExtractTest validator, validate a single condition 11 | - Comparator function:s for the ComparatorValidator, compare expected and actual values 12 | 13 | Extensions are specified for loadin at runtime with the --import_extensions argument: 14 | ```shell 15 | python pyresttest/resttest.py https://api.github.com extension_use_test.yaml --import_extensions 'sample_extension' 16 | ``` 17 | 18 | Extensions are python module names, separated by semicolons: 19 | ```shell 20 | python pyresttest/resttest.py https://api.github.com fancypants_test.yaml --import_extensions 'fancy_validator;form_data_generator' 21 | ``` 22 | 23 | ## What does an extension look like? 24 | ```python 25 | import resttest3.validators as validators 26 | 27 | # Define a simple generator that doubles with each value 28 | def parse_generator_doubling(config): 29 | 30 | start = 1 31 | if 'start' in config: 32 | start = int(config['start']) 33 | 34 | # We cannot simply use start as the variable, because of scoping limitations 35 | def generator(): 36 | val = start 37 | while True: 38 | yield val 39 | val = val*2 40 | return generator() 41 | 42 | GENERATORS = {'doubling': parse_generator_doubling} 43 | ``` 44 | 45 | If this is imported when executing the test, you can now use this generator in tests. 46 | 47 | # What Does An Extension Need To Work? 48 | 49 | 1. Function(s) to run 50 | 2. Registry Entries: these are special ALLCAPS variables binding extension names 51 | 52 | ## Functions (different for each type) 53 | 54 | ### Test Functions 55 | These are the simplest, one-argument functions that return True or False 56 | ```python 57 | def test(x): 58 | return x in [1, 2, 3] 59 | ``` 60 | 61 | ### Comparator Functions 62 | Compare two values and return True/False 63 | ```python 64 | def compare(a, b): 65 | return a > b 66 | ``` 67 | 68 | ### Generators 69 | These are [standard python generators](https://wiki.python.org/moin/Generators). 70 | There is ONE twist, they should be infinite (for benchmark use). 71 | 72 | The function takes one argument, config, which is a string or dictionary of arguments for creating the generator. 73 | 74 | ```python 75 | def factory_choice_generator(values): 76 | """ Return a generator that picks values from a list randomly """ 77 | 78 | def choice_generator(): 79 | my_list = list(values) 80 | rand = random.Random() 81 | while(True): 82 | yield random.choice(my_list) 83 | return choice_generator 84 | 85 | def parse_choice_generator(config): 86 | """ Parse choice generator """ 87 | vals = config['values'] 88 | if not vals: 89 | raise ValueError('Values for choice sequence must exist') 90 | if not isinstance(vals,list): 91 | raise ValueError('Values must be a list of entries') 92 | return factory_choice_generator(vals)() 93 | ``` 94 | 95 | **The function for the registry would be ```parse_choice_generator```** 96 | 97 | ### Extractors (Now things get a bit more complex) 98 | These need to be objects, and should extend pyresttest.AbstractExtractor 99 | The 'parse' function below will be registered in the registry. 100 | 101 | Example: 102 | ```python 103 | 104 | from resttest3.validators import AbstractExtractor 105 | class HeaderExtractor(AbstractExtractor): 106 | """ Extractor that pulls out a named header """ 107 | extractor_type = 'header' # Printable name for the type 108 | is_header_extractor = True # Use headers in extraction 109 | is_body_extractor = False # Uses body in extraction 110 | 111 | def extract_internal(self, query=None, args=None, body=None, headers=None): 112 | """ The real logic, extract a value, using a templated query string and args 113 | The query is an attribute stored in the parent, and templating is used 114 | """ 115 | try: 116 | return headers[query] 117 | except Exception: 118 | return None 119 | 120 | @classmethod 121 | def parse(cls, config, extractor_base=None): 122 | base = HeaderExtractor() 123 | # Base parser automatically handles templating logic 124 | # And reads the query 125 | return cls.configure_base(config, base) 126 | ``` 127 | 128 | ### Validators 129 | Validators should extend AbstractValidator. 130 | The parse function below will be registered in the registry VALIDATORS. 131 | 132 | ```python 133 | from resttest3.validators import AbstractValidator, _get_extractor, Failure 134 | from resttest3.utils import Parser 135 | from resttest3.constants import VALIDATOR_TESTS 136 | class ExtractTestValidator(AbstractValidator): 137 | """ Does extract and test from request body """ 138 | name = 'ExtractTestValidator' 139 | extractor = None 140 | test_fn = None 141 | test_name = None 142 | config = None 143 | 144 | @staticmethod 145 | def parse(config): 146 | """ Config is a dict """ 147 | output = ExtractTestValidator() 148 | config = Parser.flatten_lowercase_keys_dict(config) 149 | output.config = config 150 | extractor = _get_extractor(config) 151 | output.extractor = extractor 152 | 153 | test_name = config['test'] 154 | output.test_name = test_name 155 | test_fn = VALIDATOR_TESTS[test_name] 156 | output.test_fn = test_fn 157 | return output 158 | 159 | def validate(self, body=None, headers=None, context=None): 160 | try: 161 | extracted = self.extractor.extract(body=body, headers=headers, context=context) 162 | except Exception as e: 163 | return Failure(message="Exception thrown while running extraction from body", details=e, validator=self) 164 | 165 | tested = self.test_fn(extracted) 166 | if tested: 167 | return True 168 | else: 169 | failure = Failure(details=self.config, validator=self) 170 | failure.message = "Extract and test validator failed on test: {0}({1})".format(self.test_name, extracted) 171 | return failure 172 | ``` 173 | 174 | 175 | # Registry 176 | The extension loader will look for special registry variables in the module and attempt to load them. 177 | 178 | Registries are dictionarys of {registered_name: function}. 179 | Registry names are ALWAYS case-insensitive, since they are keywords for the YAML syntax. 180 | 181 | These are: 182 | - VALIDATOR_TESTS - function is just thetest function 183 | - COMPARATORS - function is just the comparison function 184 | - GENERATORS - function is a parse function to get a generator 185 | - EXTRACTORS - function is a parse function returning an AbstractExtractor implementation 186 | - VALIDATORS - function is a parse function returning an AbstractValidator implementation 187 | 188 | Each one maps to the same registry in pyresttest.validators. 189 | 190 | # Use Case Suggestions 191 | - **Need to generate complex, formatted data?** 192 | - Write a generator extension, or multiple generators may be used together to create a complex result 193 | - **Want to test whether API results fit a business rule?** 194 | - Write a validator extension, your logic can be as complex as you like 195 | - **Want to apply a business rule to the output and use the result?** 196 | - Write an extractor extension 197 | - You can do testing with the result via the validators ExtractTest and ComparatorValidator 198 | - By declaring the extractor with the test, it can be used in future tests 199 | - **Want to test with complex matching logic between two parts of a response?** 200 | - Write a Comparator to do the comparison, use extractors for pulling out the components 201 | - **Want to run external logic after a test?** 202 | - **Want to interact with an external system (a DB, for example) before tests?** 203 | + Write a custom generator function returning data 204 | - **Want to confirm results were written to a database?** 205 | + Write a custom validator or extractor that speaks to the database 206 | + An extractor can return a value from the DB for comparison 207 | + A validator can do the database fetch and return a failure if it was not right 208 | 209 | 210 | -------------------------------------------------------------------------------- /docs/lessons.md: -------------------------------------------------------------------------------- 1 | # Lessons 2 | 3 | A walkthrough of good and bad decisions in building PyRestTest and their consequences. Perhaps informative for others, and at the least interesting because of how it reflects growing knowledge of the python ecosystem. 4 | 5 | ## Mistakes: 6 | * Parsing configuration: using flatten_dictionaries to handle invalid duplicate values in configs causes more grief than it saves 7 | - Hides user errors where they can't even be logged in some cases 8 | - Very hard to change default behavior because rather than iterating through data, it is simply *lost* 9 | - Better done by using per-element calls in a streaming fashion (call a handler for each) 10 | * Execution pipelines written in a long procedural style (run_test, run_testset, run_benchmark) are easy to write, hard to extend, test, or debug 11 | - Knew this going in, but didn't expect how much some methods (run_test, for example) would grow over time 12 | - Makes it very hard to do unit testing 13 | * Parsing with the python equivalent of a switch statement is *awful* to work with, a registry/factory method system is easier 14 | * Edge cases in HTTP standards (example: duplicate headers) that could have been avoided by better research 15 | * Not making enough use of CI, Docker, or virtualenvs earlier: issues with python 2.6 vs. 2.7 vs. 3 compatibility that were hard to see and could have been handled earlier and less painfully 16 | * Libraries: I think it would have been smart to fork into two versions: a lightweight smoketesting core (basic execution only) and a heavier, library-coupled version for benchmarking and more detailed testing. 17 | - This would have made it easier to keep the lean base for healthchecks/smoketests but also grow more featureful 18 | 19 | ## Smart Decisions 20 | * Use of the integrated Django mini-app for functional testing made it *really easy* to construct full end-to-end tests and found countless subtle mistakes that unit tests won't 21 | * Refactoring to use registry/factory pattern for validators, generators, comparators, extractors 22 | - Made it super easy to add header validation 23 | - Adding new comparators is a cinch 24 | - Easy to test, easy to fork/extend, just plain nice 25 | * PyCurl: I know people prefer requests, but pycurl is *really* powerful, performant, and runs everywhere. It's a good match for complex cases too. 26 | * YAML: the gift that keeps on giving vs. JSON or XML. A pleasure to work with, especially via pyyaml 27 | * Use of the logging facilities: makes life so much easier 28 | * Switching to use contexts instead of environment variables: cleaner, easier to debug 29 | * Incorporating the jsonpath_mini extension from another contributor and keeping it working: a really good call, it's proving endlessly useful (if limited) 30 | * Registerable extension system: rather proud of this, to be honest. It might grow to support more optional modules though. 31 | * Run as a simple command line tool: YES. Makes it so much easier to use. 32 | 33 | # TBD 34 | * Focus on default string.Template for templating over Jinja or a more complex option 35 | - Pro: portable, simple, fairly fast, easy to test / Con: limited features 36 | * Not directly integrating with unittest or other test framework (going its own way) 37 | - Pro: more powerful, more flexible, superset of features / Con: more code, less consistent logging 38 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | # Getting Started: Quickstart Requirements 2 | Now, let's get started! 3 | 4 | **Most quickstarts show a case where *everything works perfectly.*** 5 | 6 | **That is *not* what we're going to do today.** 7 | 8 | **We're going to break things horribly and enjoy it!** 9 | 10 | **This is what testing is for.** 11 | 12 | ## System Requirements: 13 | - Linux or Mac OS X with python 2.6+ or 2.7 installed and pycurl 14 | - Do not use a virtualenv (or have it custom configured to find libcurl) 15 | 16 | # Quickstart Part 0: Setting Up a Sample REST Service 17 | In order to get started with PyRestTest, we will need a REST service with an API to work with. 18 | 19 | Fortunately, there is a small RESTful service included with the project. 20 | 21 | Let's **grab a copy of the code** to start: 22 | ```shell 23 | git clone https://github.com/svanoort/pyresttest.git 24 | ``` 25 | 26 | Then we'll **install the necessary dependencies** to run it (Django and Django Tastypie): 27 | ```shell 28 | sudo pip install 'django >=1.6, <1.7' django-tastypie==0.12.1 29 | ``` 30 | Now **we start a test server in one terminal** (on default port 8000) with some preloaded data, and we will test in a second terminal: 31 | ```shell 32 | cd pyresttest/pyresttest/testapp 33 | python manage.py testserver test_data.json 34 | ``` 35 | 36 | **If you get an error like this**, it's because you're using Python 2.6, and are trying to run a Django version not compatible with that: 37 | ``` 38 | Traceback (most recent call last): 39 | File "/usr/bin/django-admin.py", line 2, in 40 | from django.core import management 41 | File "/usr/lib64/python2.6/site-packages/django/core/management/__init__.py", line 68 42 | commands = {name: 'django.core' for name in find_commands(__path__[0])} 43 | ``` 44 | 45 | This is easy enough to fix though by installing a compatible Django version: 46 | ```shell 47 | sudo pip uninstall -y django django-tastypie 48 | sudo pip install 'django >=1.6, <1.7' django-tastypie==0.12.1 49 | ``` 50 | **Before going deeper, let's make sure that server works okay... in our second terminal, we run this:** 51 | ```shell 52 | curl -s http://localhost:8000/api/person/2/ | python -m json.tool 53 | ``` 54 | 55 | **If all is good, we ought to see a result like this:** 56 | ```json 57 | { 58 | "first_name": "Leeroy", 59 | "id": 2, 60 | "last_name": "Jenkins", 61 | "login": "jenkins", 62 | "resource_uri": "/api/person/2/" 63 | } 64 | ``` 65 | 66 | **Now, we've got a small but working REST API for PyRestTest to test on!** 67 | 68 | # Quickstart Part 1: Our First (Smoke) Test 69 | In our second terminal, we're going to create a basic REST smoketest, which can be used to test the server came up cleanly and works. 70 | 71 | Pop up ye olde text editor of choice and save this to a file named 'test.yaml': 72 | 73 | ```yaml 74 | --- 75 | - config: 76 | - testset: "Quickstart app tests" 77 | 78 | - test: 79 | - name: "Basic smoketest" 80 | - url: "/api/people/" 81 | ``` 82 | 83 | And when we run it: 84 | ```shell 85 | resttest.py http://localhost:8000 test.yaml 86 | ``` 87 | 88 | **OOPS!** As the more observant people will notice, **we got the API URL wrong**, and the test failed, showing the unexpected 404, and reporting the test name. At the end we see the summary, by test group ("Default" is exactly like what it sounds like). 89 | 90 | **Let's fix that, add a test group name, and re-run it!** 91 | ```yaml 92 | --- 93 | - config: 94 | - testset: "Quickstart app tests" 95 | 96 | - test: 97 | - group: "Quickstart" 98 | - name: "Basic smoketest" 99 | - url: "/api/person/" 100 | ``` 101 | 102 | Ahh, *much* better! But, that's very basic, surely we can do *better?* 103 | 104 | 105 | # Quickstart Part 2: Functional Testing - Create/Update/Delete 106 | Let's build this out into a full test scenario, creating and deleting a user: 107 | 108 | We're going to add a create for a new user, that scoundrel Gaius Baltar: 109 | ```yaml 110 | --- 111 | - config: 112 | - testset: "Quickstart app tests" 113 | 114 | - test: 115 | - group: "Quickstart" 116 | - name: "Basic smoketest" 117 | - url: "/api/person/" 118 | 119 | - test: 120 | - group: "Quickstart" 121 | - name: "Create a person" 122 | - url: "/api/person/10/" 123 | - method: "PUT" 124 | - body: '{"first_name": "Gaius","id": 10,"last_name": "Baltar","login": "baltarg"}' 125 | ``` 126 | ... and when we run it, it fails (500 error). That sneaky lowdown tried to sneak in without a Content-Type so the server knows what he is. 127 | 128 | **Let's fix it...** 129 | 130 | ```yaml 131 | - test: 132 | - group: "Quickstart" 133 | - name: "Create a person" 134 | - url: "/api/person/10/" 135 | - method: "PUT" 136 | - body: '{"first_name": "Gaius","id": 10,"last_name": "Baltar","login": "baltarg"}' 137 | - headers: {'Content-Type': 'application/json'} 138 | ``` 139 | 140 | ... and now both tests will pass. 141 | Then let's add a test the person is really there after: 142 | 143 | ```yaml 144 | --- 145 | - config: 146 | - testset: "Quickstart app tests" 147 | 148 | - test: 149 | - group: "Quickstart" 150 | - name: "Basic smoketest" 151 | - url: "/api/person/" 152 | 153 | - test: 154 | - group: "Quickstart" 155 | - name: "Create a person" 156 | - url: "/api/person/10/" 157 | - method: "PUT" 158 | - body: '{"first_name": "Gaius","id": 10,"last_name": "Baltar","login": "baltarg"}' 159 | - headers: {'Content-Type': 'application/json'} 160 | 161 | - test: 162 | - group: "Quickstart" 163 | - name: "Make sure Mr Baltar was added" 164 | - url: "/api/person/10/" 165 | ``` 166 | 167 | **Except there is a problem with this... the third test will pass if Baltar already existed in the database. Let's test he wasn't there beforehand...** 168 | 169 | ```yaml 170 | --- 171 | - config: 172 | - testset: "Quickstart app tests" 173 | 174 | - test: 175 | - group: "Quickstart" 176 | - name: "Make sure Mr Baltar ISN'T there to begin with" 177 | - url: "/api/person/10/" 178 | - expected_status: [404] 179 | 180 | - test: 181 | - group: "Quickstart" 182 | - name: "Basic smoketest" 183 | - url: "/api/person/" 184 | 185 | - test: 186 | - group: "Quickstart" 187 | - name: "Create a person" 188 | - url: "/api/person/10/" 189 | - method: "PUT" 190 | - body: '{"first_name": "Gaius","id": 10,"last_name": "Baltar","login": "baltarg"}' 191 | - headers: {'Content-Type': 'application/json'} 192 | 193 | - test: 194 | - group: "Quickstart" 195 | - name: "Make sure Mr Baltar is there after we added him" 196 | - url: "/api/person/10/" 197 | ``` 198 | 199 | **Much better, now the first test fails... so, let's add a delete for that user at the end of the test, and check he's really gone.** 200 | 201 | ```yaml 202 | --- 203 | - config: 204 | - testset: "Quickstart app tests" 205 | 206 | - test: 207 | - group: "Quickstart" 208 | - name: "Make sure Mr Baltar ISN'T there to begin with" 209 | - url: "/api/person/10/" 210 | - expected_status: [404] 211 | 212 | - test: 213 | - group: "Quickstart" 214 | - name: "Basic smoketest" 215 | - url: "/api/person/" 216 | 217 | - test: 218 | - group: "Quickstart" 219 | - name: "Create a person" 220 | - url: "/api/person/10/" 221 | - method: "PUT" 222 | - body: '{"first_name": "Gaius","id": 10,"last_name": "Baltar","login": "baltarg"}' 223 | - headers: {'Content-Type': 'application/json'} 224 | 225 | - test: 226 | - group: "Quickstart" 227 | - name: "Make sure Mr Baltar is there after we added him" 228 | - url: "/api/person/10/" 229 | 230 | - test: 231 | - group: "Quickstart" 232 | - name: "Get rid of Gaius Baltar!" 233 | - url: "/api/person/10/" 234 | - method: 'DELETE' 235 | 236 | - test: 237 | - group: "Quickstart" 238 | - name: "Make sure Mr Baltar ISN'T there after we deleted him" 239 | - url: "/api/person/10/" 240 | - expected_status: [404] 241 | ``` 242 | 243 | **And now we have a full lifecycle test of creating, fetching, and deleting a user via the API.** 244 | 245 | Basic authentication is supported: 246 | 247 | ```yaml 248 | --- 249 | - config: 250 | - testset: "Quickstart authentication test" 251 | 252 | - test: 253 | - name: "Authentication using basic auth" 254 | - url: "/api/person/" 255 | - auth_username: "foobar" 256 | - auth_password: "secret" 257 | - expected_status: [200] 258 | ``` 259 | 260 | **This is just a starting point,** see the [advanced guide](advanced_guide.md) for the advanced features (templating, generators, content extraction, complex validation). -------------------------------------------------------------------------------- /examples/dummyapp-posting-forms.yaml: -------------------------------------------------------------------------------- 1 | # Demonstrate POSTing form data, since most examples are JSON 2 | # Per issue: https://github.com/svanoort/pyresttest/issues/115 3 | 4 | # PyRestTest takes a very low-level approach to support 5 | # submitting request bodies with everything from JSON to images. 6 | # From its point of view, it's just binary data with a header. 7 | 8 | # This means that for form data, we will have to manually concatenate fields, 9 | # add separators, and do url encoding as appropriate 10 | # Per: https://en.wikipedia.org/wiki/POST_(HTTP)#Use_for_submitting_web_forms 11 | # Eventually this will get a helper method (not yet though) 12 | 13 | # The test below will post these form data elements: 14 | # data = {'Name': 'Gareth Wylie', 15 | # 'Age': 24, 16 | # 'Formula': 'a + b == 13%!' 17 | # } 18 | 19 | --- 20 | - config: 21 | - testset: "Submitting form data" 22 | - test: 23 | - name: 'POST some form data!' 24 | - method: 'POST' 25 | - url: '/form-api' 26 | - body: 'Name=Gareth+Wylie&Age=24&Formula=a+%2B+b+%3D%3D+13%25%21' 27 | - headers: {'Content-Type': 'application/x-www-form-urlencoded'} -------------------------------------------------------------------------------- /examples/github_api_smoketest.yaml: -------------------------------------------------------------------------------- 1 | # Simple tests to verify things work against a live REST service that returns JSON.. Github is a natural one 2 | --- 3 | - config: 4 | - testset: "Simple github.com API Test" 5 | - test: 6 | - name: "Basic smoketest of github API" 7 | - headers: {accept: 'application/json'} 8 | - url: "/users/svanoort" 9 | - validators: # operator is applied as: 10 | - compare: {header: "content-type", comparator: contains, expected: 'application/json'} 11 | - compare: {jsonpath_mini: "login", comparator: "eq", expected: "svanoort"} 12 | - compare: {raw_body: "", comparator: "regex", expected: '.*'} 13 | - extract_test: {jsonpath_mini: "does_not_exist", test: "not_exists"} -------------------------------------------------------------------------------- /examples/github_api_test.yaml: -------------------------------------------------------------------------------- 1 | # Simple tests to verify things work against a live REST service that returns JSON.. I picked github 2 | --- 3 | - config: 4 | - testset: "Simple github.com API Test" 5 | - test: 6 | - name: "Test with successful validations" 7 | - url: "/search/users?q=crazi-coder" 8 | - group: "Successful" 9 | - validators: # operator is applied as: 10 | - compare: { jsonpath_mini: "total_count", comparator: "eq", expected: 1 } 11 | - compare: { jsonpath_mini: "total_count", comparator: "ge", expected: 1 } 12 | - compare: { jsonpath_mini: "total_count", comparator: "gt", expected: 0 } 13 | - compare: { jsonpath_mini: "total_count", comparator: "eq", expected: 1 } # default operator of "eq" 14 | - compare: { jsonpath_mini: "items", comparator: "count_eq", expected: 1 } 15 | - compare: { jsonpath_mini: "items.0.login", comparator: "eq", expected: "abhijo89-to" } 16 | - compare: { jsonpath_mini: "items.0.id", comparator: "gt", expected: 0 } 17 | - extract_test: { jsonpath_mini: "does_not_exist", test: "not_exists" } 18 | 19 | - test: 20 | - name: "Test with validations that will fail" 21 | - url: "/search/users?q=abhijo89-to-gdfgdg" 22 | - group: "Failure" 23 | - validators: 24 | - compare: {jsonpath_mini: "total_count", comparator: "eq", expected: 0} -------------------------------------------------------------------------------- /examples/miniapp-benchmark-overhead-generators.yaml: -------------------------------------------------------------------------------- 1 | # Test using included Django test app 2 | # Does basic benchmark and measures overhead with generators 3 | --- 4 | - config: 5 | - testset: "Benchmark tests using test app" 6 | - generators: 7 | - 'id': {type: 'number_sequence', start: 10} 8 | 9 | - benchmark: # create entity 10 | - generator_binds: {id: id} 11 | - name: "Create person" 12 | - url: {template: "/api/person/$id/"} 13 | - warmup_runs: 0 14 | - method: 'PUT' 15 | - headers: {'Content-Type': 'application/json'} 16 | - body: {template: '{"first_name": "Gaius","id": "$id","last_name": "Baltar","login": "$id"}'} 17 | - 'benchmark_runs': '1000' 18 | - output_format: csv 19 | - metrics: 20 | - total_time: total 21 | - total_time: mean -------------------------------------------------------------------------------- /examples/miniapp-benchmark-overhead.yaml: -------------------------------------------------------------------------------- 1 | # Test using included Django test app 2 | # Does basic benchmark and measures overhead 3 | --- 4 | - config: 5 | - testset: "Benchmark tests using test app" 6 | 7 | - benchmark: # create entity 8 | - name: "Basic get" 9 | - url: "/api/person/" 10 | - warmup_runs: 0 11 | - 'benchmark_runs': '1000' 12 | - output_format: csv 13 | - metrics: 14 | - total_time: total 15 | - total_time: mean -------------------------------------------------------------------------------- /examples/miniapp-benchmark.yaml: -------------------------------------------------------------------------------- 1 | # Test using included Django test app 2 | # First install python-django 3 | # Then launch the app in another terminal by doing 4 | # cd testapp 5 | # python manage.py testserver test_data.json 6 | # Once launched, tests can be executed via: 7 | # python resttest.py http://localhost:8000 miniapp-test.yaml 8 | --- 9 | - config: 10 | - testset: "Benchmark tests using test app" 11 | 12 | - benchmark: # create entity 13 | - name: "Basic get" 14 | - url: "/api/person/" 15 | - warmup_runs: 7 16 | - 'benchmark_runs': '101' 17 | - output_file: 'miniapp-benchmark.csv' 18 | - metrics: 19 | - total_time 20 | - total_time: mean 21 | - total_time: median 22 | - size_download 23 | - speed_download: median 24 | 25 | - benchmark: # create entity 26 | - name: "Get single person" 27 | - url: "/api/person/1/" 28 | - metrics: {speed_upload: median, speed_download: median, redirect_time: mean} 29 | - output_format: json 30 | - output_file: 'miniapp-single.json' 31 | -------------------------------------------------------------------------------- /examples/miniapp-binding.yaml: -------------------------------------------------------------------------------- 1 | # Test variable binding using included Django test app 2 | # First install python-django 3 | # Then launch the app in another terminal by doing 4 | # cd testapp 5 | # python manage.py testserver test_data.json 6 | # Once launched, tests can be executed via: 7 | # python resttest.py http://localhost:8000 miniapp-binding.yaml 8 | --- 9 | - config: 10 | - testset: "Tests using test app" 11 | - variable_binds: {'id': 1, 'login': 'gbaltar'} 12 | 13 | - test: # create entity 14 | - name: "Basic get" 15 | - url: "/api/person/" 16 | - variable_binds: {'id': 2, 'login': 'gbaltar2'} 17 | 18 | 19 | 20 | #- test: # create entity 21 | # - name: "Get single person" 22 | # - url: "/api/person/1/" 23 | # - method: 'DELETE' 24 | #- test: # create entity by PUT 25 | # - name: "Create/update person" 26 | # - url: "/api/person/1/" 27 | # - method: "PUT" 28 | # - body: '{"first_name": "Gaius","id": 1,"last_name": "Baltar","login": "gbaltar"}' 29 | # - headers: {'Content-Type': 'application/json'} 30 | #- test: # create entity 31 | # - name: "Get single person" 32 | # - url: "/api/person/1/" 33 | #- test: # create entity by POST 34 | # - name: "Create person" 35 | # - url: "/api/person/" 36 | # - method: "POST" 37 | # - body: '{"first_name": "Willim","last_name": "Adama","login": "theadmiral"}' 38 | # - headers: {Content-Type: application/json} 39 | #- test: # create entity 40 | # - name: "Get single person" 41 | # - url: "/api/person/1/" 42 | -------------------------------------------------------------------------------- /examples/miniapp-extract-validate.yaml: -------------------------------------------------------------------------------- 1 | # Test using included Django test app 2 | # Demo for extract/validate of model 3 | --- 4 | - config: 5 | - testset: "Demonstrate use of extract after creating a person" 6 | - test: # create entity by POST 7 | - name: "Create person" 8 | - url: "/api/person/" 9 | - method: "POST" 10 | - body: '{"first_name": "Test","last_name": "User","login": "testuser"}' 11 | - headers: {Content-Type: application/json} 12 | - expected_status: [201] 13 | - extract_binds: 14 | - 'id': {'jsonpath_mini': 'id'} 15 | - test: 16 | - name: "Get person you just created and validate them" 17 | - url: {'template': "/api/person/$id/"} 18 | - validators: 19 | - compare: {jsonpath_mini: 'id', comparator: 'str_eq', expected: {template: '$id'}} 20 | - extract_test: {jsonpath_mini: 'login', test: 'exists'} 21 | - test: 22 | - name: "Delete person you just created" 23 | - url: {'template': "/api/person/$id/"} 24 | - method: "DELETE" 25 | -------------------------------------------------------------------------------- /examples/miniapp-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "Person", 4 | "description": "A person from the miniapp", 5 | "type": "object", 6 | "properties": { 7 | "id": { 8 | "type": "integer", 9 | "description": "Unique person ID" 10 | }, 11 | "first_name": { 12 | "type": "string" 13 | }, 14 | "last_name": { 15 | "type": "string" 16 | }, 17 | "login": { 18 | "type": "string" 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /examples/miniapp-test.json: -------------------------------------------------------------------------------- 1 | [ 2 | {"config": 3 | {"testset": "Tests using test app"} 4 | }, 5 | {"test": { 6 | "name": "Basic get", "url": "/api/person/"} 7 | }, 8 | { "test": 9 | {"name": "Get single person", "url": "/api/person/1/"} 10 | }, 11 | {"test": { 12 | "name": "Get single person", 13 | "url": "/api/person/1/", 14 | "method": "DELETE" 15 | }}, 16 | {"test": { 17 | "name": "Create/update person", 18 | "url": "/api/person/1/", 19 | "method": "PUT", 20 | "body": "{\"first_name\": \"Gaius\",\"id\": 1,\"last_name\": \"Baltar\",\"login\": \"gbaltar\"}", 21 | "headers": {"Content-Type": "application/json"} 22 | }}, 23 | {"test": { 24 | "name": "Create person", 25 | "url": "/api/person/", 26 | "method": "POST", 27 | "body": "{\"first_name\": \"Willim\",\"last_name\": \"Adama\",\"login\": \"theadmiral\"}", 28 | "headers": {"Content-Type": "application/json"} 29 | }} 30 | ] -------------------------------------------------------------------------------- /examples/miniapp-test.yaml: -------------------------------------------------------------------------------- 1 | # Test using included Django test app 2 | # First install python-django 3 | # Then launch the app in another terminal by doing 4 | # cd testapp 5 | # python manage.py testserver test_data.json 6 | # Once launched, tests can be executed via: 7 | # python resttest.py http://localhost:8000 miniapp-test.yaml 8 | --- 9 | - config: 10 | - testset: "Tests using test app" 11 | 12 | - test: # create entity 13 | - name: "Basic get" 14 | - url: "/api/person/" 15 | - test: # create entity 16 | - name: "Get single person" 17 | - url: "/api/person/1/" 18 | - test: # create entity 19 | - name: "Get single person" 20 | - url: "/api/person/1/" 21 | - method: 'DELETE' 22 | - test: # create entity by PUT 23 | - name: "Create/update person" 24 | - url: "/api/person/1/" 25 | - method: "PUT" 26 | - body: '{"first_name": "Gaius","id": 1,"last_name": "Baltar","login": "gbaltar"}' 27 | - headers: {'Content-Type': 'application/json'} 28 | - test: # create entity by POST 29 | - name: "Create person" 30 | - url: "/api/person/" 31 | - method: "POST" 32 | - body: '{"first_name": "Willim","last_name": "Adama","login": "theadmiral"}' 33 | - headers: {Content-Type: application/json} 34 | -------------------------------------------------------------------------------- /examples/schema_test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - test: 3 | - url: /api/person/1/ 4 | - validators: 5 | - json_schema: {schema: {file: 'miniapp-schema.json'}} 6 | -------------------------------------------------------------------------------- /render.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | render() { 4 | sedStr=" 5 | s!%%PY_VERSION%%!$version!g; 6 | " 7 | 8 | sed -r "$sedStr" $1 9 | } 10 | 11 | versions=(3.5 3.6 3.7 3.8 3.9) 12 | for version in ${versions[*]}; do 13 | [ -d $version ] || mkdir $version 14 | render Dockerfile.template >$version/Dockerfile 15 | docker build -t resttest3:py-$version -f $version/Dockerfile . 16 | done 17 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alive_progress==1.6.1 2 | pyyaml==5.4 3 | pycurl==7.43.0.6 4 | jsonpath==0.82 5 | jmespath==0.10.0 6 | jsonschema==3.2.0 7 | certifi>=2020.11.8 -------------------------------------------------------------------------------- /resttest3/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.0.0-dev' 2 | __author__ = 'Abhilash Joseph C' 3 | 4 | from resttest3.utils import register_extensions 5 | 6 | register_extensions('resttest3.ext.validator_jsonschema') 7 | register_extensions('resttest3.ext.extractor_jmespath') 8 | -------------------------------------------------------------------------------- /resttest3/binding.py: -------------------------------------------------------------------------------- 1 | """Basic context implementation for binding variables to values 2 | """ 3 | import logging 4 | import types 5 | 6 | logger = logging.getLogger('resttest3') 7 | 8 | 9 | class Context: 10 | """ Manages binding of variables & generators, with both variable name and generator name being strings """ 11 | 12 | # variables = {} 13 | def __init__(self): 14 | self.variables = {} # Maps variable name to current value 15 | self.generators = {} # Maps generator name to generator function 16 | self.mod_count = 0 # Lets us see if something has been altered, avoiding needless retemplating 17 | 18 | def bind_variable(self, variable_name, variable_value): 19 | """ Bind a named variable to a value within the context 20 | This allows for passing in variables in testing """ 21 | str_name = str(variable_name) 22 | prev = self.variables.get(str_name) 23 | 24 | if prev != variable_value: 25 | self.variables[str(variable_name)] = variable_value 26 | self.mod_count = self.mod_count + 1 27 | logger.info('Context: altered variable named %s to value %s', str_name, variable_value) 28 | 29 | def bind_variables(self, variable_map): 30 | """bind variable for the key """ 31 | for key, value in variable_map.items(): 32 | self.bind_variable(key, value) 33 | 34 | def add_generator(self, generator_name, generator): 35 | """ Adds a generator to the context, this can be used to set values for a variable 36 | Once created, you can set values with the generator via bind_generator_next """ 37 | 38 | if not isinstance(generator, types.GeneratorType): 39 | raise ValueError( 40 | 'Cannot add generator named {0}, it is not a generator type'.format(generator_name)) 41 | 42 | self.generators[str(generator_name)] = generator 43 | logging.debug('Context: Added generator named %s', generator_name) 44 | 45 | def bind_generator_next(self, variable_name, generator_name): 46 | """ Binds the next value for generator_name to variable_name and return value used """ 47 | str_gen_name = str(generator_name) 48 | str_name = str(variable_name) 49 | val = next(self.generators[str_gen_name]) 50 | 51 | prev = self.variables.get(str_name) 52 | if prev != val: 53 | self.variables[str_name] = val 54 | self.mod_count = self.mod_count + 1 55 | logging.debug( 56 | 'Context: Set variable named %s to next value ' 57 | '%s from generator named %s', variable_name, val, generator_name) 58 | return val 59 | 60 | def get_values(self): 61 | """Return the values can bind to a key """ 62 | return self.variables 63 | 64 | def get_value(self, variable_name): 65 | """ Get bound variable value, or return none if not set """ 66 | return self.variables.get(str(variable_name)) 67 | 68 | def get_generators(self): 69 | """return all generators defined """ 70 | return self.generators 71 | 72 | def get_generator(self, generator_name): 73 | """return generators for the given name""" 74 | return self.generators.get(str(generator_name)) 75 | -------------------------------------------------------------------------------- /resttest3/constants.py: -------------------------------------------------------------------------------- 1 | import operator 2 | import re 3 | from enum import Enum 4 | 5 | import pycurl 6 | 7 | DEFAULT_TIMEOUT = 10 8 | HEADER_ENCODING = 'ISO-8859-1' # Per RFC 2616 9 | 10 | 11 | def safe_length(var): 12 | """ Exception-safe length check, returns -1 if no length on type or error """ 13 | try: 14 | output = len(var) 15 | except TypeError: 16 | output = -1 17 | return output 18 | 19 | 20 | def regex_compare(input_val, regex): 21 | return bool(re.search(regex, input_val)) 22 | 23 | 24 | def test_type(val, _type): 25 | type_list = TYPES.get(_type.lower()) 26 | 27 | if type_list is None: 28 | raise TypeError( 29 | "Type {0} is not a valid type to test against!".format(_type.lower())) 30 | try: 31 | for type_obj in type_list: 32 | if isinstance(val, type_obj): 33 | return True 34 | return False 35 | except TypeError: 36 | return isinstance(val, type_list) 37 | 38 | 39 | class YamlKeyWords: 40 | INCLUDE = 'include' 41 | IMPORT = 'import' 42 | TEST = 'test' 43 | URL = 'url' 44 | BENCHMARK = 'benchmark' 45 | CONFIG = 'config' 46 | 47 | 48 | class TestCaseKeywords: 49 | auth_username = 'auth_username' 50 | auth_password = 'auth_password' 51 | method = 'method' 52 | delay = 'delay' 53 | group = 'group' 54 | name = 'name' 55 | expected_status = 'expected_status' 56 | stop_on_failure = 'stop_on_failure' 57 | url = 'url' 58 | body = 'body' 59 | headers = 'headers' 60 | extract_binds = 'extract_binds' 61 | variable_binds = 'variable_binds' 62 | generator_binds = 'generator_binds' 63 | validators = 'validators' 64 | options = 'options' 65 | global_env = 'global_env' 66 | absolute_urls = 'absolute-url' 67 | 68 | 69 | class EnumHttpMethod(Enum): 70 | GET = pycurl.HTTPGET 71 | PUT = pycurl.UPLOAD 72 | PATCH = pycurl.POSTFIELDS 73 | POST = pycurl.POST 74 | DELETE = pycurl.CUSTOMREQUEST 75 | HEAD = pycurl.CUSTOMREQUEST 76 | 77 | 78 | class AuthType: 79 | BASIC = pycurl.HTTPAUTH_BASIC 80 | NONE = pycurl.HTTPAUTH_NONE 81 | 82 | 83 | FAILURE_INVALID_RESPONSE = 'Invalid HTTP Response Code' 84 | FAILURE_CURL_EXCEPTION = 'Curl Exception' 85 | FAILURE_TEST_EXCEPTION = 'Test Execution Exception' 86 | FAILURE_VALIDATOR_FAILED = 'Validator Failed' 87 | FAILURE_VALIDATOR_EXCEPTION = 'Validator Exception' 88 | FAILURE_EXTRACTOR_EXCEPTION = 'Extractor Exception' 89 | 90 | 91 | COMPARATORS = { 92 | "count_eq": lambda x, y: safe_length(x) == y, 93 | "str_eq": lambda x, y: operator.eq(str(x), str(y)), 94 | "contains": lambda x, y: x and operator.contains(x, y), 95 | "contained_by": lambda x, y: y and operator.contains(y, x), 96 | "regex": lambda x, y: regex_compare(str(x), str(y)), 97 | "type": lambda x, y: test_type(x, y), 98 | "eq": operator.eq, 99 | "ne": operator.ne, 100 | "lt": operator.lt, 101 | "le": operator.le, 102 | "ge": operator.ge, 103 | "gt": operator.gt 104 | 105 | } 106 | 107 | TYPES = { 108 | 'null': type(None), 109 | 'none': type(None), 110 | 'number': (int, float), 111 | 'int': (int,), 112 | 'float': float, 113 | 'boolean': bool, 114 | 'string': str, 115 | 'array': list, 116 | 'list': list, 117 | 'dict': dict, 118 | 'map': dict, 119 | 'scalar': (bool, int, float, str, type(None)), 120 | 'collection': (list, dict, set) 121 | } 122 | 123 | VALIDATOR_TESTS = { 124 | 'exists': lambda x: x is not None, 125 | 'not_exists': lambda x: x is None 126 | } 127 | -------------------------------------------------------------------------------- /resttest3/contenthandling.py: -------------------------------------------------------------------------------- 1 | import os 2 | import string 3 | 4 | from resttest3.utils import Parser 5 | 6 | """ 7 | Encapsulates contend handling logic, for pulling file content into tests 8 | """ 9 | 10 | 11 | class ContentHandler: 12 | """ Handles content that may be (lazily) read from filesystem and/or templated to various degrees 13 | Also creates pixie dust and unicorn farts on demand 14 | This is pulled out because logic gets complex rather fast 15 | 16 | Covers 6 states: 17 | - Inline body content, no templating 18 | - Inline body content, with templating 19 | - File path to content, NO templating 20 | - File path to content, content gets templated 21 | - Templated path to file content (path itself is templated), file content UNtemplated 22 | - Templated path to file content (path itself is templated), file content TEMPLATED 23 | """ 24 | 25 | content = None # Inline content 26 | is_file = False 27 | is_template_path = False 28 | is_template_content = False 29 | 30 | def is_dynamic(self): 31 | """ Is templating used? """ 32 | return self.is_template_path or self.is_template_content 33 | 34 | def get_content(self, context=None): 35 | """ Does all context binding and pathing to get content, templated out """ 36 | 37 | if self.is_file: 38 | path = self.content 39 | if self.is_template_path and context: 40 | path = string.Template(path).safe_substitute( 41 | context.get_values()) 42 | with open(path, 'r') as f: 43 | data = f.read() 44 | 45 | if self.is_template_content and context: 46 | return string.Template(data).safe_substitute(context.get_values()) 47 | else: 48 | return data 49 | else: 50 | if self.is_template_content and context: 51 | return Parser.safe_substitute_unicode_template(self.content, context.get_values()) 52 | else: 53 | return self.content 54 | 55 | def create_noread_version(self): 56 | """ Read file content if it is static and return content handler with no I/O """ 57 | if not self.is_file or self.is_template_path: 58 | return self 59 | output = ContentHandler() 60 | output.is_template_content = self.is_template_content 61 | with open(self.content, 'r') as f: 62 | output.content = f.read() 63 | return output 64 | 65 | def setup(self, file_path, is_file=False, is_template_path=False, is_template_content=False): 66 | """ Self explanatory, input is inline content or file path. """ 67 | if not isinstance(file_path, str): 68 | raise TypeError("Input is not a string") 69 | if is_file: 70 | file_path = os.path.abspath(file_path) 71 | self.content = file_path 72 | self.is_file = is_file 73 | self.is_template_path = is_template_path 74 | self.is_template_content = is_template_content 75 | 76 | @staticmethod 77 | def parse_content(node): 78 | """ Parse content from input node and returns ContentHandler object 79 | it'll look like: 80 | 81 | - template: 82 | - file: 83 | - temple: path 84 | 85 | or something 86 | 87 | """ 88 | 89 | # Tread carefully, this one is a bit narly because of nesting 90 | output = ContentHandler() 91 | is_template_path = False 92 | is_template_content = False 93 | is_file = False 94 | is_done = False 95 | 96 | if not isinstance(node, (str, dict, list)): 97 | raise TypeError( 98 | "Content must be a string, dictionary, or list of dictionaries") 99 | 100 | while node and not is_done: # Dive through the configuration tree 101 | # Finally we've found the value! 102 | if isinstance(node, str): 103 | output.content = node 104 | output.setup(node, is_file=is_file, is_template_path=is_template_path, 105 | is_template_content=is_template_content) 106 | return output 107 | 108 | is_done = True 109 | 110 | # Dictionary or list of dictionaries 111 | flat_dict = Parser.flatten_lowercase_keys_dict(node) 112 | for key, value in flat_dict.items(): 113 | if key == 'template': 114 | if isinstance(value, str): 115 | if is_file: 116 | value = os.path.abspath(value) 117 | output.content = value 118 | is_template_content = is_template_content or not is_file 119 | output.is_template_content = is_template_content 120 | output.is_template_path = is_file 121 | output.is_file = is_file 122 | return output 123 | else: 124 | is_template_content = True 125 | node = value 126 | is_done = False 127 | break 128 | 129 | elif key == 'file': 130 | if isinstance(value, str): 131 | output.content = os.path.abspath(value) 132 | output.is_file = True 133 | output.is_template_content = is_template_content 134 | return output 135 | else: 136 | is_file = True 137 | node = value 138 | is_done = False 139 | break 140 | 141 | raise Exception("Invalid configuration for content.") 142 | -------------------------------------------------------------------------------- /resttest3/exception.py: -------------------------------------------------------------------------------- 1 | class HttpMethodError(Exception): 2 | pass 3 | 4 | 5 | class BindError(Exception): 6 | pass 7 | 8 | 9 | class ValidatorError(Exception): 10 | pass 11 | -------------------------------------------------------------------------------- /resttest3/ext/__init__.py: -------------------------------------------------------------------------------- 1 | # Allow extensions to see root folder 2 | -------------------------------------------------------------------------------- /resttest3/ext/extractor_jmespath.py: -------------------------------------------------------------------------------- 1 | """JMESPathExtractor file""" 2 | import json 3 | 4 | import jmespath 5 | 6 | from resttest3.validators import AbstractExtractor 7 | 8 | 9 | class JMESPathExtractor(AbstractExtractor): 10 | """ Extractor that uses JMESPath syntax 11 | See http://jmespath.org/specification.html for details 12 | """ 13 | extractor_type = 'jmespath' 14 | is_body_extractor = True 15 | 16 | def extract_internal(self, query=None, body=None, headers=None, args=None): 17 | if isinstance(body, bytes): 18 | body = body.decode('utf-8') 19 | 20 | try: 21 | res = jmespath.search(query, json.loads(body)) 22 | return res 23 | except Exception as Exe: 24 | raise ValueError("Invalid query: " + query + " : " + str(Exe)) from Exe 25 | 26 | @classmethod 27 | def parse(cls, config): 28 | """Parse the JMESPathExtractor config dict""" 29 | base = JMESPathExtractor() 30 | return cls.configure_base(config, base) 31 | 32 | 33 | EXTRACTORS = {'jmespath': JMESPathExtractor.parse} 34 | -------------------------------------------------------------------------------- /resttest3/ext/validator_jsonschema.py: -------------------------------------------------------------------------------- 1 | import json 2 | import traceback 3 | 4 | import jsonschema 5 | import yaml 6 | 7 | from resttest3.constants import FAILURE_VALIDATOR_EXCEPTION 8 | from resttest3.contenthandling import ContentHandler 9 | from resttest3.utils import Parser 10 | from resttest3.validators import AbstractValidator, Failure 11 | 12 | 13 | class JsonSchemaValidator(AbstractValidator): 14 | """ Json schema validator using the jsonschema library """ 15 | 16 | def __init__(self): 17 | super(JsonSchemaValidator, self).__init__() 18 | self.schema_context = None 19 | 20 | def validate(self, body=None, headers=None, context=None): 21 | schema_text = self.schema_context.get_content(context=context) 22 | schema = yaml.safe_load(schema_text) 23 | try: 24 | if isinstance(body, bytes): 25 | body = body.decode() 26 | jsonschema.validate(json.loads(body), schema) 27 | return True 28 | except jsonschema.exceptions.ValidationError: 29 | return self.__failed("JSON Schema Validation Failed") 30 | except json.decoder.JSONDecodeError: 31 | # trace = traceback.format_exc() 32 | return self.__failed("Invalid response json body") 33 | 34 | def __failed(self, message): 35 | trace = traceback.format_exc() 36 | return Failure(message=message, details=trace, validator=self, 37 | failure_type=FAILURE_VALIDATOR_EXCEPTION) 38 | 39 | @staticmethod 40 | def get_readable_config(context=None): 41 | return "JSON schema validation" 42 | 43 | @classmethod 44 | def parse(cls, config): 45 | validator = JsonSchemaValidator() 46 | config = Parser.lowercase_keys(config) 47 | if 'schema' not in config: 48 | raise ValueError( 49 | "Cannot create schema validator without a 'schema' configuration element!") 50 | validator.schema_context = ContentHandler.parse_content(config['schema']) 51 | 52 | return validator 53 | 54 | 55 | VALIDATORS = {'json_schema': JsonSchemaValidator.parse} 56 | -------------------------------------------------------------------------------- /resttest3/generators.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import random 4 | import string 5 | """ Collection of generators to be used in templating for test data 6 | 7 | Plans: extend these by allowing generators that take generators for input 8 | Example: generators that case-swap 9 | """ 10 | 11 | INT32_MAX_VALUE = 2147483647 # Max of 32 bit unsigned int 12 | 13 | logger = logging.getLogger('resttest3.generators') 14 | 15 | # Character sets to use in text generation, python string plus extras 16 | CHARACTER_SETS = { 17 | 'ascii_letters': string.ascii_letters, 18 | 'ascii_lowercase': string.ascii_lowercase, 19 | 'ascii_uppercase': string.ascii_uppercase, 20 | 'digits': string.digits, 21 | 'hexdigits': string.hexdigits, 22 | 'hex_lower': string.digits + 'abcdef', 23 | 'hex_upper': string.digits + 'ABCDEF', 24 | 'letters': string.ascii_letters, 25 | 'lowercase': string.ascii_lowercase, 26 | 'octdigits': string.octdigits, 27 | 'punctuation': string.punctuation, 28 | 'printable': string.printable, 29 | 'uppercase': string.ascii_uppercase, 30 | 'whitespace': string.whitespace, 31 | 'url.slug': string.ascii_lowercase + string.digits + '-', 32 | 'url.safe': string.ascii_letters + string.digits + '-~_.', 33 | 'alphanumeric': string.ascii_letters + string.digits, 34 | 'alphanumeric_lower': string.ascii_lowercase + string.digits, 35 | 'alphanumeric_upper': string.ascii_uppercase + string.digits 36 | } 37 | 38 | 39 | def factory_generate_ids(starting_id=1, increment=1): 40 | """ Return function generator for ids starting at starting_id 41 | Note: needs to be called with () to make generator """ 42 | 43 | def generate_started_ids(): 44 | val = starting_id 45 | local_increment = increment 46 | while True: 47 | yield val 48 | val += local_increment 49 | 50 | return generate_started_ids 51 | 52 | 53 | def generator_basic_ids(): 54 | """ Return ids generator starting at 1 """ 55 | return factory_generate_ids(1)() 56 | 57 | 58 | def generator_random_int32(): 59 | """ Random integer generator for up to 32-bit signed ints """ 60 | system_random = random.SystemRandom() 61 | while True: 62 | yield system_random.randint(0, INT32_MAX_VALUE) 63 | 64 | 65 | def factory_generate_text(legal_characters=string.ascii_letters, min_length=8, max_length=8): 66 | """ Returns a generator function for text with given legal_characters string and length 67 | Default is ascii letters, length 8 68 | 69 | For hex digits, combine with string.hexstring, etc 70 | """ 71 | system_random = random.SystemRandom() # To Cryptographically secure random 72 | 73 | def generate_text(): 74 | local_min_len = min_length 75 | local_max_len = max_length 76 | while True: 77 | length = system_random.randint(local_min_len, local_max_len) 78 | array = [system_random.choice(legal_characters) for _ in range(0, length)] 79 | yield ''.join(array) 80 | 81 | return generate_text 82 | 83 | 84 | def factory_fixed_sequence(values): 85 | """ Return a generator that runs through a list of values in order, looping after end """ 86 | 87 | def seq_generator(): 88 | my_list = list(values) 89 | i = 0 90 | while True: 91 | yield my_list[i] 92 | i += 1 93 | if i == len(my_list): 94 | i = 0 95 | 96 | return seq_generator 97 | 98 | 99 | def parse_fixed_sequence(config): 100 | """ Parse fixed sequence string """ 101 | vals = config['values'] 102 | if not vals: 103 | raise ValueError('Values for fixed sequence must exist') 104 | if not isinstance(vals, list): 105 | raise ValueError('Values must be a list of entries') 106 | return factory_fixed_sequence(vals)() 107 | 108 | 109 | def factory_choice_generator(values): 110 | """ Return a generator that picks values from a list randomly """ 111 | system_random = random.SystemRandom() # To Cryptographically secure random 112 | 113 | def choice_generator(): 114 | my_list = list(values) 115 | while True: 116 | yield system_random.choice(my_list) 117 | 118 | return choice_generator 119 | 120 | 121 | def parse_choice_generator(config): 122 | """ Parse choice generator """ 123 | vals = config['values'] 124 | if not vals or (not isinstance(vals, list)): 125 | raise ValueError('Values must be a list of entries') 126 | return factory_choice_generator(vals)() 127 | 128 | 129 | def factory_env_variable(env_variable): 130 | """ Return a generator function that reads from an environment variable """ 131 | 132 | def return_variable(): 133 | variable_name = env_variable 134 | while True: 135 | yield os.environ.get(variable_name) 136 | 137 | return return_variable 138 | 139 | 140 | def factory_env_string(env_string): 141 | """ Return a generator function that uses OS expand path to expand environment variables in string """ 142 | 143 | def return_variable(): 144 | while True: 145 | yield os.path.expandvars(env_string) 146 | 147 | return return_variable 148 | 149 | 150 | """ Implements the parsing logic for YAML, and acts as single point for reading configuration """ 151 | 152 | 153 | def parse_random_text_generator(configuration): 154 | """ Parses configuration options for a random text generator """ 155 | character_set = configuration.get('character_set') 156 | 157 | if character_set: 158 | character_set = character_set.lower() 159 | if character_set not in CHARACTER_SETS: 160 | raise ValueError( 161 | "Illegal character set name, is not defined: {0}".format(character_set)) 162 | characters = CHARACTER_SETS[character_set] 163 | else: # Custom characters listing, not a character set 164 | characters = configuration.get('characters') 165 | 166 | min_length = int(configuration.get('min_length', 8)) 167 | max_length = int(configuration.get('max_length', 8)) 168 | if not characters: 169 | return factory_generate_text(min_length=min_length, max_length=max_length)() 170 | characters = str(characters) 171 | 172 | if configuration.get('length'): 173 | length = int(configuration.get('length')) 174 | min_length = length 175 | max_length = length 176 | 177 | return factory_generate_text( 178 | legal_characters=characters, min_length=min_length, max_length=max_length)() 179 | 180 | 181 | # List of valid generator types 182 | GENERATOR_TYPES = {'env_variable', 183 | 'env_string', 184 | 'number_sequence', 185 | 'random_int', 186 | 'random_text', 187 | 'fixed_sequence' 188 | } 189 | 190 | GENERATOR_PARSING = {'fixed_sequence': parse_fixed_sequence} 191 | 192 | 193 | def register_generator(typename: str, parse_function): 194 | """ Register a new generator for use in testing 195 | typename is the new generator type name (must not already exist) 196 | parse_function will parse a configuration object (dict) 197 | """ 198 | if not isinstance(typename, str): 199 | raise TypeError( 200 | 'Generator type name {0} is invalid, must be a string'.format(typename)) 201 | if typename in GENERATOR_TYPES: 202 | raise ValueError( 203 | 'Generator type named {0} already exists'.format(typename)) 204 | GENERATOR_TYPES.add(typename) 205 | GENERATOR_PARSING[typename] = parse_function 206 | 207 | 208 | # Try registering a new generator 209 | register_generator('choice', parse_choice_generator) 210 | 211 | 212 | def parse_generator(configuration): 213 | """ Parses a configuration built from yaml and returns a generator 214 | Configuration should be a map 215 | """ 216 | from resttest3.utils import Parser 217 | configuration = Parser.lowercase_keys(Parser.flatten_dictionaries(configuration)) 218 | gen_type = str(configuration.get(u'type')).lower() 219 | 220 | if gen_type not in GENERATOR_TYPES: 221 | raise ValueError( 222 | 'Generator type given {0} is not valid '.format(gen_type)) 223 | 224 | # Do the easy parsing, delegate more complex logic to parsing functions 225 | if gen_type == 'env_variable': 226 | return factory_env_variable(configuration['variable_name'])() 227 | elif gen_type == 'env_string': 228 | return factory_env_string(configuration['string'])() 229 | elif gen_type == 'number_sequence': 230 | start = int(configuration.get('start', 1)) 231 | increment = int(configuration.get('increment', 1)) 232 | return factory_generate_ids(start, increment)() 233 | elif gen_type == 'random_int': 234 | return generator_random_int32() 235 | elif gen_type == 'random_text': 236 | return parse_random_text_generator(configuration) 237 | elif gen_type in GENERATOR_TYPES: 238 | return GENERATOR_PARSING[gen_type](configuration) 239 | 240 | raise Exception("Unknown generator type: {0}".format('gen_type')) 241 | -------------------------------------------------------------------------------- /resttest3/reports/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crazi-coder/resttest3/a6078036a04498f4c2a9c3053aed463b6e98a542/resttest3/reports/__init__.py -------------------------------------------------------------------------------- /resttest3/reports/template/report_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Resttest Run Result 5 | 6 | 7 | 9 | 10 | 11 |
12 |
13 |
14 |

Test Run Result

15 |

Start Time: {{ stat_time }}

16 |

Duration: {{ elapsed }}

17 |

Summary: Total: {{ total_testcase_count }}

18 |
19 |
20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {% for testcase in context_list %} 33 | 34 | 35 | 36 | 37 | 38 | 45 | 46 | {% endfor %} 47 | 48 |
NameGroupIs PassedDescription
{{ testcase.name }}{{ testcase.group }} {{testcase.is_passed }} 39 | {% for f in testcase.failures %} 40 |
    41 | {{ f }} 42 |
43 | {% endfor %} 44 |
49 |
50 |
51 | {% for testcase in context_list %} 52 | {{ testcase.name }} 53 | {% endfor %} 54 |
55 | 56 | 76 | 77 | Hello {{name|upper}}! 100 | {% for topic in topics %} 101 |

You are interested in {{topic}}.

102 | {% endif %} 103 | ''', 104 | {'upper': str.upper}, 105 | ) 106 | text = templite.render({ 107 | 'name': "Ned", 108 | 'topics': ['Python', 'Geometry', 'Juggling'], 109 | }) 110 | 111 | """ 112 | 113 | def __init__(self, text, *contexts): 114 | """Construct a Templite with the given `text`. 115 | 116 | `contexts` are dictionaries of values to use for future renderings. 117 | These are good for filters and global values. 118 | 119 | """ 120 | self.context = {} 121 | for context in contexts: 122 | self.context.update(context) 123 | 124 | self.all_vars = set() 125 | self.loop_vars = set() 126 | 127 | # We construct a function in source form, then compile it and hold onto 128 | # it, and execute it to render the template. 129 | code = CodeBuilder() 130 | 131 | code.add_line("def render_function(context, do_dots):") 132 | code.indent() 133 | vars_code = code.add_section() 134 | code.add_line("result = []") 135 | code.add_line("append_result = result.append") 136 | code.add_line("extend_result = result.extend") 137 | code.add_line("to_str = str") 138 | 139 | buffered = [] 140 | 141 | def flush_output(): 142 | """Force `buffered` to the code builder.""" 143 | if len(buffered) == 1: 144 | code.add_line("append_result(%s)" % buffered[0]) 145 | elif len(buffered) > 1: 146 | code.add_line("extend_result([%s])" % ", ".join(buffered)) 147 | del buffered[:] 148 | 149 | ops_stack = [] 150 | 151 | # Split the text to form a list of tokens. 152 | tokens = re.split(r"(?s)({{.*?}}|{%.*?%}|{#.*?#})", text) 153 | 154 | squash = in_joined = False 155 | 156 | self.__process(buffered, code, flush_output, in_joined, ops_stack, squash, tokens) 157 | 158 | if ops_stack: 159 | self._syntax_error("Unmatched action tag", ops_stack[-1]) 160 | 161 | flush_output() 162 | 163 | for var_name in self.all_vars - self.loop_vars: 164 | vars_code.add_line("c_%s = context[%r]" % (var_name, var_name)) 165 | 166 | code.add_line('return "".join(result)') 167 | code.dedent() 168 | self._render_function = code.get_globals()['render_function'] 169 | 170 | def __process(self, buffered, code, flush_output, in_joined, ops_stack, squash, tokens): 171 | for token in tokens: 172 | if token.startswith('{'): 173 | start, end = 2, -2 174 | squash = (token[-3] == '-') 175 | if squash: 176 | end = -3 177 | 178 | if token.startswith('{#'): 179 | # Comment: ignore it and move on. 180 | continue 181 | if token.startswith('{{'): 182 | # An expression to evaluate. 183 | expr = self._expr_code(token[start:end].strip()) 184 | buffered.append("to_str(%s)" % expr) 185 | else: 186 | # token.startswith('{%') 187 | # Action tag: split into words and parse further. 188 | flush_output() 189 | 190 | words = token[start:end].strip().split() 191 | if words[0] == 'if': 192 | # An if statement: evaluate the expression to determine if. 193 | if len(words) != 2: 194 | self._syntax_error("Don't understand if", token) 195 | ops_stack.append('if') 196 | code.add_line("if %s:" % self._expr_code(words[1])) 197 | code.indent() 198 | elif words[0] == 'for': 199 | # A loop: iterate over expression result. 200 | if len(words) != 4 or words[2] != 'in': 201 | self._syntax_error("Don't understand for", token) 202 | ops_stack.append('for') 203 | self._variable(words[1], self.loop_vars) 204 | code.add_line( 205 | "for c_%s in %s:" % ( 206 | words[1], 207 | self._expr_code(words[3]) 208 | ) 209 | ) 210 | code.indent() 211 | elif words[0] == 'joined': 212 | ops_stack.append('joined') 213 | in_joined = True 214 | elif words[0].startswith('end'): 215 | # Endsomething. Pop the ops stack. 216 | if len(words) != 1: 217 | self._syntax_error("Don't understand end", token) 218 | end_what = words[0][3:] 219 | if not ops_stack: 220 | self._syntax_error("Too many ends", token) 221 | start_what = ops_stack.pop() 222 | if start_what != end_what: 223 | self._syntax_error("Mismatched end tag", end_what) 224 | if end_what == 'joined': 225 | in_joined = False 226 | else: 227 | code.dedent() 228 | else: 229 | self._syntax_error("Don't understand tag", words[0]) 230 | else: 231 | # Literal content. If it isn't empty, output it. 232 | if in_joined: 233 | token = re.sub(r"\s*\n\s*", "", token.strip()) 234 | elif squash: 235 | token = token.lstrip() 236 | if token: 237 | buffered.append(repr(token)) 238 | 239 | def _expr_code(self, expr): 240 | """Generate a Python expression for `expr`.""" 241 | if "|" in expr: 242 | pipes = expr.split("|") 243 | code = self._expr_code(pipes[0]) 244 | for func in pipes[1:]: 245 | self._variable(func, self.all_vars) 246 | code = "c_%s(%s)" % (func, code) 247 | elif "." in expr: 248 | dots = expr.split(".") 249 | code = self._expr_code(dots[0]) 250 | args = ", ".join(repr(d) for d in dots[1:]) 251 | code = "do_dots(%s, %s)" % (code, args) 252 | else: 253 | self._variable(expr, self.all_vars) 254 | code = "c_%s" % expr 255 | return code 256 | 257 | def _syntax_error(self, msg, thing): 258 | """Raise a syntax error using `msg`, and showing `thing`.""" 259 | raise TempliteSyntaxError("%s: %r" % (msg, thing)) 260 | 261 | def _variable(self, name, vars_set): 262 | """Track that `name` is used as a variable. 263 | 264 | Adds the name to `vars_set`, a set of variable names. 265 | 266 | Raises an syntax error if `name` is not a valid name. 267 | 268 | """ 269 | if not re.match(r"[_a-zA-Z][_a-zA-Z0-9]*$", name): 270 | self._syntax_error("Not a valid name", name) 271 | vars_set.add(name) 272 | 273 | def render(self, context=None): 274 | """Render this template by applying it to `context`. 275 | 276 | `context` is a dictionary of values to use in this rendering. 277 | 278 | """ 279 | # Make the complete context we'll use. 280 | render_context = dict(self.context) 281 | if context: 282 | render_context.update(context) 283 | return self._render_function(render_context, self._do_dots) 284 | 285 | def _do_dots(self, value, *dots): 286 | """Evaluate dotted expressions at run-time.""" 287 | for dot in dots: 288 | try: 289 | value = getattr(value, dot) 290 | except AttributeError: 291 | try: 292 | value = value[dot] 293 | except (TypeError, KeyError) as e: 294 | raise TempliteValueError( 295 | "Couldn't evaluate %r.%s" % (value, dot) 296 | ) from e 297 | if callable(value): 298 | value = value() 299 | return value 300 | -------------------------------------------------------------------------------- /resttest3/runner.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import os 4 | import sys 5 | from argparse import ArgumentParser 6 | from inspect import getframeinfo, currentframe 7 | from pathlib import Path 8 | from typing import Dict, List 9 | 10 | import yaml 11 | from alive_progress import alive_bar 12 | 13 | from resttest3.testcase import TestSet 14 | from resttest3.utils import register_extensions 15 | 16 | logger = logging.getLogger('resttest3') 17 | logging.basicConfig(format='%(levelname)s:%(message)s') 18 | 19 | filename = getframeinfo(currentframe()).filename 20 | current_module_path = Path(filename) 21 | 22 | 23 | class ArgsRunner: 24 | 25 | def __init__(self): 26 | self.log = logging.ERROR 27 | self.interactive = None 28 | self.url = None 29 | self.test = None 30 | self.extensions = None 31 | self.vars = None 32 | self.verbose = None 33 | self.insecure = None 34 | self.absolute_urls = None 35 | self.skip_term_colors = None 36 | self.html = 'html' 37 | 38 | def args(self): 39 | parser = ArgumentParser(description='usage: %prog base_url test_filename.yaml [options]') 40 | # parser.add_argument("--log", help="Logging level", action="store", type=str, 41 | # choices=["info", "error", "warning", "debug"]) 42 | # parser.add_argument("--interactive", help="Interactive mode", action="store", type=str) 43 | parser.add_argument("--url", help="Base URL to run tests against", action="store", type=str, required=True) 44 | parser.add_argument("--test", help="Test file to use", action="store", type=str, required=True) 45 | # parser.add_argument('--vars', help='Variables to set, as a YAML dictionary', action="store", type=str) 46 | parser.add_argument('--html', help='Generate HTML Report', action="store") 47 | # parser.add_argument(u'--insecure', help='Disable cURL host and peer cert verification', action='store_true', 48 | # default=False) 49 | # parser.add_argument(u'--absolute_urls', help='Enable absolute URLs in tests instead of relative paths', 50 | # action="store_true") 51 | # parser.add_argument(u'--skip_term_colors', help='Turn off the output term colors', 52 | # action='store_true', default=False) 53 | 54 | parser.parse_args(namespace=self) 55 | 56 | 57 | class Runner: 58 | 59 | FAIL = '\033[91m' 60 | SUCCESS = '\033[92m' 61 | NOCOL = '\033[0m' 62 | 63 | def __init__(self): 64 | self.__args = ArgsRunner() 65 | 66 | @staticmethod 67 | def read_test_file(file_location: str) -> List[Dict]: 68 | with open(file_location, 'r') as f: 69 | test_dict = yaml.safe_load(f.read()) 70 | return test_dict 71 | 72 | def main(self) -> int: 73 | self.__args.args() # Set the arguments 74 | logger.setLevel(self.__args.log) 75 | 76 | # If user provided any custom extension add it into the system path and import it 77 | working_folder = os.path.realpath(os.path.abspath(os.getcwd())) 78 | if self.__args.extensions is not None: 79 | 80 | if working_folder not in sys.path: 81 | sys.path.insert(0, working_folder) 82 | register_extensions(self.__args.extensions) 83 | p = Path(self.__args.test) 84 | 85 | test_case_dict = self.read_test_file(str(p.absolute())) 86 | testcase_set = TestSet() 87 | testcase_set.parse(self.__args.url, testcase_list=test_case_dict, working_directory=p.parent.absolute()) 88 | 89 | success_dict = {} 90 | failure_dict = {} 91 | context_list = [] 92 | total_testcase_count = len([y for x, y in testcase_set.test_group_list_dict.items() for c in y.testcase_list]) 93 | stat_time = datetime.datetime.now() 94 | with alive_bar(total_testcase_count) as bar: 95 | for test_group, test_group_object in testcase_set.test_group_list_dict.items(): 96 | for testcase_object in test_group_object.testcase_list: 97 | bar() 98 | testcase_object.run() 99 | if testcase_object.is_passed: 100 | try: 101 | (count, case_list) = success_dict[test_group] 102 | case_list.append(testcase_object) 103 | success_dict[test_group] = (count + 1, case_list) 104 | except KeyError: 105 | success_dict[test_group] = (1, [testcase_object]) 106 | else: 107 | try: 108 | count, case_list = failure_dict[test_group] 109 | case_list.append(testcase_object) 110 | failure_dict[test_group] = (count + 1, case_list) 111 | except KeyError: 112 | failure_dict[test_group] = (1, [testcase_object]) 113 | context_list.append(testcase_object) 114 | end_time = datetime.datetime.now() 115 | if self.__args.html: 116 | with open(current_module_path.parent.joinpath('reports/template/report_template.html').absolute()) as f: 117 | html = f.read() 118 | from resttest3.reports.templite import Templite 119 | _engine = Templite(html) 120 | _context = { 121 | 'total_testcase_count': total_testcase_count, 122 | 'stat_time': stat_time, 123 | 'elapsed': divmod((end_time - stat_time).total_seconds(), 60), 124 | 'context_list': context_list, 125 | } 126 | html = _engine.render(_context) 127 | path = Path(os.getcwd()).joinpath(self.__args.html) 128 | if not os.path.isdir(path): 129 | os.makedirs(path) 130 | with open(Path(os.getcwd()).joinpath(self.__args.html).joinpath('report.html'), 'w') as f: 131 | f.write(html) 132 | 133 | print("========== TEST RESULT ===========") 134 | print("Total Test to run: %s" % total_testcase_count) 135 | for group_name, case_list_tuple in failure_dict.items(): 136 | print("%sGroup Name: %s %s" % (self.FAIL, group_name, self.NOCOL)) 137 | count, courtcase_list = case_list_tuple 138 | print('%sTotal testcase failed: %s %s' % (self.FAIL, count, self.NOCOL)) 139 | for index, testcase in enumerate(courtcase_list): 140 | print('\t%s %s. Case Name: %s %s' % (self.FAIL, index + 1, testcase.name, self.NOCOL)) 141 | for f in testcase.failures: 142 | print('\t\t%s %s %s' % (self.FAIL, f, self.NOCOL)) 143 | 144 | for group_name, case_list_tuple in success_dict.items(): 145 | print("%sGroup Name: %s %s" % (self.SUCCESS, group_name, self.NOCOL)) 146 | count, courtcase_list = case_list_tuple 147 | print('%sTotal testcase success: %s %s' % (self.SUCCESS, count, self.NOCOL)) 148 | for index, testcase in enumerate(courtcase_list): 149 | print('\t%s %s. Case Name: %s %s' % (self.SUCCESS, index+1, testcase.name, self.NOCOL)) 150 | return 0 151 | 152 | 153 | def main(): 154 | r = Runner() 155 | r.main() 156 | 157 | 158 | if __name__ == '__main__': 159 | main() 160 | -------------------------------------------------------------------------------- /resttest3/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import string 4 | import threading 5 | from email import message_from_string 6 | from functools import reduce 7 | from pathlib import Path 8 | from typing import Dict, Union, List, Any 9 | 10 | import yaml 11 | 12 | from resttest3.generators import register_generator 13 | from resttest3.validators import register_test, register_comparator, register_extractor 14 | from resttest3.validators import register_validator 15 | 16 | logger = logging.getLogger('resttest3') 17 | 18 | 19 | class ChangeDir: 20 | """Context manager for changing the current working directory""" 21 | DIR_LOCK = threading.RLock() # Guards operations changing the working directory 22 | 23 | def __init__(self, new_path): 24 | self.new_path = str(Path(new_path).resolve()) 25 | self.saved_path = None 26 | 27 | def __enter__(self): 28 | if self.new_path: # Don't CD to nothingness 29 | ChangeDir.DIR_LOCK.acquire() 30 | self.saved_path = os.getcwd() 31 | os.chdir(self.new_path) 32 | return self 33 | 34 | def __exit__(self, etype, value, traceback): 35 | if self.new_path: # Don't CD to nothingness 36 | os.chdir(self.saved_path) 37 | ChangeDir.DIR_LOCK.release() 38 | 39 | 40 | def read_testcase_file(path): 41 | with open(path, "r") as f: 42 | testcase = yaml.safe_load(f.read()) 43 | return testcase 44 | 45 | 46 | class Parser: 47 | 48 | @staticmethod 49 | def encode_unicode_bytes(my_string): 50 | """ Shim function, converts Unicode to UTF-8 encoded bytes regardless of the source format 51 | Intended for python 3 compatibility mode, and b/c PyCurl only takes raw bytes 52 | """ 53 | if isinstance(my_string, (bytearray, bytes)): 54 | return my_string 55 | 56 | my_string = str(my_string) 57 | my_string = my_string.encode('utf-8') 58 | return my_string 59 | 60 | @staticmethod 61 | def safe_substitute_unicode_template(templated_string, variable_map): 62 | """ Perform string.Template safe_substitute on unicode input with unicode variable values by using escapes 63 | Catch: cannot accept unicode variable names, just values 64 | Returns a Unicode type output, if you want UTF-8 bytes, do encode_unicode_bytes on it 65 | """ 66 | return string.Template(templated_string).safe_substitute(variable_map) 67 | 68 | @staticmethod 69 | def safe_to_json(in_obj): 70 | """ Safely get dict from object if present for json dumping """ 71 | if isinstance(in_obj, bytes): 72 | return in_obj.decode('utf-8') 73 | elif hasattr(in_obj, '__dict__'): 74 | return {k: v for k, v in in_obj.__dict__.items() if not k.startswith('__')} 75 | elif isinstance(in_obj, str): 76 | return in_obj 77 | 78 | return repr(in_obj) 79 | 80 | @staticmethod 81 | def flatten_dictionaries(input_dict: Union[Dict, List[Dict]]): 82 | """ Flatten a list of dictionaries into a single dictionary, to allow flexible YAML use 83 | Dictionary comprehensions can do this, but would like to allow for pre-Python 2.7 use 84 | If input isn't a list, just return it.... """ 85 | 86 | if isinstance(input_dict, list): 87 | output = reduce(lambda d, src: d.update(src) or d, input_dict, {}) 88 | else: 89 | output = input_dict 90 | return output 91 | 92 | @staticmethod 93 | def lowercase_keys(input_dict): 94 | """ Take input and if a dictionary, return version with keys all lowercase and cast to str """ 95 | if not isinstance(input_dict, dict): 96 | return input_dict 97 | return {str(k).lower(): v for k, v in input_dict.items()} 98 | 99 | @staticmethod 100 | def flatten_lowercase_keys_dict(input_dict: Any): 101 | """ Take input and if a dictionary, return version with keys all lowercase and cast to str """ 102 | if isinstance(input_dict, list): 103 | output_dict = Parser.flatten_dictionaries(input_dict) 104 | output_dict = Parser.lowercase_keys(output_dict) 105 | elif not isinstance(input_dict, dict): 106 | return input_dict 107 | else: 108 | output_dict = Parser.lowercase_keys(input_dict) 109 | return output_dict 110 | 111 | @staticmethod 112 | def safe_to_bool(value): 113 | """ Safely convert user input to a boolean, throwing exception if not boolean or boolean-appropriate string 114 | For flexibility, we allow case insensitive string matching to false/true values 115 | If it's not a boolean or string that matches 'false' or 'true' when ignoring case, throws an exception """ 116 | if isinstance(value, bool): 117 | return value 118 | elif isinstance(value, str) and value.lower() == 'false': 119 | return False 120 | elif isinstance(value, str) and value.lower() == 'true': 121 | return True 122 | 123 | raise TypeError( 124 | 'Input Object is not a boolean or string form of boolean!') 125 | 126 | @staticmethod 127 | def coerce_to_string(val): 128 | if isinstance(val, str): 129 | return val 130 | elif isinstance(val, int): 131 | return str(val) 132 | elif isinstance(val, (bytes, bytearray)): 133 | return val.decode('utf-8') 134 | raise TypeError("Input {0} is not a string or integer, and it needs to be!".format(val)) 135 | 136 | @staticmethod 137 | def coerce_string_to_ascii(val): 138 | if isinstance(val, str): 139 | return val.encode('ascii') 140 | elif isinstance(val, bytes): 141 | return val.decode('utf-8').encode('ascii') 142 | raise TypeError("Input {0} is not a string, string expected".format(val)) 143 | 144 | @staticmethod 145 | def coerce_http_method(val): 146 | try: 147 | val = val.decode() 148 | except (UnicodeDecodeError, AttributeError): 149 | pass 150 | if not isinstance(val, str) or len(val) == 0: 151 | raise TypeError("Invalid HTTP method name: input {0} is not a string or has 0 length".format(val)) 152 | 153 | return val.upper() 154 | 155 | @staticmethod 156 | def coerce_list_of_ints(val): 157 | """ If single value, try to parse as integer, else try to parse as list of integer """ 158 | if isinstance(val, list): 159 | return [int(x) for x in val] 160 | 161 | return [int(val)] 162 | 163 | @staticmethod 164 | def parse_headers(header_string): 165 | """ Parse a header-string into individual headers 166 | Implementation based on: http://stackoverflow.com/a/5955949/95122 167 | Note that headers are a list of (key, value) since duplicate headers are allowed 168 | NEW NOTE: keys & values are unicode strings, but can only contain ISO-8859-1 characters 169 | """ 170 | if isinstance(header_string, bytes): 171 | header_string = header_string.decode() 172 | # First line is request line, strip it out 173 | if not header_string: 174 | return list() 175 | request, headers = header_string.split('\r\n', 1) 176 | if not headers: 177 | return list() 178 | 179 | header_msg = message_from_string(headers) 180 | # Note: HTTP headers are *case-insensitive* per RFC 2616 181 | return [(k.lower(), v) for k, v in header_msg.items()] 182 | 183 | 184 | def register_extensions(modules): 185 | """ Import the modules and register their respective extensions """ 186 | if isinstance(modules, str): # Catch supplying just a string arg 187 | modules = [modules] 188 | for ext in modules: 189 | # Get the package prefix and final module name 190 | segments = ext.split('.') 191 | module = segments.pop() 192 | package = '.'.join(segments) 193 | # Necessary to get the root module back 194 | module = __import__(ext, globals(), locals(), package) 195 | 196 | # Extensions are registered by applying a register function to sets of 197 | # registry name/function pairs inside an object 198 | extension_applies = { 199 | 'VALIDATORS': register_validator, 200 | 'COMPARATORS': register_comparator, 201 | 'VALIDATOR_TESTS': register_test, 202 | 'EXTRACTORS': register_extractor, 203 | 'GENERATORS': register_generator 204 | } 205 | 206 | has_registry = False 207 | for registry_name, register_function in extension_applies.items(): 208 | if hasattr(module, registry_name): 209 | registry = getattr(module, registry_name) 210 | for key, val in registry.items(): 211 | register_function(key, val) 212 | if registry: 213 | has_registry = True 214 | 215 | if not has_registry: 216 | raise ImportError( 217 | "Extension to register did not contain any registries: {0}".format(ext)) 218 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [bdist_wheel] 5 | universal=1 6 | 7 | [pycodestyle] 8 | count = True 9 | ignore = E226,E302,E41 10 | max-line-length = 120 11 | statistics = True 12 | 13 | [pylint.FORMAT] 14 | max-line-length = 120 15 | 16 | [pylint.DESIGN] 17 | max-args = 8 18 | max-public-methods=30 19 | max-locals=20 20 | max-statements=75 21 | max-branches=20 22 | max-attributes=40 23 | 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | with open("requirements.txt") as fp: 3 | install_requires = [lib_str.strip() for lib_str in fp.read().split("\n") if not lib_str.startswith("#")] 4 | 5 | with open('README.md') as fp: 6 | readme = fp.read() 7 | 8 | setup( 9 | name='resttest3', 10 | version="1.0.4", 11 | description='Python RESTful API Testing & Micro benchmarking Tool', 12 | long_description=readme, 13 | author="Abhilash Joseph C", 14 | author_email='abhilash@softlinkweb.com', 15 | url='https://github.com/abhijo89-to/resttest3', 16 | keywords=['rest', 'web', 'http', 'testing', 'api'], 17 | classifiers=[ 18 | 'Environment :: Console', 19 | 'License :: OSI Approved :: Apache Software License', 20 | 'Natural Language :: English', 21 | 'Programming Language :: Python :: 3.5', 22 | 'Programming Language :: Python :: 3.6', 23 | 'Programming Language :: Python :: 3.7', 24 | 'Programming Language :: Python :: 3.8', 25 | 'Programming Language :: Python :: 3.9', 26 | 'Topic :: Software Development :: Testing', 27 | 'Topic :: Software Development :: Quality Assurance', 28 | 'Topic :: Utilities' 29 | ], 30 | packages=find_packages(), 31 | python_requires='>=3.5', 32 | license='Apache License, Version 2.0', 33 | install_requires=install_requires, 34 | tests_require=install_requires, 35 | include_package_data=True, 36 | package_data={'report': ['reports/template/*.html']}, 37 | test_suite="resttest3.tests", 38 | entry_points={ 39 | 'console_scripts': ['resttest3=resttest3.runner:main'], 40 | } 41 | 42 | ) 43 | -------------------------------------------------------------------------------- /tests/content-test-include.yaml: -------------------------------------------------------------------------------- 1 | - include: 2 | - tests.content-test -------------------------------------------------------------------------------- /tests/content-test.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | - config: 4 | - testset: "Test content/url/header templating & file read" 5 | - variable_binds: { 'headername': 'Content-Type', 'headervalue': 'application/json' } 6 | - timeout: 10 7 | - print_bodies: true 8 | - retries: false 9 | 10 | - generators: 11 | - 'id': { type: 'number_sequence', start: 10 } 12 | 13 | - test: 14 | - name: "Get person, validate that header validator works right" 15 | - url: "/api/person/1/" 16 | - global_env: true 17 | - validators: 18 | - assertTrue: { header: content-type, test: exists } 19 | 20 | - test: 21 | - name: "Create/update person 7, no template" 22 | - url: "/api/person/7/" 23 | - method: "PUT" 24 | - headers: { template: { '$headername': '$headervalue' } } 25 | - body: '{"first_name": "Gaius","id": "7","last_name": "Romani","login": "gromani"}' 26 | -------------------------------------------------------------------------------- /tests/extension_use_test.yaml: -------------------------------------------------------------------------------- 1 | # Simple tests to verify things work against a live REST service that returns JSON.. I picked github 2 | --- 3 | - config: 4 | - testset: "Simple github.com API Test with custom validator" 5 | - generators: 6 | - 'number': { type: 'doubling', start: 8 } 7 | - test: 8 | - name: "Test with successful validations" 9 | - url: "/search/users?q=jewzaam" 10 | - group: "Successful" 11 | - validators: 12 | - contains: 'subscriptions_url' 13 | - extract_test: { jsonpath_mini: 'items.0', test: 'is_dict' } 14 | - compare: { jsonpath_mini: 'items.0.login', comparator: 'str.eq.lower', expected: 'JewZaam' } 15 | - extract_test: { 'weirdzo': 'blah', test: 'exists' } 16 | - test: 17 | - generator_binds: { num: number } 18 | - name: "Test with generator" 19 | - url: { template: "/search/users?q=$num&in=id" } 20 | - group: "Successful" 21 | - validators: 22 | - compare: { jsonpath_mini: 'items.0.login', comparator: 'contains', expected: '8' } 23 | - test: 24 | - generator_binds: { num: number } 25 | - name: "Test with generator run 2" 26 | - url: { template: "/search/users?q=$num&in=id" } 27 | - group: "Successful" 28 | - validators: 29 | - compare: { jsonpath_mini: 'items.0.login', comparator: 'contains', expected: '16' } -------------------------------------------------------------------------------- /tests/fun_test.yaml: -------------------------------------------------------------------------------- 1 | - config: 2 | - variable_binds: { 'version': 'v3' } # Defaine the variable which can use for all testcase 3 | - print_bodies: false 4 | - test: 5 | - name: Check status Code to be 200 6 | - group: "Basic" 7 | - url: { 'template': api/rest/$version/ } 8 | - validators: 9 | - compare: { jsonpath_mini: 'dockets', expected: 'https://www.courtlistener.com/api/rest/v3/dockets/' } 10 | - extract_binds: 11 | - "docket": { jsonpath_mini: "dockets" } 12 | 13 | - test: 14 | - name: Get docket information 15 | - group: "Basic" 16 | - url: { 'template': $docket } 17 | - absolute-url: true 18 | - validators: 19 | - compare: { jsonpath_mini: 'previous', expected: null } 20 | 21 | 22 | - test: 23 | - name: Get docket information for docket 1 24 | - group: "Basic" 25 | - url: api/rest/v3/dockets/1/ 26 | - validators: 27 | - compare: { jsonpath_mini: 'id', compare: eq, expected: 1 } 28 | - extract_binds: 29 | - "clusters_url": { jsonpath_mini: "clusters.0" } 30 | 31 | - test: 32 | - name: Get docket clusters information 33 | - group: "Basic" 34 | - url: { 'template': $clusters_url } 35 | - absolute-url: true 36 | - validators: 37 | - compare: { jsonpath_mini: 'id', compare: eq, expected: 2442562 } 38 | - compare: { jsonpath_mini: 'date_filed', compare: str_eq, expected: '2010-08-10' } 39 | 40 | 41 | -------------------------------------------------------------------------------- /tests/jmespath-test-all: -------------------------------------------------------------------------------- 1 | { 2 | "test1" : {"a": "foo", "b": "bar", "c": "baz"}, 3 | "test2" : {"a": {"b": {"c": {"d": "value"}}}}, 4 | "test3" : ["a", "b", "c", "d", "e", "f"], 5 | "test4" : { 6 | "a": { 7 | "b": { 8 | "c": [ 9 | {"d": [0, [1, 2]]}, 10 | {"d": [3, 4]} 11 | ] 12 | } 13 | } }, 14 | "test5" : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 15 | "test6" : { 16 | "people": [ 17 | {"first": "James", "last": "d"}, 18 | {"first": "Jacob", "last": "e"}, 19 | {"first": "Jayden", "last": "f"}, 20 | {"missing": "different"} 21 | ], 22 | "foo": {"bar": "baz"} 23 | }, 24 | "test7" : { 25 | "ops": { 26 | "functionA": {"numArgs": 2}, 27 | "functionB": {"numArgs": 3}, 28 | "functionC": {"variadic": 4} 29 | } 30 | }, 31 | "test8" : { 32 | "reservations": [ 33 | { "instances": [ {"state": "running"}, {"state": "stopped"} ] }, 34 | { "instances": [ {"state": "terminated"}, {"state": "runnning"} ] } 35 | ] 36 | }, 37 | "test9" : [ [0, 1], 2, [3], 4, [5, [6, 7]] ], 38 | "test10" : { "machines": [ {"name": "a", "state": "running"}, {"name": "b", "state": "stopped"}, {"name": "c", "state": "running"} ] }, 39 | "test11" : { 40 | "people": [ 41 | { "name": "b", "age": 30, "state": {"hired": "ooo"} }, 42 | { "name": "a", "age": 50, "state": {"fired": "ooo"} }, 43 | { "name": "c", "age": 40, "state": {"hired": "atwork"} } ] 44 | } , 45 | "test12" : { "myarray": [ "foo", "foobar", "barfoo", "bar", "baz", "barbaz", "barfoobaz" ] } 46 | } 47 | -------------------------------------------------------------------------------- /tests/jmespath-test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - config: 3 | - testset: "Basic tests" 4 | 5 | - test: 6 | - name: "test-all" 7 | - url: "/jmespath-test-all" 8 | - expected_status: [ 200 ] 9 | - validators: 10 | - compare: { jmespath: 'test1.a', comparator: 'eq', expected: 'foo' } 11 | - compare: { jmespath: 'test1.b', comparator: 'eq', expected: 'bar' } 12 | - compare: { jmespath: 'test1.c', comparator: 'eq', expected: 'baz' } 13 | - compare: { jmespath: 'test2.a.b.c.d', comparator: 'eq', expected: 'value' } 14 | - compare: { jmespath: 'test3[1]', comparator: 'eq', expected: 'b' } 15 | - compare: { jmespath: 'test4.a.b.c[0].d[1][0]', comparator: 'eq', expected: 1 } 16 | - compare: { jmespath: 'length(test5[0:5])', comparator: 'eq', expected: 5 } 17 | - compare: { jmespath: 'test5[1:3]', comparator: 'eq', expected: '[1, 2]' } 18 | - compare: { jmespath: 'test5[::2]', comparator: 'eq', expected: '[0, 2, 4, 6, 8]' } 19 | - compare: { jmespath: 'test5[5:0:-1]', comparator: 'eq', expected: '[5, 4, 3, 2, 1]' } 20 | - compare: { jmespath: 'test6.people[*].first', comparator: 'eq', expected: "['James', 'Jacob', 'Jayden']" } 21 | - compare: { jmespath: 'test6.people[:2].first', comparator: 'eq', expected: "['James', 'Jacob']" } 22 | - compare: { jmespath: 'test6.people[*].first | [0]', comparator: 'eq', expected: 'James' } 23 | - compare: { jmespath: 'test7.ops.*.numArgs', comparator: 'eq', expected: '[2, 3]' } 24 | - compare: { jmespath: 'test8.reservations[*].instances[*].state', comparator: 'eq', expected: "[['running', 'stopped'], ['terminated', 'runnning']]" } 25 | - compare: { jmespath: 'test9[]', comparator: 'eq', expected: '[0, 1, 2, 3, 4, 5, [6, 7]]' } 26 | - compare: { jmespath: "test10.machines[?state=='running'].name", comparator: 'eq', expected: "['a', 'c']" } 27 | - compare: { jmespath: "test10.machines[?state!='running'][name, state] | [0]", comparator: 'eq', expected: "['b', 'stopped']" } 28 | - compare: { jmespath: 'length(test11.people)', comparator: 'eq', expected: 3 } 29 | - compare: { jmespath: 'max_by(test11.people, &age).name', comparator: 'eq', expected: 'a' } 30 | - compare: { jmespath: "test12.myarray[?contains(@, 'foo') == `true`]", comparator: 'eq', expected: "['foo', 'foobar', 'barfoo', 'barfoobaz']" } 31 | 32 | -------------------------------------------------------------------------------- /tests/lcov.info: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/miniapp-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "Person", 4 | "description": "A person from the miniapp", 5 | "type": "object", 6 | "additionalProperties": false, 7 | "properties": { 8 | "id": { 9 | "type": "integer", 10 | "description": "Unique person ID" 11 | }, 12 | "first_name": { 13 | "type": "string" 14 | }, 15 | "last_name": { 16 | "type": "string" 17 | }, 18 | "login": { 19 | "type": "string" 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /tests/person_body_notemplate.json: -------------------------------------------------------------------------------- 1 | { 2 | "first_name": "Trius", 3 | "id": "10", 4 | "last_name": "Traltar", 5 | "login": "ttraltar" 6 | } -------------------------------------------------------------------------------- /tests/person_body_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "first_name": "Trius", 3 | "id": "$id", 4 | "last_name": "Traltar", 5 | "login": "$login" 6 | } -------------------------------------------------------------------------------- /tests/sample.yaml: -------------------------------------------------------------------------------- 1 | include: 2 | example -------------------------------------------------------------------------------- /tests/test_binding.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from resttest3.binding import Context 4 | 5 | 6 | def count_gen(): # Generator that counts up from 1 7 | val = 1 8 | while True: 9 | yield val 10 | val += 1 11 | 12 | 13 | """ Tests variable/generator binding """ 14 | 15 | 16 | class BindingTest(unittest.TestCase): 17 | 18 | def test_variables(self): 19 | """ Test bind/return of variables """ 20 | 21 | context = Context() 22 | context.variables = {} 23 | self.assertTrue(context.get_value('foo') is None) 24 | self.assertEqual(0, context.mod_count) 25 | 26 | context.bind_variable('foo', 'bar') 27 | self.assertEqual('bar', context.get_value('foo')) 28 | self.assertEqual('bar', context.get_values()['foo']) 29 | self.assertEqual(1, context.mod_count) 30 | 31 | context.bind_variable('foo', 'bar2') 32 | self.assertEqual('bar2', context.get_value('foo')) 33 | self.assertEqual(2, context.mod_count) 34 | 35 | def test_generator(self): 36 | """ Test adding a generator """ 37 | context = Context() 38 | self.assertEqual(0, len(context.get_generators())) 39 | my_gen = count_gen() 40 | context.add_generator('gen', my_gen) 41 | 42 | self.assertEqual(1, len(context.get_generators())) 43 | self.assertTrue('gen' in context.get_generators()) 44 | self.assertTrue(context.get_generator('gen') is not None) 45 | 46 | def test_generator_bind(self): 47 | """ Test generator setting to variables """ 48 | context = Context() 49 | self.assertEqual(0, len(context.get_generators())) 50 | my_gen = count_gen() 51 | context.add_generator('gen', my_gen) 52 | 53 | context.bind_generator_next('foo', 'gen') 54 | self.assertEqual(1, context.mod_count) 55 | self.assertEqual(1, context.get_value('foo')) 56 | self.assertTrue(2, next(context.get_generator('gen'))) 57 | self.assertTrue(3, next(my_gen)) 58 | 59 | def test_mixing_binds(self): 60 | """ Ensure that variables are set correctly when mixing explicit declaration and variables """ 61 | context = Context() 62 | context.add_generator('gen', count_gen()) 63 | context.bind_variable('foo', '100') 64 | self.assertEqual(1, context.mod_count) 65 | context.bind_generator_next('foo', 'gen') 66 | self.assertEqual(1, context.get_value('foo')) 67 | self.assertEqual(2, context.mod_count) 68 | 69 | 70 | if __name__ == '__main__': 71 | unittest.main() 72 | -------------------------------------------------------------------------------- /tests/test_contenthandling.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import os 4 | import string 5 | import unittest 6 | 7 | from resttest3.binding import Context 8 | from resttest3.contenthandling import ContentHandler 9 | 10 | 11 | class ContentHandlerTest(unittest.TestCase): 12 | """ Testing for content handler """ 13 | 14 | def test_content_templating(self): 15 | """ Test content and templating of it """ 16 | handler = ContentHandler() 17 | body = '$variable value' 18 | templated_body = 'bar value' 19 | context = Context() 20 | context.bind_variable('variable', 'bar') 21 | 22 | # No templating 23 | handler.setup(body, is_template_content=False) 24 | self.assertEqual(body, handler.get_content()) 25 | self.assertEqual(body, handler.get_content(context)) 26 | self.assertRaises(TypeError, handler.setup, []) 27 | # Templating 28 | handler.setup(body, is_template_content=True) 29 | self.assertEqual(body, handler.get_content()) 30 | self.assertEqual(templated_body, handler.get_content(context)) 31 | 32 | def test_unicode_templating(self): 33 | """ A couple combination of templating using Unicode data """ 34 | handler = ContentHandler() 35 | context = Context() 36 | 37 | # ASCII body, unicode data 38 | body = '$variable value' 39 | context.bind_variable('variable', u'😽') 40 | handler.setup(body, is_template_content=True) 41 | self.assertEqual(body, handler.get_content()) 42 | self.assertEqual(u'😽 value', handler.get_content(context)) 43 | 44 | # Unicode body, ASCII data 45 | context.bind_variable('variable', u'string') 46 | body = u'$variable 😽 value' 47 | handler.setup(body, is_template_content=True) 48 | self.assertEqual(u'$variable 😽 value', handler.get_content()) 49 | self.assertEqual(u'string 😽 value', handler.get_content(context)) 50 | 51 | # All the Unicodes, all the times! 52 | context.bind_variable('variable', u'😽') 53 | body = u'$variable 😽 value' 54 | handler.setup(body, is_template_content=True) 55 | self.assertEqual(u'😽 😽 value', handler.get_content(context)) 56 | 57 | def test_content_file_template(self): 58 | """ Test file read and templating of read files in this directory """ 59 | variables = {'id': 1, 'login': 'thewizard'} 60 | context = Context() 61 | 62 | file_path = os.path.dirname(os.path.realpath(__file__)) 63 | file_path = os.path.join(file_path, 'person_body_template.json') 64 | 65 | file_content = None 66 | with open(file_path, 'r') as f: 67 | file_content = f.read() 68 | 69 | # Test basic read 70 | handler = ContentHandler() 71 | handler.setup(file_path, is_file=True) 72 | self.assertEqual(file_content, handler.get_content()) 73 | 74 | # Test templating of read content 75 | handler.setup(file_path, is_file=True, is_template_content=True) 76 | self.assertEqual(file_content, handler.get_content()) 77 | self.assertEqual(file_content, handler.get_content( 78 | context)) # No substitution 79 | substituted = string.Template(file_content).safe_substitute(variables) 80 | context.bind_variables(variables) 81 | self.assertEqual(substituted, handler.get_content(context)) 82 | 83 | # Test path templating 84 | templated_file_path = '$filepath' 85 | context.bind_variable('filepath', file_path) 86 | handler.setup(file_path, is_file=True, is_template_path=True) 87 | self.assertEqual(file_content, handler.get_content(context)) 88 | 89 | # Test double templating with files 90 | handler.setup(file_path, is_file=True, 91 | is_template_path=True, is_template_content=True) 92 | self.assertEqual(substituted, handler.get_content(context=context)) 93 | 94 | def test_cached_read(self): 95 | """ Test method that creates a copy with file read already performed """ 96 | file_path = os.path.dirname(os.path.realpath(__file__)) 97 | file_path = os.path.join(file_path, 'person_body_template.json') 98 | 99 | # Read file to compare 100 | file_content = None 101 | with open(file_path, 'r') as f: 102 | file_content = f.read() 103 | 104 | handler = ContentHandler() 105 | handler.setup(file_path, is_file=True) 106 | 107 | # Check it read the file correctly 108 | cached_handler = handler.create_noread_version() 109 | self.assertEqual(file_content, handler.get_content()) 110 | self.assertFalse(cached_handler.is_file) 111 | 112 | # Check with templating 113 | handler.is_template_content = True 114 | cached_handler = handler.create_noread_version() 115 | self.assertEqual(file_content, handler.get_content()) 116 | self.assertEqual(handler.is_template_content, 117 | cached_handler.is_template_content) 118 | self.assertFalse(cached_handler.is_file) 119 | 120 | # Check dynamic paths don't try to template 121 | handler.is_template_path = True 122 | cached_handler = handler.create_noread_version() 123 | self.assertTrue(handler is cached_handler) 124 | 125 | def test_parse_content_simple(self): 126 | """ Test parsing of simple content """ 127 | node = "myval" 128 | handler = ContentHandler.parse_content(node) 129 | self.assertEqual(node, handler.content) 130 | self.assertEqual(node, handler.get_content()) 131 | self.assertFalse(handler.is_dynamic()) 132 | self.assertFalse(handler.is_file) 133 | self.assertFalse(handler.is_template_path) 134 | self.assertFalse(handler.is_template_content) 135 | 136 | node = 2 137 | self.assertRaises(TypeError, ContentHandler.parse_content, node) 138 | 139 | def test_parse_content_file(self): 140 | """ Test parsing of file content """ 141 | node = {'file': '/myval'} 142 | handler = ContentHandler.parse_content(node) 143 | self.assertEqual(node['file'], handler.content) 144 | self.assertFalse(handler.is_dynamic()) 145 | self.assertTrue(handler.is_file) 146 | self.assertFalse(handler.is_template_path) 147 | self.assertFalse(handler.is_template_content) 148 | 149 | def test_parse_content_templated(self): 150 | """ Test parsing of templated content """ 151 | node = {'template': 'myval $var'} 152 | handler = ContentHandler.parse_content(node) 153 | context = Context() 154 | context.bind_variable('var', 'cheese') 155 | self.assertEqual(node['template'], handler.content) 156 | self.assertEqual('myval cheese', handler.get_content(context)) 157 | self.assertTrue(handler.is_dynamic()) 158 | self.assertFalse(handler.is_file) 159 | self.assertFalse(handler.is_template_path) 160 | self.assertTrue(handler.is_template_content) 161 | 162 | def test_parse_content_template_unicode(self): 163 | """ Unicode parsing tests """ 164 | node = {'template': u'myval 😽 $var'} 165 | handler = ContentHandler.parse_content(node) 166 | context = Context() 167 | context.bind_variable('var', 'cheese') 168 | self.assertEqual(u'myval 😽 cheese', handler.get_content(context)) 169 | 170 | def test_parse_content_templated_file_path(self): 171 | """ Test parsing of templated file path """ 172 | node = {'file': {'template': '/$host-path.yaml'}} 173 | handler = ContentHandler.parse_content(node) 174 | self.assertEqual('/$host-path.yaml', handler.content) 175 | self.assertTrue(handler.is_dynamic()) 176 | self.assertTrue(handler.is_file) 177 | self.assertTrue(handler.is_template_path) 178 | self.assertFalse(handler.is_template_content) 179 | 180 | def test_parse_content_templated_file_content(self): 181 | """ Test parsing of templated file content """ 182 | node = {'template': {'file': '/path.yaml'}} 183 | handler = ContentHandler.parse_content(node) 184 | self.assertEqual('/path.yaml', handler.content) 185 | self.assertTrue(handler.is_dynamic()) 186 | self.assertTrue(handler.is_file) 187 | self.assertFalse(handler.is_template_path) 188 | self.assertTrue(handler.is_template_content) 189 | 190 | def test_parse_content_double_templated_file(self): 191 | """ Test parsing of file with path and content templated """ 192 | node = {'template': {'file': {'template': '/$var-path.yaml'}}} 193 | handler = ContentHandler.parse_content(node) 194 | self.assertEqual('/$var-path.yaml', handler.content) 195 | self.assertTrue(handler.is_dynamic()) 196 | self.assertTrue(handler.is_file) 197 | self.assertTrue(handler.is_template_path) 198 | self.assertTrue(handler.is_template_content) 199 | 200 | def test_parse_content_breaks(self): 201 | """ Test for handling parsing of some bad input cases """ 202 | failing_configs = list() 203 | failing_configs.append({'template': None}) 204 | failing_configs.append({'file': None}) 205 | failing_configs.append({'file': {'template': None}}) 206 | failing_configs.append({'file': {'template': 1}}) 207 | failing_configs.append({'file': {'template': 1}}) 208 | failing_configs.append({'fil': {'template': 'pathname.yaml'}}) 209 | 210 | for config in failing_configs: 211 | try: 212 | handler = ContentHandler.parse_content(config) 213 | self.fail("Should raise an exception on invalid parse, config: " + 214 | json.dumps(config, default=lambda o: o.__dict__)) 215 | except Exception: 216 | pass 217 | 218 | 219 | if __name__ == '__main__': 220 | unittest.main() 221 | -------------------------------------------------------------------------------- /tests/test_generators.py: -------------------------------------------------------------------------------- 1 | import os 2 | import string 3 | import types 4 | import unittest 5 | 6 | import pytest 7 | 8 | from resttest3 import generators 9 | from resttest3.binding import Context 10 | 11 | 12 | class GeneratorTest(unittest.TestCase): 13 | """ Tests for generators """ 14 | 15 | def generator_basic_test(self, generator, value_test_function=None): 16 | """ Basic test for a generator, checks values and applies test function """ 17 | self.assertTrue(isinstance(generator, types.GeneratorType)) 18 | 19 | for x in range(0, 100): 20 | val = next(generator) 21 | self.assertTrue(val is not None) 22 | if value_test_function: 23 | self.assertTrue(value_test_function( 24 | val), 'Test failed with value {0}'.format(val)) 25 | 26 | def generator_repeat_test(self, generator_input): 27 | """ Basic test of a configured generator """ 28 | val = next(generator_input) 29 | 30 | # Check for not repeating easily 31 | for x in range(0, 5): 32 | val2 = next(generator_input) 33 | self.assertTrue(val) 34 | self.assertTrue(val != val2) 35 | val = val2 36 | 37 | def test_invalid_gen_type(self): 38 | c = Context() 39 | with self.assertRaises(ValueError) as context: 40 | c.add_generator('example', [1, 2, 3]) 41 | 42 | self.assertTrue('Cannot add generator' in str(context.exception)) 43 | 44 | self.assertRaises(ValueError, c.add_generator, 'example', [1, 2, 3]) 45 | 46 | def test_factory_ids(self): 47 | f = generators.factory_generate_ids(1)() 48 | f2 = generators.factory_generate_ids(101)() 49 | f3 = generators.factory_generate_ids(1)() 50 | 51 | vals = [next(f), next(f)] 52 | vals2 = [next(f2), next(f2)] 53 | vals3 = [next(f3), next(f3)] 54 | 55 | self.assertEqual(1, vals[0]) 56 | self.assertEqual(2, vals[1]) 57 | 58 | self.assertEqual(101, vals2[0]) 59 | self.assertEqual(102, vals2[1]) 60 | 61 | # Check for accidental closure 62 | self.assertEqual(1, vals3[0]) 63 | self.assertEqual(2, vals3[1]) 64 | 65 | def test_basic_ids(self): 66 | """ Test starting ids """ 67 | ids1 = generators.generator_basic_ids() 68 | ids2 = generators.generator_basic_ids() 69 | self.generator_repeat_test(ids1) 70 | self.generator_repeat_test(ids2) 71 | self.assertEqual(next(ids1), next(ids2)) 72 | 73 | c = Context() 74 | c.variables = {'x': '$x'} 75 | x = generators.factory_generate_ids(1, increment=0)() 76 | c.add_generator('x', x) 77 | 78 | self.assertEqual(c.bind_generator_next('x', 'x'), c.bind_generator_next('x', 'x')) 79 | 80 | c.bind_variable('x', 1) 81 | var1 = c.get_value('x') 82 | c.bind_variable('x', 1) 83 | var2 = c.get_value('x') 84 | self.assertEqual(var1, var2) 85 | 86 | def test_random_ids(self): 87 | """ Test random in ids generator """ 88 | gen = generators.generator_random_int32() 89 | print(next(gen)) 90 | self.generator_repeat_test(gen) 91 | 92 | def test_system_variables(self): 93 | """ Test generator for binding system variables """ 94 | variable = 'FOOBARBAZ' 95 | value = 'myTestVal' 96 | old_val = os.environ.get(variable) 97 | 98 | generator = generators.factory_env_variable(variable)() 99 | self.assertTrue(next(generator) is None) 100 | 101 | os.environ[variable] = value 102 | self.assertEqual(value, next(generator)) 103 | self.assertEqual(next(generator), os.path.expandvars('$' + variable)) 104 | 105 | # Restore environment 106 | if old_val is not None: 107 | os.environ[variable] = old_val 108 | else: 109 | del os.environ[variable] 110 | 111 | def test_factory_text(self): 112 | """ Test the basic generator """ 113 | charsets = [string.ascii_letters, string.digits, 114 | string.ascii_uppercase, string.hexdigits] 115 | # Test multiple charsets and string lengths 116 | for charset in charsets: 117 | # Test different lengths for charset 118 | for my_length in range(1, 17): 119 | gen = generators.factory_generate_text( 120 | legal_characters=charset, min_length=my_length, max_length=my_length)() 121 | for x in range(0, 10): 122 | val = next(gen) 123 | self.assertEqual(my_length, len(val)) 124 | 125 | def test_factory_sequence(self): 126 | """ Tests linear sequences """ 127 | vals = [1] 128 | gen = generators.factory_fixed_sequence(vals)() 129 | self.generator_basic_test(gen, lambda x: x in vals) 130 | 131 | vals = ['moobie', 'moby', 'moo'] 132 | gen = generators.factory_fixed_sequence(vals)() 133 | self.generator_basic_test(gen, lambda x: x in vals) 134 | 135 | vals = {'a', 'b', 'c'} 136 | gen = generators.factory_fixed_sequence(vals)() 137 | self.generator_basic_test(gen, lambda x: x in vals) 138 | 139 | def test_parse_fixed_sequence(self): 140 | vals = ['moobie', 'moby', 'moo'] 141 | config = {'type': 'fixed_sequence', 142 | 'values': vals} 143 | gen = generators.parse_generator(config) 144 | self.generator_basic_test(gen, lambda x: x in vals) 145 | self.assertRaises(ValueError, generators.parse_generator, {'type': 'fixed_sequence', 'values': []}) 146 | self.assertRaises(ValueError, generators.parse_generator, {'type': 'fixed_sequence', 'values': {'x': 1}}) 147 | 148 | def test_factory_choice(self): 149 | """ Tests linear sequences """ 150 | vals = [1] 151 | gen = generators.factory_choice_generator(vals)() 152 | self.generator_basic_test(gen, lambda x: x in vals) 153 | 154 | vals = ['moobie', 'moby', 'moo'] 155 | gen = generators.factory_choice_generator(vals)() 156 | self.generator_basic_test(gen, lambda x: x in vals) 157 | 158 | vals = {'a', 'b', 'c'} 159 | gen = generators.factory_choice_generator(vals)() 160 | self.generator_basic_test(gen, lambda x: x in vals) 161 | 162 | with pytest.raises(ValueError) as e: 163 | gen = generators.parse_choice_generator({'values': []})() 164 | 165 | def test_register_generator(self): 166 | 167 | with pytest.raises(TypeError) as e: 168 | gen = generators.register_generator(1, lambda x: x) 169 | 170 | with pytest.raises(ValueError) as e: 171 | gen = generators.register_generator('env_string', lambda x: x) 172 | 173 | def test_parse_choice_generatpr(self): 174 | vals = ['moobie', 'moby', 'moo'] 175 | config = {'type': 'choice', 176 | 'values': vals} 177 | gen = generators.parse_generator(config) 178 | self.generator_basic_test(gen, lambda x: x in vals) 179 | 180 | def test_factory_text_multilength(self): 181 | """ Test that the random text generator can handle multiple lengths """ 182 | gen = generators.factory_generate_text( 183 | legal_characters='abcdefghij', min_length=1, max_length=100)() 184 | lengths = set() 185 | for x in range(0, 100): 186 | lengths.add(len(next(gen))) 187 | self.assertTrue(len( 188 | lengths) > 1, "Variable length string generator did not generate multiple string lengths") 189 | 190 | def test_character_sets(self): 191 | """ Verify all charsets are valid """ 192 | sets = generators.CHARACTER_SETS 193 | for key, value in sets.items(): 194 | self.assertTrue(value) 195 | 196 | def test_parse_text_generator(self): 197 | """ Test the text generator parsing """ 198 | config = dict() 199 | config['type'] = 'random_text' 200 | config['character_set'] = 'reallyINVALID' 201 | 202 | try: 203 | gen = generators.parse_generator(config) 204 | self.fail( 205 | "Should never parse an invalid character_set successfully, but did!") 206 | except ValueError: 207 | pass 208 | test_config = {'type': 'random_text'} 209 | try: 210 | gen = generators.parse_generator(test_config) 211 | except Exception: 212 | self.fail("Should never Raise an exception!") 213 | 214 | # Test for character set handling 215 | for charset in generators.CHARACTER_SETS: 216 | try: 217 | config['character_set'] = charset 218 | gen = generators.parse_generator(config) 219 | myset = set(generators.CHARACTER_SETS[charset]) 220 | for x in range(0, 50): 221 | val = next(gen) 222 | self.assertTrue(set(val).issubset(set(myset))) 223 | except Exception as e: 224 | print('Exception occurred with charset: ' + charset) 225 | raise e 226 | 227 | my_min = 1 228 | my_max = 10 229 | 230 | # Test for explicit character setting 231 | del config['character_set'] 232 | temp_chars = 'ay78%&' 233 | config['characters'] = temp_chars 234 | gen = generators.parse_generator(config) 235 | self.generator_basic_test( 236 | gen, value_test_function=lambda x: set(x).issubset(set(temp_chars))) 237 | 238 | # Test for length setting 239 | config['length'] = '3' 240 | gen = generators.parse_generator(config) 241 | self.generator_basic_test( 242 | gen, value_test_function=lambda x: len(x) == 3) 243 | del config['length'] 244 | 245 | # Test for explicit min/max length 246 | config['min_length'] = '9' 247 | config['max_length'] = 12 248 | gen = generators.parse_generator(config) 249 | self.generator_basic_test( 250 | gen, value_test_function=lambda x: len(x) >= 9 and len(x) <= 12) 251 | 252 | def test_parse_basic(self): 253 | """ Test basic parsing, simple cases that should succeed or throw known errors """ 254 | config = {'type': 'unsupported'} 255 | 256 | try: 257 | gen = generators.parse_generator(config) 258 | self.fail( 259 | "Expected failure due to invalid generator type, did not emit it") 260 | except ValueError: 261 | pass 262 | 263 | # Try creating a random_int generator 264 | config['type'] = 'random_int' 265 | gen = generators.parse_generator(config) 266 | self.generator_basic_test( 267 | gen, value_test_function=lambda x: isinstance(x, int)) 268 | self.generator_repeat_test(gen) 269 | 270 | # Sample variable 271 | os.environ['SAMPLEVAR'] = 'goober' 272 | 273 | config['type'] = 'env_variable' 274 | config['variable_name'] = 'SAMPLEVAR' 275 | gen = generators.parse_generator(config) 276 | self.generator_basic_test(gen) 277 | del config['variable_name'] 278 | 279 | config['type'] = 'env_string' 280 | config['string'] = '$SAMPLEVAR' 281 | gen = generators.parse_generator(config) 282 | self.generator_basic_test(gen) 283 | del config['string'] 284 | 285 | config['type'] = 'number_sequence' 286 | config['start'] = '1' 287 | config['increment'] = '10' 288 | gen = generators.parse_generator(config) 289 | self.assertEqual(1, next(gen)) 290 | self.assertEqual(11, next(gen)) 291 | self.generator_basic_test(gen) 292 | del config['type'] 293 | 294 | 295 | if __name__ == '__main__': 296 | unittest.main() 297 | -------------------------------------------------------------------------------- /tests/test_jmespath_extractor.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from resttest3.ext.extractor_jmespath import JMESPathExtractor 4 | 5 | 6 | class MTestJMESPathExtractor(unittest.TestCase): 7 | 8 | def setUp(self) -> None: 9 | self.ext = JMESPathExtractor() 10 | 11 | def test_extract_internal(self): 12 | unicoded_body = '{"test":"指事字"}' 13 | b = bytes('{"test":23}', 'utf-8') 14 | 15 | self.ext.extract_internal('test', unicoded_body, None) 16 | data = self.ext.extract_internal('test', b) 17 | self.assertEqual(data, 23) 18 | self.assertRaises(ValueError, self.ext.extract_internal, 'test', None, 'abc') 19 | 20 | 21 | if __name__ == '__main__': 22 | unittest.main() 23 | -------------------------------------------------------------------------------- /tests/test_parse.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pytest import fail 4 | 5 | from resttest3.utils import Parser 6 | 7 | 8 | class ParserTest(unittest.TestCase): 9 | """ Testing for basic REST test methods, how meta! """ 10 | 11 | # Parsing methods 12 | def test_coerce_to_string(self): 13 | self.assertEqual(u'1', Parser.coerce_to_string(1)) 14 | self.assertEqual(u'stuff', Parser.coerce_to_string(u'stuff')) 15 | self.assertEqual(u'stuff', Parser.coerce_to_string('stuff')) 16 | self.assertEqual(u'st😽uff', Parser.coerce_to_string(u'st😽uff')) 17 | self.assertRaises(TypeError, Parser.coerce_to_string, {'key': 'value'}) 18 | self.assertRaises(TypeError, Parser.coerce_to_string, None) 19 | 20 | def test_coerce_http_method(self): 21 | self.assertEqual(u'HEAD', Parser.coerce_http_method(u'hEaD')) 22 | self.assertEqual(u'HEAD', Parser.coerce_http_method(b'hEaD')) 23 | self.assertRaises(TypeError, Parser.coerce_http_method, 5) 24 | self.assertRaises(TypeError, Parser.coerce_http_method, None) 25 | self.assertRaises(TypeError, Parser.coerce_http_method, u'') 26 | 27 | def test_coerce_string_to_ascii(self): 28 | self.assertEqual(b'stuff', Parser.coerce_string_to_ascii(u'stuff')) 29 | self.assertRaises(UnicodeEncodeError, Parser.coerce_string_to_ascii, u'st😽uff') 30 | self.assertRaises(TypeError, Parser.coerce_string_to_ascii, 1) 31 | self.assertRaises(TypeError, Parser.coerce_string_to_ascii, None) 32 | 33 | def test_coerce_list_of_ints(self): 34 | self.assertEqual([1], Parser.coerce_list_of_ints(1)) 35 | self.assertEqual([2], Parser.coerce_list_of_ints('2')) 36 | self.assertEqual([18], Parser.coerce_list_of_ints(u'18')) 37 | self.assertEqual([1, 2], Parser.coerce_list_of_ints([1, 2])) 38 | self.assertEqual([1, 2], Parser.coerce_list_of_ints([1, '2'])) 39 | 40 | try: 41 | val = Parser.coerce_list_of_ints('goober') 42 | fail("Shouldn't allow coercing a random string to a list of ints") 43 | except: 44 | pass 45 | 46 | 47 | if __name__ == '__main__': 48 | unittest.main() 49 | -------------------------------------------------------------------------------- /tests/test_parsing.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import unittest 3 | 4 | from resttest3.utils import Parser 5 | 6 | 7 | class TestParsing(unittest.TestCase): 8 | """ Tests for parsing utility functions """ 9 | 10 | def test_encode_unicode_bytes(self): 11 | val = 8 12 | unicoded = u'指事字' 13 | byteform = b'\xe6\x8c\x87\xe4\xba\x8b\xe5\xad\x97' 14 | num = 156 15 | 16 | self.assertEqual(byteform, Parser.encode_unicode_bytes(unicoded)) 17 | self.assertEqual(byteform, Parser.encode_unicode_bytes(byteform)) 18 | self.assertEqual(b'156', Parser.encode_unicode_bytes(num)) 19 | 20 | def test_unicode_templating(self): 21 | # Unicode template and unicode substitution 22 | unicode_template_string = u'my name is 指 and my value is $var' 23 | unicode_variables = {'var': u'漢'} 24 | normal_variables = {'var': u'bob'} 25 | substituted = Parser.safe_substitute_unicode_template(unicode_template_string, unicode_variables) 26 | self.assertEqual(u'my name is 指 and my value is 漢', substituted) 27 | 28 | # Normal template and unicode substitution 29 | normal_template_string = 'my normal name is blah and my unicode name is $var' 30 | substituted = Parser.safe_substitute_unicode_template(normal_template_string, unicode_variables) 31 | self.assertEqual(u'my normal name is blah and my unicode name is 漢', substituted) 32 | 33 | # Unicode template and normal substitution 34 | substituted = Parser.safe_substitute_unicode_template(unicode_template_string, normal_variables) 35 | self.assertEqual(u'my name is 指 and my value is bob', substituted) 36 | 37 | def test_flatten(self): 38 | """ Test flattening of lists of dictionaries to single dictionaries """ 39 | 40 | # Test happy path: list of single-item dictionaries in 41 | array = [{"url": "/cheese"}, {"method": "POST"}] 42 | expected = {"url": "/cheese", "method": "POST"} 43 | output = Parser.flatten_dictionaries(array) 44 | self.assertTrue(isinstance(output, dict)) 45 | # Test that expected output matches actual 46 | self.assertFalse(len(set(output.items()) ^ set(expected.items()))) 47 | 48 | # Test dictionary input 49 | array = {"url": "/cheese", "method": "POST"} 50 | expected = {"url": "/cheese", "method": "POST"} 51 | output = Parser.flatten_dictionaries(array) 52 | self.assertTrue(isinstance(output, dict)) 53 | # Test that expected output matches actual 54 | self.assertTrue(len(set(output.items()) ^ set(expected.items())) == 0) 55 | 56 | # Test empty list input 57 | array = [] 58 | expected = {} 59 | output = Parser.flatten_dictionaries(array) 60 | self.assertTrue(isinstance(output, dict)) 61 | # Test that expected output matches actual 62 | self.assertFalse(len(set(output.items()) ^ set(expected.items()))) 63 | 64 | # Test empty dictionary input 65 | array = {} 66 | expected = {} 67 | output = Parser.flatten_dictionaries(array) 68 | self.assertTrue(isinstance(output, dict)) 69 | # Test that expected output matches actual 70 | self.assertFalse(len(set(output.items()) ^ set(expected.items()))) 71 | 72 | # Test mixed-size input dictionaries 73 | array = [{"url": "/cheese"}, {"method": "POST", "foo": "bar"}] 74 | expected = {"url": "/cheese", "method": "POST", "foo": "bar"} 75 | output = Parser.flatten_dictionaries(array) 76 | self.assertTrue(isinstance(output, dict)) 77 | # Test that expected output matches actual 78 | self.assertFalse(len(set(output.items()) ^ set(expected.items()))) 79 | 80 | def test_safe_boolean(self): 81 | """ Test safe conversion to boolean """ 82 | self.assertFalse(Parser.safe_to_bool(False)) 83 | self.assertTrue(Parser.safe_to_bool(True)) 84 | self.assertTrue(Parser.safe_to_bool('True')) 85 | self.assertTrue(Parser.safe_to_bool('true')) 86 | self.assertTrue(Parser.safe_to_bool('truE')) 87 | self.assertFalse(Parser.safe_to_bool('false')) 88 | 89 | # Try things that should throw exceptions 90 | try: 91 | boolean = Parser.safe_to_bool('fail') 92 | raise AssertionError('Failed to throw type error that should have') 93 | except TypeError: 94 | pass # Good 95 | 96 | try: 97 | boolean = Parser.safe_to_bool([]) 98 | raise AssertionError('Failed to throw type error that should have') 99 | except TypeError: 100 | pass # Good 101 | 102 | try: 103 | boolean = Parser.safe_to_bool(None) 104 | raise AssertionError('Failed to throw type error that should have') 105 | except TypeError: 106 | pass # Good 107 | 108 | def test_safe_to_json(self): 109 | 110 | self.assertEqual(u'adj12321nv', Parser.safe_to_json(u'adj12321nv')) 111 | 112 | self.assertEqual(u'5.2', Parser.safe_to_json(5.2)) 113 | 114 | class Special(object): 115 | bal = 5.3 116 | test = 'stuffing' 117 | 118 | def __init__(self): 119 | self.newval = 'cherries' 120 | 121 | self.assertEqual({'newval': 'cherries'}, Parser.safe_to_json(Special())) 122 | 123 | 124 | if __name__ == '__main__': 125 | unittest.main() 126 | -------------------------------------------------------------------------------- /tests/test_schema_validation.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crazi-coder/resttest3/a6078036a04498f4c2a9c3053aed463b6e98a542/tests/test_schema_validation.py -------------------------------------------------------------------------------- /tests/test_templite.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 3 | # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt 4 | 5 | """Tests for coverage.templite.""" 6 | 7 | import re 8 | import unittest 9 | 10 | from resttest3.reports.templite import Templite, TempliteSyntaxError, TempliteValueError 11 | 12 | 13 | # pylint: disable=possibly-unused-variable 14 | 15 | class AnyOldObject(object): 16 | """Simple testing object. 17 | 18 | Use keyword arguments in the constructor to set attributes on the object. 19 | 20 | """ 21 | 22 | def __init__(self, **attrs): 23 | for n, v in attrs.items(): 24 | setattr(self, n, v) 25 | 26 | 27 | class TempliteTest(unittest.TestCase): 28 | """Tests for Templite.""" 29 | 30 | run_in_temp_dir = False 31 | 32 | def try_render(self, text, ctx=None, result=None): 33 | """Render `text` through `ctx`, and it had better be `result`. 34 | 35 | Result defaults to None so we can shorten the calls where we expect 36 | an exception and never get to the result comparison. 37 | 38 | """ 39 | actual = Templite(text).render(ctx or {}) 40 | # If result is None, then an exception should have prevented us getting 41 | # to here. 42 | assert result is not None 43 | self.assertEqual(actual, result) 44 | 45 | def assertSynErr(self, msg): 46 | """Assert that a `TempliteSyntaxError` will happen. 47 | 48 | A context manager, and the message should be `msg`. 49 | 50 | """ 51 | pat = "^" + re.escape(msg) + "$" 52 | return self.assertRaisesRegex(TempliteSyntaxError, pat) 53 | 54 | def test_passthrough(self): 55 | # Strings without variables are passed through unchanged. 56 | self.assertEqual(Templite("Hello").render(), "Hello") 57 | self.assertEqual( 58 | Templite("Hello, 20% fun time!").render(), 59 | "Hello, 20% fun time!" 60 | ) 61 | 62 | def test_variables(self): 63 | # Variables use {{var}} syntax. 64 | self.try_render("Hello, {{name}}!", {'name': 'Ned'}, "Hello, Ned!") 65 | 66 | def test_undefined_variables(self): 67 | # Using undefined names is an error. 68 | with self.assertRaisesRegex(Exception, "'name'"): 69 | self.try_render("Hi, {{name}}!") 70 | 71 | def test_pipes(self): 72 | # Variables can be filtered with pipes. 73 | data = { 74 | 'name': 'Ned', 75 | 'upper': lambda x: x.upper(), 76 | 'second': lambda x: x[1], 77 | } 78 | self.try_render("Hello, {{name|upper}}!", data, "Hello, NED!") 79 | 80 | # Pipes can be concatenated. 81 | self.try_render("Hello, {{name|upper|second}}!", data, "Hello, E!") 82 | 83 | def test_reusability(self): 84 | # A single Templite can be used more than once with different data. 85 | globs = { 86 | 'upper': lambda x: x.upper(), 87 | 'punct': '!', 88 | } 89 | 90 | template = Templite("This is {{name|upper}}{{punct}}", globs) 91 | self.assertEqual(template.render({'name': 'Ned'}), "This is NED!") 92 | self.assertEqual(template.render({'name': 'Ben'}), "This is BEN!") 93 | 94 | def test_attribute(self): 95 | # Variables' attributes can be accessed with dots. 96 | obj = AnyOldObject(a="Ay") 97 | self.try_render("{{obj.a}}", locals(), "Ay") 98 | 99 | obj2 = AnyOldObject(obj=obj, b="Bee") 100 | self.try_render("{{obj2.obj.a}} {{obj2.b}}", locals(), "Ay Bee") 101 | 102 | def test_member_function(self): 103 | # Variables' member functions can be used, as long as they are nullary. 104 | class WithMemberFns(AnyOldObject): 105 | """A class to try out member function access.""" 106 | 107 | def ditto(self): 108 | """Return twice the .txt attribute.""" 109 | return self.txt + self.txt 110 | 111 | obj = WithMemberFns(txt="Once") 112 | self.try_render("{{obj.ditto}}", locals(), "OnceOnce") 113 | 114 | def test_item_access(self): 115 | # Variables' items can be used. 116 | d = {'a': 17, 'b': 23} 117 | self.try_render("{{d.a}} < {{d.b}}", locals(), "17 < 23") 118 | 119 | def test_loops(self): 120 | # Loops work like in Django. 121 | nums = [1, 2, 3, 4] 122 | self.try_render( 123 | "Look: {% for n in nums %}{{n}}, {% endfor %}done.", 124 | locals(), 125 | "Look: 1, 2, 3, 4, done." 126 | ) 127 | 128 | # Loop iterables can be filtered. 129 | def rev(l): 130 | """Return the reverse of `l`.""" 131 | l = l[:] 132 | l.reverse() 133 | return l 134 | 135 | self.try_render( 136 | "Look: {% for n in nums|rev %}{{n}}, {% endfor %}done.", 137 | locals(), 138 | "Look: 4, 3, 2, 1, done." 139 | ) 140 | 141 | def test_empty_loops(self): 142 | self.try_render( 143 | "Empty: {% for n in nums %}{{n}}, {% endfor %}done.", 144 | {'nums': []}, 145 | "Empty: done." 146 | ) 147 | 148 | def test_multiline_loops(self): 149 | self.try_render( 150 | "Look: \n{% for n in nums %}\n{{n}}, \n{% endfor %}done.", 151 | {'nums': [1, 2, 3]}, 152 | "Look: \n\n1, \n\n2, \n\n3, \ndone." 153 | ) 154 | 155 | def test_multiple_loops(self): 156 | self.try_render( 157 | "{% for n in nums %}{{n}}{% endfor %} and " 158 | "{% for n in nums %}{{n}}{% endfor %}", 159 | {'nums': [1, 2, 3]}, 160 | "123 and 123" 161 | ) 162 | 163 | def test_comments(self): 164 | # Single-line comments work: 165 | self.try_render( 166 | "Hello, {# Name goes here: #}{{name}}!", 167 | {'name': 'Ned'}, "Hello, Ned!" 168 | ) 169 | # and so do multi-line comments: 170 | self.try_render( 171 | "Hello, {# Name\ngoes\nhere: #}{{name}}!", 172 | {'name': 'Ned'}, "Hello, Ned!" 173 | ) 174 | 175 | def test_if(self): 176 | self.try_render( 177 | "Hi, {% if ned %}NED{% endif %}{% if ben %}BEN{% endif %}!", 178 | {'ned': 1, 'ben': 0}, 179 | "Hi, NED!" 180 | ) 181 | self.try_render( 182 | "Hi, {% if ned %}NED{% endif %}{% if ben %}BEN{% endif %}!", 183 | {'ned': 0, 'ben': 1}, 184 | "Hi, BEN!" 185 | ) 186 | self.try_render( 187 | "Hi, {% if ned %}NED{% if ben %}BEN{% endif %}{% endif %}!", 188 | {'ned': 0, 'ben': 0}, 189 | "Hi, !" 190 | ) 191 | self.try_render( 192 | "Hi, {% if ned %}NED{% if ben %}BEN{% endif %}{% endif %}!", 193 | {'ned': 1, 'ben': 0}, 194 | "Hi, NED!" 195 | ) 196 | self.try_render( 197 | "Hi, {% if ned %}NED{% if ben %}BEN{% endif %}{% endif %}!", 198 | {'ned': 1, 'ben': 1}, 199 | "Hi, NEDBEN!" 200 | ) 201 | 202 | def test_complex_if(self): 203 | class Complex(AnyOldObject): 204 | """A class to try out complex data access.""" 205 | 206 | def getit(self): 207 | """Return it.""" 208 | return self.it 209 | 210 | obj = Complex(it={'x': "Hello", 'y': 0}) 211 | self.try_render( 212 | "@" 213 | "{% if obj.getit.x %}X{% endif %}" 214 | "{% if obj.getit.y %}Y{% endif %}" 215 | "{% if obj.getit.y|str %}S{% endif %}" 216 | "!", 217 | {'obj': obj, 'str': str}, 218 | "@XS!" 219 | ) 220 | 221 | def test_loop_if(self): 222 | self.try_render( 223 | "@{% for n in nums %}{% if n %}Z{% endif %}{{n}}{% endfor %}!", 224 | {'nums': [0, 1, 2]}, 225 | "@0Z1Z2!" 226 | ) 227 | self.try_render( 228 | "X{%if nums%}@{% for n in nums %}{{n}}{% endfor %}{%endif%}!", 229 | {'nums': [0, 1, 2]}, 230 | "X@012!" 231 | ) 232 | self.try_render( 233 | "X{%if nums%}@{% for n in nums %}{{n}}{% endfor %}{%endif%}!", 234 | {'nums': []}, 235 | "X!" 236 | ) 237 | 238 | def test_nested_loops(self): 239 | self.try_render( 240 | "@" 241 | "{% for n in nums %}" 242 | "{% for a in abc %}{{a}}{{n}}{% endfor %}" 243 | "{% endfor %}" 244 | "!", 245 | {'nums': [0, 1, 2], 'abc': ['a', 'b', 'c']}, 246 | "@a0b0c0a1b1c1a2b2c2!" 247 | ) 248 | 249 | def test_whitespace_handling(self): 250 | self.try_render( 251 | "@{% for n in nums %}\n" 252 | " {% for a in abc %}{{a}}{{n}}{% endfor %}\n" 253 | "{% endfor %}!\n", 254 | {'nums': [0, 1, 2], 'abc': ['a', 'b', 'c']}, 255 | "@\n a0b0c0\n\n a1b1c1\n\n a2b2c2\n!\n" 256 | ) 257 | self.try_render( 258 | "@{% for n in nums -%}\n" 259 | " {% for a in abc -%}\n" 260 | " {# this disappears completely -#}\n" 261 | " {{a-}}\n" 262 | " {{n -}}\n" 263 | " {{n -}}\n" 264 | " {% endfor %}\n" 265 | "{% endfor %}!\n", 266 | {'nums': [0, 1, 2], 'abc': ['a', 'b', 'c']}, 267 | "@a00b00c00\na11b11c11\na22b22c22\n!\n" 268 | ) 269 | self.try_render( 270 | "@{% for n in nums -%}\n" 271 | " {{n -}}\n" 272 | " x\n" 273 | "{% endfor %}!\n", 274 | {'nums': [0, 1, 2]}, 275 | "@0x\n1x\n2x\n!\n" 276 | ) 277 | self.try_render(" hello ", {}, " hello ") 278 | 279 | def test_eat_whitespace(self): 280 | self.try_render( 281 | "Hey!\n" 282 | "{% joined %}\n" 283 | "@{% for n in nums %}\n" 284 | " {% for a in abc %}\n" 285 | " {# this disappears completely #}\n" 286 | " X\n" 287 | " Y\n" 288 | " {{a}}\n" 289 | " {{n }}\n" 290 | " {% endfor %}\n" 291 | "{% endfor %}!\n" 292 | "{% endjoined %}\n", 293 | {'nums': [0, 1, 2], 'abc': ['a', 'b', 'c']}, 294 | "Hey!\n@XYa0XYb0XYc0XYa1XYb1XYc1XYa2XYb2XYc2!\n" 295 | ) 296 | 297 | def test_non_ascii(self): 298 | self.try_render( 299 | u"{{where}} ollǝɥ", 300 | {'where': u'ǝɹǝɥʇ'}, 301 | u"ǝɹǝɥʇ ollǝɥ" 302 | ) 303 | 304 | def test_exception_during_evaluation(self): 305 | # TypeError: Couldn't evaluate {{ foo.bar.baz }}: 306 | regex = "^Couldn't evaluate None.bar$" 307 | with self.assertRaisesRegex(TempliteValueError, regex): 308 | self.try_render( 309 | "Hey {{foo.bar.baz}} there", {'foo': None}, "Hey ??? there" 310 | ) 311 | 312 | def test_bad_names(self): 313 | with self.assertSynErr("Not a valid name: 'var%&!@'"): 314 | self.try_render("Wat: {{ var%&!@ }}") 315 | with self.assertSynErr("Not a valid name: 'filter%&!@'"): 316 | self.try_render("Wat: {{ foo|filter%&!@ }}") 317 | with self.assertSynErr("Not a valid name: '@'"): 318 | self.try_render("Wat: {% for @ in x %}{% endfor %}") 319 | 320 | def test_bogus_tag_syntax(self): 321 | with self.assertSynErr("Don't understand tag: 'bogus'"): 322 | self.try_render("Huh: {% bogus %}!!{% endbogus %}??") 323 | 324 | def test_malformed_if(self): 325 | with self.assertSynErr("Don't understand if: '{% if %}'"): 326 | self.try_render("Buh? {% if %}hi!{% endif %}") 327 | with self.assertSynErr("Don't understand if: '{% if this or that %}'"): 328 | self.try_render("Buh? {% if this or that %}hi!{% endif %}") 329 | 330 | def test_malformed_for(self): 331 | with self.assertSynErr("Don't understand for: '{% for %}'"): 332 | self.try_render("Weird: {% for %}loop{% endfor %}") 333 | with self.assertSynErr("Don't understand for: '{% for x from y %}'"): 334 | self.try_render("Weird: {% for x from y %}loop{% endfor %}") 335 | with self.assertSynErr("Don't understand for: '{% for x, y in z %}'"): 336 | self.try_render("Weird: {% for x, y in z %}loop{% endfor %}") 337 | 338 | def test_bad_nesting(self): 339 | with self.assertSynErr("Unmatched action tag: 'if'"): 340 | self.try_render("{% if x %}X") 341 | with self.assertSynErr("Mismatched end tag: 'for'"): 342 | self.try_render("{% if x %}X{% endfor %}") 343 | with self.assertSynErr("Too many ends: '{% endif %}'"): 344 | self.try_render("{% if x %}{% endif %}{% endif %}") 345 | 346 | def test_malformed_end(self): 347 | with self.assertSynErr("Don't understand end: '{% end if %}'"): 348 | self.try_render("{% if x %}X{% end if %}") 349 | with self.assertSynErr("Don't understand end: '{% endif now %}'"): 350 | self.try_render("{% if x %}X{% endif now %}") 351 | -------------------------------------------------------------------------------- /tests/test_testcase.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from inspect import getframeinfo, currentframe 3 | from pathlib import Path 4 | 5 | import pycurl 6 | import yaml 7 | 8 | from resttest3.binding import Context 9 | from resttest3.testcase import TestCaseConfig, TestSet, TestCase 10 | from resttest3.validators import MiniJsonExtractor 11 | 12 | filename = getframeinfo(currentframe()).filename 13 | current_module_path = Path(filename) 14 | 15 | 16 | class TestTestCase(unittest.TestCase): 17 | 18 | def setUp(self) -> None: 19 | with open("%s/content-test.yaml" % current_module_path.parent, 'r') as f: 20 | self.test_dict_list = yaml.safe_load(f.read()) 21 | 22 | def test_testcase_set_config(self): 23 | conf = TestCaseConfig() 24 | conf.parse(self.test_dict_list[0]['config']) 25 | self.assertEqual({'headername': 'Content-Type', 'headervalue': 'application/json'}, conf.variable_binds) 26 | 27 | def test_testset(self): 28 | ts = TestSet() 29 | ts.parse('', self.test_dict_list) 30 | self.assertIsInstance(ts.test_group_list_dict, dict) 31 | group = ts.test_group_list_dict["NO GROUP"] 32 | self.assertEqual(group.variable_binds, {'headername': 'Content-Type', 'headervalue': 'application/json'}) 33 | 34 | def test_config(self): 35 | config_object = TestCaseConfig() 36 | context = Context() 37 | config_list = [ 38 | { 39 | 'variable_binds': {'content_type': 'application/json', 'pid': 5} 40 | } 41 | ] 42 | config_object.parse(config_list) 43 | 44 | test_case = TestCase('', None, None, context, config=config_object) 45 | self.assertEqual(test_case.variable_binds, {'content_type': 'application/json', 'pid': 5}) 46 | testcase_list = [ 47 | {'name': 'Create/update person 7, no template'}, {'url': '/api/person/7/'}, {'method': 'PUT'}, 48 | {'headers': {'template': {'Content-Type': '$content_type'}}}, 49 | {'body': '{"first_name": "Gaius","id": "7","last_name": "Romani","login": "gromani"}'} 50 | ] 51 | test_case.parse(testcase_list) 52 | test_case.pre_update(context) 53 | self.assertEqual({'Content-Type': 'application/json'}, test_case.headers) 54 | 55 | testcase_list = [ 56 | {'name': 'Create/update person 7, no template'}, {'url': {'template': '/api/person/$pid/'}}, 57 | {'method': 'PUT'}, 58 | {'headers': {'template': {'Content-Type': '$content_type'}}}, 59 | {'body': '{"first_name": "Gaius","id": "7","last_name": "Romani","login": "gromani"}'}, 60 | [] # Make sure it will not through exception or error 61 | ] 62 | test_case.parse(testcase_list) 63 | test_case.pre_update(context) 64 | self.assertEqual('/api/person/5/', test_case.url) 65 | 66 | testcase_list = [ 67 | {'auth_username': 'Abhilash'}, {'auth_password': '5'}, 68 | ] 69 | test_case.parse(testcase_list) 70 | test_case.pre_update(context) 71 | self.assertEqual(bytes('5', 'utf-8'), test_case.auth_password) 72 | self.assertEqual(bytes('Abhilash', 'utf-8'), test_case.auth_username) 73 | 74 | _input = [ 75 | { 76 | "url": '/test'}, 77 | {'extract_binds': [ 78 | { 79 | 'id': {'jsonpath_mini': 'key.val'} 80 | } 81 | ] 82 | }] 83 | test_case.parse(_input) 84 | test_case.pre_update(context) 85 | self.assertIsInstance(test_case.extract_binds['id'], MiniJsonExtractor) 86 | 87 | testcase_list = [ 88 | {'name': 'Create/update person 7, no template'}, {'url': '/api/person/7/'}, {'method': 'PUT'}, 89 | {'headers': {'Content-Type': {'template': '$content_type'}}}, 90 | {'body': '{"first_name": "Gaius","id": "7","last_name": "Romani","login": "gromani"}'} 91 | ] 92 | test_case.parse(testcase_list) 93 | test_case.pre_update(context) 94 | self.assertEqual({'Content-Type': 'application/json'}, test_case.headers) 95 | 96 | testcase_list = [ 97 | {'name': 'Create/update person 7, no template'}, {'url': '/api/person/7/'}, {'method': 'PUT'}, 98 | {'headers': {'Content-Type': {'x': 'application/json'}}}, 99 | {'body': '{"first_name": "Gaius","id": "7","last_name": "Romani","login": "gromani"}'} 100 | ] 101 | test_case.parse(testcase_list) 102 | test_case.pre_update(context) 103 | self.assertEqual({'Content-Type': 'application/json'}, test_case.headers) 104 | 105 | testcase_list = [ 106 | {'name': 'Create/update person 7, no template'}, {'delay': '5'}, {'method': 'PUT'}, 107 | 108 | ] 109 | test_case.parse(testcase_list) 110 | test_case.pre_update(context) 111 | self.assertEqual(5, test_case.delay) 112 | 113 | config_list = [ 114 | { 115 | 'generators': "H" 116 | } 117 | ] 118 | self.assertRaises(TypeError, config_object.parse, config_list) 119 | test_case = TestCase('https://api.github.com', None, None) 120 | testcase_list = [ 121 | {'name': 'Get github user abihijo89-to'}, 122 | {'url': '/search/users?q=abhijo89-to'}, 123 | {'method': 'GET'}, 124 | {'headers': {'Content-Type': 'application/json'}}, 125 | 126 | ] 127 | 128 | test_case.parse(testcase_list) 129 | test_case.run() 130 | self.assertTrue(test_case.is_passed) 131 | 132 | def test_include(self): 133 | with open("%s/content-test-include.yaml" % current_module_path.parent, 'r') as f: 134 | test_dict_list = yaml.safe_load(f.read()) 135 | 136 | ts = TestSet() 137 | ts.parse('', test_dict_list) 138 | self.assertEqual(1, len(ts.test_group_list_dict)) 139 | ts.parse('', [{'url': 'http://google.com'}]) 140 | self.assertEqual(1, len(ts.test_group_list_dict)) 141 | ts.parse('', [{'import': 'tests/content-test.yaml'}]) 142 | self.assertEqual(1, len(ts.test_group_list_dict)) 143 | 144 | def test_pycurl_run(self): 145 | x = TestCase('https://api.github.com', None, None, None, None) 146 | x.run() 147 | self.assertEqual(True, x.is_passed) 148 | x.ssl_insecure = True 149 | x.run() 150 | self.assertEqual(True, x.is_passed) 151 | curl_handler = pycurl.Curl() 152 | x.run(curl_handler=curl_handler) 153 | self.assertEqual(True, x.is_passed) 154 | curl_handler.close() 155 | x.run(curl_handler=curl_handler) 156 | self.assertEqual(True, x.is_passed) 157 | x.url = "http://api.github.com/v1/search/?q=Abhilash Joseph C&" 158 | self.assertEqual(x.url, "http://api.github.com/v1/search/?q=Abhilash+Joseph+C&") 159 | 160 | 161 | if __name__ == '__main__': 162 | unittest.main() 163 | -------------------------------------------------------------------------------- /tests/test_tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import string 3 | import unittest 4 | 5 | from pytest import fail 6 | 7 | from resttest3 import generators 8 | from resttest3.binding import Context 9 | from resttest3.contenthandling import ContentHandler 10 | from resttest3.exception import BindError, HttpMethodError, ValidatorError 11 | from resttest3.testcase import TestCase 12 | from resttest3.validators import ComparatorValidator, ExtractTestValidator 13 | 14 | 15 | class TestsTest(unittest.TestCase): 16 | """ Testing for basic REST test methods, how meta! """ 17 | 18 | # def test_parse_curloption(self): 19 | # """ Verify issue with curloption handling from https://github.com/svanoort/pyresttest/issues/138 """ 20 | # testdefinition = {"url": "/ping", "curl_option_timeout": 14, 'curl_Option_interface': 'doesnotexist'} 21 | # test = Test.parse_test('', testdefinition) 22 | # self.assertTrue('TIMEOUT' in test.curl_options) 23 | # self.assertTrue('INTERFACE' in test.curl_options) 24 | # self.assertEqual(14, test.curl_options['TIMEOUT']) 25 | # self.assertEqual('doesnotexist', test.curl_options['INTERFACE']) 26 | 27 | # def test_parse_illegalcurloption(self): 28 | # testdefinition = {"url": "/ping", 'curl_Option_special': 'value'} 29 | # try: 30 | # test = Test.parse_test('', testdefinition) 31 | # fail("Error: test parsing should fail when given illegal curl option") 32 | # except ValueError: 33 | # pass 34 | 35 | def test_parse_test(self): 36 | """ Test basic ways of creating test objects from input object structure """ 37 | # Most basic case 38 | _input_dict = { 39 | "url": "/ping", "method": "DELETE", "NAME": "foo", "group": "bar", 40 | "body": "input", "headers": {"Accept": "Application/json"} 41 | } 42 | test = TestCase('', None, None) 43 | test.parse(_input_dict) 44 | self.assertEqual(test.url, _input_dict['url']) 45 | self.assertEqual(test.http_method, _input_dict['method']) 46 | self.assertEqual(test.name, _input_dict['NAME']) 47 | self.assertEqual(test.group, _input_dict['group']) 48 | self.assertEqual(test.body, _input_dict['body']) 49 | # Test headers match 50 | self.assertFalse(set(test.headers.values()) ^ set(_input_dict['headers'].values())) 51 | 52 | def test_parse_test_case_sensitivity(self): 53 | # Happy path, only gotcha is that it's a POST, so must accept 200 or 54 | # 204 response code 55 | my_input = {"url": "/ping", "meThod": "POST"} 56 | test = TestCase('', None, None) 57 | test.parse(my_input) 58 | self.assertEqual(test.url, my_input['url']) 59 | self.assertEqual(test.http_method, my_input['meThod']) 60 | 61 | def test_parse_test_expected_http_status_code(self): 62 | my_input = {"url": "/ping", "method": "POST"} 63 | test = TestCase('', None, None) 64 | test.parse(my_input) 65 | self.assertEqual(test.expected_http_status_code_list, [200, 201, 204]) 66 | 67 | def test_parse_test_auth(self): 68 | # Authentication 69 | my_input = {"url": "/ping", "method": "GET", 70 | "auth_username": "foo", "auth_password": "bar"} 71 | test = TestCase('', None, None) 72 | test.parse(my_input) 73 | self.assertEqual('foo', my_input['auth_username']) 74 | self.assertEqual('bar', my_input['auth_password']) 75 | self.assertEqual(test.expected_http_status_code_list, [200]) 76 | 77 | def test_parse_test_basic_header(self): 78 | # Test that headers propagate 79 | my_input = {"url": "/ping", "method": "GET", 80 | "headers": [{"Accept": "application/json"}, {"Accept-Encoding": "gzip"}]} 81 | test = TestCase('', None, None) 82 | test.parse(my_input) 83 | expected_headers = {"Accept": "application/json", 84 | "Accept-Encoding": "gzip"} 85 | 86 | self.assertEqual(test.url, my_input['url']) 87 | self.assertEqual(test.http_method, 'GET') 88 | self.assertEqual(test.expected_http_status_code_list, [200]) 89 | self.assertTrue(isinstance(test.headers, dict)) 90 | 91 | # Test no header mappings differ 92 | self.assertFalse(set(test.headers.values()) ^ set(expected_headers.values())) 93 | 94 | def test_parse_test_http_statuscode_with_mixed_type(self): 95 | # Test expected status propagates and handles conversion to integer 96 | my_input = [{"url": "/ping"}, {"name": "cheese"}, 97 | {"expected_status": ["200", 204, "202"]}] 98 | test = TestCase('', None, None) 99 | test.parse(my_input) 100 | self.assertEqual(test.name, "cheese") 101 | self.assertEqual(test.expected_http_status_code_list, [200, 204, 202]) 102 | 103 | def test_parse_nonstandard_http_method(self): 104 | my_input = {"url": "/ping", "method": "PATCH", "NAME": "foo", "group": "bar", 105 | "body": "input", "headers": {"Accept": "Application/json"}} 106 | test = TestCase('', None, None) 107 | test.parse(my_input) 108 | self.assertEqual("PATCH", test.http_method) 109 | 110 | try: 111 | my_input['method'] = 1 112 | test.parse(my_input) 113 | fail("Should fail to pass a nonstring HTTP method") 114 | except AttributeError: 115 | pass 116 | 117 | try: 118 | my_input['method'] = '' 119 | test.parse(my_input) 120 | fail("Should fail to pass a nonstring HTTP method") 121 | except HttpMethodError: 122 | pass 123 | 124 | def test_parse_custom_curl(self): 125 | raise unittest.SkipTest("Skipping test of CURL configuration") 126 | 127 | 128 | 129 | # We can't use version specific skipIf decorator b/c python 2.6 unittest lacks it 130 | def test_use_custom_curl(self): 131 | """ Test that test method really does configure correctly """ 132 | 133 | # In python 3, use of mocks for the curl setopt version (or via setattr) 134 | # Will not modify the actual curl object... so test fails 135 | raise unittest.SkipTest("Skipping test of CURL configuration for redirects because the mocks fail") 136 | 137 | def test_basic_auth(self): 138 | """ Test that basic auth configures correctly """ 139 | # In python 3, use of mocks for the curl setopt version (or via setattr) 140 | # Will not modify the actual curl object... so test fails 141 | print("Skipping test of CURL configuration for basic auth because the mocks fail in Py3") 142 | return 143 | 144 | def test_parse_test_templated_headers(self): 145 | """ Test parsing with templated headers """ 146 | 147 | heads = {"Accept": "Application/json", "$AuthHeader": "$AuthString"} 148 | templated_heads = {"Accept": "Application/json", 149 | "apikey": "magic_passWord"} 150 | context = Context() 151 | context.bind_variables( 152 | {'AuthHeader': 'apikey', 'AuthString': 'magic_passWord'}) 153 | 154 | # If this doesn't throw errors we have silent failures 155 | input_invalid = {"url": "/ping", "method": "DELETE", "NAME": "foo", 156 | "group": "bar", "body": "input", "headers": 'goat'} 157 | try: 158 | test = TestCase('', None, context) 159 | test.parse(input_invalid) 160 | fail("Expected error not thrown") 161 | except ValidatorError: 162 | pass 163 | 164 | 165 | 166 | def test_parse_test_validators(self): 167 | """ Test that for a test it can parse the validators section correctly """ 168 | input = {"url": '/test', 'validators': [ 169 | {'comparator': { 170 | 'jsonpath_mini': 'key.val', 171 | 'comparator': 'eq', 172 | 'expected': 3 173 | }}, 174 | {'extract_test': {'jsonpath_mini': 'key.val', 'test': 'exists'}} 175 | ]} 176 | 177 | test = TestCase('', None, None) 178 | test.parse(input) 179 | self.assertTrue(test.validators) 180 | self.assertEqual(2, len(test.validators)) 181 | self.assertTrue(isinstance( 182 | test.validators[0], ComparatorValidator)) 183 | self.assertTrue(isinstance( 184 | test.validators[1], ExtractTestValidator)) 185 | 186 | # Check the validators really work 187 | self.assertTrue(test.validators[0].validate( 188 | '{"id": 3, "key": {"val": 3}}')) 189 | 190 | def test_parse_validators_fail(self): 191 | """ Test an invalid validator syntax throws exception """ 192 | input = {"url": '/test', 'validators': ['comparator']} 193 | try: 194 | test = TestCase('', None, None) 195 | test.parse(input) 196 | self.fail( 197 | "Should throw exception if not giving a dictionary-type comparator") 198 | except ValidatorError: 199 | pass 200 | 201 | def test_parse_extractor_bind(self): 202 | """ Test parsing of extractors """ 203 | test_config = { 204 | "url": '/api', 205 | 'extract_binds': { 206 | 'id': {'jsonpath_mini': 'idfield'}, 207 | 'name': {'jsonpath_mini': 'firstname'} 208 | } 209 | } 210 | context = Context() 211 | test = TestCase('', None, None, context) 212 | test.parse(test_config) 213 | test.pre_update(context) 214 | self.assertTrue(test.extract_binds) 215 | self.assertEqual(2, len(test.extract_binds)) 216 | self.assertTrue('id' in test.extract_binds) 217 | self.assertTrue('name' in test.extract_binds) 218 | # 219 | # Test extractors config'd correctly for extraction 220 | myjson = '{"idfield": 3, "firstname": "bob"}' 221 | extracted = test.extract_binds['id'].extract(myjson) 222 | self.assertEqual(3, extracted) 223 | # 224 | extracted = test.extract_binds['name'].extract(myjson) 225 | self.assertEqual('bob', extracted) 226 | 227 | def test_parse_extractor_errors(self): 228 | """ Test that expected errors are thrown on parsing """ 229 | test_config = { 230 | "url": '/api', 231 | 'extract_binds': {'id': {}} 232 | } 233 | try: 234 | test = TestCase('', None, None) 235 | test.parse(test_config) 236 | self.fail("Should throw an error when doing empty mapping") 237 | except BindError: 238 | pass 239 | 240 | test_config['extract_binds']['id'] = { 241 | 'jsonpath_mini': 'query', 242 | 'test': 'anotherquery' 243 | } 244 | try: 245 | test = TestCase('', None, None) 246 | test.parse(test_config) 247 | self.fail("Should throw an error when given multiple extractors") 248 | except BindError as te: 249 | pass 250 | 251 | def test_parse_validator_comparator(self): 252 | """ Test parsing a comparator validator """ 253 | test_config = { 254 | 'name': 'Default', 255 | 'url': '/api', 256 | 'validators': [ 257 | {'comparator': {'jsonpath_mini': 'id', 258 | 'comparator': 'eq', 259 | 'expected': {'template': '$id'}}} 260 | ] 261 | } 262 | test = TestCase('', None, None) 263 | test.parse(test_config) 264 | self.assertTrue(test.validators) 265 | self.assertEqual(1, len(test.validators)) 266 | 267 | context = Context() 268 | context.bind_variable('id', 3) 269 | 270 | myjson = '{"id": "3"}' 271 | failure = test.validators[0].validate(myjson, context=context) 272 | self.assertTrue(test.validators[0].validate(myjson, context=context)) 273 | self.assertFalse(test.validators[0].validate(myjson)) 274 | 275 | def test_parse_validator_extract_test(self): 276 | """ Tests parsing extract-test validator """ 277 | test_config = { 278 | 'name': 'Default', 279 | 'url': '/api', 280 | 'validators': [ 281 | {'extract_test': {'jsonpath_mini': 'login', 282 | 'test': 'exists'}} 283 | ] 284 | } 285 | test = TestCase('', None, None) 286 | test.parse(test_config) 287 | self.assertTrue(test.validators) 288 | self.assertEqual(1, len(test.validators)) 289 | 290 | myjson = '{"login": "testval"}' 291 | self.assertTrue(test.validators[0].validate(myjson)) 292 | 293 | def test_variable_binding(self): 294 | """ Test that tests successfully bind variables """ 295 | element = 3 296 | test_config = [ 297 | {"url": "/ping"}, {"name": "cheese"}, 298 | {"expected_status": ["200", 204, "202"]}, 299 | {"variable_binds": {'var': 'value'}} 300 | ] 301 | context = Context() 302 | test = TestCase('', None, context) 303 | test.parse(test_config) 304 | binds = test.variable_binds 305 | self.assertEqual(1, len(binds)) 306 | self.assertEqual('value', binds['var']) 307 | 308 | # Test that updates context correctly 309 | 310 | test.pre_update(context) 311 | self.assertEqual('value', context.get_value('var')) 312 | 313 | def test_url_templating(self): 314 | context = Context() 315 | test = TestCase('', None, None, context) 316 | test.url = {'template': "$cheese"} 317 | self.assertTrue(test.is_dynamic()) 318 | self.assertEqual({'template': '$cheese'}, test.url) 319 | self.assertTrue(test.templates['url']) 320 | context.bind_variable('cheese', 'liquid_cheese') 321 | self.assertEqual('liquid_cheese', test.url) 322 | 323 | def test_test_content_templating(self): 324 | context = Context() 325 | test = TestCase('', None, None, context) 326 | handler = ContentHandler() 327 | handler.is_template_content = True 328 | handler.content = '{"first_name": "Gaius","id": "$id","last_name": "Baltar","login": "$login"}' 329 | context.bind_variables({'id': 9, 'login': 'username'}) 330 | test.body = handler 331 | test.pre_update(context=context) 332 | self.assertEqual(string.Template(handler.content).safe_substitute(context.get_values()), 333 | test.body) 334 | 335 | def test_header_templating(self): 336 | context = Context() 337 | test = TestCase('', None, None, context) 338 | head_templated = {'x': {'template': "$val"}} 339 | 340 | context.bind_variables({'val': 'gouda'}) 341 | # No templating applied 342 | test.headers = head_templated 343 | self.assertEqual(1, len(test.headers)) 344 | self.assertEqual('gouda', test.headers['x']) 345 | 346 | def test_update_context_variables(self): 347 | variable_binds = {'foo': 'correct', 'test': 'value'} 348 | context = Context() 349 | test = TestCase('', None, variable_binds, context=context) 350 | context.bind_variable('foo', 'broken') 351 | test.pre_update(context) 352 | self.assertEqual('correct', context.get_value('foo')) 353 | self.assertEqual('value', context.get_value('test')) 354 | 355 | def test_update_context_generators(self): 356 | """ Test updating context variables using generator """ 357 | variable_binds = {'foo': 'initial_value'} 358 | generator_binds = {'foo': 'gen'} 359 | context = Context() 360 | test = TestCase('', None, variable_binds, context=context) 361 | test.generator_binds = generator_binds 362 | context.bind_variable('foo', 'broken') 363 | context.add_generator('gen', generators.generator_basic_ids()) 364 | context.bind_generator_next('foo', 'gen') 365 | self.assertEqual(1, context.get_value('foo')) 366 | context.bind_generator_next('foo', 'gen') 367 | self.assertEqual(2, context.get_value('foo')) 368 | 369 | 370 | if __name__ == '__main__': 371 | unittest.main() 372 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | from inspect import currentframe, getframeinfo 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | from resttest3.utils import ChangeDir, read_testcase_file, Parser 10 | 11 | filename = getframeinfo(currentframe()).filename 12 | current_module_path = Path(filename) 13 | 14 | 15 | class TestCaseUtils(unittest.TestCase): 16 | 17 | def test_ch_dir(self): 18 | ch_dir = os.getcwd() 19 | with ChangeDir('../') as cd: 20 | self.assertEqual(cd.saved_path, ch_dir) 21 | self.assertNotEqual(cd.new_path, ch_dir) 22 | 23 | def test_read_file(self): 24 | with ChangeDir(current_module_path.parent) as cd: 25 | data = read_testcase_file(cd.new_path + '/sample.yaml') 26 | self.assertEqual(data['include'], 'example') 27 | 28 | def test_encode_unicode_bytes(self): 29 | decode_str = Parser.encode_unicode_bytes('my😽') 30 | self.assertEqual(decode_str, bytes('my😽', 'utf-8')) 31 | decode_str = Parser.encode_unicode_bytes(bytes('my😽', 'utf-8')) 32 | self.assertEqual(decode_str, bytes('my😽', 'utf-8')) 33 | decode_str = Parser.encode_unicode_bytes('hello') 34 | self.assertEqual(decode_str, bytes('hello', 'utf-8')) 35 | 36 | def test_safe_substitute_unicode_template(self): 37 | result = Parser.safe_substitute_unicode_template("This is $x test", {'x': 'unit'}) 38 | self.assertEqual("This is unit test", result) 39 | 40 | def test_safe_to_json(self): 41 | class Example: 42 | x = 1 43 | y = 2 44 | 45 | result = Parser.safe_to_json(Example) 46 | self.assertEqual(result, {'x': 1, 'y': 2}) 47 | 48 | result = Parser.safe_to_json("Example1") 49 | self.assertEqual(result, "Example1") 50 | 51 | result = Parser.safe_to_json(bytes("Example", "utf-8")) 52 | self.assertEqual(result, "Example") 53 | result = Parser.safe_to_json(1) 54 | self.assertEqual(result, "1") 55 | 56 | def test_flatten_dictionaries(self): 57 | input_dict = {"x": 1, "y": 2} 58 | result_dict = Parser.flatten_dictionaries(input_dict) 59 | self.assertEqual(input_dict, result_dict) 60 | input_dict.update({"y": {"a": 1}}) 61 | result_dict = Parser.flatten_dictionaries(input_dict) 62 | self.assertEqual(input_dict, result_dict) 63 | 64 | result_dict = Parser.flatten_dictionaries([input_dict, input_dict, input_dict]) 65 | self.assertEqual(input_dict, result_dict) 66 | result_dict = Parser.flatten_dictionaries([input_dict]) 67 | self.assertEqual(input_dict, result_dict) 68 | result_dict = Parser.flatten_dictionaries([{'x': 1}, input_dict]) 69 | self.assertEqual(input_dict, result_dict) 70 | result_dict = Parser.flatten_dictionaries([input_dict, {'x': 2}]) 71 | self.assertNotEqual(input_dict, result_dict) 72 | result_dict = Parser.flatten_dictionaries([{'x': 2}, input_dict]) 73 | self.assertEqual(input_dict, result_dict) 74 | 75 | def test_flatten_lowercase_keys(self): 76 | input_dict = "22" # unexpected 77 | result_dict = Parser.flatten_lowercase_keys_dict(input_dict) 78 | self.assertEqual("22", result_dict) 79 | 80 | @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7") 81 | def test_flatten_lowercase_keys_dict(self): 82 | input_dict = {"x": 1, "y": 2} 83 | result_dict = Parser.flatten_lowercase_keys_dict([{'x': 2}, input_dict]) 84 | self.assertEqual(input_dict, result_dict) 85 | input_dict = {"X": 1, "y": 2} 86 | result_dict = Parser.flatten_lowercase_keys_dict([{'x': 2}, input_dict]) 87 | self.assertEqual({'x': 1, 'y': 2}, result_dict) 88 | 89 | input_dict = {"X": 1, "y": 2} 90 | result_dict = Parser.flatten_lowercase_keys_dict(input_dict) 91 | self.assertEqual({'x': 1, 'y': 2}, result_dict) 92 | 93 | input_dict = 22 # unexpected 94 | result_dict = Parser.flatten_lowercase_keys_dict(input_dict) 95 | self.assertEqual(22, result_dict) 96 | 97 | def test_lowercase_keys(self): 98 | input_val = 23 99 | result_dict = Parser.lowercase_keys(input_val) 100 | self.assertEqual(23, result_dict) 101 | 102 | def test_coerce_string_to_ascii(self): 103 | result = Parser.coerce_string_to_ascii(bytes("Hello", 'utf-8')) 104 | self.assertEqual(result, "Hello".encode('ascii')) 105 | 106 | def test_coerce_to_string(self): 107 | result = Parser.coerce_to_string(bytes("Hello", 'utf-8')) 108 | self.assertEqual(result, "Hello") 109 | 110 | def test_parse_headers(self): 111 | request_text = ( 112 | b'GET /who/ken/trust.html HTTP/1.1\r\n' 113 | b'Host: cm.bell-labs.com\r\n' 114 | b'Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.3\r\n' 115 | b'Accept: text/html;q=0.9,text/plain\r\n' 116 | b'\r\n' 117 | ) 118 | result_list = Parser.parse_headers(request_text) 119 | self.assertEqual(3, len(result_list)) 120 | self.assertEqual(('host', 'cm.bell-labs.com'), result_list[0]) 121 | 122 | request_text = "" 123 | result_list = Parser.parse_headers(request_text) 124 | self.assertEqual(0, len(result_list)) 125 | 126 | request_text = '\r\n' 127 | result_list = Parser.parse_headers(request_text) 128 | self.assertEqual(0, len(result_list)) 129 | 130 | 131 | if __name__ == '__main__': 132 | unittest.main() 133 | -------------------------------------------------------------------------------- /tests/unicode-test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - config: 3 | - testset: "Test Unicode handling within tests" 4 | - variable_binds: { 'firstname': '指事字' } 5 | - variable_binds: { 'firstname2': 'my指事字name' } 6 | - test: # Inline body, template URL and body 7 | - name: "Create/update a person with unicode in it" 8 | - url: { "template": "/api/person/100/" } 9 | - method: "PUT" 10 | - headers: { 'Content-Type': 'application/json' } 11 | - body: '{"first_name": "指事字","id": "100","last_name": "Baltar","login": "指字"}' 12 | - test: 13 | - name: "See if we can retrieve and validate unicode bodies" 14 | - url: { template: "/api/person/100/" } 15 | - validators: 16 | - compare: { 'jsonpath_mini': 'first_name', 'comparator': 'contains', 'expected': '指事字' } 17 | - test: # Inline body, template URL and body 18 | - name: "Create/update a person with templated Unicode bodu" 19 | - url: { "template": "/api/person/101/" } 20 | - method: "PUT" 21 | - headers: { 'Content-Type': 'application/json' } 22 | - body: { template: '{"first_name": "$firstname2","id": "101","last_name": "Baltar","login": "2指字3"}' } 23 | - test: 24 | - name: "Test retrieval of user with templated unicode body, and templated query" 25 | - url: { template: "/api/person/101/" } 26 | - validators: 27 | - compare: { 'jsonpath_mini': 'first_name', 'comparator': 'eq', 'expected': 'my指事字name' } 28 | - compare: { 'jsonpath_mini': 'first_name', 'comparator': 'eq', 'expected': { template: "$firstname2" } } 29 | # Below needs RFC3986 IRI to URI conversion support to work (there are libraries for this) 30 | # See: http://www.ietf.org/rfc/rfc3986.txt 31 | #- test: 32 | # - name: "Let's try using unicode URL" 33 | # - url: '/api/person/?login=指字' 34 | # - validators: 35 | # - assertTrue: {'jsonpath_mini':'objects.0', 'test':'exists'} 36 | # - compare: {'jsonpath_mini':'objects.0.first_name', 'expected':'指事字'} 37 | #- test: 38 | # - name: "Using a unicode template for the url" 39 | # - variable_binds: {'myname': '指字'} 40 | # - url: {template: '/api/person/?login=$myname'} 41 | # - validators: 42 | # - assertTrue: {'jsonpath_mini':'objects.0', 'test':'exists'} 43 | # - compare: {'jsonpath_mini':'objects.0.first_name', 'expected':'指事字'} 44 | #- test: 45 | # - name: "Unicode template for the expected value" 46 | # - url: {template: '/api/person/?login=指字'} 47 | # - validators: 48 | # - assertTrue: {'jsonpath_mini':'objects.0', 'test':'exists'} 49 | # - compare: {'jsonpath_mini':'objects.0.first_name', 'expected': {template: '$firstname'}} 50 | --------------------------------------------------------------------------------