├── requirements.txt ├── requirements.testing.txt ├── sanic_testing ├── __init__.py ├── manager.py ├── websocket.py ├── reusable.py └── testing.py ├── Makefile ├── .gitignore ├── tests ├── conftest.py ├── test_reusable_client.py ├── test_basic.py ├── test_asgi_client.py └── test_test_client.py ├── .github └── workflows │ ├── python-package.yml │ └── python-publish.yml ├── tox.ini ├── LICENSE ├── setup.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | black 2 | isort 3 | -------------------------------------------------------------------------------- /requirements.testing.txt: -------------------------------------------------------------------------------- 1 | git+https://github.com/sanic-org/sanic.git 2 | pytest 3 | pytest-asyncio 4 | setuptools 5 | -------------------------------------------------------------------------------- /sanic_testing/__init__.py: -------------------------------------------------------------------------------- 1 | from sanic_testing.manager import TestManager 2 | 3 | __version__ = "24.6.0" 4 | __all__ = ("TestManager",) 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | pretty: 2 | black --line-length 79 sanic_testing tests 3 | isort --line-length 79 sanic_testing tests 4 | install: 5 | python -m venv venv 6 | venv/bin/pip install --editable .[dev] 7 | test: 8 | venv/bin/pytest tests 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.egg-info 3 | *.egg 4 | *.eggs 5 | *.pyc 6 | .coverage 7 | .coverage.* 8 | coverage 9 | .tox 10 | settings.py 11 | .idea/* 12 | .cache/* 13 | .python-version 14 | docs/_build/ 15 | docs/_api/ 16 | build/* 17 | .DS_Store 18 | dist/* 19 | pip-wheel-metadata/ 20 | .venv 21 | .vscode 22 | -------------------------------------------------------------------------------- /sanic_testing/manager.py: -------------------------------------------------------------------------------- 1 | from sanic import Sanic # type: ignore 2 | 3 | from sanic_testing.testing import SanicASGITestClient, SanicTestClient 4 | 5 | 6 | class TestManager: 7 | __test__ = False 8 | 9 | def __init__(self, app: Sanic) -> None: 10 | self.test_client = SanicTestClient(app) 11 | self.asgi_client = SanicASGITestClient(app) 12 | app._test_manager = self # type: ignore 13 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sanic import Sanic, response 3 | 4 | from sanic_testing import TestManager 5 | 6 | 7 | def _basic_response(request): 8 | return response.text("foo") 9 | 10 | 11 | @pytest.fixture 12 | def app(): 13 | sanic_app = Sanic(__name__) 14 | TestManager(sanic_app) 15 | 16 | sanic_app.route( 17 | "/", methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] 18 | )(_basic_response) 19 | return sanic_app 20 | 21 | 22 | @pytest.fixture 23 | def manager(): 24 | sanic_app = Sanic(__name__) 25 | return TestManager(sanic_app) 26 | -------------------------------------------------------------------------------- /tests/test_reusable_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sanic import Sanic, response 3 | 4 | from sanic_testing.reusable import ReusableClient 5 | 6 | 7 | @pytest.fixture 8 | def reusable_app(): 9 | sanic_app = Sanic(__name__) 10 | 11 | @sanic_app.get("/") 12 | def basic(request): 13 | return response.text("foo") 14 | 15 | return sanic_app 16 | 17 | 18 | @pytest.mark.asyncio 19 | def test_basic_asgi_client(reusable_app): 20 | client = ReusableClient(reusable_app) 21 | with client: 22 | request, response = client.get("/") 23 | 24 | assert request.method.lower() == "get" 25 | assert response.body == b"foo" 26 | assert response.status == 200 27 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install tox tox-gh-actions 24 | - name: Test with tox 25 | run: tox 26 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38, py39, py310, py311, py312, check 3 | 4 | [gh-actions] 5 | python = 6 | 3.8: py38, check 7 | 3.9: py39 8 | 3.10: py310 9 | 3.11: py311 10 | 3.12: py312 11 | 12 | [testenv] 13 | deps = 14 | -r{toxinidir}/requirements.testing.txt 15 | commands = 16 | pytest {posargs:tests sanic_testing} 17 | 18 | [testenv:check] 19 | deps = 20 | flake8 21 | black 22 | isort 23 | mypy 24 | sanic 25 | 26 | commands = 27 | flake8 sanic_testing 28 | black --check --line-length 79 sanic_testing tests 29 | isort --check --line-length 79 sanic_testing tests 30 | mypy sanic_testing 31 | 32 | 33 | [pytest] 34 | filterwarnings = 35 | ignore:.*async with lock.* instead:DeprecationWarning 36 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | import pytest 4 | from sanic import Sanic 5 | 6 | from sanic_testing import TestManager 7 | from sanic_testing.testing import SanicASGITestClient, SanicTestClient 8 | 9 | 10 | def test_legacy_support_initialization(app): 11 | assert isinstance(app.test_client, SanicTestClient) 12 | assert isinstance(app.asgi_client, SanicASGITestClient) 13 | 14 | 15 | def test_manager_initialization(manager): 16 | assert isinstance(manager.test_client, SanicTestClient) 17 | assert isinstance(manager.asgi_client, SanicASGITestClient) 18 | assert isinstance(manager, TestManager) 19 | 20 | 21 | @pytest.mark.parametrize("protocol", [3, 4]) 22 | def test_pickle_app(protocol): 23 | app = Sanic("test_pickle_app") 24 | manager = TestManager(app) 25 | assert app._test_manager == manager 26 | my_dict = {"app": app} 27 | app.router.reset() 28 | app.signal_router.reset() 29 | my_pickled = pickle.dumps(my_dict, protocol=protocol) 30 | del my_dict 31 | del app 32 | del manager 33 | my_new_dict = pickle.loads(my_pickled) 34 | assert my_new_dict["app"]._test_manager 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sanic Community Organization 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 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | workflow_dispatch: 7 | jobs: 8 | build-n-publish: 9 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: "3.x" 17 | - name: Install pypa/build 18 | run: >- 19 | python3 -m 20 | pip install 21 | build 22 | --user 23 | - name: Build a binary wheel and a source tarball 24 | run: >- 25 | python3 -m 26 | build 27 | --sdist 28 | --wheel 29 | --outdir dist/ 30 | . 31 | - name: Publish distribution 📦 to Test PyPI 32 | uses: pypa/gh-action-pypi-publish@release/v1 33 | with: 34 | password: ${{ secrets.SANIC_TEST_PYPI_API_TOKEN }} 35 | repository-url: https://test.pypi.org/legacy/ 36 | - name: Publish distribution 📦 to PyPI 37 | uses: pypa/gh-action-pypi-publish@release/v1 38 | with: 39 | password: ${{ secrets.SANIC_PYPI_API_TOKEN }} -------------------------------------------------------------------------------- /sanic_testing/websocket.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from websockets.exceptions import ConnectionClosedOK 4 | from websockets.legacy.client import connect 5 | 6 | 7 | class WebsocketProxy: 8 | def __init__(self, ws): 9 | self.ws = ws 10 | self.opened = True 11 | self.client_received: typing.List[str] = [] 12 | self.client_sent: typing.List[str] = [] 13 | 14 | @property 15 | def server_received(self): 16 | return self.client_sent 17 | 18 | @property 19 | def server_sent(self): 20 | return self.client_received 21 | 22 | 23 | async def websocket_proxy(url, *args, **kwargs) -> WebsocketProxy: 24 | mimic = kwargs.pop("mimic", None) 25 | async with connect(url, *args, **kwargs) as websocket: 26 | ws_proxy = WebsocketProxy(websocket) 27 | 28 | if mimic: 29 | do_send = websocket.send 30 | do_recv = websocket.recv 31 | 32 | async def send(data): 33 | ws_proxy.client_sent.append(data) 34 | await do_send(data) 35 | 36 | async def recv(): 37 | message = await do_recv() 38 | ws_proxy.client_received.append(message) 39 | return message 40 | 41 | websocket.send = send # type: ignore 42 | websocket.recv = recv # type: ignore 43 | 44 | try: 45 | await mimic(websocket) 46 | except ConnectionClosedOK: 47 | pass 48 | else: 49 | await websocket.send("") 50 | return ws_proxy 51 | -------------------------------------------------------------------------------- /tests/test_asgi_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | from sanic.request import Request 5 | 6 | 7 | @pytest.mark.asyncio 8 | @pytest.mark.parametrize( 9 | "method", ["get", "post", "patch", "put", "delete", "options"] 10 | ) 11 | async def test_basic_asgi_client(app, method): 12 | request, response = await getattr(app.asgi_client, method)("/") 13 | 14 | assert isinstance(request, Request) 15 | assert response.body == b"foo" 16 | assert response.status == 200 17 | assert response.content_type == "text/plain; charset=utf-8" 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_websocket_route(app): 22 | ev = asyncio.Event() 23 | 24 | @app.websocket("/ws") 25 | async def handler(request, ws): 26 | ev.set() 27 | 28 | await app.asgi_client.websocket("/ws") 29 | assert ev.is_set() 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_listeners(app): 34 | listeners = [] 35 | available = ( 36 | "before_server_start", 37 | "after_server_start", 38 | "before_server_stop", 39 | "after_server_stop", 40 | ) 41 | 42 | @app.before_server_start 43 | async def before_server_start(*_): 44 | listeners.append("before_server_start") 45 | 46 | @app.after_server_start 47 | async def after_server_start(*_): 48 | listeners.append("after_server_start") 49 | 50 | @app.before_server_stop 51 | async def before_server_stop(*_): 52 | listeners.append("before_server_stop") 53 | 54 | @app.after_server_stop 55 | async def after_server_stop(*_): 56 | listeners.append("after_server_stop") 57 | 58 | await app.asgi_client.get("/") 59 | 60 | assert len(listeners) == 4 61 | assert all(x in listeners for x in available) 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sanic 3 | """ 4 | import codecs 5 | import os 6 | import re 7 | 8 | from setuptools import setup 9 | 10 | 11 | def open_local(paths, mode="r", encoding="utf8"): 12 | path = os.path.join(os.path.abspath(os.path.dirname(__file__)), *paths) 13 | 14 | return codecs.open(path, mode, encoding) 15 | 16 | 17 | with open_local(["sanic_testing", "__init__.py"], encoding="latin1") as fp: 18 | try: 19 | version = re.findall( 20 | r"^__version__ = \"([^']+)\"\r?$", fp.read(), re.M 21 | )[0] 22 | except IndexError: 23 | raise RuntimeError("Unable to determine version.") 24 | 25 | with open_local(["README.md"]) as rm: 26 | long_description = rm.read() 27 | 28 | setup_kwargs = { 29 | "name": "sanic-testing", 30 | "version": version, 31 | "url": "https://github.com/sanic-org/sanic-testing/", 32 | "license": "MIT", 33 | "author": "Adam Hopkins", 34 | "author_email": "admhpkns@gmail.com", 35 | "description": ("Core testing clients for Sanic"), 36 | "long_description": long_description, 37 | "long_description_content_type": "text/markdown", 38 | "packages": ["sanic_testing"], 39 | "platforms": "any", 40 | "classifiers": [ 41 | "Development Status :: 4 - Beta", 42 | "Environment :: Web Environment", 43 | "License :: OSI Approved :: MIT License", 44 | "Programming Language :: Python :: 3.8", 45 | "Programming Language :: Python :: 3.9", 46 | "Programming Language :: Python :: 3.10", 47 | "Programming Language :: Python :: 3.11", 48 | "Programming Language :: Python :: 3.12", 49 | ], 50 | } 51 | requirements = ["httpx>=0.18"] 52 | 53 | tests_require = [ 54 | "pytest", "sanic>=22.12", "pytest-asyncio", 55 | "setuptools;python_version>'3.11'" 56 | ] 57 | 58 | setup_kwargs["install_requires"] = requirements 59 | setup_kwargs["tests_require"] = tests_require 60 | setup_kwargs["extras_require"] = { 61 | 'dev': tests_require 62 | } 63 | setup(**setup_kwargs) 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sanic Core Test 2 | 3 | This package is meant to be the core testing utility and clients for testing Sanic applications. It is mainly derived from `sanic.testing` which has (or will be) removed from the main Sanic repository in the future. 4 | 5 | [Documentation](https://sanicframework.org/en/plugins/sanic-testing/getting-started.html) 6 | 7 | ## Getting Started 8 | 9 | pip install sanic-testing 10 | 11 | The package is meant to create an almost seemless transition. Therefore, after loading the package, it will attach itself to your Sanic instance and insert test clients. 12 | 13 | ```python 14 | from sanic import Sanic 15 | from sanic_testing import TestManager 16 | 17 | sanic_app = Sanic(__name__) 18 | TestManager(sanic_app) 19 | ``` 20 | 21 | This will provide access to both the sync (`sanic.test_client`) and async (`sanic.asgi_client`) clients. Both of these clients are also available directly on the `TestManager` instance. 22 | 23 | ## Writing a sync test 24 | 25 | Testing should be pretty much the same as when the test client was inside Sanic core. The difference is just that you need to run `TestManager`. 26 | 27 | ```python 28 | import pytest 29 | 30 | @pytest.fixture 31 | def app(): 32 | sanic_app = Sanic(__name__) 33 | TestManager(sanic_app) 34 | 35 | @sanic_app.get("/") 36 | def basic(request): 37 | return response.text("foo") 38 | 39 | return sanic_app 40 | 41 | def test_basic_test_client(app): 42 | request, response = app.test_client.get("/") 43 | 44 | assert response.body == b"foo" 45 | assert response.status == 200 46 | ``` 47 | 48 | ## Writing an async test 49 | 50 | Testing of an async method is best done with `pytest-asyncio` installed. Again, the following test should look familiar to anyone that has used `asgi_client` in the Sanic core package before. 51 | 52 | The main benefit of using the `asgi_client` is that it is able to reach inside your application, and execute your handlers without ever having to stand up a server or make a network call. 53 | 54 | ```python 55 | import pytest 56 | 57 | @pytest.fixture 58 | def app(): 59 | sanic_app = Sanic(__name__) 60 | TestManager(sanic_app) 61 | 62 | @sanic_app.get("/") 63 | def basic(request): 64 | return response.text("foo") 65 | 66 | return sanic_app 67 | 68 | @pytest.mark.asyncio 69 | async def test_basic_asgi_client(app): 70 | request, response = await app.asgi_client.get("/") 71 | 72 | assert response.body == b"foo" 73 | assert response.status == 200 74 | ``` 75 | -------------------------------------------------------------------------------- /tests/test_test_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | from sanic import Sanic, Websocket 5 | from sanic.request import Request 6 | from websockets.client import WebSocketClientProtocol 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "method", ["get", "post", "patch", "put", "delete", "options"] 11 | ) 12 | def test_basic_test_client(app, method): 13 | request, response = getattr(app.test_client, method)("/") 14 | 15 | assert isinstance(request, Request) 16 | assert response.body == b"foo" 17 | assert response.status == 200 18 | assert response.content_type == "text/plain; charset=utf-8" 19 | 20 | 21 | def test_websocket_route_basic(app): 22 | ev = asyncio.Event() 23 | 24 | @app.websocket("/ws") 25 | async def handler(request, ws): 26 | assert request.scheme == "ws" 27 | assert ws.subprotocol is None 28 | ev.set() 29 | 30 | request, response = app.test_client.websocket("/ws") 31 | assert response.opened is True 32 | assert ev.is_set() 33 | 34 | 35 | def test_websocket_route_queue(app: Sanic): 36 | async def client_mimic(websocket: WebSocketClientProtocol): 37 | await websocket.send("foo") 38 | await websocket.recv() 39 | 40 | @app.websocket("/ws") 41 | async def handler(request, ws: Websocket): 42 | while True: 43 | await ws.send("hello!") 44 | if not await ws.recv(): 45 | break 46 | 47 | _, response = app.test_client.websocket("/ws", mimic=client_mimic) 48 | assert response.server_sent == ["hello!"] 49 | assert response.server_received == ["foo", ""] 50 | 51 | 52 | def test_websocket_client_mimic_failed(app: Sanic): 53 | @app.websocket("/ws") 54 | async def handler(request, ws: Websocket): 55 | pass 56 | 57 | async def client_mimic(websocket: WebSocketClientProtocol): 58 | raise Exception("Should fails") 59 | 60 | with pytest.raises(Exception, match="Should fails"): 61 | app.test_client.websocket("/ws", mimic=client_mimic) 62 | 63 | 64 | def test_listeners(app): 65 | listeners = [] 66 | available = ( 67 | "before_server_start", 68 | "after_server_start", 69 | "before_server_stop", 70 | "after_server_stop", 71 | ) 72 | 73 | @app.before_server_start 74 | async def before_server_start(*_): 75 | listeners.append("before_server_start") 76 | 77 | @app.after_server_start 78 | async def after_server_start(*_): 79 | listeners.append("after_server_start") 80 | 81 | @app.before_server_stop 82 | async def before_server_stop(*_): 83 | listeners.append("before_server_stop") 84 | 85 | @app.after_server_stop 86 | async def after_server_stop(*_): 87 | listeners.append("after_server_stop") 88 | 89 | app.test_client.get("/") 90 | 91 | assert len(listeners) == 4 92 | assert all(x in listeners for x in available) 93 | -------------------------------------------------------------------------------- /sanic_testing/reusable.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import typing 3 | from functools import partial 4 | from random import randint 5 | from typing import Any, Dict, List, Optional, Tuple 6 | 7 | import httpx 8 | from sanic import Sanic 9 | from sanic.application.state import ApplicationServerInfo 10 | from sanic.log import logger 11 | from sanic.request import Request 12 | 13 | from sanic_testing.websocket import websocket_proxy 14 | 15 | from .testing import HOST, PORT, TestingResponse 16 | 17 | 18 | class ReusableClient: 19 | def __init__( 20 | self, 21 | app: Sanic, 22 | host=HOST, 23 | port=PORT, 24 | loop=None, 25 | server_kwargs=None, 26 | client_kwargs=None, 27 | ): 28 | if not loop: 29 | loop = asyncio.new_event_loop() 30 | asyncio.set_event_loop(loop) 31 | server_kwargs = server_kwargs or {} 32 | client_kwargs = client_kwargs or {} 33 | 34 | Sanic.test_mode = True 35 | self.app = app 36 | self.host = host 37 | self.port = port or randint(5000, 65000) 38 | self._loop = loop 39 | self.debug = False 40 | self._server = None 41 | self.app.state.server_info.append( 42 | ApplicationServerInfo( 43 | settings={ 44 | "version": "1.1", 45 | "ssl": None, 46 | "unix": None, 47 | "sock": None, 48 | "loop": None, 49 | "host": self.host, 50 | "port": self.port, 51 | } 52 | ) 53 | ) 54 | 55 | self._session = httpx.AsyncClient(verify=False, **client_kwargs) 56 | self._server_co = self.app.create_server( 57 | host=self.host, 58 | debug=self.debug, 59 | port=self.port, 60 | return_asyncio_server=True, 61 | **server_kwargs, 62 | ) 63 | 64 | def __enter__(self): 65 | self.run() 66 | return self 67 | 68 | def __exit__(self, *_): 69 | self.stop() 70 | 71 | def run(self): 72 | self._loop._stopping = False 73 | self.app.router.reset() 74 | self.app.signal_router.reset() 75 | self._run(self.app._startup()) 76 | self._run(self.app._server_event("init", "before", loop=self._loop)) 77 | self._server = self._run(self._server_co) 78 | self._run(self.app._server_event("init", "after", loop=self._loop)) 79 | 80 | def stop(self): 81 | self._run( 82 | self.app._server_event("shutdown", "before", loop=self._loop) 83 | ) 84 | if self._session: 85 | self._run(self._session.aclose()) 86 | self._session = None 87 | 88 | if self._server: 89 | self._server.close() 90 | self._run(self._server.wait_closed()) 91 | self._server = None 92 | 93 | self._run(self.app._server_event("shutdown", "after", loop=self._loop)) 94 | 95 | def _sanic_endpoint_test( 96 | self, 97 | method: str = "get", 98 | uri: str = "/", 99 | gather_request: bool = True, 100 | debug: bool = False, 101 | server_kwargs: Optional[Dict[str, Any]] = None, 102 | host: Optional[str] = None, 103 | port: Optional[int] = None, 104 | allow_none: bool = False, 105 | *request_args, 106 | **request_kwargs, 107 | ) -> Tuple[Optional[Request], Optional[TestingResponse]]: 108 | request_data: Dict[str, Request] = {} 109 | exceptions: List[Exception] = [] 110 | 111 | host = host or self.host 112 | port = port or self.port 113 | 114 | if gather_request: 115 | _collect_request = partial(self._collect_request, request_data) 116 | self.app.request_middleware.appendleft(_collect_request) # type: ignore # noqa 117 | 118 | for route in self.app.router.routes: 119 | if _collect_request not in route.extra.request_middleware: 120 | route.extra.request_middleware.appendleft(_collect_request) 121 | 122 | if uri.startswith( 123 | ("http:", "https:", "ftp:", "ftps://", "//", "ws:", "wss:") 124 | ): 125 | url = uri 126 | else: 127 | uri = uri if uri.startswith("/") else f"/{uri}" 128 | scheme = "ws" if method == "websocket" else "http" 129 | url = f"{scheme}://{host}:{port}{uri}" 130 | 131 | if exceptions: 132 | raise ValueError(f"Exception during request: {exceptions}") 133 | 134 | response = self._run( 135 | self._local_request(method, url, *request_args, **request_kwargs) 136 | ) 137 | 138 | try: 139 | self.app.request_middleware.remove(_collect_request) # type: ignore # noqa 140 | except BaseException: # noqa 141 | pass 142 | 143 | try: 144 | request = request_data.get("request") if gather_request else None 145 | if response is None: 146 | if not allow_none: 147 | raise ValueError( 148 | "No response returned to Sanic Test Client." 149 | ) 150 | return request, response 151 | except BaseException: # noqa 152 | if not allow_none: 153 | raise ValueError( 154 | "Request and response object expected, " 155 | f"got ({request}, {response})" 156 | ) 157 | 158 | return None, None 159 | 160 | async def _local_request(self, method, url, *args, **kwargs): 161 | raw_cookies = kwargs.pop("raw_cookies", None) 162 | 163 | if method == "websocket": 164 | return await websocket_proxy(url, *args, **kwargs) 165 | else: 166 | session = self._session 167 | 168 | try: 169 | if method == "request": 170 | args = tuple([url] + list(args)) 171 | url = kwargs.pop("http_method", "GET").upper() 172 | response = await getattr(session, method.lower())( 173 | url, *args, **kwargs 174 | ) 175 | except httpx.HTTPError as e: 176 | if hasattr(e, "response"): 177 | response = getattr(e, "response") 178 | else: 179 | logger.error( 180 | f"{method.upper()} {url} received no response!", 181 | exc_info=True, 182 | ) 183 | return None 184 | 185 | response.__class__ = TestingResponse 186 | 187 | if raw_cookies: 188 | response.raw_cookies = {} 189 | 190 | for cookie in response.cookies.jar: 191 | response.raw_cookies[cookie.name] = cookie 192 | 193 | return response 194 | 195 | def _run(self, coro): 196 | if not self._loop: 197 | raise RuntimeError("Test client has no loop") 198 | return self._loop.run_until_complete(coro) 199 | 200 | @staticmethod 201 | def _collect_request(data, request): 202 | data["request"] = request 203 | 204 | def request(self, *args, **kwargs): 205 | return self._sanic_endpoint_test("request", *args, **kwargs) 206 | 207 | def get(self, *args, **kwargs): 208 | return self._sanic_endpoint_test("get", *args, **kwargs) 209 | 210 | def post(self, *args, **kwargs): 211 | return self._sanic_endpoint_test("post", *args, **kwargs) 212 | 213 | def put(self, *args, **kwargs): 214 | return self._sanic_endpoint_test("put", *args, **kwargs) 215 | 216 | def delete(self, *args, **kwargs): 217 | return self._sanic_endpoint_test("delete", *args, **kwargs) 218 | 219 | def patch(self, *args, **kwargs): 220 | return self._sanic_endpoint_test("patch", *args, **kwargs) 221 | 222 | def options(self, *args, **kwargs): 223 | return self._sanic_endpoint_test("options", *args, **kwargs) 224 | 225 | def head(self, *args, **kwargs): 226 | return self._sanic_endpoint_test("head", *args, **kwargs) 227 | 228 | def websocket( 229 | self, 230 | *args, 231 | mimic: typing.Optional[ 232 | typing.Callable[..., typing.Coroutine[None, None, typing.Any]] 233 | ] = None, 234 | **kwargs, 235 | ): 236 | kwargs["mimic"] = mimic 237 | return self._sanic_endpoint_test("websocket", *args, **kwargs) 238 | -------------------------------------------------------------------------------- /sanic_testing/testing.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from functools import partial 3 | from ipaddress import IPv6Address, ip_address 4 | from json import JSONDecodeError 5 | from socket import AF_INET6, SOCK_STREAM, socket 6 | from string import ascii_lowercase 7 | 8 | import httpx 9 | from sanic import Sanic # type: ignore 10 | from sanic.asgi import ASGIApp # type: ignore 11 | from sanic.exceptions import MethodNotSupported, ServerError # type: ignore 12 | from sanic.log import logger # type: ignore 13 | from sanic.request import Request # type: ignore 14 | from sanic.response import text # type: ignore 15 | 16 | from sanic_testing.websocket import websocket_proxy 17 | 18 | ASGI_HOST = "mockserver" 19 | ASGI_PORT = 1234 20 | ASGI_BASE_URL = f"http://{ASGI_HOST}:{ASGI_PORT}" 21 | HOST = "127.0.0.1" 22 | PORT = None 23 | 24 | 25 | httpx_version = tuple( 26 | map(int, httpx.__version__.strip(ascii_lowercase).split(".")) 27 | ) 28 | 29 | 30 | class TestingResponse(httpx.Response): 31 | @property 32 | def status(self): 33 | return self.status_code 34 | 35 | @property 36 | def body(self): 37 | return self.content 38 | 39 | @property 40 | def content_type(self): 41 | return self.headers.get("content-type") 42 | 43 | @property 44 | def json(self): 45 | if getattr(self, "_json", None): 46 | return self._json 47 | try: 48 | self._json = super().json() 49 | except (JSONDecodeError, UnicodeDecodeError): 50 | self._json = None 51 | 52 | return self._json 53 | 54 | 55 | def _blank(*_, **__): 56 | ... 57 | 58 | 59 | class SanicTestClient: 60 | def __init__( 61 | self, app: Sanic, port: typing.Optional[int] = PORT, host: str = HOST 62 | ) -> None: 63 | """Use port=None to bind to a random port""" 64 | Sanic.test_mode = True 65 | self.app = app 66 | self.port = port 67 | self.host = host 68 | self._do_request = _blank 69 | app.after_server_start(self._run_request) 70 | 71 | def _run_request(self, *args, **kwargs): 72 | return self._do_request(*args, **kwargs) 73 | 74 | @classmethod 75 | def _start_test_mode(cls, sanic, *args, **kwargs): 76 | Sanic.test_mode = True 77 | 78 | @classmethod 79 | def _end_test_mode(cls, sanic, *args, **kwargs): 80 | Sanic.test_mode = False 81 | 82 | def get_new_session(self, **kwargs) -> httpx.AsyncClient: 83 | return httpx.AsyncClient(verify=False, **kwargs) 84 | 85 | async def _local_request(self, method: str, url: str, *args, **kwargs): 86 | logger.info(url) 87 | raw_cookies = kwargs.pop("raw_cookies", None) 88 | session_kwargs = kwargs.pop("session_kwargs", {}) 89 | if httpx_version >= (0, 20) and method != "websocket": 90 | kwargs["follow_redirects"] = True 91 | allow_redirects = kwargs.pop("allow_redirects", None) 92 | if allow_redirects is not None: 93 | kwargs["follow_redirects"] = allow_redirects 94 | 95 | if method == "websocket": 96 | return await websocket_proxy(url, *args, **kwargs) 97 | else: 98 | async with self.get_new_session(**session_kwargs) as session: 99 | try: 100 | if method == "request": 101 | args = tuple([url] + list(args)) 102 | url = kwargs.pop("http_method", "GET").upper() 103 | response = await getattr(session, method.lower())( 104 | url, *args, **kwargs 105 | ) 106 | except httpx.HTTPError as e: 107 | if hasattr(e, "response"): 108 | response = getattr(e, "response") 109 | else: 110 | logger.error( 111 | f"{method.upper()} {url} received no response!", 112 | exc_info=True, 113 | ) 114 | return None 115 | 116 | response.__class__ = TestingResponse 117 | 118 | if raw_cookies: 119 | response.raw_cookies = {} 120 | 121 | for cookie in response.cookies.jar: 122 | response.raw_cookies[cookie.name] = cookie 123 | 124 | return response 125 | 126 | @classmethod 127 | def _collect_request(cls, results, request): 128 | if results[0] is None: 129 | results[0] = request 130 | 131 | async def _collect_response( 132 | self, 133 | method, 134 | url, 135 | exceptions, 136 | results, 137 | sanic, 138 | loop, 139 | **request_kwargs, 140 | ): 141 | try: 142 | response = await self._local_request(method, url, **request_kwargs) 143 | results[-1] = response 144 | if method == "websocket": 145 | await response.ws.close() 146 | except Exception as e: 147 | logger.exception("Exception") 148 | exceptions.append(e) 149 | finally: 150 | self.app.stop() 151 | 152 | async def _error_handler(self, request, exception): 153 | if request.method in ["HEAD", "PATCH", "PUT", "DELETE"]: 154 | return text("", exception.status_code, headers=exception.headers) 155 | else: 156 | return self.app.error_handler.default(request, exception) 157 | 158 | def _sanic_endpoint_test( 159 | self, 160 | method: str = "get", 161 | uri: str = "/", 162 | gather_request: bool = True, 163 | debug: bool = False, 164 | server_kwargs: typing.Optional[typing.Dict[str, typing.Any]] = None, 165 | host: typing.Optional[str] = None, 166 | allow_none: bool = False, 167 | *request_args, 168 | **request_kwargs, 169 | ) -> typing.Tuple[ 170 | typing.Optional[Request], typing.Optional[TestingResponse] 171 | ]: 172 | results = [None, None] 173 | exceptions: typing.List[Exception] = [] 174 | 175 | server_kwargs = server_kwargs or {"auto_reload": False} 176 | _collect_request = partial(self._collect_request, results) 177 | 178 | self.app.router.reset() 179 | self.app.signal_router.reset() 180 | 181 | if gather_request: 182 | self.app.request_middleware.appendleft(_collect_request) # type: ignore # noqa 183 | 184 | try: 185 | self.app.exception(MethodNotSupported)(self._error_handler) 186 | except ServerError: 187 | ... 188 | 189 | if self.port: 190 | server_kwargs = dict( 191 | host=host or self.host, 192 | port=self.port, 193 | **server_kwargs, 194 | ) 195 | host, port = host or self.host, self.port 196 | else: 197 | bind = host or self.host 198 | ip = ip_address(bind) 199 | if isinstance(ip, IPv6Address): 200 | sock = socket(AF_INET6, SOCK_STREAM) 201 | port = ASGI_PORT 202 | else: 203 | sock = socket() 204 | port = 0 205 | sock.bind((bind, port)) 206 | server_kwargs = dict(sock=sock, **server_kwargs) 207 | 208 | if isinstance(ip, IPv6Address): 209 | host, port, _, _ = sock.getsockname() 210 | host = f"[{host}]" 211 | else: 212 | host, port = sock.getsockname() 213 | self.port = port 214 | 215 | if uri.startswith( 216 | ("http:", "https:", "ftp:", "ftps://", "//", "ws:", "wss:") 217 | ): 218 | url = uri 219 | else: 220 | uri = uri if uri.startswith("/") else f"/{uri}" 221 | scheme = "ws" if method == "websocket" else "http" 222 | url = f"{scheme}://{host}:{port}{uri}" 223 | # Tests construct URLs using PORT = None, which means random port not 224 | # known until this function is called, so fix that here 225 | url = url.replace(":None/", f":{port}/") 226 | 227 | self._do_request = partial( 228 | self._collect_response, 229 | method, 230 | url, 231 | exceptions, 232 | results, 233 | **request_kwargs, 234 | ) 235 | 236 | self.app.run( # type: ignore 237 | debug=debug, 238 | single_process=True, 239 | **server_kwargs, 240 | ) 241 | 242 | if exceptions: 243 | raise ValueError(f"Exception during request: {exceptions}") 244 | 245 | if gather_request: 246 | try: 247 | self.app.request_middleware.remove(_collect_request) # type: ignore # noqa 248 | except BaseException: # noqa 249 | pass 250 | 251 | try: 252 | request, response = results 253 | if response is None: 254 | if not allow_none: 255 | raise ValueError( 256 | "No response returned to Sanic Test Client." 257 | ) 258 | return request, response 259 | except BaseException: # noqa 260 | if not allow_none: 261 | raise ValueError( 262 | "Request and response object expected, " 263 | f"got ({results})" 264 | ) 265 | return None, None 266 | else: 267 | try: 268 | if results[-1] is None: 269 | if not allow_none: 270 | raise ValueError( 271 | "No response returned to Sanic Test Client." 272 | ) 273 | return None, results[-1] 274 | except BaseException: # noqa 275 | if not allow_none: 276 | raise ValueError( 277 | f"Request object expected, got ({results})" 278 | ) 279 | return None, None 280 | 281 | def request(self, *args, **kwargs): 282 | return self._sanic_endpoint_test("request", *args, **kwargs) 283 | 284 | def get(self, *args, **kwargs): 285 | return self._sanic_endpoint_test("get", *args, **kwargs) 286 | 287 | def post(self, *args, **kwargs): 288 | return self._sanic_endpoint_test("post", *args, **kwargs) 289 | 290 | def put(self, *args, **kwargs): 291 | return self._sanic_endpoint_test("put", *args, **kwargs) 292 | 293 | def delete(self, *args, **kwargs): 294 | return self._sanic_endpoint_test("delete", *args, **kwargs) 295 | 296 | def patch(self, *args, **kwargs): 297 | return self._sanic_endpoint_test("patch", *args, **kwargs) 298 | 299 | def options(self, *args, **kwargs): 300 | return self._sanic_endpoint_test("options", *args, **kwargs) 301 | 302 | def head(self, *args, **kwargs): 303 | return self._sanic_endpoint_test("head", *args, **kwargs) 304 | 305 | def websocket( 306 | self, 307 | *args, 308 | mimic: typing.Optional[ 309 | typing.Callable[..., typing.Coroutine[None, None, typing.Any]] 310 | ] = None, 311 | **kwargs, 312 | ): 313 | kwargs["mimic"] = mimic 314 | return self._sanic_endpoint_test("websocket", *args, **kwargs) 315 | 316 | 317 | class TestASGIApp(ASGIApp): 318 | async def __call__(self): 319 | await super().__call__() 320 | return self.request 321 | 322 | 323 | async def app_call_with_return(self, scope, receive, send): 324 | asgi_app = await TestASGIApp.create(self, scope, receive, send) 325 | return await asgi_app() 326 | 327 | 328 | class SanicASGITestClient(httpx.AsyncClient): 329 | def __init__( 330 | self, 331 | app: Sanic, 332 | base_url: str = ASGI_BASE_URL, 333 | suppress_exceptions: bool = False, 334 | ) -> None: 335 | Sanic.test_mode = True 336 | 337 | app.__class__.__call__ = app_call_with_return # type: ignore 338 | app.asgi = True 339 | 340 | self.sanic_app = app 341 | 342 | transport = httpx.ASGITransport(app=app, client=(ASGI_HOST, ASGI_PORT)) 343 | 344 | super().__init__(transport=transport, base_url=base_url) 345 | 346 | self.gather_request = True 347 | self.last_request = None 348 | 349 | def _collect_request(self, request): 350 | if self.gather_request: 351 | self.last_request = request 352 | else: 353 | self.last_request = None 354 | 355 | @classmethod 356 | def _start_test_mode(cls, sanic, *args, **kwargs): 357 | Sanic.test_mode = True 358 | 359 | @classmethod 360 | def _end_test_mode(cls, sanic, *args, **kwargs): 361 | Sanic.test_mode = False 362 | 363 | async def request( # type: ignore 364 | self, method, url, gather_request=True, *args, **kwargs 365 | ) -> typing.Tuple[ 366 | typing.Optional[Request], typing.Optional[TestingResponse] 367 | ]: 368 | self.sanic_app.router.reset() 369 | self.sanic_app.signal_router.reset() 370 | await self.sanic_app._startup() # type: ignore 371 | await self.sanic_app._server_event("init", "before") 372 | await self.sanic_app._server_event("init", "after") 373 | for route in self.sanic_app.router.routes: 374 | if self._collect_request not in route.extra.request_middleware: 375 | route.extra.request_middleware.appendleft( 376 | self._collect_request 377 | ) 378 | 379 | if not url.startswith( 380 | ("http:", "https:", "ftp:", "ftps://", "//", "ws:", "wss:") 381 | ): 382 | url = url if url.startswith("/") else f"/{url}" 383 | scheme = "ws" if method == "websocket" else "http" 384 | url = f"{scheme}://{ASGI_HOST}:{ASGI_PORT}{url}" 385 | 386 | if self._collect_request not in self.sanic_app.request_middleware: 387 | self.sanic_app.request_middleware.appendleft( 388 | self._collect_request # type: ignore 389 | ) 390 | 391 | self.gather_request = gather_request 392 | response = await super().request(method, url, *args, **kwargs) 393 | 394 | await self.sanic_app._server_event("shutdown", "before") 395 | await self.sanic_app._server_event("shutdown", "after") 396 | 397 | response.__class__ = TestingResponse 398 | 399 | if gather_request: 400 | return self.last_request, response # type: ignore 401 | return None, response # type: ignore 402 | 403 | @classmethod 404 | async def _ws_receive(cls): 405 | return {} 406 | 407 | @classmethod 408 | async def _ws_send(cls, message): 409 | pass 410 | 411 | async def websocket( 412 | self, 413 | uri, 414 | subprotocols=None, 415 | *args, 416 | mimic: typing.Optional[ 417 | typing.Callable[..., typing.Coroutine[None, None, typing.Any]] 418 | ] = None, 419 | **kwargs, 420 | ): 421 | if mimic: 422 | raise RuntimeError( 423 | "SanicASGITestClient does not currently support the mimic " 424 | "keyword argument. Please use SanicTestClient instead." 425 | ) 426 | scheme = "ws" 427 | path = uri 428 | root_path = f"{scheme}://{ASGI_HOST}:{ASGI_PORT}" 429 | 430 | headers = kwargs.get("headers", {}) 431 | headers.setdefault("connection", "upgrade") 432 | headers.setdefault("sec-websocket-key", "testserver==") 433 | headers.setdefault("sec-websocket-version", "13") 434 | if subprotocols is not None: 435 | headers.setdefault( 436 | "sec-websocket-protocol", ", ".join(subprotocols) 437 | ) 438 | 439 | scope = { 440 | "type": "websocket", 441 | "asgi": {"version": "3.0"}, 442 | "http_version": "1.1", 443 | "headers": [map(lambda y: y.encode(), x) for x in headers.items()], 444 | "scheme": scheme, 445 | "root_path": root_path, 446 | "path": path, 447 | "raw_path": path.encode(), 448 | "query_string": b"", 449 | "subprotocols": subprotocols, 450 | } 451 | 452 | self.sanic_app.router.reset() 453 | self.sanic_app.signal_router.reset() 454 | await self.sanic_app._startup() 455 | 456 | await self.sanic_app(scope, self._ws_receive, self._ws_send) 457 | 458 | return None, {"opened": True} 459 | 460 | def __getstate__(self): 461 | # Cookies cannot be pickled, because they contain a ThreadLock 462 | try: 463 | del self._cookies 464 | except AttributeError: 465 | pass 466 | return self.__dict__ 467 | 468 | def __setstate__(self, d): 469 | try: 470 | del d["_cookies"] 471 | except LookupError: 472 | pass 473 | self.__dict__.update(d) 474 | # Need to create a new CookieJar when unpickling, 475 | # because it was killed on Pickle 476 | self._cookies = httpx.Cookies() 477 | --------------------------------------------------------------------------------