diff --git a/.gitignore b/.gitignore index 72364f9..4309515 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,8 @@ ENV/ # Rope project settings .ropeproject + +.python-version + +# generated by setuptools_scm +pytest_asyncio/_version.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..bf5f9e2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,67 @@ +--- +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: check-merge-conflict + exclude: rst$ +- repo: https://github.com/asottile/yesqa + rev: v1.3.0 + hooks: + - id: yesqa +- repo: https://github.com/Zac-HD/shed + rev: 0.6.0 # 0.7 does not support Python 3.7 + hooks: + - id: shed + args: + - --refactor + - --py37-plus + types_or: + - python + - markdown + - rst +- repo: https://github.com/jumanjihouse/pre-commit-hook-yamlfmt + rev: 0.1.0 + hooks: + - id: yamlfmt + args: [--mapping, '2', --sequence, '2', --offset, '0'] +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: fix-encoding-pragma + args: [--remove] + - id: check-case-conflict + - id: check-json + - id: check-xml + - id: check-yaml + - id: debug-statements +- repo: https://gitlab.com/pycqa/flake8 + rev: 3.9.2 + hooks: + - id: flake8 + language_version: python3 +- repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.9.0 + hooks: + - id: python-use-type-annotations +- repo: https://github.com/rhysd/actionlint + rev: v1.6.8 + hooks: + - id: actionlint-docker + args: + - -ignore + - 'SC2155:' + - -ignore + - 'SC2086:' + - -ignore + - 'SC1004:' +- repo: https://github.com/sirosen/check-jsonschema + rev: 0.9.1 + hooks: + - id: check-github-actions +ci: + skip: + - actionlint-docker + - check-github-actions diff --git a/CHANGES.rst b/CHANGES.rst index 24a2c15..ad9a4d9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,13 @@ CHANGES ======= +1.0.0 (2022-1-20) +------------------ + +- The plugin is compatible with ``pytest-asyncio`` now. It uses ``pytest-asyncio`` for + async tests running and async fixtures support, providing by itself only fixtures for + creating aiohttp test server and client. + 0.2.0 (2017-11-30) ------------------ diff --git a/README.rst b/README.rst index 226af99..b6cfca9 100644 --- a/README.rst +++ b/README.rst @@ -3,37 +3,54 @@ pytest-aiohttp pytest plugin for aiohttp support -The library allows to use `aiohttp pytest plugin -`_ -without need for explicit loading it like `pytest_plugins = -'aiohttp.pytest_plugin'`. +The library provides useful fixtures for creation test aiohttp server and client. - - -Just run: +Installation +------------ .. code-block:: console $ pip install pytest-aiohttp -and write tests with the plugin support: +Add ``asyncio_mode = auto`` line to `pytest configuration +`_ (see `pytest-asyncio modes +`_ for details). The plugin works +with ``strict`` mode also. + + + +Usage +----- + +Write tests in `pytest-asyncio `_ style +using provided fixtures for aiohttp test server and client creation. The plugin provides +resources cleanup out-of-the-box. + +The simple usage example: .. code-block:: python from aiohttp import web + async def hello(request): - return web.Response(body=b'Hello, world') + return web.Response(body=b"Hello, world") + def create_app(loop): app = web.Application(loop=loop) - app.router.add_route('GET', '/', hello) + app.router.add_route("GET", "/", hello) return app + async def test_hello(test_client): client = await test_client(create_app) - resp = await client.get('/') + resp = await client.get("/") assert resp.status == 200 text = await resp.text() - assert 'Hello, world' in text + assert "Hello, world" in text + + +See `aiohttp documentation ` for +more details about fixtures usage. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4e4830d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[build-system] +requires = [ + "setuptools>=51.0", + "wheel>=0.36", + "setuptools_scm>=6.2" +] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +write_to = "pytest_aiohttp/_version.py" diff --git a/pytest_aiohttp/__init__.py b/pytest_aiohttp/__init__.py index 9aeb27c..6c02300 100644 --- a/pytest_aiohttp/__init__.py +++ b/pytest_aiohttp/__init__.py @@ -1,4 +1 @@ -__version__ = '0.3.0' - - -from aiohttp.pytest_plugin import * +from ._version import version as __version__ # noqa diff --git a/pytest_aiohttp/_version.py b/pytest_aiohttp/_version.py new file mode 100644 index 0000000..48e0a7e --- /dev/null +++ b/pytest_aiohttp/_version.py @@ -0,0 +1,4 @@ +# file generated by setuptools_scm +# don't change, don't track in version control +version = "0.3.1.dev2+ge22c265.d20220120" +version_tuple = (0, 3, 1, "dev2", "ge22c265.d20220120") diff --git a/pytest_aiohttp/plugin.py b/pytest_aiohttp/plugin.py new file mode 100644 index 0000000..6c5e1c8 --- /dev/null +++ b/pytest_aiohttp/plugin.py @@ -0,0 +1,172 @@ +import asyncio +import warnings +from typing import Any, Awaitable, Callable, Dict, Generator, Optional, Type, Union + +import pytest +import pytest_asyncio +from aiohttp.test_utils import BaseTestServer, RawTestServer, TestClient, TestServer +from aiohttp.web import Application, BaseRequest, StreamResponse + +AiohttpClient = Callable[[Union[Application, BaseTestServer]], Awaitable[TestClient]] + + +LEGACY_MODE = DeprecationWarning( + "The 'asyncio_mode' is 'legacy', switching to 'auto' for the sake of " + "pytest-aiohttp backward compatibility. " + "Please explicitly use 'asyncio_mode=strict' or 'asyncio_mode=auto' " + "in pytest configuration file." +) + + +@pytest.mark.tryfirst +def pytest_configure(config) -> None: + val = config.getoption("asyncio_mode") + if val is None: + val = config.getini("asyncio_mode") + if val == "legacy": + config.option.asyncio_mode = "auto" + config.issue_config_time_warning(LEGACY_MODE, stacklevel=2) + + +@pytest.fixture +def loop(event_loop: asyncio.AbstractEventLoop) -> asyncio.AbstractEventLoop: + warnings.warn( + "'loop' fixture is deprecated and scheduled for removal, " + "please use 'event_loop' instead", + DeprecationWarning, + ) + return loop + + +@pytest.fixture +def proactor_loop(event_loop: asyncio.AbstractEventLoop) -> asyncio.AbstractEventLoop: + warnings.warn( + "'proactor_loop' fixture is deprecated and scheduled for removal, " + "please use 'event_loop' instead", + DeprecationWarning, + ) + return loop + + +@pytest.fixture +def aiohttp_unused_port( + unused_tcp_port_factory: Callable[[], int] +) -> Callable[[], int]: + warnings.warn( + "'aiohttp_unused_port' fixture is deprecated " + "and scheduled for removal, " + "please use 'unused_tcp_port_factory' instead", + DeprecationWarning, + ) + return unused_tcp_port_factory + + +@pytest_asyncio.fixture +async def aiohttp_server() -> Callable[..., Awaitable[TestServer]]: + """Factory to create a TestServer instance, given an app. + + aiohttp_server(app, **kwargs) + """ + servers = [] + + async def go( + app: Application, *, port: Optional[int] = None, **kwargs: Any + ) -> TestServer: + server = TestServer(app, port=port) + await server.start_server(**kwargs) + servers.append(server) + return server + + yield go + + while servers: + await servers.pop().close() + + +@pytest_asyncio.fixture +async def aiohttp_raw_server() -> Callable[..., Awaitable[RawTestServer]]: + """Factory to create a RawTestServer instance, given a web handler. + + aiohttp_raw_server(handler, **kwargs) + """ + servers = [] + + async def go( + handler: Callable[[BaseRequest], Awaitable[StreamResponse]], + *, + port: Optional[int] = None, + **kwargs: Any, + ) -> RawTestServer: + server = RawTestServer(handler, port=port) + await server.start_server(**kwargs) + servers.append(server) + return server + + yield go + + while servers: + await servers.pop().close() + + +@pytest.fixture +def aiohttp_client_cls() -> Type[TestClient]: + """ + Client class to use in ``aiohttp_client`` factory. + + Use it for passing custom ``TestClient`` implementations. + + Example:: + + class MyClient(TestClient): + async def login(self, *, user, pw): + payload = {"username": user, "password": pw} + return await self.post("/login", json=payload) + + @pytest.fixture + def aiohttp_client_cls(): + return MyClient + + def test_login(aiohttp_client): + app = web.Application() + client = await aiohttp_client(app) + await client.login(user="admin", pw="s3cr3t") + + """ + return TestClient + + +@pytest.fixture +async def aiohttp_client( + aiohttp_client_cls: Type[TestClient], +) -> Generator[AiohttpClient, None, None]: + """Factory to create a TestClient instance. + + aiohttp_client(app, **kwargs) + aiohttp_client(server, **kwargs) + aiohttp_client(raw_server, **kwargs) + """ + clients = [] + + async def go( + __param: Union[Application, BaseTestServer], + *, + server_kwargs: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> TestClient: + if isinstance(__param, Application): + server_kwargs = server_kwargs or {} + server = TestServer(__param, **server_kwargs) + client = aiohttp_client_cls(server, **kwargs) + elif isinstance(__param, BaseTestServer): + client = aiohttp_client_cls(__param, **kwargs) + else: + raise ValueError("Unknown argument type: %r" % type(__param)) + + await client.start_server() + clients.append(client) + return client + + yield go + + while clients: + await clients.pop().close() diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..a17f5f0 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,70 @@ +[metadata] +name = pytest-aiohttp +version = attr: pytest_aiohttp.__version__ +url = https://github.com/aio-libs/pytest-aiohttp +project_urls = + GitHub = https://github.com/aio-libs/pytest-aiohttp +description = Pytest plugin for aiohttp support +long_description = file: README.rst +long_description_content_type = text/x-rst +maintainer = aiohttp team +maintainer_email = team@aiohttp.org +license = Apache 2.0 +license_file = LICENSE +classifiers = + Development Status :: 4 - Beta + + Intended Audience :: Developers + + License :: OSI Approved :: Apache Software License + + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + + Topic :: Software Development :: Testing + + Framework :: AsyncIO + Framework :: Pytest + Framework :: aiohttp + Typing :: Typed + +[options] +python_requires = >=3.7 +packages = find: +include_package_data = True + +setup_requires = + setuptools_scm >= 6.2 + +install_requires = + pytest >= 6.1.0 + aiohttp >= 3.8.1 + pytest-asyncio >= 0.17.2 + +[options.extras_require] +testing = + coverage == 6.2 + mypy == 0.931 + +[options.entry_points] +pytest11 = + aiohttp = pytest_aiohttp.plugin + +[coverage:run] +source = pytest_aiohttp +branch = true + +[coverage:report] +show_missing = true + +[tool:pytest] +addopts = -rsx --tb=short +testpaths = tests +asyncio_mode = auto +junit_family=xunit2 +filterwarnings = error + +[flake8] +max-line-length = 88 diff --git a/setup.py b/setup.py index d02ce81..6068493 100644 --- a/setup.py +++ b/setup.py @@ -1,50 +1,3 @@ -import codecs -import os -import re -import sys from setuptools import setup - -with codecs.open(os.path.join(os.path.abspath(os.path.dirname( - __file__)), 'pytest_aiohttp', '__init__.py'), 'r', 'latin1') as fp: - try: - version = re.findall(r"^__version__ = '([^']+)'\r?$", - fp.read(), re.M)[0] - except IndexError: - raise RuntimeError('Unable to determine version.') - - -def read(f): - return open(os.path.join(os.path.dirname(__file__), f)).read().strip() - - -setup( - name='pytest-aiohttp', - version=version, - description=('pytest plugin for aiohttp support'), - long_description='\n\n'.join((read('README.rst'), read('CHANGES.rst'))), - classifiers=[ - 'License :: OSI Approved :: Apache Software License', - 'Intended Audience :: Developers', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Topic :: Software Development :: Testing', - 'Framework :: Pytest', - 'Framework :: AsyncIO', - ], - author='Andrew Svetlov', - author_email='andrew.svetlov@gmail.com', - url='https://github.com/aio-libs/pytest-aiohttp/', - license='Apache 2', - install_requires=[ - 'pytest', - 'aiohttp>=2.3.5' - ], - packages=['pytest_aiohttp'], - entry_points={ - 'pytest11': ['aiohttp = pytest_aiohttp'], - }, -) +setup() diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py new file mode 100644 index 0000000..7768f04 --- /dev/null +++ b/tests/test_fixtures.py @@ -0,0 +1,220 @@ +from typing import Any + +pytest_plugins: str = "pytester" + + +def test_aiohttp_plugin(testdir: Any) -> None: + testdir.makepyfile( + """\ +import pytest +from unittest import mock + +from aiohttp import web + + +async def hello(request): + return web.Response(body=b'Hello, world') + + +async def create_app(): + app = web.Application() + app.router.add_route('GET', '/', hello) + return app + + +async def test_hello(aiohttp_client) -> None: + client = await aiohttp_client(await create_app()) + resp = await client.get('/') + assert resp.status == 200 + text = await resp.text() + assert 'Hello, world' in text + + +async def test_hello_from_app(aiohttp_client) -> None: + app = web.Application() + app.router.add_get('/', hello) + client = await aiohttp_client(app) + resp = await client.get('/') + assert resp.status == 200 + text = await resp.text() + assert 'Hello, world' in text + + +async def test_hello_with_loop(aiohttp_client) -> None: + client = await aiohttp_client(await create_app()) + resp = await client.get('/') + assert resp.status == 200 + text = await resp.text() + assert 'Hello, world' in text + + +async def test_noop() -> None: + pass + + +async def previous(request): + if request.method == 'POST': + with pytest.warns(DeprecationWarning): + request.app['value'] = (await request.post())['value'] + return web.Response(body=b'thanks for the data') + else: + v = request.app.get('value', 'unknown') + return web.Response(body='value: {}'.format(v).encode()) + + +def create_stateful_app(): + app = web.Application() + app.router.add_route('*', '/', previous) + return app + + +@pytest.fixture +async def cli(aiohttp_client): + return await aiohttp_client(create_stateful_app()) + + +def test_noncoro() -> None: + assert True + + +async def test_failed_to_create_client(aiohttp_client) -> None: + + def make_app(): + raise RuntimeError() + + with pytest.raises(RuntimeError): + await aiohttp_client(make_app()) + + +async def test_custom_port_aiohttp_client(aiohttp_client, unused_tcp_port): + client = await aiohttp_client(await create_app(), + server_kwargs={'port': unused_tcp_port}) + assert client.port == unused_tcp_port + resp = await client.get('/') + assert resp.status == 200 + text = await resp.text() + assert 'Hello, world' in text + + +async def test_custom_port_test_server(aiohttp_server, unused_tcp_port): + app = await create_app() + server = await aiohttp_server(app, port=unused_tcp_port) + assert server.port == unused_tcp_port + +""" + ) + result = testdir.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=8) + + +def test_aiohttp_raw_server(testdir: Any) -> None: + testdir.makepyfile( + """\ +import pytest + +from aiohttp import web + + +async def handler(request): + return web.Response(text="OK") + + +@pytest.fixture +async def server(aiohttp_raw_server): + return await aiohttp_raw_server(handler) + + +@pytest.fixture +async def cli(aiohttp_client, server): + client = await aiohttp_client(server) + return client + + +async def test_hello(cli) -> None: + resp = await cli.get('/') + assert resp.status == 200 + text = await resp.text() + assert 'OK' in text + +""" + ) + result = testdir.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + + +def test_aiohttp_client_cls_fixture_custom_client_used(testdir: Any) -> None: + testdir.makepyfile( + """ +import pytest +from aiohttp.web import Application +from aiohttp.test_utils import TestClient + + +class CustomClient(TestClient): + pass + + +@pytest.fixture +def aiohttp_client_cls(): + return CustomClient + + +async def test_hello(aiohttp_client) -> None: + client = await aiohttp_client(Application()) + assert isinstance(client, CustomClient) + +""" + ) + result = testdir.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + + +def test_aiohttp_client_cls_fixture_factory(testdir: Any) -> None: + testdir.makeconftest( + """\ + +def pytest_configure(config): + config.addinivalue_line("markers", "rest: RESTful API tests") + config.addinivalue_line("markers", "graphql: GraphQL API tests") + +""" + ) + testdir.makepyfile( + """ +import pytest +from aiohttp.web import Application +from aiohttp.test_utils import TestClient + + +class RESTfulClient(TestClient): + pass + + +class GraphQLClient(TestClient): + pass + + +@pytest.fixture +def aiohttp_client_cls(request): + if request.node.get_closest_marker('rest') is not None: + return RESTfulClient + elif request.node.get_closest_marker('graphql') is not None: + return GraphQLClient + return TestClient + + +@pytest.mark.rest +async def test_rest(aiohttp_client) -> None: + client = await aiohttp_client(Application()) + assert isinstance(client, RESTfulClient) + + +@pytest.mark.graphql +async def test_graphql(aiohttp_client) -> None: + client = await aiohttp_client(Application()) + assert isinstance(client, GraphQLClient) + +""" + ) + result = testdir.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=2) diff --git a/tests/test_obsolete_fixtures.py b/tests/test_obsolete_fixtures.py new file mode 100644 index 0000000..ac95e09 --- /dev/null +++ b/tests/test_obsolete_fixtures.py @@ -0,0 +1,60 @@ +from typing import Any + +pytest_plugins: str = "pytester" + + +def test_loop_fixture(testdir: Any) -> None: + testdir.makepyfile( + """\ +async def test_a(loop): + pass + +""" + ) + result = testdir.runpytest_subprocess("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines( + [ + "*DeprecationWarning: 'loop' fixture is deprecated " + "and scheduled for removal, " + "please use 'event_loop' instead*" + ] + ) + + +def test_proactor_loop_fixture(testdir: Any) -> None: + testdir.makepyfile( + """\ +async def test_a(proactor_loop): + pass + +""" + ) + result = testdir.runpytest_subprocess("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines( + [ + "*DeprecationWarning: 'proactor_loop' fixture is deprecated " + "and scheduled for removal, " + "please use 'event_loop' instead*" + ] + ) + + +def test_aiohttp_unused_port(testdir: Any) -> None: + testdir.makepyfile( + """\ +async def test_a(aiohttp_unused_port): + aiohttp_unused_port() + +""" + ) + result = testdir.runpytest_subprocess("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines( + [ + "*DeprecationWarning: 'aiohttp_unused_port' fixture is deprecated " + "and scheduled for removal, " + "please use 'unused_tcp_port_factory' instead*" + ] + ) diff --git a/tests/test_switch_mode.py b/tests/test_switch_mode.py new file mode 100644 index 0000000..88d1a5f --- /dev/null +++ b/tests/test_switch_mode.py @@ -0,0 +1,48 @@ +from typing import Any + +from pytest_aiohttp.plugin import LEGACY_MODE + +pytest_plugins: str = "pytester" + + +def test_warning_for_legacy_mode(testdir: Any) -> None: + testdir.makepyfile( + """\ +async def test_a(): + pass + +""" + ) + result = testdir.runpytest_subprocess("--asyncio-mode=legacy") + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines(["*" + str(LEGACY_MODE) + "*"]) + + +def test_auto_mode(testdir: Any) -> None: + testdir.makepyfile( + """\ +async def test_a(): + pass + +""" + ) + result = testdir.runpytest_subprocess("--asyncio-mode=auto") + result.assert_outcomes(passed=1) + result.stdout.no_fnmatch_line("*" + str(LEGACY_MODE) + "*") + + +def test_strict_mode(testdir: Any) -> None: + testdir.makepyfile( + """\ +import pytest + + +@pytest.mark.asyncio +async def test_a(): + pass + +""" + ) + result = testdir.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + result.stdout.no_fnmatch_line("*" + str(LEGACY_MODE) + "*")