From 2291aa3e551fa22135722b5be2e601dc2fc12276 Mon Sep 17 00:00:00 2001 From: Yusuke Tsutsumi Date: Fri, 3 Jun 2016 15:32:32 -0700 Subject: [PATCH] adding a fuller testclient, and documentation. (#910) --- aiohttp/test_utils.py | 120 +++++++++++++++++++++++++++++++-------- docs/api.rst | 8 --- docs/index.rst | 1 + docs/testing.rst | 48 ++++++++++++---- tests/test_test_utils.py | 64 ++++++++++++++++++--- 5 files changed, 191 insertions(+), 50 deletions(-) diff --git a/aiohttp/test_utils.py b/aiohttp/test_utils.py index 11fb3868ff0..3d6b21cdcad 100644 --- a/aiohttp/test_utils.py +++ b/aiohttp/test_utils.py @@ -21,10 +21,10 @@ import asyncio import aiohttp -from aiohttp import server -from aiohttp import helpers -from aiohttp import ClientSession -from aiohttp.client import _RequestContextManager +from . import server +from . import helpers +from . import ClientSession +from . import hdrs def run_briefly(loop): @@ -322,15 +322,16 @@ class TestClient: """ A test client implementation, for a aiohttp.web.Application. - :param app: the aiohttp.web application passed - to create_test_server + :param app: the aiohttp.web application passed to create_test_server :type app: aiohttp.web.Application - :param protocol: the aiohttp.web application passed - to create_test_server + :param protocol: the aiohttp.web application passed to create_test_server :type app: aiohttp.web.Application + + TestClient can also be used as a contextmanager, returning + the instance of itself instantiated. """ def __init__(self, app, protocol="http"): @@ -347,23 +348,72 @@ def __init__(self, app, protocol="http"): ) self._closed = False - def request(self, method, url, *args, **kwargs): - return _RequestContextManager(self._request( - method, url, *args, **kwargs - )) + @property + def session(self): + """a raw handler to the aiohttp.ClientSession. unlike the methods on + the TestClient, client session requests do not automatically + include the host in the url queried, and will require an + absolute path to the resource. + """ + return self._session - @asyncio.coroutine - def _request(self, method, url, *args, **kwargs): + def request(self, method, path, *args, **kwargs): """ routes a request to the http server. the interface is identical to asyncio.request, except the loop kwarg is overriden by the instance used by the application. """ - return (yield from self._session.request( - method, self._root + url, *args, **kwargs - )) + return self._session.request( + method, self._root + path, *args, **kwargs + ) + + def get(self, path, *args, **kwargs): + """Perform an HTTP GET request. """ + return self.request(hdrs.METH_GET, path, *args, **kwargs) + + def post(self, path, *args, **kwargs): + """Perform an HTTP POST request. """ + return self.request(hdrs.METH_POST, path, *args, **kwargs) + + def options(self, path, *args, **kwargs): + """Perform an HTTP OPTIONS request. """ + return self.request(hdrs.METH_OPTIONS, path, *args, **kwargs) + + def head(self, path, *args, **kwargs): + """Perform an HTTP HEAD request. """ + return self.request(hdrs.METH_HEAD, path, *args, **kwargs) + + def put(self, path, *args, **kwargs): + """Perform an HTTP PUT request.""" + return self.request(hdrs.METH_PUT, path, *args, **kwargs) + + def patch(self, path, *args, **kwargs): + """Perform an HTTP PATCH request.""" + return self.request(hdrs.METH_PATCH, path, *args, **kwargs) + + def delete(self, path, *args, **kwargs): + """Perform an HTTP PATCH request.""" + return self.request(hdrs.METH_DELETE, path, *args, **kwargs) + + def ws_connect(self, path, *args, **kwargs): + """Initiate websocket connection. the api is identical to + aiohttp.ClientSession.ws_connect. + """ + return self._session.ws_connect( + self._root + path, *args, **kwargs + ) def close(self): + """ close all fixtures created by the test client. + After that point, the TestClient is no longer + usable. + + This is an idempotent function: running close + multiple times will not have any additional effects. + + close is also run when the object is garbage collected, + and on exit when used as a context manager. + """ if not self._closed: loop = self._loop loop.run_until_complete(self._session.close()) @@ -384,6 +434,20 @@ def __exit__(self, exc_type, exc_value, traceback): class AioHTTPTestCase(unittest.TestCase): + """A base class to allow for unittest web applications using + aiohttp. + + provides the following: + + * self.client (aiohttp.test_utils.TestClient): an aiohttp test client. + * self.loop (asyncio.BaseEventLoop): the event loop in which the + application and server are running. + * self.app (aiohttp.web.Application): the application returned by + self.get_app() + + note that the TestClient's methods are asynchronous: you will have to + execute function on the test client using asynchronous methods. + """ def get_app(self, loop): """ @@ -406,11 +470,10 @@ def tearDown(self): teardown_test_loop(self.loop) -def run_loop(func): - """ - to be used with AioHTTPTestCase. Handles - executing an asynchronous function, using - the event loop of AioHTTPTestCase. +def unittest_run_loop(func): + """a decorator that should be used with asynchronous methods of an + AioHTTPTestCase. Handles executing an asynchronous function, using + the self.loop of the AioHTTPTestCase. """ @functools.wraps(func) @@ -422,8 +485,7 @@ def new_func(self): @contextlib.contextmanager def loop_context(): - """ - create an event_loop, for test purposes. + """a contextmanager that creates an event_loop, for test purposes. handles the creation and cleanup of a test loop. """ loop = setup_test_loop() @@ -432,12 +494,22 @@ def loop_context(): def setup_test_loop(): + """create and return an asyncio.BaseEventLoop + instance. The caller should also call teardown_test_loop, + once they are done with the loop. + """ loop = asyncio.new_event_loop() asyncio.set_event_loop(None) return loop def teardown_test_loop(loop): + """teardown and cleanup an event_loop created + by setup_test_loop. + + :param loop: the loop to teardown + :type loop: asyncio.BaseEventLoop + """ is_closed = getattr(loop, 'is_closed') if is_closed is not None: closed = is_closed() diff --git a/docs/api.rst b/docs/api.rst index 6351162a6fe..e9f6d46775a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -59,14 +59,6 @@ aiohttp.streams module :show-inheritance: -aiohttp.test_utils module -------------------------- - -.. automodule:: aiohttp.test_utils - :members: - :undoc-members: - :show-inheritance: - aiohttp.websocket module ------------------------ diff --git a/docs/index.rst b/docs/index.rst index fb47cd0bca9..52a6fab3ac8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -149,6 +149,7 @@ Contents multipart api logging + testing gunicorn faq new_router diff --git a/docs/testing.rst b/docs/testing.rst index 2a299c51433..025cb6058af 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -3,8 +3,10 @@ Testing ======= -Testing aiohttp servers ------------------------ +.. currentmodule:: aiohttp.test_utils + +Testing aiohttp web servers +--------------------------- aiohttp provides test framework agnostic utilities for web servers. An example would be:: @@ -20,7 +22,7 @@ servers. An example would be:: async def test_get_route(): nonlocal client - resp = await client.request("GET", "/") + resp = await client.get("/") assert resp.status == 200 text = await resp.text() assert "Hello, world" in text @@ -39,7 +41,7 @@ basis, the TestClient object can be used directly:: root = "http://127.0.0.1:{}".format(port) async def test_get_route(): - resp = await test_client.request("GET", url) + resp = await client.get("/") assert resp.status == 200 text = await resp.text() assert "Hello, world" in text @@ -49,8 +51,24 @@ basis, the TestClient object can be used directly:: # the deletion of the TestServer. del client -pytest example -============== + +A full list of the utilities provided can be found at the +:data:`api reference ` + +The Test Client +~~~~~~~~~~~~~~~ + +The :data:`aiohttp.test_utils.TestClient` creates an asyncio server +for the web.Application object, as well as a ClientSession to perform +requests. In addition, TestClient provides proxy methods to the client for +common operations such as ws_connect, get, post, etc. + +Please see the full api at the :class:`TestClass api reference ` + + + +Pytest example +~~~~~~~~~~~~~~ A pytest example could look like:: @@ -85,13 +103,13 @@ A pytest example could look like:: loop.run_until_complete(test_get_route()) -unittest example -================ +Unittest example +~~~~~~~~~~~~~~~~ To test applications with the standard library's unittest or unittest-based functionality, the AioHTTPTestCase is provided:: - from aiohttp.test_utils import AioHTTPTestCase, run_loop + from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop from aiohttp import web class MyAppTestCase(AioHTTPTestCase): @@ -104,10 +122,10 @@ functionality, the AioHTTPTestCase is provided:: # it's important to use the loop passed here. return web.Application(loop=loop) - # the run_loop decorator can be used in tandem with + # the unittest_run_loop decorator can be used in tandem with # the AioHTTPTestCase to simplify running # tests that are asynchronous - @run_loop + @unittest_run_loop async def test_example(self): request = await self.client.request("GET", "/") assert request.status == 200 @@ -124,3 +142,11 @@ functionality, the AioHTTPTestCase is provided:: assert "Hello, world" in text self.loop.run_until_complete(test_get_route()) + +aiohttp.test_utils +------------------ + +.. automodule:: aiohttp.test_utils + :members: TestClient, AioHTTPTestCase, run_loop, loop_context, setup_test_loop, teardown_test_loop + :undoc-members: + :show-inheritance: diff --git a/tests/test_test_utils.py b/tests/test_test_utils.py index e46b24d2c13..002e03950c5 100644 --- a/tests/test_test_utils.py +++ b/tests/test_test_utils.py @@ -1,8 +1,10 @@ import asyncio +import aiohttp from aiohttp import web from aiohttp.test_utils import ( TestClient, loop_context, - AioHTTPTestCase, run_loop + AioHTTPTestCase, unittest_run_loop, + setup_test_loop, teardown_test_loop ) import pytest @@ -13,8 +15,23 @@ def _create_example_app(loop): def hello(request): return web.Response(body=b"Hello, world") + @asyncio.coroutine + def websocket_handler(request): + + ws = web.WebSocketResponse() + yield from ws.prepare(request) + msg = yield from ws.receive() + if msg.tp == aiohttp.MsgType.text: + if msg.data == 'close': + yield from ws.close() + else: + ws.send_str(msg.data + '/answer') + + return ws + app = web.Application(loop=loop) - app.router.add_route('GET', '/', hello) + app.router.add_route('*', '/', hello) + app.router.add_route('*', '/websocket', websocket_handler) return app @@ -55,10 +72,11 @@ def test_test_client_close_is_idempotent(): a test client, called multiple times, should not attempt to close the loop again. """ - with loop_context() as loop: - app = _create_example_app(loop) - client = TestClient(app) - client.close() + loop = setup_test_loop() + app = _create_example_app(loop) + client = TestClient(app) + client.close() + teardown_test_loop(loop) client.close() @@ -67,7 +85,7 @@ class TestAioHTTPTestCase(AioHTTPTestCase): def get_app(self, loop): return _create_example_app(loop) - @run_loop + @unittest_run_loop @asyncio.coroutine def test_example_with_loop(self): request = yield from self.client.request("GET", "/") @@ -115,3 +133,35 @@ def test_get_route(): assert "Hello, world" in text loop.run_until_complete(test_get_route()) + + +@pytest.mark.run_loop +@asyncio.coroutine +def test_client_websocket(loop, test_client): + resp = yield from test_client.ws_connect("/websocket") + resp.send_str("foo") + msg = yield from resp.receive() + assert msg.tp == aiohttp.MsgType.text + assert "foo" in msg.data + resp.send_str("close") + msg = yield from resp.receive() + assert msg.tp == aiohttp.MsgType.close + + +@pytest.mark.run_loop +@pytest.mark.parametrize("method", [ + "get", "post", "options", "post", "put", "patch", "delete" +]) +@asyncio.coroutine +def test_test_client_methods(method, loop, test_client): + resp = yield from getattr(test_client, method)("/") + assert resp.status == 200 + text = yield from resp.text() + assert "Hello, world" in text + + +@pytest.mark.run_loop +@asyncio.coroutine +def test_test_client_head(loop, test_client): + resp = yield from test_client.head("/") + assert resp.status == 200