Easier use of recorder beta feature?
gswilcox01 opened this issue · comments
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
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()
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.
for now we are not planning to change the behavior