├── .gitignore ├── .travis.yml ├── CHANGES.rst ├── LICENSE ├── README.rst ├── pytest.ini ├── setup.py ├── src └── pytest_tornasync │ ├── __init__.py │ └── plugin.py ├── test ├── __init__.py ├── conftest.py ├── test_plugin.py └── test_plugin2.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | .eggs 2 | *.egg-info 3 | __pycache__ 4 | build 5 | dist 6 | .tox 7 | .pytest_cache 8 | .coverage.eager.* 9 | .coverage 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | python: 4 | - 3.5 5 | - 3.6 6 | - 3.7 7 | - pypy3.5 8 | 9 | cache: 10 | directories: 11 | - $PWD/wheelhouse 12 | env: 13 | global: 14 | - PIP_FIND_LINKS=$PWD/wheelhouse 15 | 16 | # command to install dependencies 17 | install: 18 | - pip install tox . 19 | script: 20 | - tox -e $(echo py$TRAVIS_PYTHON_VERSION | tr -d . | sed -e 's/pypypy/pypy/') 21 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | 0.6.0 (2018-11-19) 5 | ------------------ 6 | 7 | - minor updates to avoid a pytest warning under pytest 4 8 | - repo switch to using a 'src' dir 9 | 10 | 11 | 0.5.0 (2018-05-28) 12 | ------------------ 13 | 14 | - updated to work with Tornado 5, which is now the minimum required version 15 | - require pytest >= 3.0 16 | - the `io_loop` fixture always refers to a `tornado.ioloop.IOLoop instance` now 17 | - the `io_loop_asyncio` and `io_loop_tornado` fixtures have been removed, since 18 | now that Tornado 5 always uses asyncio under Python 3, there would be no 19 | difference between the two fixtures, so `io_loop` is all that is needed 20 | - tox tests now test more versions of Tornado (5.0.* and latest 5.*), 21 | Pytest (3.0.* and latest 3.*), and Python (3.5, 3.6, 3.7, and pypy3). 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 eukaryote 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | pytest-tornasync 3 | ================ 4 | 5 | .. image:: https://travis-ci.org/eukaryote/pytest-tornasync.svg?branch=master 6 | :target: https://travis-ci.org/eukaryote/pytest-tornasync 7 | 8 | A simple pytest plugin that provides some helpful fixtures for testing 9 | Tornado (version 5.0 or newer) apps and easy handling of plain 10 | (undecoratored) native coroutine tests (Python 3.5+). 11 | 12 | Why another Tornado pytest plugin when the excellent ``pytest-tornado`` already 13 | exists? The main reason is that I didn't want to have to decorate every test 14 | coroutine with ``@pytest.mark.gen_test``. This plugin doesn't have anything 15 | like ``gen_test``. Defining a test with ``async def`` and a name that 16 | begins with ``test_`` is all that is required. 17 | 18 | 19 | Installation 20 | ------------ 21 | 22 | Install using pip, which must be run with Python 3.5+: 23 | 24 | .. code-block:: sh 25 | 26 | pip install pytest-tornasync 27 | 28 | 29 | Usage 30 | ----- 31 | 32 | Define an ``app`` fixture: 33 | 34 | .. code-block:: python 35 | 36 | import pytest 37 | 38 | 39 | @pytest.fixture 40 | def app(): 41 | import yourapp 42 | return yourapp.make_app() # a tornado.web.Application 43 | 44 | 45 | Create tests as native coroutines using Python 3.5+ ``async def``: 46 | 47 | .. code-block:: python 48 | 49 | async def test_app(http_server_client): 50 | resp = await http_server_client.fetch('/') 51 | assert resp.code == 200 52 | # ... 53 | 54 | 55 | Fixtures 56 | -------- 57 | 58 | When the plugin is installed, then ``pytest --fixtures`` will show 59 | the fixtures that are available: 60 | 61 | http_server_port 62 | Port used by `http_server`. 63 | http_server 64 | Start a tornado HTTP server that listens on all available interfaces. 65 | 66 | You must create an `app` fixture, which returns 67 | the `tornado.web.Application` to be tested. 68 | 69 | Raises: 70 | FixtureLookupError: tornado application fixture not found 71 | http_server_client 72 | Create an asynchronous HTTP client that can fetch from `http_server`. 73 | http_client 74 | Create an asynchronous HTTP client that can fetch from anywhere. 75 | io_loop 76 | Create a new `tornado.ioloop.IOLoop` for each test case. 77 | 78 | 79 | 80 | Examples 81 | -------- 82 | 83 | .. code-block:: python 84 | 85 | import time 86 | 87 | import tornado.web 88 | import tornado.gen 89 | 90 | import pytest 91 | 92 | 93 | class MainHandler(tornado.web.RequestHandler): 94 | def get(self): 95 | self.write("Hello, world!") 96 | 97 | 98 | @pytest.fixture 99 | def app(): 100 | return tornado.web.Application([(r"/", MainHandler)]) 101 | 102 | 103 | async def test_http_server_client(http_server_client): 104 | # http_server_client fetches from the `app` fixture and takes path 105 | resp = await http_server_client.fetch('/') 106 | assert resp.code == 200 107 | assert resp.body == b"Hello, world!" 108 | 109 | 110 | async def test_http_client(http_client): 111 | # http_client fetches from anywhere and takes full URL 112 | resp = await http_client.fetch('http://httpbin.org/status/204') 113 | assert resp.code == 204 114 | 115 | 116 | async def example_coroutine(period): 117 | await tornado.gen.sleep(period) 118 | 119 | 120 | async def test_example(): 121 | # no fixtures needed 122 | period = 1.0 123 | start = time.time() 124 | await example_coroutine(period) 125 | elapsed = time.time() - start 126 | assert elapsed >= period 127 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --color yes -s --tb short 3 | norecursedirs = .* __pycache__ .tox 4 | looponfailroots = src test 5 | testpaths = test 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | from setuptools import find_packages, setup 5 | from setuptools.command.test import test 6 | 7 | 8 | here_dir = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | 11 | # require python-3.5+, since we only support the native coroutine 'async def' 12 | # style for tests that were introduced in python 3.5. 13 | if sys.version_info < (3, 5): 14 | print("pytest-tornasync requires Python 3.5 or newer") 15 | sys.exit(1) 16 | 17 | 18 | def read(*filenames): 19 | buf = [] 20 | for filename in filenames: 21 | filepath = os.path.join(here_dir, filename) 22 | try: 23 | with open(filepath) as f: 24 | buf.append(f.read()) 25 | except FileNotFoundError: 26 | pass 27 | return '\n\n'.join(buf) 28 | 29 | 30 | class PyTest(test): 31 | 32 | def finalize_options(self): 33 | test.finalize_options(self) 34 | self.test_args = [] 35 | self.test_suite = True 36 | 37 | def run_tests(self): 38 | import pytest 39 | status = pytest.main(self.test_args) 40 | sys.exit(status) 41 | 42 | 43 | _reqs = ['pytest>=3.0', 'tornado>=5.0'] 44 | 45 | 46 | setup( 47 | name='pytest-tornasync', 48 | version='0.6.0.post2', 49 | license='http://www.opensource.org/licenses/mit-license.php', 50 | url='https://github.com/eukaryote/pytest-tornasync', 51 | description='py.test plugin for testing Python 3.5+ Tornado code', 52 | long_description=read('README.rst', 'CHANGES.rst'), 53 | keywords='testing py.test tornado', 54 | author='Calvin Smith', 55 | author_email='sapientdust+pytest-tornasync@gmail.com', 56 | packages=find_packages(where="src"), 57 | package_dir={"": "src"}, 58 | platforms='any', 59 | cmdclass={'test': PyTest}, 60 | install_requires=_reqs, 61 | tests_require=_reqs, 62 | test_suite='test', 63 | data_files=[("", ["LICENSE"])], 64 | entry_points={ 65 | 'pytest11': ['tornado = pytest_tornasync.plugin'], 66 | }, 67 | classifiers=[ 68 | 'Programming Language :: Python :: 3 :: Only', 69 | 'License :: OSI Approved :: MIT License', 70 | 'Environment :: Console', 71 | 'Development Status :: 3 - Alpha', 72 | 'Intended Audience :: Developers', 73 | 'Framework :: Pytest', 74 | 'Topic :: Software Development :: Testing', 75 | ] + [ 76 | ("Programming Language :: Python :: %s" % x) 77 | for x in "3 3.5 3.6 3.7".split() 78 | ] 79 | ) 80 | -------------------------------------------------------------------------------- /src/pytest_tornasync/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A pytest plugin for testing Tornado apps using plain (undecorated) coroutine tests. 3 | """ 4 | 5 | __version_info__ = (0, 6, 0, 'post2') 6 | __version__ = '.'.join(map(str, __version_info__)) 7 | -------------------------------------------------------------------------------- /src/pytest_tornasync/plugin.py: -------------------------------------------------------------------------------- 1 | from contextlib import closing, contextmanager 2 | import inspect 3 | from inspect import iscoroutinefunction 4 | 5 | import tornado.ioloop 6 | import tornado.testing 7 | import tornado.simple_httpclient 8 | 9 | import pytest 10 | 11 | 12 | def get_test_timeout(pyfuncitem): 13 | timeout = pyfuncitem.config.option.async_test_timeout 14 | marker = pyfuncitem.get_closest_marker("timeout") 15 | if marker: 16 | timeout = marker.kwargs.get("seconds", timeout) 17 | return timeout 18 | 19 | 20 | def pytest_addoption(parser): 21 | parser.addoption( 22 | "--async-test-timeout", 23 | type=float, 24 | help=("timeout in seconds before failing the test " "(default is no timeout)"), 25 | ) 26 | parser.addoption( 27 | "--app-fixture", 28 | default="app", 29 | help=("fixture name returning a tornado application " '(default is "app")'), 30 | ) 31 | 32 | 33 | @pytest.mark.tryfirst 34 | def pytest_pycollect_makeitem(collector, name, obj): 35 | if collector.funcnamefilter(name) and iscoroutinefunction(obj): 36 | return list(collector._genfunctions(name, obj)) 37 | 38 | 39 | @pytest.mark.tryfirst 40 | def pytest_pyfunc_call(pyfuncitem): 41 | funcargs = pyfuncitem.funcargs 42 | testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames} 43 | 44 | if not iscoroutinefunction(pyfuncitem.obj): 45 | pyfuncitem.obj(**testargs) 46 | return True 47 | 48 | try: 49 | loop = funcargs["io_loop"] 50 | except KeyError: 51 | loop = tornado.ioloop.IOLoop.current() 52 | 53 | loop.run_sync( 54 | lambda: pyfuncitem.obj(**testargs), timeout=get_test_timeout(pyfuncitem) 55 | ) 56 | return True 57 | 58 | 59 | @pytest.fixture 60 | def io_loop(): 61 | """ 62 | Create new io loop for each test, and tear it down after. 63 | """ 64 | loop = tornado.ioloop.IOLoop() 65 | loop.make_current() 66 | yield loop 67 | loop.clear_current() 68 | loop.close(all_fds=True) 69 | 70 | 71 | @pytest.fixture 72 | def http_server_port(): 73 | """ 74 | Port used by `http_server`. 75 | """ 76 | return tornado.testing.bind_unused_port() 77 | 78 | 79 | @pytest.yield_fixture 80 | def http_server(request, io_loop, http_server_port): 81 | """Start a tornado HTTP server that listens on all available interfaces. 82 | 83 | You must create an `app` fixture, which returns 84 | the `tornado.web.Application` to be tested. 85 | 86 | Raises: 87 | FixtureLookupError: tornado application fixture not found 88 | """ 89 | http_app = request.getfixturevalue(request.config.option.app_fixture) 90 | server = tornado.httpserver.HTTPServer(http_app) 91 | server.add_socket(http_server_port[0]) 92 | 93 | yield server 94 | 95 | server.stop() 96 | 97 | if hasattr(server, "close_all_connections"): 98 | io_loop.run_sync( 99 | server.close_all_connections, 100 | timeout=request.config.option.async_test_timeout, 101 | ) 102 | 103 | 104 | class AsyncHTTPServerClient(tornado.simple_httpclient.SimpleAsyncHTTPClient): 105 | def initialize(self, *, http_server=None): 106 | super().initialize() 107 | self._http_server = http_server 108 | 109 | def fetch(self, path, **kwargs): 110 | """ 111 | Fetch `path` from test server, passing `kwargs` to the `fetch` 112 | of the underlying `tornado.simple_httpclient.SimpleAsyncHTTPClient`. 113 | """ 114 | return super().fetch(self.get_url(path), **kwargs) 115 | 116 | def get_protocol(self): 117 | return "http" 118 | 119 | def get_http_port(self): 120 | for sock in self._http_server._sockets.values(): 121 | return sock.getsockname()[1] 122 | 123 | def get_url(self, path): 124 | return "%s://127.0.0.1:%s%s" % (self.get_protocol(), self.get_http_port(), path) 125 | 126 | 127 | @pytest.fixture 128 | def http_server_client(http_server): 129 | """ 130 | Create an asynchronous HTTP client that can fetch from `http_server`. 131 | """ 132 | with closing(AsyncHTTPServerClient(http_server=http_server)) as client: 133 | yield client 134 | 135 | 136 | @pytest.fixture 137 | def http_client(http_server): 138 | """ 139 | Create an asynchronous HTTP client that can fetch from anywhere. 140 | """ 141 | with closing(tornado.httpclient.AsyncHTTPClient()) as client: 142 | yield client 143 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | import tornado.web 2 | 3 | MESSAGE = "Hello, world!" 4 | PAUSE_TIME = 0.05 5 | 6 | 7 | class MainHandler(tornado.web.RequestHandler): 8 | def get(self): 9 | self.write(MESSAGE) 10 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import tornado.web 4 | 5 | from test import MainHandler 6 | 7 | pytest_plugins = ["pytester"] 8 | 9 | 10 | @pytest.fixture 11 | def app(): 12 | return tornado.web.Application([(r"/", MainHandler)]) 13 | -------------------------------------------------------------------------------- /test/test_plugin.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import tornado.gen 4 | import tornado.ioloop 5 | import tornado.web 6 | 7 | import pytest 8 | 9 | from test import MESSAGE, PAUSE_TIME 10 | 11 | 12 | class ExpectedError(RuntimeError): 13 | 14 | """ 15 | A dedicated error type for raising from tests to verify a fixture was run. 16 | """ 17 | 18 | 19 | @pytest.fixture 20 | def mynumber(): 21 | return 42 22 | 23 | 24 | class MainHandler(tornado.web.RequestHandler): 25 | def get(self): 26 | self.write(MESSAGE) 27 | 28 | 29 | def _pause_coro(period): 30 | return tornado.gen.sleep(period) 31 | 32 | 33 | async def pause(): 34 | await _pause_coro(PAUSE_TIME) 35 | 36 | 37 | def test_plain_function(): 38 | # non-coroutine test function without fixtures 39 | assert True 40 | 41 | 42 | def test_plain_function_with_fixture(mynumber): 43 | # non-coroutine test function that uses a fixture 44 | assert mynumber == 42 45 | 46 | 47 | async def nontest_coroutine(io_loop): 48 | # Non-test coroutine function that shouldn't be run 49 | assert False 50 | 51 | 52 | def nontest_function(io_loop): 53 | # Non-test function that shouldn't be run 54 | assert False 55 | 56 | 57 | async def test_pause(io_loop): 58 | start = time.time() 59 | await pause() 60 | elapsed = time.time() - start 61 | assert elapsed >= PAUSE_TIME 62 | 63 | 64 | async def test_http_client_fetch(http_client, http_server, http_server_port): 65 | url = "http://localhost:%s/" % http_server_port[1] 66 | resp = await http_client.fetch(url) 67 | assert resp.code == 200 68 | assert resp.body.decode("utf8") == MESSAGE 69 | 70 | 71 | async def test_http_server_client_fetch(http_server_client): 72 | resp = await http_server_client.fetch("/") 73 | assert resp.code == 200 74 | assert resp.body.decode("utf8") == MESSAGE 75 | 76 | 77 | @pytest.mark.xfail(raises=ExpectedError) 78 | def test_expected_noncoroutine_fail(): 79 | raise ExpectedError() 80 | 81 | 82 | @pytest.mark.xfail(raises=ExpectedError) 83 | async def test_expected_coroutine_fail_no_ioloop(): 84 | """A coroutine test without an io_loop param.""" 85 | raise ExpectedError() 86 | 87 | 88 | @pytest.mark.xfail(raises=ExpectedError) 89 | async def test_expected_coroutine_fail_io_loop(io_loop): 90 | """A coroutine test with an io_loop param.""" 91 | raise ExpectedError() 92 | 93 | 94 | @pytest.mark.xfail(strict=True, raises=tornado.ioloop.TimeoutError) 95 | @pytest.mark.timeout(seconds=0.1) 96 | async def test_timeout(io_loop): 97 | await _pause_coro(0.15) 98 | -------------------------------------------------------------------------------- /test/test_plugin2.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def io_loop(): 8 | yield asyncio.SelectorEventLoop() 9 | 10 | 11 | @pytest.mark.xfail(type=TypeError) 12 | async def test_bad_io_loop_fixture(io_loop): 13 | assert False # won't be run 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = {pypy35,py35,py36,py37}-pytest{3,4}latest-tornado{50,latest} 3 | 4 | [testenv] 5 | deps = 6 | pytest3latest: pytest>=3.0,<4.0 7 | pytest4latest: pytest>=4.0,<5.0 8 | tornado50: tornado==5.0 9 | tornado5latest: tornado>5.0.0,<6.0 10 | pytest-pep8 11 | pytest-cov 12 | setenv = 13 | COV_CORE_SOURCE={toxinidir}/src 14 | COV_CORE_CONFIG={toxinidir}/.coveragerc 15 | COV_CORE_DATAFILE={toxinidir}/.coverage.eager 16 | commands = pytest --disable-pytest-warnings --cov=src --cov-append --cov-report=term-missing -s {posargs} test 17 | --------------------------------------------------------------------------------