Repository: boto/botocore
Pull request #784 (2016-02-02 18:59:38)

Assert ``expected_params`` stubber
Allows you to specify what parameters that you are to expect for a response and if they do not match, throw an error.

I have been writing a fair amount of tests using this stubber. I have found this sort of response/expected_params matching as being pretty useful and was using it an awful lot.

The only behavior I am not sure about and would be up for opinions is what happens to the stubber's corresponding response when an ``expected_params`` does not match. Should it use up the response in the queue? Or should it keep the original response in the queue so that it can be called later? I decided to go with the later because in the current stubber logic if there is a parameter validation error with the client, we will not pop the response off of the queue. So I thought ``expected_params`` would have a similar expectation. 

cc @jamesls @mtdowling @rayluo @JordonPhillips 


Patch:
diff --git a/botocore/stub.py b/botocore/stub.py
index 47f4f282a..acdbae042 100644
--- a/botocore/stub.py
+++ b/botocore/stub.py
@@ -54,7 +54,9 @@ class Stubber(object):
             'Marker': ''
         }
 
-        stubber.add_response('list_objects', response)
+        expected_params = {'Bucket': 'test-bucket'}
+
+        stubber.add_response('list_objects', response, expected_params)
         stubber.activate()
 
         service_response = s3.list_objects(Bucket='test-bucket')
@@ -66,27 +68,36 @@ class Stubber(object):
         """
         self.client = client
         self._event_id = 'boto_stubber'
+        self._expected_params_event_id = 'boto_stubber_expected_params'
         self._queue = deque()
 
     def activate(self):
         """
         Activates the stubber on the client
         """
+        self.client.meta.events.register_first(
+            'before-parameter-build.*.*',
+            self._assert_expected_params,
+            unique_id=self._expected_params_event_id)
         self.client.meta.events.register(
-                'before-call.*.*',
-                self._get_response_handler,
-                unique_id=self._event_id)
+            'before-call.*.*',
+            self._get_response_handler,
+            unique_id=self._event_id)
 
     def deactivate(self):
         """
         Deactivates the stubber on the client
         """
         self.client.meta.events.unregister(
-                'before-call.*.*',
-                self._get_response_handler,
-                unique_id=self._event_id)
+            'before-parameter-build.*.*',
+            self._assert_expected_params,
+            unique_id=self._expected_params_event_id)
+        self.client.meta.events.unregister(
+            'before-call.*.*',
+            self._get_response_handler,
+            unique_id=self._event_id)
 
-    def add_response(self, method, service_response):
+    def add_response(self, method, service_response, expected_params=None):
         """
         Adds a service response to the response queue. This will be validated
         against the service model to ensure correctness. It should be noted,
@@ -101,24 +112,34 @@ class Stubber(object):
         :param service_response: A dict response stub. Provided parameters will
             be validated against the service model.
         :type service_response: dict
+
+        :param expected_params: A dictionary of the expected parameters to
+            be called for the provided service_response. The parameters match
+            the names of keyword arguments passed to that client call. If
+            any of the parameters differ a StubResponseError is thrown.
         """
-        self._add_response(method, service_response)
+        self._add_response(method, service_response, expected_params)
 
-    def _add_response(self, method, service_response, http_response=None):
+    def _add_response(self, method, service_response, expected_params):
         if not hasattr(self.client, method):
             raise ValueError(
                 "Client %s does not have method: %s"
                 % (self.client.meta.service_model.service_name, method))
 
-        if http_response is None:
-            http_response = Response()
-            http_response.status_code = 200
-            http_response.reason = 'OK'
+        # Create a successful http response
+        http_response = Response()
+        http_response.status_code = 200
+        http_response.reason = 'OK'
 
         operation_name = self.client.meta.method_to_api_mapping.get(method)
         self._validate_response(operation_name, service_response)
 
-        response = (operation_name, (http_response, service_response))
+        # Add the service_response to the queue for returning responses
+        response = {
+            'operation_name': operation_name,
+            'response': (http_response, service_response),
+            'expected_params': expected_params
+        }
         self._queue.append(response)
 
     def add_client_error(self, method, service_error_code='',
@@ -143,8 +164,9 @@ class Stubber(object):
         http_response = Response()
         http_response.status_code = http_status_code
 
-        # We don't look to the model to build this because the caller would need
-        # to know the details of what the HTTP body would need to look like.
+        # We don't look to the model to build this because the caller would
+        # need to know the details of what the HTTP body would need to
+        # look like.
         parsed_response = {
             'ResponseMetadata': {'HTTPStatusCode': http_status_code},
             'Error': {
@@ -154,7 +176,13 @@ class Stubber(object):
         }
 
         operation_name = self.client.meta.method_to_api_mapping.get(method)
-        response = (operation_name, (http_response, parsed_response))
+        # Note that we do not allow for expected_params while
+        # adding errors into the queue yet.
+        response = {
+            'operation_name': operation_name,
+            'response': (http_response, parsed_response),
+            'expected_params': None
+        }
         self._queue.append(response)
 
     def assert_no_pending_responses(self):
@@ -163,22 +191,35 @@ class Stubber(object):
         """
         remaining = len(self._queue)
         if remaining != 0:
