getsentry / responses

A utility for mocking out the Python Requests library.
Apache License 2.0
4.08k stars 347 forks source link

Easier use of recorder beta feature? #696

Open gswilcox01 opened 7 months ago

gswilcox01 commented 7 months ago

I'm kind of a python noob. But I thought i'd see if you are interested in me contributing this back. Or maybe you could correct my understanding/show me what i missed.

I initially went down this path where i followed the docs/example to record/activate, and created a helper function to make a directory & calculate a filename based on the test name (1 file for each test):

def yaml_filename(test_name):
    directory = os.path.splitext(__file__)[0] + "_files"
    os.makedirs(directory, exist_ok=True)

    filename = test_name + ".yaml"
    return os.path.join(directory, filename)

# Record OOTB setup, see: https://github.com/getsentry/responses#record-responses-to-files
# @_recorder.record(file_path=yaml_filename("test_responses_recorder"))
# Replay.1 OOTB setup, see: https://github.com/getsentry/responses#replay-responses-populate-registry-from-files
@responses.activate
def test_responses_recorder(runner, greetings_with_2res, two_users):
    # Replay.2 OOTB setup, see: https://github.com/getsentry/responses#replay-responses-populate-registry-from-files
    responses._add_from_file(file_path=yaml_filename("test_responses_recorder"))

It felt kind of annoying to have to repeat the test_name as a string (and make sure i don't copy/paste wrong), and also to comment/uncomment multiple lines to activate and load the file. So i made 2 simple decorators that did all of this for me.

My tests now look like this:

# @activate_recorder()
@activate_responses()
def test_get(runner, greetings_with_2res, two_users):
    pass

And for a single test_get.py module with 4 test functions in it, after recording i wind up with this "test_get_files" directory created & 4 output yaml files in it.

http/
    test_get_files/
        test_get.yaml
        test_get_401.yaml
        test_quiet_get.yaml
        test_various_gets.yaml
    test_get.py

Code for the 2 new decorators is here if your are interested:

def default_filename(func):
    module = inspect.getmodule(func)

    directory = os.path.splitext(module.__file__)[0] + "_files"
    os.makedirs(directory, exist_ok=True)

    filename = func.__name__ + ".yaml"
    return os.path.join(directory, filename)

def activate_responses(file_path=None):
    def outer_decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            nonlocal file_path
            if file_path is None:
                file_path = default_filename(func)

            with responses.RequestsMock() as rsp:
                rsp._add_from_file(file_path=file_path)
                func(*args, **kwargs)

        return wrapper

    return outer_decorator

def activate_recorder(file_path=None):
    def outer_decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            nonlocal file_path
            if file_path is None:
                file_path = default_filename(func)

            recorder = Recorder()
            with recorder:
                result = func(*args, **kwargs)
                recorder.dump_to_file(
                    file_path=file_path, registered=recorder.get_registry().registered
                )
                return result

        return wrapper

    return outer_decorator
olivierdalang commented 5 months ago

Hey ! Thanks @gswilcox01 for sharing your setup.

I definitely agree that providing facilities and documenting how back and forths between record/activate is supposed to be done would help a lot in using this otherwise great library.

Here I went one small step further and combined both decorators in a third one that conditionally switches between recorder and apply, based on a configuration (in my case a django setting but could just as well be an env var). I also added a try...finally clause in the recorder decorator so that it still saves values if an exception is hit to facilitate debugging/iterations (working around #705). Here's what it looks like:


import functools
import inspect
import pathlib

import responses
import responses._recorder

def make_filename(func):
    module = inspect.getmodule(func)
    # FIXME: include test case class name to avoid clashes
    directory = pathlib.Path(module.__file__).parent.joinpath("_testing_results")
    directory.mkdir(exist_ok=True)
    return directory.joinpath(f"{func.__name__}.yaml")

def activate_responses():
    def outer_decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            with responses.RequestsMock() as rsp:
                rsp._add_from_file(file_path=make_filename(func))
                return func(*args, **kwargs)

        return wrapper

    return outer_decorator

def activate_recorder():
    def outer_decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            recorder = responses._recorder.Recorder()
            with recorder:
                try:
                    result = func(*args, **kwargs)
                finally:
                    recorder.dump_to_file(
                        file_path=make_filename(func),
                        registered=recorder.get_registry().registered,
                    )
                return result

        return wrapper

    return outer_decorator

def mock_responses(update_results=False):
    """Decorator to record then mock requests made with the requests module.

    When update_results is True, will store requests to a yaml file. When it
    is false, it will retrieve the results, allowing to run tests offline.

    Usage:
        import requests
        from mdmodelpoc.testing.requests import mock_responses
        from django.conf import settings

        class MyTestCase(TestCase):
            @mock_responses(update_results=settings.TESTS_UPDATE_STORED_RESULTS)
            def test_mytest(self):
                request.get("https://example.com)
                ...
    """
    if update_results:
        return activate_recorder()
    else:
        return activate_responses()
olivierdalang commented 5 months ago

Another aspect that would facilitate this workflow is handling domain that are environment dependent. It's very common to have code like

def get_data():
    # a  service function that returns data
    return requests.get(f"{os.environ['DATA_ENDPOINT']}/mydata.json")

The issue is that this may not match the queries when run in different environments (e.g. I record requests in my local dev env to get test data from a local server, then want to retrieve responses in CI that has a different upstreams setting).

I worked around this by adding an aliases parameter to the decorator above that abstracts away the actual hostname. IMO that would be a very good addition the the API to facilitate usage of the recorder feature.