Kartoza - Mocking Requests with Responses
This blog will show you an alternative to requests_mock, the one that is simpler to use yet offers more features: responses
Sometimes, making a request to third-party service is a requirement in our code. And when we test it, those requests could affect the test result i.e. when the service is down, hence causing a test error. It's also a bad idea if our request incurs a cost, like when our code requests a subscription to a third party. If you have read my blog "Mocking Requests with requests_mock", then you would know that all those issues can be solved by mocking our requests in tests using requests_mock. This blog will show you an alternative to requests_mock, the one that is simpler to use yet offers more features: responses. For ease of understanding, the return value from requests will be called "response" and the library that we use is "responses" (i.e. an extra "s" in "responses").
Installation
Simpy do pip install responses
.
Basic Usage
We will test our weather subscription code in "Mocking Requests with requests_mock", and update our test in my requests_mock blog to use responses. First, test_create_weather_subscription_success
can be updated to
@responses.activatedef test_create_weather_subscription_success(self):"""Simply test when subscription is created successfully."""return_value = {'id': 101,"user_id": self.user_id,"package_id": self.package_id,"start_date": self.start_date,"end_date": self.end_date,}# ====== mocking part starts ===== #responses.add(method=responses.POST,url='https://real-weather-service.com/weather/subscribe/',json=return_value,status=201)# ====== mocking part ends ===== #response = create_weather_subscription(self.user_id, self.package_id, self.start_date, self.end_date)self.assertEqual(response, 'Weather data subscribed successfully!')
@response.activate
is used to activate the mocking. Remove this line, and the mocking won't work. Then, add the desired response to our request as in
responses.add(method=responses.POST,url='https://real-weather-service.com/weather/subscribe/',json=return_value,status=201)
There, we specify the method, url, json, and status. You can check other parameters available in the responses documentation, then try updating test_create_weather_subscription_authorization_error
and test_create_weather_subscription_overlap
to use responses.
Matching Requests
responses provides advanced request-matching response that is easily configured. Their documentation gives a comprehensive example on how we set things up. The current module provides multiple matchers that you can use to match:
- body contents in JSON format
- body contents in URL encoded data format
- request query parameters
- request query string (similar to query parameters but takes string as input)
- kwargs provided to request e.g.
stream
,verify
- ‘multipart/form-data’ content and headers in request
- request headers
- request fragment identifier
Awesome, right? Now I will give you an example for matching body contents in URL-encoded data, because that is what is applicable to our weather subscription function.
@responses.activatedef test_request_matching(self):return_value_1 = {'message': 'User not found'}return_value_2 = {'message': 'Package not found'}responses.add(method=responses.POST,url='https://real-weather-service.com/weather/subscribe/',json=return_value_1,status=404,match=[matchers.urlencoded_params_matcher({'user_id': '9999','package_id': self.package_id,'start_date': self.start_date,'end_date': self.end_date,})])responses.add(method=responses.POST,url='https://real-weather-service.com/weather/subscribe/',json=return_value_2,status=404,match=[matchers.urlencoded_params_matcher({'user_id': str(self.user_id),'package_id': 'non-existing-package-id','start_date': self.start_date,'end_date': self.end_date,})])# test user_id not foundresponse = create_weather_subscription(9999, self.package_id, self.start_date, self.end_date)self.assertEqual(response, 'User not found')# test package_id not foundresponse = create_weather_subscription(self.user_id, 'non-existing-package-id', self.start_date, self.end_date)self.assertEqual(response, 'Package not found')
The key for the mocking is here.
responses.add(method=responses.POST,url='https://real-weather-service.com/weather/subscribe/',json=return_value_2,status=404,match=[matchers.urlencoded_params_matcher({'user_id': str(self.user_id),'package_id': 'non-existing-package-id','start_date': self.start_date,'end_date': self.end_date,})])
NOTE
Notice that inside matchers, we provide 'user_id': str(self.user_id)
even though our used_id is an integer. That is because the payload is converted to a string in urlencoded parameters. If we don't do so, the requests won't match and this error happens. Try updating 'user_id': str(self.user_id)
to 'user_id': self.user_id
and this will happen.
requests.exceptions.ConnectionError: Connection refused by Responses - the call doesn't match any registered mock.Request:- POST https://real-weather-service.com/weather/subscribe/Available matches:- POST https://real-weather-service.com/weather/subscribe/ request.body doesn't match:{end_date: 2020-12-31, package_id: non-existing-package-id, start_date: 2020-10-01, user_id: 10} doesn't match{end_date: 2020-12-31, package_id: dummy-package-id, start_date: 2020-10-01, user_id: 9999}- POST https://real-weather-service.com/weather/subscribe/ request.body doesn't match:{end_date: 2020-12-31, package_id: non-existing-package-id, start_date: 2020-10-01, user_id: 10} doesn't match{end_date: 2020-12-31, package_id: non-existing-package-id, start_date: 2020-10-01, user_id: 10}----------------------------------------------------------------------Ran 1 test in 0.004sFAILED (errors=1)
Dynamic Response
We could use callbacks to provide a dynamic response. The callbacks basically check the request for its body/headers/anything, and must return a tuple of (status, headers, and body)
. Now, we will update test_request_matching
to use a dynamic response.
from urllib.parse import parse_qsl@responses.activatedef test_dynamic_response(self):return_value_1 = {'message': 'User not found'}return_value_2 = {'message': 'Package not found'}def request_callback(request):payload = dict(parse_qsl(request.body))if payload['user_id'] != str(self.user_id):resp_body = return_value_1status = 404elif payload['package_id'] != self.package_id:resp_body = return_value_2status = 404headers = {'request-id': 'some-request-id'}return (status, headers, json.dumps(resp_body))responses.add_callback(method=responses.POST,url='https://real-weather-service.com/weather/subscribe/',callback=request_callback)# test user_id not foundresponse = create_weather_subscription(9999, self.package_id, self.start_date, self.end_date)self.assertEqual(response, 'User not found')# test package_id not foundresponse = create_weather_subscription(self.user_id, 'non-existing-package-id', self.start_date, self.end_date)self.assertEqual(response, 'Package not found')
The callbacks we provide check whether user_id
and package_id
exists, then return the expected response.
Final Thoughts
I think responses provides more advanced features that are easier to use, compared to requests_mock. I will most likely ditch requests_mock in favor of responses.
Zulfikar Akbar Muzakki
Zakki is a software developers from Indonesia and is based in Purworejo, a small town in Central Java. He studied Information System in Universitas Indonesia. His journey with Python and Django started when he first worked as a web developer. After discovering the simplicity and power of Python he has stuck to it. You know what people say, “Once you go Python, you’ll never move on!”. His interest in GIS stemmed from his activities exploring random things in the digital map. He looks for cities, interesting places, even following street view from one place to another, imagining he was there physically. Zakki is a volunteer in kids learning centre, where he and his team run fun activities every Sunday. When he is not dating his computer, he spends his time sleeping or watching documentary and fantasy movies. Give him more free time, and he will ride his motorbike to someplace far away. He enjoys watching cultural shows and learning about people, places, and cultures. He also loves singing and dancing, but that doesn’t mean he is good at it.
Thanks for the article! Two things: 1. The link to your previous article about requests_mock is broken. 2. The foreground color you are using for your code blocks makes them impossible to read. It should be in a much higher contrast color for legibility!
Hi, I'm using responses library to mock external api's.
There is one api which returns jenkins object how to mock this api. Jenkins object is expecting base url and I'm not able to mock. Can you please help me on this?