-            raise AssertionError("%d responses remaining in queue." % remaining)
+            raise AssertionError(
+                "%d responses remaining in queue." % remaining)
 
-    def _get_response_handler(self, model, params, **kwargs):
+    def _assert_expected_call_order(self, model, params):
         if not self._queue:
             raise StubResponseError(
                 operation_name=model.name,
                 reason='Unexpected API Call: called with parameters %s' %
                        params)
 
-        name, response = self._queue.popleft()
+        name = self._queue[0]['operation_name']
         if name != model.name:
             raise StubResponseError(
                 operation_name=model.name,
                 reason='Operation mismatch: found response for %s.' % name)
 
-        return response
+    def _get_response_handler(self, model, params, **kwargs):
+        self._assert_expected_call_order(model, params)
+        # Pop off the entire response once everything has been validated
+        return self._queue.popleft()['response']
+
+    def _assert_expected_params(self, model, params, **kwargs):
+        self._assert_expected_call_order(model, params)
+        expected_params = self._queue[0]['expected_params']
+        if expected_params is not None and params != expected_params:
+            raise StubResponseError(
+                operation_name=model.name,
+                reason='Expected parameters: %s, but received: %s' % (
+                    params, expected_params))
 
     def _validate_response(self, operation_name, service_response):
         service_model = self.client.meta.service_model
@@ -198,4 +239,5 @@ class Stubber(object):
             # If the output shape is None, that means the response should be
             # empty apart from ResponseMetadata
             raise ParamValidationError(
-                report="Service response should only contain ResponseMetadata.")
+                report=(
+                    "Service response should only contain ResponseMetadata."))


Test patch:
diff --git a/tests/functional/test_stub.py b/tests/functional/test_stub.py
index c11571a74..9ad1f6077 100644
--- a/tests/functional/test_stub.py
+++ b/tests/functional/test_stub.py
@@ -17,6 +17,7 @@ import botocore
 import botocore.session
 from botocore.stub import Stubber
 from botocore.exceptions import StubResponseError, ClientError
+from botocore.exceptions import ParamValidationError
 import botocore.client
 import botocore.retryhandler
 import botocore.translate
@@ -53,8 +54,82 @@ class TestStubber(unittest.TestCase):
     def test_client_error_response(self):
         error_code = "AccessDenied"
         error_message = "Access Denied"
-        self.stubber.add_client_error('list_objects', error_code, error_message)
+        self.stubber.add_client_error(
+            'list_objects', error_code, error_message)
         self.stubber.activate()
 
         with self.assertRaises(ClientError):
             self.client.list_objects(Bucket='foo')
