├── .coveralls.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── pytest_tornado ├── __init__.py └── plugin.py ├── setup.cfg ├── setup.py └── test ├── conftest.py ├── create_cert.py ├── test_async.py ├── test_async_await.py ├── test_fixtures.py ├── test_https_support.py ├── test_param.py └── test_server.py /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci 2 | parallel: true # if the CI is running your build in parallel 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | .ropeproject/ 43 | 44 | # Translations 45 | *.mo 46 | *.pot 47 | 48 | # Django stuff: 49 | *.log 50 | 51 | # Sphinx documentation 52 | docs/_build/ 53 | 54 | # PyBuilder 55 | target/ 56 | 57 | # Cert for testing 58 | testcert.pem 59 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | dist: xenial 4 | python: 5 | - 2.7 6 | - 3.5 7 | - 3.6 8 | - 3.7 9 | - 3.8 10 | env: 11 | - TORNADO_VERSION=6.0 PYTEST_VERSION=5.2 12 | - TORNADO_VERSION=6.0 PYTEST_VERSION=4.3 13 | - TORNADO_VERSION=5.0 PYTEST_VERSION=5.2 14 | - TORNADO_VERSION=5.0 PYTEST_VERSION=4.0 15 | - TORNADO_VERSION=5.0.0 PYTEST_VERSION=3.6 16 | - TORNADO_VERSION=5.0.0 PYTEST_VERSION=4.0.0 17 | - TORNADO_VERSION=4.3.0 PYTEST_VERSION=3.6 18 | - TORNADO_VERSION=4.1.0 PYTEST_VERSION=3.6.4 19 | install: 20 | - pip install tornado~=$TORNADO_VERSION pytest~=$PYTEST_VERSION 21 | - pip install PyOpenSSL # used to create a cert for testing 22 | - python setup.py install 23 | - pip install coverage coveralls 24 | script: 25 | - python test/create_cert.py --cert test/testcert.pem 26 | - coverage run --branch --source pytest_tornado.plugin -m pytest --strict 27 | - coverage report -m 28 | after_success: 29 | - coveralls 30 | 31 | jobs: 32 | exclude: 33 | # Tornado 6 only supports Python >= 3.5 34 | - python: 2.7 35 | env: TORNADO_VERSION=6.0 PYTEST_VERSION=4.3 36 | # Pytest 4.0.0 requires a version of attrs that does not work with Python 3.8. 37 | - python: 3.8 38 | env: TORNADO_VERSION=5.0.0 PYTEST_VERSION=4.0.0 39 | # pytest 5 requires Python 3 40 | - python: 2.7 41 | env: TORNADO_VERSION=6.0 PYTEST_VERSION=5.2 42 | - python: 2.7 43 | env: TORNADO_VERSION=5.0 PYTEST_VERSION=5.2 44 | include: 45 | - stage: deploy 46 | python: 3.7 47 | deploy: 48 | provider: pypi 49 | distributions: sdist bdist_wheel 50 | user: eugeniy 51 | password: 52 | secure: gmpsfekSDT9LuF3XkJK64myhQ6Xg8WyqI/0m5Gnnb+hSrCFGD8/dgtRdLEULuDkFPvL/FsoE2L6XcdKuqaKkqSM3kk4Min8hqGapcfe/OSOkCDaYSUBCXmV2dlbtMTAzHfo3zpueToUelZvblw9y3wXVFWkmr1mf47Q9kUPIgnU= 53 | on: 54 | tags: true 55 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # Unreleased 2 | 3 | # 0.8.1 4 | 5 | * Fixed deprecation warning for pytest >= 5.4 6 | 7 | # 0.8.0 8 | 9 | * Added support for HTTPS testing (#50) 10 | * Group commandline options for pytest (#53) 11 | 12 | # 0.7 (2019-04-08) 13 | 14 | * Added support for Tornado 6 15 | * Requires at least Tornado 4.1 16 | 17 | # 0.6 (2019-03-28) 18 | 19 | * Added support for Tornado 5 20 | * Requires at least pytest 3.6 21 | * Removed support for Tornado 3 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 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 LICENSE 2 | 3 | prune test 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build clean upload test-upload 2 | 3 | default: build 4 | 5 | build: 6 | python setup.py sdist bdist_wheel 7 | 8 | clean: 9 | rm -rf build dist *.egg-info 10 | 11 | upload: clean build 12 | twine upload dist/* 13 | 14 | test-upload: clean build 15 | twine upload -r pypitest dist/* 16 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pytest-tornado 2 | ============== 3 | 4 | .. image:: https://travis-ci.org/eugeniy/pytest-tornado.svg?branch=master 5 | :target: https://travis-ci.org/eugeniy/pytest-tornado 6 | 7 | .. image:: https://coveralls.io/repos/eugeniy/pytest-tornado/badge.svg 8 | :target: https://coveralls.io/r/eugeniy/pytest-tornado 9 | 10 | A py.test_ plugin providing fixtures and markers to simplify testing 11 | of asynchronous tornado applications. 12 | 13 | Installation 14 | ------------ 15 | 16 | :: 17 | 18 | pip install pytest-tornado 19 | 20 | 21 | Example 22 | ------- 23 | 24 | .. code-block:: python 25 | 26 | import pytest 27 | import tornado.web 28 | 29 | class MainHandler(tornado.web.RequestHandler): 30 | def get(self): 31 | self.write("Hello, world") 32 | 33 | application = tornado.web.Application([ 34 | (r"/", MainHandler), 35 | ]) 36 | 37 | @pytest.fixture 38 | def app(): 39 | return application 40 | 41 | @pytest.mark.gen_test 42 | def test_hello_world(http_client, base_url): 43 | response = yield http_client.fetch(base_url) 44 | assert response.code == 200 45 | 46 | 47 | Running tests 48 | ------------- 49 | 50 | :: 51 | 52 | py.test 53 | 54 | 55 | Fixtures 56 | -------- 57 | 58 | io_loop 59 | creates an instance of the `tornado.ioloop.IOLoop`_ for each test case 60 | 61 | http_port 62 | get a port used by the test server 63 | 64 | base_url 65 | Get an absolute base url for the test server, 66 | for example ``http://localhost:59828``. 67 | Can also be used in a test with HTTPS fixture and will then return 68 | a corresponding url, for example ``http://localhost:48372``. 69 | 70 | http_server 71 | start a tornado HTTP server, you must create an ``app`` fixture, 72 | which returns the `tornado.web.Application`_ to be tested 73 | 74 | http_client 75 | get an asynchronous HTTP client 76 | 77 | 78 | There is also the possibility to test applications with HTTPS. 79 | For running a server with HTTPS you need a certificate. 80 | 81 | https_port 82 | Get a port used by the test server. 83 | 84 | https_server 85 | Start a tornado HTTPS server. You must create an ``app`` fixture, 86 | which returns the `tornado.web.Application`_ to be tested, and 87 | an ``ssl_options`` fixture which returns the SSL options for the 88 | `tornado.httpserver.HTTPServer`_. 89 | 90 | https_client 91 | Get an asynchronous HTTP client. 92 | In case your test uses an self-signed certificate you can set 93 | ``verify=False`` on the fetch method. 94 | 95 | 96 | Show fixtures provided by the plugin:: 97 | 98 | py.test --fixtures 99 | 100 | 101 | Markers 102 | ------- 103 | 104 | A ``gen_test`` marker lets you write a coroutine-style tests used with the 105 | `tornado.gen`_ module: 106 | 107 | .. code-block:: python 108 | 109 | @pytest.mark.gen_test 110 | def test_tornado(http_client): 111 | response = yield http_client.fetch('http://www.tornadoweb.org/') 112 | assert response.code == 200 113 | 114 | 115 | This marker supports writing tests with async/await syntax as well: 116 | 117 | .. code-block:: python 118 | 119 | @pytest.mark.gen_test 120 | async def test_tornado(http_client): 121 | response = await http_client.fetch('http://www.tornadoweb.org/') 122 | assert response.code == 200 123 | 124 | 125 | Marked tests will time out after 5 seconds. The timeout can be modified by 126 | setting an ``ASYNC_TEST_TIMEOUT`` environment variable, 127 | ``--async-test-timeout`` command line argument or a marker argument. 128 | 129 | .. code-block:: python 130 | 131 | @pytest.mark.gen_test(timeout=5) 132 | def test_tornado(http_client): 133 | yield http_client.fetch('http://www.tornadoweb.org/') 134 | 135 | The mark can also receive a run_sync flag, which if turned off will, instead of running the test synchronously, will add it as a coroutine and run the IOLoop (until the timeout). For instance, this allows to test things on both a client and a server at the same time. 136 | 137 | .. code-block:: python 138 | 139 | @pytest.mark.gen_test(run_sync=False) 140 | def test_tornado(http_server, http_client): 141 | response = yield http_client.fetch('http://localhost:5555/my_local_server_test/') 142 | assert response.body == 'Run on the same IOLoop!' 143 | 144 | 145 | Show markers provided by the plugin:: 146 | 147 | py.test --markers 148 | 149 | 150 | .. _py.test: http://pytest.org/ 151 | .. _`tornado.httpserver.HTTPServer`: https://www.tornadoweb.org/en/latest/httpserver.html#http-server 152 | .. _`tornado.ioloop.IOLoop`: http://tornado.readthedocs.org/en/latest/ioloop.html#ioloop-objects 153 | .. _`tornado.web.Application`: http://tornado.readthedocs.org/en/latest/web.html#application-configuration 154 | .. _`tornado.gen`: http://tornado.readthedocs.org/en/latest/gen.html 155 | -------------------------------------------------------------------------------- /pytest_tornado/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eugeniy/pytest-tornado/fb163a5952fcc2bc6e9fce211e72dbdc81bb1d0e/pytest_tornado/__init__.py -------------------------------------------------------------------------------- /pytest_tornado/plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import types 4 | import inspect 5 | import datetime 6 | import pkg_resources 7 | import pytest 8 | import tornado 9 | import tornado.gen 10 | import tornado.testing 11 | import tornado.httpserver 12 | import tornado.httpclient 13 | 14 | if sys.version_info[:2] >= (3, 5): 15 | iscoroutinefunction = inspect.iscoroutinefunction 16 | else: 17 | iscoroutinefunction = lambda f: False 18 | 19 | _PYTEST_VERSION = pkg_resources.parse_version(pytest.__version__) 20 | 21 | 22 | def _get_async_test_timeout(): 23 | try: 24 | return float(os.environ.get('ASYNC_TEST_TIMEOUT')) 25 | except (ValueError, TypeError): 26 | return 5 27 | 28 | 29 | def pytest_addoption(parser): 30 | tornado_group = parser.getgroup('tornado options') 31 | tornado_group.addoption('--async-test-timeout', type=float, 32 | default=_get_async_test_timeout(), 33 | help='timeout in seconds before failing the test') 34 | tornado_group.addoption('--app-fixture', default='app', 35 | help='fixture name returning a tornado application') 36 | tornado_group.addoption('--ssl-options-fixture', default='ssl_options', 37 | help='fixture name returning a certificate configuration') 38 | 39 | 40 | def pytest_configure(config): 41 | config.addinivalue_line("markers", 42 | "gen_test(timeout=None): " 43 | "mark the test as asynchronous, it will be " 44 | "run using tornado's event loop") 45 | 46 | 47 | def _argnames(func): 48 | if hasattr(inspect, "signature"): 49 | sig = inspect.signature(func) 50 | return [name for name, param in sig.parameters.items() 51 | if param.default is param.empty] 52 | else: 53 | spec = inspect.getargspec(func) 54 | if spec.defaults: 55 | return spec.args[:-len(spec.defaults)] 56 | if isinstance(func, types.FunctionType): 57 | return spec.args 58 | # Func is a bound method, skip "self" 59 | return spec.args[1:] 60 | 61 | 62 | def _timeout(item): 63 | default_timeout = item.config.getoption('async_test_timeout') 64 | gen_test = item.get_closest_marker('gen_test') 65 | if gen_test: 66 | return gen_test.kwargs.get('timeout', default_timeout) 67 | return default_timeout 68 | 69 | 70 | @pytest.mark.tryfirst 71 | def pytest_pycollect_makeitem(collector, name, obj): 72 | if collector.funcnamefilter(name) and inspect.isgeneratorfunction(obj): 73 | if _PYTEST_VERSION >= pkg_resources.parse_version("5.4.0"): 74 | item = pytest.Function.from_parent(collector, name=name) 75 | else: 76 | item = pytest.Function(name, parent=collector) 77 | if 'gen_test' in item.keywords: 78 | return list(collector._genfunctions(name, obj)) 79 | 80 | 81 | def pytest_runtest_setup(item): 82 | if 'gen_test' in item.keywords and 'io_loop' not in item.fixturenames: 83 | # inject an event loop fixture for all async tests 84 | item.fixturenames.append('io_loop') 85 | 86 | 87 | @pytest.mark.tryfirst 88 | def pytest_pyfunc_call(pyfuncitem): 89 | gen_test_mark = pyfuncitem.get_closest_marker('gen_test') 90 | if gen_test_mark: 91 | io_loop = pyfuncitem.funcargs.get('io_loop') 92 | run_sync = gen_test_mark.kwargs.get('run_sync', True) 93 | 94 | funcargs = dict((arg, pyfuncitem.funcargs[arg]) 95 | for arg in _argnames(pyfuncitem.obj)) 96 | if iscoroutinefunction(pyfuncitem.obj): 97 | coroutine = pyfuncitem.obj 98 | future = tornado.gen.convert_yielded(coroutine(**funcargs)) 99 | else: 100 | coroutine = tornado.gen.coroutine(pyfuncitem.obj) 101 | future = coroutine(**funcargs) 102 | if run_sync: 103 | io_loop.run_sync(lambda: future, timeout=_timeout(pyfuncitem)) 104 | else: 105 | # Run this test function as a coroutine, until the timeout. When completed, stop the IOLoop 106 | # and reraise any exceptions 107 | 108 | future_with_timeout = tornado.gen.with_timeout( 109 | datetime.timedelta(seconds=_timeout(pyfuncitem)), 110 | future) 111 | io_loop.add_future(future_with_timeout, lambda f: io_loop.stop()) 112 | io_loop.start() 113 | 114 | # This will reraise any exceptions that occurred. 115 | future_with_timeout.result() 116 | 117 | # prevent other pyfunc calls from executing 118 | return True 119 | 120 | 121 | @pytest.fixture 122 | def io_loop(request): 123 | """Create an instance of the `tornado.ioloop.IOLoop` for each test case. 124 | """ 125 | io_loop = tornado.ioloop.IOLoop() 126 | io_loop.make_current() 127 | 128 | def _close(): 129 | io_loop.clear_current() 130 | io_loop.close(all_fds=True) 131 | 132 | request.addfinalizer(_close) 133 | return io_loop 134 | 135 | 136 | @pytest.fixture 137 | def _unused_port(): 138 | return tornado.testing.bind_unused_port() 139 | 140 | 141 | @pytest.fixture 142 | def http_port(_unused_port): 143 | """Get a port used by the test server. 144 | """ 145 | return _unused_port[1] 146 | 147 | 148 | @pytest.fixture 149 | def https_port(_unused_port): 150 | """Get a port used by the test server. 151 | """ 152 | return _unused_port[1] 153 | 154 | 155 | @pytest.fixture 156 | def base_url(request): 157 | """Create an absolute base url (scheme://host:port) 158 | """ 159 | fixturenames = request.fixturenames 160 | if 'https_port' in fixturenames or 'https_client' in fixturenames or 'https_server' in fixturenames: 161 | return 'https://localhost:%s' % request.getfixturevalue('https_port') 162 | 163 | return 'http://localhost:%s' % request.getfixturevalue('http_port') 164 | 165 | 166 | @pytest.fixture 167 | def http_server(request, io_loop, _unused_port): 168 | """Start a tornado HTTP server. 169 | 170 | You must create an `app` fixture, which returns 171 | the `tornado.web.Application` to be tested. 172 | 173 | Raises: 174 | FixtureLookupError: tornado application fixture not found 175 | """ 176 | http_app = request.getfixturevalue(request.config.option.app_fixture) 177 | server = tornado.httpserver.HTTPServer(http_app) 178 | server.add_socket(_unused_port[0]) 179 | 180 | def _stop(): 181 | server.stop() 182 | 183 | if hasattr(server, 'close_all_connections'): 184 | io_loop.run_sync(server.close_all_connections, 185 | timeout=request.config.option.async_test_timeout) 186 | 187 | request.addfinalizer(_stop) 188 | return server 189 | 190 | 191 | @pytest.fixture 192 | def http_client(request, http_server): 193 | """Get an asynchronous HTTP client. 194 | """ 195 | client = tornado.httpclient.AsyncHTTPClient() 196 | 197 | def _close(): 198 | client.close() 199 | 200 | request.addfinalizer(_close) 201 | return client 202 | 203 | 204 | @pytest.fixture 205 | def https_server(request, io_loop, _unused_port): 206 | """Start a tornado HTTPS server. 207 | 208 | You must create an `app` fixture, which returns 209 | the `tornado.web.Application` to be tested. 210 | 211 | Raises: 212 | FixtureLookupError: tornado application fixture not found 213 | """ 214 | https_app = request.getfixturevalue(request.config.option.app_fixture) 215 | ssl_options = request.getfixturevalue(request.config.option.ssl_options_fixture) 216 | server = tornado.httpserver.HTTPServer(https_app, ssl_options=ssl_options) 217 | server.add_socket(_unused_port[0]) 218 | 219 | def _stop(): 220 | server.stop() 221 | 222 | if hasattr(server, 'close_all_connections'): 223 | io_loop.run_sync(server.close_all_connections, 224 | timeout=request.config.option.async_test_timeout) 225 | 226 | request.addfinalizer(_stop) 227 | return server 228 | 229 | 230 | @pytest.fixture 231 | def https_client(request, https_server): 232 | """Get an asynchronous HTTPS client. 233 | """ 234 | # How does on get ca_certs from the user 235 | client = tornado.httpclient.AsyncHTTPClient() 236 | 237 | def _close(): 238 | client.close() 239 | 240 | request.addfinalizer(_close) 241 | return client 242 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import io 3 | from setuptools import setup, find_packages 4 | 5 | 6 | cwd = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | with io.open(os.path.join(cwd, 'README.rst'), encoding='utf-8') as fd: 9 | long_description = fd.read() 10 | 11 | 12 | setup( 13 | name='pytest-tornado', 14 | version='0.8.1', 15 | description=('A py.test plugin providing fixtures and markers ' 16 | 'to simplify testing of asynchronous tornado applications.'), 17 | long_description=long_description, 18 | url='https://github.com/eugeniy/pytest-tornado', 19 | author='Eugeniy Kalinin', 20 | author_email='burump@gmail.com', 21 | maintainer='Vidar Tonaas Fauske', 22 | maintainer_email='vidartf@gmail.com', 23 | license='Apache License, Version 2.0', 24 | classifiers=[ 25 | 'Development Status :: 5 - Production/Stable', 26 | 'Environment :: Plugins', 27 | 'Intended Audience :: Developers', 28 | 'License :: OSI Approved :: Apache Software License', 29 | 'Operating System :: OS Independent', 30 | 'Programming Language :: Python', 31 | 'Programming Language :: Python :: 2', 32 | 'Programming Language :: Python :: 3', 33 | 'Topic :: Software Development', 34 | 'Topic :: Software Development :: Testing', 35 | ], 36 | keywords=('pytest py.test tornado async asynchronous ' 37 | 'testing unit tests plugin'), 38 | packages=find_packages(exclude=["tests.*", "tests"]), 39 | install_requires=['pytest>=3.6', 'tornado>=4.1', 'setuptools'], 40 | entry_points={ 41 | 'pytest11': ['tornado = pytest_tornado.plugin'], 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import tornado 3 | 4 | collect_ignore = [] 5 | if sys.version_info[:2] < (3, 5) or tornado.version_info[:2] < (4, 3): 6 | collect_ignore.append("test_async_await.py") 7 | 8 | 9 | pytest_plugins = ["pytester"] 10 | -------------------------------------------------------------------------------- /test/create_cert.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Create a cert with pyOpenSSL for tests. 4 | 5 | Heavily based on python-opsi's OPSI.Util.Task.Certificate. 6 | Source: https://github.com/opsi-org/python-opsi/blob/stable/OPSI/Util/Task/Certificate.py 7 | """ 8 | import argparse 9 | import os 10 | import random 11 | import socket 12 | from tempfile import NamedTemporaryFile 13 | 14 | from OpenSSL import crypto 15 | 16 | try: 17 | import secrets 18 | except ImportError: 19 | secrets = None 20 | 21 | 22 | def createCertificate(path): 23 | """ 24 | Creates a certificate. 25 | """ 26 | cert = crypto.X509() 27 | cert.get_subject().C = "DE" # Country 28 | cert.get_subject().ST = "HE" # State 29 | cert.get_subject().L = "Wiesbaden" # Locality 30 | cert.get_subject().O = "pytest-tornado" # Organisation 31 | cert.get_subject().OU = "Testing Department" # organisational unit 32 | cert.get_subject().CN = socket.getfqdn() # common name 33 | 34 | # As described in RFC5280 this value is required and must be a 35 | # positive and unique integer. 36 | # Source: http://tools.ietf.org/html/rfc5280#page-19 37 | cert.set_serial_number(random.randint(0, pow(2, 16))) 38 | 39 | cert.gmtime_adj_notBefore(0) 40 | cert.gmtime_adj_notAfter(60 * 60) # Valid 1 hour 41 | 42 | k = crypto.PKey() 43 | k.generate_key(crypto.TYPE_RSA, 2048) 44 | 45 | cert.set_issuer(cert.get_subject()) 46 | cert.set_pubkey(k) 47 | cert.set_version(2) 48 | 49 | cert.sign(k, 'sha512') 50 | 51 | certcontext = b"".join( 52 | ( 53 | crypto.dump_certificate(crypto.FILETYPE_PEM, cert), 54 | crypto.dump_privatekey(crypto.FILETYPE_PEM, k) 55 | ) 56 | ) 57 | 58 | with open(path, "wt") as certfile: 59 | certfile.write(certcontext.decode()) 60 | 61 | try: 62 | with NamedTemporaryFile(mode="wb", delete=False) as randfile: 63 | randfile.write(randomBytes(512)) 64 | 65 | command = u"openssl dhparam -rand {tempfile} 512 >> {target}".format( 66 | tempfile=randfile.name, target=path 67 | ) 68 | os.system(command) 69 | finally: 70 | os.remove(randfile.name) 71 | 72 | 73 | def randomBytes(length): 74 | """ 75 | Return _length_ random bytes. 76 | 77 | :rtype: bytes 78 | """ 79 | if secrets: 80 | return secrets.token_bytes(512) 81 | else: 82 | return os.urandom(512) 83 | 84 | 85 | if __name__ == '__main__': 86 | parser = argparse.ArgumentParser(description='Create certificate for testing') 87 | parser.add_argument('--cert', dest='cert', default="testcert.pem", 88 | help='Name of the certificate') 89 | 90 | args = parser.parse_args() 91 | createCertificate(args.cert) 92 | -------------------------------------------------------------------------------- /test/test_async.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import pytest 3 | import tornado 4 | from tornado import gen 5 | from tornado.ioloop import TimeoutError 6 | 7 | 8 | @gen.coroutine 9 | def dummy_coroutine(io_loop): 10 | yield gen.sleep(0) 11 | raise gen.Return(True) 12 | 13 | 14 | def test_explicit_start_and_stop(io_loop): 15 | future = dummy_coroutine(io_loop) 16 | future.add_done_callback(lambda *args: io_loop.stop()) 17 | io_loop.start() 18 | assert future.result() 19 | 20 | 21 | def test_run_sync(io_loop): 22 | dummy = functools.partial(dummy_coroutine, io_loop) 23 | finished = io_loop.run_sync(dummy) 24 | assert finished 25 | 26 | 27 | @pytest.mark.gen_test 28 | def test_gen_test_sync(io_loop): 29 | assert True 30 | 31 | 32 | @pytest.mark.gen_test 33 | def test_gen_test(io_loop): 34 | result = yield dummy_coroutine(io_loop) 35 | assert result 36 | 37 | 38 | @pytest.mark.gen_test(run_sync=False) 39 | def test_gen_test_run_sync_false(io_loop): 40 | result = yield dummy_coroutine(io_loop) 41 | assert result 42 | 43 | 44 | @pytest.mark.gen_test 45 | def test_gen_test_swallows_exceptions(io_loop): 46 | with pytest.raises(ZeroDivisionError): 47 | 1 / 0 48 | 49 | 50 | @pytest.mark.gen_test 51 | def test_generator_raises(io_loop): 52 | with pytest.raises(ZeroDivisionError): 53 | yield gen.sleep(0) 54 | 1 / 0 55 | 56 | 57 | @pytest.mark.gen_test 58 | def test_explicit_gen_test_marker(request, io_loop): 59 | yield gen.sleep(0) 60 | assert 'gen_test' in request.keywords 61 | 62 | 63 | @pytest.mark.gen_test(timeout=2.1) 64 | def test_gen_test_marker_with_params(request, io_loop): 65 | yield gen.sleep(0) 66 | assert request.node.get_closest_marker('gen_test').kwargs['timeout'] == 2.1 67 | 68 | 69 | @pytest.mark.xfail(raises=TimeoutError) 70 | @pytest.mark.gen_test(timeout=0.1) 71 | def test_gen_test_with_timeout(io_loop): 72 | yield gen.sleep(1) 73 | 74 | 75 | def test_sync_tests_no_gen_test_marker(request): 76 | assert 'gen_test' not in request.keywords 77 | 78 | 79 | class TestClass: 80 | @pytest.mark.gen_test 81 | def test_gen_test(self, io_loop): 82 | result = yield dummy_coroutine(io_loop) 83 | assert result 84 | 85 | @pytest.mark.gen_test 86 | def test_generator_raises(self, io_loop): 87 | with pytest.raises(ZeroDivisionError): 88 | yield gen.sleep(0) 89 | 1 / 0 90 | -------------------------------------------------------------------------------- /test/test_async_await.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tornado import gen 3 | 4 | async def dummy_native_coroutine(io_loop): 5 | await gen.sleep(0) 6 | return True 7 | 8 | 9 | @pytest.mark.gen_test 10 | async def test_native_coroutine_gen_test(io_loop): 11 | result = await dummy_native_coroutine(io_loop) 12 | assert result 13 | 14 | 15 | @pytest.mark.gen_test(run_sync=False) 16 | async def test_native_coroutine_run_sync_false(io_loop): 17 | result = await dummy_native_coroutine(io_loop) 18 | assert result 19 | -------------------------------------------------------------------------------- /test/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sys 3 | from tornado import gen 4 | 5 | _used_fixture = False 6 | 7 | 8 | @gen.coroutine 9 | def dummy(io_loop): 10 | yield gen.sleep(0) 11 | raise gen.Return(True) 12 | 13 | 14 | @pytest.fixture(scope='module') 15 | def preparations(): 16 | global _used_fixture 17 | _used_fixture = True 18 | 19 | 20 | pytestmark = pytest.mark.usefixtures('preparations') 21 | 22 | 23 | @pytest.mark.xfail(pytest.__version__ < '2.7.0', 24 | reason='py.test 2.7 adds hookwrapper, fixes collection') 25 | @pytest.mark.gen_test 26 | def test_uses_pytestmark_fixtures(io_loop): 27 | assert (yield dummy(io_loop)) 28 | assert _used_fixture 29 | 30 | class TestClass: 31 | def beforeEach(self): 32 | global _used_fixture 33 | _used_fixture = False 34 | 35 | @pytest.mark.gen_test 36 | def test_uses_pytestmark_fixtures(self, io_loop): 37 | assert (yield dummy(io_loop)) 38 | assert _used_fixture 39 | 40 | 41 | @pytest.mark.xfail(sys.version_info < (3, 5), 42 | reason='Type hints added in Python 3.5') 43 | def test_type_annotation(testdir): 44 | 45 | testdir.makepyfile( 46 | test_type_annotation=""" 47 | import pytest 48 | from tornado.ioloop import IOLoop 49 | 50 | @pytest.mark.gen_test 51 | def test_type_attrib(io_loop: IOLoop): 52 | pass # Only check that gen_test works 53 | """, 54 | ) 55 | 56 | # Run tests 57 | result = testdir.runpytest_inprocess() 58 | 59 | # Check tests went off as they should: 60 | assert result.ret == 0 61 | -------------------------------------------------------------------------------- /test/test_https_support.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ssl 3 | import pytest 4 | import tornado.web 5 | 6 | 7 | class MainHandler(tornado.web.RequestHandler): 8 | def get(self): 9 | self.write("Hello, world") 10 | 11 | 12 | @pytest.fixture 13 | def ssl_options(): 14 | cert_file = os.path.join(os.path.dirname(__file__), 'testcert.pem') 15 | if not os.path.exists(cert_file): 16 | pytest.skip("Missing cert file {!r}") 17 | key_file = os.path.join(os.path.dirname(__file__), 'testcert.pem') 18 | if not os.path.exists(key_file): 19 | pytest.skip("Missing key file {!r}") 20 | 21 | ssl_context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH, cafile=None, capath=None, cadata=None) 22 | ssl_context.load_cert_chain(cert_file, keyfile=key_file) 23 | return ssl_context 24 | 25 | 26 | @pytest.fixture 27 | def app(): 28 | return tornado.web.Application([ 29 | (r"/", MainHandler), 30 | ]) 31 | 32 | 33 | @pytest.mark.gen_test 34 | def test_hello_world(https_client, base_url): 35 | response = yield https_client.fetch(base_url, validate_cert=False) 36 | assert response.code == 200 37 | 38 | 39 | def test_base_url_is_https_with_https_client(https_client, base_url): 40 | assert base_url.startswith('https://') 41 | 42 | 43 | 44 | def test_base_url_is_https_with_https_port(https_port, base_url): 45 | assert base_url.startswith('https://') 46 | 47 | 48 | 49 | def test_base_url_is_https_with_https_server(https_server, base_url): 50 | assert base_url.startswith('https://') 51 | -------------------------------------------------------------------------------- /test/test_param.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tornado import gen 3 | 4 | DUMMY_PARAMS = ['f00', 'bar'] 5 | 6 | 7 | @pytest.fixture(params=DUMMY_PARAMS) 8 | def _dummy(request): 9 | return request.param 10 | 11 | 12 | @pytest.mark.parametrize('input,expected', [ 13 | ('3+5', 8), 14 | ('2+4', 6), 15 | ]) 16 | def test_eval(input, expected): 17 | assert eval(input) == expected 18 | 19 | 20 | @pytest.mark.parametrize('input,expected', [ 21 | ('3+5', 8), 22 | ('2+4', 6), 23 | pytest.param("6*9", 42, 24 | marks=pytest.mark.xfail), 25 | ]) 26 | def test_eval_marking(input, expected): 27 | assert eval(input) == expected 28 | 29 | 30 | @pytest.mark.parametrize('input,expected', [ 31 | ('3+5', 8), 32 | ('2+4', 6), 33 | ]) 34 | @pytest.mark.gen_test 35 | def test_sync_eval_with_gen_test(input, expected): 36 | assert eval(input) == expected 37 | 38 | 39 | @pytest.mark.parametrize('input,expected', [ 40 | ('3+5', 8), 41 | ('2+4', 6), 42 | ]) 43 | def test_eval_with_fixtures(input, io_loop, expected): 44 | assert eval(input) == expected 45 | 46 | 47 | def test_param_fixture(_dummy): 48 | assert _dummy in DUMMY_PARAMS 49 | 50 | 51 | @pytest.mark.gen_test 52 | @pytest.mark.parametrize('input,expected', [ 53 | ('3+5', 8), 54 | ('2+4', 6), 55 | ]) 56 | def test_gen_test_parametrize(io_loop, input, expected): 57 | yield gen.sleep(0) 58 | assert eval(input) == expected 59 | 60 | 61 | @pytest.mark.parametrize('input,expected', [ 62 | ('3+5', 8), 63 | ('2+4', 6), 64 | ]) 65 | @pytest.mark.gen_test 66 | def test_gen_test_fixture_any_order(input, io_loop, expected): 67 | yield gen.sleep(0) 68 | assert eval(input) == expected 69 | 70 | 71 | @pytest.mark.gen_test 72 | def test_gen_test_param_fixture(io_loop, _dummy): 73 | yield gen.sleep(0) 74 | assert _dummy in DUMMY_PARAMS 75 | -------------------------------------------------------------------------------- /test/test_server.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import pytest 3 | import tornado.ioloop 4 | import tornado.web 5 | 6 | 7 | class MainHandler(tornado.web.RequestHandler): 8 | def get(self): 9 | self.write('Hello, world') 10 | 11 | 12 | application = tornado.web.Application([ 13 | (r'/', MainHandler), 14 | (r'/f00', MainHandler), 15 | ]) 16 | 17 | 18 | @pytest.fixture(scope='module') 19 | def app(): 20 | return application 21 | 22 | 23 | def _fetch(http_client, url): 24 | return http_client.io_loop.run_sync( 25 | functools.partial(http_client.fetch, url)) 26 | 27 | 28 | def test_http_server(http_server, io_loop): 29 | status = {'done': False} 30 | 31 | def _done(): 32 | status['done'] = True 33 | io_loop.stop() 34 | 35 | io_loop.add_callback(_done) 36 | io_loop.start() 37 | 38 | assert status['done'] 39 | 40 | 41 | def test_http_client(http_client, base_url): 42 | request = http_client.fetch(base_url) 43 | request.add_done_callback(lambda future: http_client.io_loop.stop()) 44 | http_client.io_loop.start() 45 | 46 | response = request.result() 47 | assert response.code == 200 48 | 49 | 50 | def test_http_client_with_fetch_helper(http_client, base_url): 51 | response = _fetch(http_client, base_url) 52 | assert response.code == 200 53 | 54 | 55 | @pytest.mark.gen_test 56 | def test_http_client_with_gen_test(http_client, base_url): 57 | response = yield http_client.fetch(base_url) 58 | assert response.code == 200 59 | 60 | 61 | @pytest.mark.gen_test 62 | def test_get_url_with_path(http_client, base_url): 63 | response = yield http_client.fetch('%s/f00' % base_url) 64 | assert response.code == 200 65 | 66 | 67 | @pytest.mark.gen_test 68 | def test_http_client_raises_on_404(http_client, base_url): 69 | with pytest.raises(tornado.httpclient.HTTPError): 70 | yield http_client.fetch('%s/bar' % base_url) 71 | 72 | 73 | def test_base_url_default_is_http(base_url): 74 | assert base_url.startswith('http://') --------------------------------------------------------------------------------