aio-libs / aiohttp

Asynchronous HTTP client/server framework for asyncio and Python
https://docs.aiohttp.org
Other
14.94k stars 1.99k forks source link

Add test utils for testing ClientSession #1704

Open sashgorokhov opened 7 years ago

sashgorokhov commented 7 years ago

Currently there are test utils in aiohttp.test_utils that helps to test aiohttp servers and clients. But what about aiohttp.ClientSession ? How people could test that, mock it properly? Make it return developer-defined response without actually touching network?

That was a real pain for me until i wrote some helper functions for that.

@contextlib.contextmanager
def mock_session(response, session=None, mock_object=None):
    """
    :param aiohttp.ClientSession session:
    :param aiohttp.ClientResponse|list[aiohttp.ClientResponse] response:
    """
    session = session or aiohttp.ClientSession()
    request = session._request

    session.mock = mock_object or mock.Mock()
    if isinstance(response, (list, tuple)):
        session.mock.side_effect = response
    else:
        session.mock.return_value = response

    async def _request(*args, **kwargs):
        return session.mock(*args, **kwargs)

    session._request = _request

    try:
        yield session
    finally:
        session._request = request
        delattr(session, 'mock')
def create_response(method, url, content, loop=None):
    loop = loop or asyncio.get_event_loop()

    response = aiohttp.ClientResponse(method.lower(), URL(url))

    def side_effect(*args, **kwargs):
        fut = loop.create_future()
        if isinstance(content, str):
            fut.set_result(content.encode())
        else:
            fut.set_result(content)
        return fut

    response.content = mock.Mock()
    response.content.read.side_effect = side_effect

    return response

Does aiohttp.test_utils need such functions? I could provide a PR for that.

fafhrd91 commented 7 years ago

@kxepal @asvetlov

balloob commented 7 years ago

For Home Assistant we have developed our own aiohttp.ClientSession mock: https://github.com/home-assistant/home-assistant/blob/dev/tests/test_util/aiohttp.py

We are down to contribute this back if there is interest. I checked for interest before on the aio-libs mailing list but got no reply.

fafhrd91 commented 7 years ago

@balloob just create PR

balloob commented 7 years ago

It right now only works with a subset of keywords of the aiohttp API for request. It is easy to extract for me and create a PR, but making it API complete and writing tests for it is not something I have time for right now.

fafhrd91 commented 7 years ago

@balloob that's fine.

asvetlov commented 7 years ago

There is example for fake server: https://github.com/aio-libs/aiohttp/blob/master/examples/fake_server.py It's based on making custom resolver which redirects known DNS names to local web server. I believe this technique is safe and easier to use than mock objects usage. Maybe we need some scaffolds for fake server to test_utils.

owaaa commented 6 years ago

found this, https://github.com/CircleUp/aresponses which works very well. The fake server is neat but when testing external interfaces really just want to add 1 line for the response vs 30-40 to create a fake server

asvetlov commented 6 years ago

@owaaa do you know that aresponses actually starts a fake server? :D

owaaa commented 6 years ago

@asvetlov thats great if thats how it works, but if i had to create a whole fake server per the facebook example, I wouldn't have done it. The annotation is very useful if only mocking out ClientSession requests vs testing the aiohttp web server

asvetlov commented 6 years ago

It is a question of syntax sugar and scaffolding, not the evidence of fake server approach failure.

owaaa commented 6 years ago

@asvetlov I agree, I didn't mean to apply the technical approach is wrong. Its just as a library consumer, I just want to write 2-3 lines to mock out a few things vs create a whole series of classes to mock out a few lines which isn't really internally maintainable. If anything is to be updated I'd suggest in the testing documentation would be nice to point to this type of direction, as I spent several hours evaluating options this morning, as approaches were non-obvious.

The "sugar" of these types of annotations is very similar to https://botocore.readthedocs.io/en/latest/reference/stubber.html, which goes a long way in cleaning up test code and the easier testing is the easier it is to get other developers on my team to write unit tests.

shizacat commented 5 years ago

I will add some fresher code.

import asyncio
import contextlib
from json import dumps
from unittest import mock

import aiohttp
from yarl import URL
from aiohttp.helpers import TimerNoop

@contextlib.contextmanager
def mock_session(response, session=None, mock_object=None):
    """
    :param aiohttp.ClientSession session:
    :param aiohttp.ClientResponse|list[aiohttp.ClientResponse] response:
    """
    session = session or aiohttp.ClientSession()
    request = session._request

    session.mock = mock_object or mock.Mock()
    if isinstance(response, (list, tuple)):
        session.mock.side_effect = response
    else:
        session.mock.return_value = response

    async def _request(*args, **kwargs):
        return session.mock(*args, **kwargs)

    session._request = _request

    try:
        yield session
    finally:
        session._request = request
        delattr(session, 'mock')

def create_response(
        method,
        url,
        session,
        loop=None,
        content=None,
        json=None,
        status=None):
    encode = "utf-8"

    loop = loop or asyncio.get_event_loop()

    response = aiohttp.ClientResponse(
        method.lower(),
        URL(url),
        request_info=mock.Mock(),
        writer=mock.Mock(),
        continue100=None,
        timer=TimerNoop(),
        traces=[],
        loop=loop,
        session=session
    )

    cnt = content

    if status is not None:
        response.status = int(status)

    response._headers = {}

    if json is not None:
        response._headers.update({
            'Content-Type':
                'application/json;charset=' + encode})

        cnt = dumps(json)

    def side_effect(*args, **kwargs):
        fut = loop.create_future()
        if isinstance(cnt, str):
            fut.set_result(cnt.encode(encode))
        else:
            fut.set_result(cnt)
        return fut

    response.content = mock.Mock()
    response.content.read.side_effect = side_effect

    return response
asvetlov commented 5 years ago

Too much mocking from my taste

gjcarneiro commented 5 years ago

+1 for aresponses, it is a joy to use.