+
+    def test_expected_params_success(self):
+        service_response = {}
+        expected_params = {'Bucket': 'foo'}
+        self.stubber.add_response(
+            'list_objects', service_response, expected_params)
+        self.stubber.activate()
+        # This should be called successfully with no errors being thrown
+        # for mismatching expected params.
+        response = self.client.list_objects(Bucket='foo')
+        self.assertEqual(response, service_response)
+
+    def test_expected_params_fail(self):
+        service_response = {}
+        expected_params = {'Bucket': 'bar'}
+        self.stubber.add_response(
+            'list_objects', service_response, expected_params)
+        self.stubber.activate()
+        # This should call should raise an for mismatching expected params.
+        with self.assertRaises(StubResponseError):
+            self.client.list_objects(Bucket='foo')
+
+    def test_expected_params_mixed_with_errors_responses(self):
+        # Add an error response
+        error_code = "AccessDenied"
+        error_message = "Access Denied"
+        self.stubber.add_client_error(
+            'list_objects', error_code, error_message)
+
+        # Add a response with incorrect expected params
+        service_response = {}
+        expected_params = {'Bucket': 'bar'}
+        self.stubber.add_response(
+            'list_objects', service_response, expected_params)
+
+        self.stubber.activate()
+
+        # The first call should throw and error as expected.
+        with self.assertRaises(ClientError):
+            self.client.list_objects(Bucket='foo')
+
+        # The second call should throw an error for unexpected parameters
+        with self.assertRaisesRegexp(StubResponseError, 'Expected parameters'):
+            self.client.list_objects(Bucket='foo')
+
+    def test_can_continue_to_call_after_expected_params_fail(self):
+        service_response = {}
+        expected_params = {'Bucket': 'bar'}
+
+        self.stubber.add_response(
+            'list_objects', service_response, expected_params)
+
+        self.stubber.activate()
+        # Throw an error for unexpected parameters
+        with self.assertRaises(StubResponseError):
+            self.client.list_objects(Bucket='foo')
+
+        # The stubber should still have the responses queued up
+        # even though the original parameters did not match the expected ones.
+        self.client.list_objects(Bucket='bar')
+        self.stubber.assert_no_pending_responses()
+
+    def test_still_relies_on_param_validation_with_expected_params(self):
+        service_response = {}
+        expected_params = {'Buck': 'bar'}
+
+        self.stubber.add_response(
+            'list_objects', service_response, expected_params)
+
+        self.stubber.activate()
+        # Throw an error for invalid parameters
+        with self.assertRaises(ParamValidationError):
+            self.client.list_objects(Buck='bar')
diff --git a/tests/unit/test_stub.py b/tests/unit/test_stub.py
index 9c18da9d9..b57c87022 100644
--- a/tests/unit/test_stub.py
+++ b/tests/unit/test_stub.py
@@ -51,14 +51,22 @@ class TestStubber(unittest.TestCase):
         self.event_emitter = mock.Mock()
         self.client.meta.events = self.event_emitter
         self.stubber.activate()
-        self.assertTrue(self.event_emitter.register.called)
+        # This just ensures that we register at the correct event
+        # and nothing more
+        self.event_emitter.register_first.assert_called_with(
+            'before-parameter-build.*.*', mock.ANY, unique_id=mock.ANY)
+        self.event_emitter.register.assert_called_with(
+            'before-call.*.*', mock.ANY, unique_id=mock.ANY)
 
     def test_stubber_unregisters_events(self):
         self.event_emitter = mock.Mock()
         self.client.meta.events = self.event_emitter
         self.stubber.activate()
         self.stubber.deactivate()
-        self.assertTrue(self.event_emitter.unregister.called)
+        self.event_emitter.unregister.assert_any_call(
+            'before-parameter-build.*.*', mock.ANY, unique_id=mock.ANY)
+        self.event_emitter.unregister.assert_any_call(
+            'before-call.*.*', mock.ANY, unique_id=mock.ANY)
 
     def test_add_response(self):
         response = {'foo': 'bar'}
@@ -100,7 +108,8 @@ class TestStubber(unittest.TestCase):
             {}, output_shape)
 
         # Make sure service response hasn't been mutated
-        self.assertEqual(service_response, {'ResponseMetadata': {'foo': 'bar'}})
+        self.assertEqual(
+            service_response, {'ResponseMetadata': {'foo': 'bar'}})
 
     def test_validates_on_empty_output_shape(self):
         service_model = ServiceModel({