diff --git a/designate/api/v2/controllers/rest.py b/designate/api/v2/controllers/rest.py
index 7f6c2c929..8055cb0af 100644
--- a/designate/api/v2/controllers/rest.py
+++ b/designate/api/v2/controllers/rest.py
@@ -24,6 +24,8 @@
 # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
 import inspect
 
 import pecan
@@ -63,9 +65,9 @@ class RestController(pecan.rest.RestController):
             return criterion
 
     def _handle_post(self, method, remainder, request=None):
-        '''
+        """
         Routes ``POST`` actions to the appropriate controller.
-        '''
+        """
         # route to a post_all or get if no additional parts are available
         if not remainder or remainder == ['']:
             controller = self._find_controller('post_all', 'post')
@@ -86,9 +88,9 @@ class RestController(pecan.rest.RestController):
         pecan.abort(405)
 
     def _handle_patch(self, method, remainder, request=None):
-        '''
+        """
         Routes ``PATCH`` actions to the appropriate controller.
-        '''
+        """
         # route to a patch_all or get if no additional parts are available
         if not remainder or remainder == ['']:
             controller = self._find_controller('patch_all', 'patch')
@@ -109,9 +111,9 @@ class RestController(pecan.rest.RestController):
         pecan.abort(405)
 
     def _handle_put(self, method, remainder, request=None):
-        '''
+        """
         Routes ``PUT`` actions to the appropriate controller.
-        '''
+        """
         # route to a put_all or get if no additional parts are available
         if not remainder or remainder == ['']:
             controller = self._find_controller('put_all', 'put')
@@ -132,9 +134,9 @@ class RestController(pecan.rest.RestController):
         pecan.abort(405)
 
     def _handle_delete(self, method, remainder, request=None):
-        '''
+        """
         Routes ``DELETE`` actions to the appropriate controller.
-        '''
+        """
         # route to a delete_all or get if no additional parts are available
         if not remainder or remainder == ['']:
             controller = self._find_controller('delete_all', 'delete')
diff --git a/designate/api/v2/controllers/zones/__init__.py b/designate/api/v2/controllers/zones/__init__.py
index 95f087ae5..af88039e6 100644
--- a/designate/api/v2/controllers/zones/__init__.py
+++ b/designate/api/v2/controllers/zones/__init__.py
@@ -53,7 +53,7 @@ class ZonesController(rest.RestController):
 
         zone = self.central_api.get_zone(context, zone_id)
 
-        LOG.info("Retrieved %(zone)s", {'zone': zone})
+        LOG.info('Retrieved %(zone)s', {'zone': zone})
 
         return DesignateAdapter.render('API_v2', zone, request=request)
 
@@ -76,7 +76,7 @@ class ZonesController(rest.RestController):
         zones = self.central_api.find_zones(
             context, criterion, marker, limit, sort_key, sort_dir)
 
-        LOG.info("Retrieved %(zones)s", {'zones': zones})
+        LOG.info('Retrieved %(zones)s', {'zones': zones})
 
         return DesignateAdapter.render('API_v2', zones, request=request)
 
@@ -89,9 +89,8 @@ class ZonesController(rest.RestController):
 
         zone = request.body_dict
 
-        if isinstance(zone, dict):
-            if 'type' not in zone:
-                zone['type'] = 'PRIMARY'
+        if 'type' not in zone:
+            zone['type'] = 'PRIMARY'
 
         zone = DesignateAdapter.parse('API_v2', zone, objects.Zone())
         zone.validate()
@@ -107,11 +106,10 @@ class ZonesController(rest.RestController):
         # new zone cannot yet be shared.
         zone.shared = False
 
-        LOG.info("Created %(zone)s", {'zone': zone})
+        LOG.info('Created %(zone)s', {'zone': zone})
 
         # Prepare the response headers
         # If the zone has been created asynchronously
-
         if zone['status'] == 'PENDING':
             response.status_int = 202
         else:
@@ -141,7 +139,7 @@ class ZonesController(rest.RestController):
         zone = self.central_api.get_zone(context, zone_id)
 
         # Don't allow updates to zones that are being deleted
-        if zone.action == "DELETE":
+        if zone.action == 'DELETE':
             raise exceptions.BadRequest('Can not update a deleting zone')
 
         if request.content_type == 'application/json-patch+json':
@@ -170,15 +168,11 @@ class ZonesController(rest.RestController):
 
             # Update and persist the resource
 
-            if zone.type == 'SECONDARY' and 'email' in zone.obj_what_changed():
-                msg = "Changed email is not allowed."
-                raise exceptions.InvalidObject(msg)
-
             increment_serial = zone.type == 'PRIMARY'
             zone = self.central_api.update_zone(
                 context, zone, increment_serial=increment_serial)
 
-        LOG.info("Updated %(zone)s", {'zone': zone})
+        LOG.info('Updated %(zone)s', {'zone': zone})
 
         if zone.status == 'PENDING':
             response.status_int = 202
@@ -198,6 +192,6 @@ class ZonesController(rest.RestController):
         zone = self.central_api.delete_zone(context, zone_id)
         response.status_int = 202
 
-        LOG.info("Deleted %(zone)s", {'zone': zone})
+        LOG.info('Deleted %(zone)s', {'zone': zone})
 
         return DesignateAdapter.render('API_v2', zone, request=request)
diff --git a/designate/api/v2/controllers/zones/recordsets.py b/designate/api/v2/controllers/zones/recordsets.py
index 8a954435c..3d6d1033c 100644
--- a/designate/api/v2/controllers/zones/recordsets.py
+++ b/designate/api/v2/controllers/zones/recordsets.py
@@ -70,7 +70,8 @@ class RecordSetsController(rest.RestController):
         # SOA recordsets cannot be created manually
         if recordset.type == 'SOA':
             raise exceptions.BadRequest(
-                "Creating a SOA recordset is not allowed")
+                'Creating a SOA recordset is not allowed'
+            )
 
         # Create the recordset
         recordset = self.central_api.create_recordset(
@@ -102,7 +103,6 @@ class RecordSetsController(rest.RestController):
         # Fetch the existing recordset
         recordset = self.central_api.get_recordset(context, zone_id,
                                                    recordset_id)
-
         # TODO(graham): Move this further down the stack
         if recordset.managed and not context.edit_managed_records:
             raise exceptions.BadRequest('Managed records may not be updated')
@@ -110,14 +110,16 @@ class RecordSetsController(rest.RestController):
         # SOA recordsets cannot be updated manually
         if recordset['type'] == 'SOA':
             raise exceptions.BadRequest(
-                'Updating SOA recordsets is not allowed')
+                'Updating SOA recordsets is not allowed'
+            )
 
         # NS recordsets at the zone root cannot be manually updated
         if recordset['type'] == 'NS':
             zone = self.central_api.get_zone(context, zone_id)
             if recordset['name'] == zone['name']:
                 raise exceptions.BadRequest(
-                    'Updating a root zone NS record is not allowed')
+                    'Updating a root zone NS record is not allowed'
+                )
 
         # Convert to APIv2 Format
 
diff --git a/designate/api/v2/patches.py b/designate/api/v2/patches.py
index 999fd2f67..b3fae7e6b 100644
--- a/designate/api/v2/patches.py
+++ b/designate/api/v2/patches.py
@@ -33,7 +33,8 @@ class Request(pecan.core.Request):
         """
         if self.content_type not in JSON_TYPES:
             raise exceptions.UnsupportedContentType(
-                'Content-type must be application/json')
+                'Content-type must be application/json'
+            )
 
         try:
             json_dict = jsonutils.load(self.body_file)
diff --git a/designate/tests/unit/api/test_root.py b/designate/tests/unit/api/test_root.py
index 84f84f7b8..a559bcd3f 100644
--- a/designate/tests/unit/api/test_root.py
+++ b/designate/tests/unit/api/test_root.py
@@ -74,6 +74,27 @@ class RootTest(oslotest.base.BaseTestCase):
             namespace=mock.ANY, names=['v2'], invoke_on_load=True
         )
 
+    @mock.patch('stevedore.named.NamedExtensionManager')
+    def test_v2_root_object_not_found(self, mock_manger):
+        mock_extension = mock.Mock()
+        mock_extension.obj.get_path.return_value = '..test.a'
+        mock_manger.return_value = [mock_extension]
+        CONF.set_override(
+            'enabled_extensions_v2',
+            ['v2'],
+            'service:api'
+        )
+
+        self.assertRaisesRegex(
+            AttributeError,
+            "object has no attribute 'test'",
+            v2_root.RootController
+        )
+
+        mock_manger.assert_called_with(
+            namespace=mock.ANY, names=['v2'], invoke_on_load=True
+        )
+
     @mock.patch('stevedore.named.NamedExtensionManager')
     def test_v2_root_no_extensions(self, mock_manger):
         mock_manger.return_value = []
diff --git a/designate/tests/unit/api/v2/test_abandon.py b/designate/tests/unit/api/v2/test_abandon.py
new file mode 100644
index 000000000..24b5abe40
--- /dev/null
+++ b/designate/tests/unit/api/v2/test_abandon.py
@@ -0,0 +1,48 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+from unittest import mock
+
+import oslotest.base
+
+from designate.api.v2.controllers.zones.tasks import abandon
+from designate.central import rpcapi
+from designate import objects
+
+
+class TestAbandonAPI(oslotest.base.BaseTestCase):
+    def setUp(self):
+        super().setUp()
+        self.central_api = mock.Mock()
+        self.zone = objects.Zone(
+            id='1e8952a5-e5a4-426a-afab-4cd10131a351',
+            name='example.com.',
+            email='example@example.com'
+        )
+        mock.patch.object(rpcapi.CentralAPI, 'get_instance',
+                          return_value=self.central_api).start()
+
+        self.controller = abandon.AbandonController()
+
+    @mock.patch('pecan.response')
+    @mock.patch('pecan.request')
+    def test_post_all_move_error(self, mock_request, mock_response):
+        mock_request.environ = {'context': mock.Mock()}
+        mock_delete_zone = mock.Mock()
+        mock_delete_zone.deleted_at = None
+
+        self.central_api.delete_zone.return_value = mock_delete_zone
+
+        self.controller.post_all(self.zone.id)
+
+        self.assertEqual(500, mock_response.status_int)
diff --git a/designate/tests/unit/api/test_api_v2.py b/designate/tests/unit/api/v2/test_api_v2.py
similarity index 100%
rename from designate/tests/unit/api/test_api_v2.py
rename to designate/tests/unit/api/v2/test_api_v2.py
diff --git a/designate/tests/unit/api/test_floatingips.py b/designate/tests/unit/api/v2/test_floatingips.py
similarity index 100%
rename from designate/tests/unit/api/test_floatingips.py
rename to designate/tests/unit/api/v2/test_floatingips.py
diff --git a/designate/tests/unit/api/v2/test_patches.py b/designate/tests/unit/api/v2/test_patches.py
new file mode 100644
index 000000000..bd9b2e6d1
--- /dev/null
+++ b/designate/tests/unit/api/v2/test_patches.py
@@ -0,0 +1,50 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+from unittest import mock
+
+import oslotest.base
+import testtools
+
+from designate.api.v2 import patches
+from designate import exceptions
+
+
+class TestPatches(oslotest.base.BaseTestCase):
+    def setUp(self):
+        super().setUp()
+        self.environ = {
+            'CONTENT_TYPE': 'application/json',
+        }
+        self.request = patches.Request(self.environ)
+
+    def test_unsupported_content_type(self):
+        self.environ['CONTENT_TYPE'] = 'invalid'
+
+        with testtools.ExpectedException(exceptions.UnsupportedContentType):
+            self.assertIsNone(self.request.body_dict)
+
+    @mock.patch('oslo_serialization.jsonutils.load')
+    def test_request_body_empty(self, mock_load):
+        mock_load.side_effect = ValueError()
+
+        with testtools.ExpectedException(exceptions.EmptyRequestBody):
+            self.assertIsNone(self.request.body_dict)
+
+    @mock.patch('oslo_serialization.jsonutils.load')
+    def test_invalid_json(self, mock_load):
+        mock_load.side_effect = ValueError()
+        self.request.body = b'invalid'
+
+        with testtools.ExpectedException(exceptions.InvalidJson):
+            self.assertIsNone(self.request.body_dict)
diff --git a/designate/tests/unit/api/v2/test_quotas.py b/designate/tests/unit/api/v2/test_quotas.py
new file mode 100644
index 000000000..b4b5190f7
--- /dev/null
+++ b/designate/tests/unit/api/v2/test_quotas.py
@@ -0,0 +1,45 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+from unittest import mock
+
+import oslotest.base
+
+from designate.api.v2.controllers import quotas
+from designate.central import rpcapi
+from designate import exceptions
+
+
+class TestQuotasAPI(oslotest.base.BaseTestCase):
+    def setUp(self):
+        super().setUp()
+        self.central_api = mock.Mock()
+        mock.patch.object(rpcapi.CentralAPI, 'get_instance',
+                          return_value=self.central_api).start()
+
+        self.controller = quotas.QuotasController()
+
+    @mock.patch('pecan.response')
+    @mock.patch('pecan.request')
+    def test_post_all_move_error(self, mock_request, mock_response):
+        mock_context = mock.Mock()
+        mock_context.project_id = None
+        mock_context.all_tenants = False
+        mock_request.environ = {'context': mock_context}
+
+        self.assertRaisesRegex(
+            exceptions.MissingProjectID,
+            'The all-projects flag must be used when using non-project '
+            'scoped tokens.',
+            self.controller.patch_one, 'b0758367-4ac7-436d-917e-390d2b3df734'
+        )
diff --git a/designate/tests/unit/api/v2/test_recordsets.py b/designate/tests/unit/api/v2/test_recordsets.py
new file mode 100644
index 000000000..4867d226a
--- /dev/null
+++ b/designate/tests/unit/api/v2/test_recordsets.py
@@ -0,0 +1,157 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+from unittest import mock
+
+import oslotest.base
+
+from designate.api.v2.controllers.zones import recordsets
+from designate.central import rpcapi
+from designate import exceptions
+from designate import objects
+
+
+class TestRecordsetAPI(oslotest.base.BaseTestCase):
+    def setUp(self):
+        super().setUp()
+        self.central_api = mock.Mock()
+        self.zone = objects.Zone(
+            id='1e8952a5-e5a4-426a-afab-4cd10131a351',
+            name='example.com.',
+            email='example@example.com'
+        )
+        mock.patch.object(rpcapi.CentralAPI, 'get_instance',
+                          return_value=self.central_api).start()
+
+        self.controller = recordsets.RecordSetsController()
+
+    @mock.patch('pecan.response', mock.Mock())
+    @mock.patch('pecan.request')
+    def test_post_all_soa_not_allowed(self, mock_request):
+        mock_request.environ = {'context': mock.Mock()}
+        mock_request.body_dict = {
+            'name': 'soa.example.com.',
+            'type': 'SOA'
+        }
+
+        self.assertRaisesRegex(
+            exceptions.BadRequest,
+            'Creating a SOA recordset is not allowed',
+            self.controller.post_all, self.zone.id
+        )
+
+    @mock.patch('pecan.response')
+    @mock.patch('pecan.request')
+    def test_put_one(self, mock_request, mock_response):
+        mock_context = mock.Mock()
+        mock_context.edit_managed_records = False
+
+        mock_request.environ = {'context': mock_context}
+
+        record_set = objects.RecordSet(name='www.example.org.', type='NS')
+        record_set.records = objects.RecordList(
+            objects=[objects.Record(data='ns1.example.org.', action='NONE')]
+        )
+
+        self.central_api.get_recordset.return_value = record_set
+        self.central_api.update_recordset.return_value = record_set
+        self.central_api.get_zone.return_value = self.zone
+
+        self.controller.put_one(
+            self.zone.id, '99a60ad0-b9ac-4e83-9eee-859e99299bcf'
+        )
+        self.assertEqual(200, mock_response.status_int)
+
+    @mock.patch('pecan.response', mock.Mock())
+    @mock.patch('pecan.request')
+    def test_put_one_managed_not_allowed(self, mock_request):
+        mock_context = mock.Mock()
+        mock_context.edit_managed_records = False
+
+        mock_request.environ = {'context': mock_context}
+
+        record_set = objects.RecordSet(name='www.example.org.', type='A')
+        record_set.records = objects.RecordList(
+            objects=[objects.Record(data='192.0.2.1', managed=True)]
+        )
+
+        self.central_api.get_recordset.return_value = record_set
+
+        self.assertRaisesRegex(
+            exceptions.BadRequest,
+            'Managed records may not be updated',
+            self.controller.put_one, self.zone.id,
+            '3a2a2c3a-8f47-4788-8622-231f1c8f19c3'
+        )
+
+    @mock.patch('pecan.response', mock.Mock())
+    @mock.patch('pecan.request')
+    def test_put_one_soa_not_allowed(self, mock_request):
+        mock_context = mock.Mock()
+
+        mock_request.environ = {'context': mock_context}
+
+        record_set = objects.RecordSet(name='soa.example.org.', type='SOA')
+        record_set.records = objects.RecordList(
+            objects=[objects.Record(data='192.0.2.2', managed=True)]
+        )
+
+        self.central_api.get_recordset.return_value = record_set
+
+        self.assertRaisesRegex(
+            exceptions.BadRequest,
+            'Updating SOA recordsets is not allowed',
+            self.controller.put_one, self.zone.id,
+            '3a2a2c3a-8f47-4788-8622-231f1c8f19c3'
+        )
+
+    @mock.patch('pecan.response', mock.Mock())
+    @mock.patch('pecan.request')
+    def test_put_one_update_root_ns_not_allowed(self, mock_request):
+        mock_context = mock.Mock()
+
+        mock_request.environ = {'context': mock_context}
+
+        record_set = objects.RecordSet(name='example.com.', type='NS')
+        record_set.records = objects.RecordList(
+            objects=[objects.Record(data='192.0.2.3', managed=True)]
+        )
+
+        self.central_api.get_recordset.return_value = record_set
+        self.central_api.get_zone.return_value = self.zone
+
+        self.assertRaisesRegex(
+            exceptions.BadRequest,
+            'Updating a root zone NS record is not allowed',
+            self.controller.put_one, self.zone.id,
+            '3a2a2c3a-8f47-4788-8622-231f1c8f19c3'
+        )
+
+    @mock.patch('pecan.response', mock.Mock())
+    @mock.patch('pecan.request')
+    def test_delete_one_soa_not_allowed(self, mock_request):
+        mock_request.environ = {'context': mock.Mock()}
+
+        record_set = objects.RecordSet(name='soa.example.com.', type='SOA')
+        record_set.records = objects.RecordList(
+            objects=[objects.Record(data='192.0.2.4')]
+        )
+
+        self.central_api.get_recordset.return_value = record_set
+
+        self.assertRaisesRegex(
+            exceptions.BadRequest,
+            'Deleting a SOA recordset is not allowed',
+            self.controller.delete_one, self.zone.id,
+            '3a2a2c3a-8f47-4788-8622-231f1c8f19c3'
+        )
diff --git a/designate/tests/unit/api/v2/test_rest_controller.py b/designate/tests/unit/api/v2/test_rest_controller.py
new file mode 100644
index 000000000..915e085ac
--- /dev/null
+++ b/designate/tests/unit/api/v2/test_rest_controller.py
@@ -0,0 +1,146 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+from unittest import mock
+
+import oslotest.base
+from webob import exc
+
+from designate.api.v2.controllers import rest
+
+
+class TestRestController(oslotest.base.BaseTestCase):
+    def setUp(self):
+        super().setUp()
+
+        self.controller = rest.RestController()
+
+    def test_handle_post(self):
+        self.controller._find_controller = mock.Mock()
+        self.controller._find_controller.return_value = mock.Mock()
+
+        self.assertEqual(
+            (mock.ANY, []), self.controller._handle_post(mock.Mock(), None)
+        )
+
+    def test_handle_patch(self):
+        self.controller._find_controller = mock.Mock()
+        self.controller._find_controller.return_value = mock.Mock()
+
+        self.assertEqual(
+            (mock.ANY, []), self.controller._handle_patch(mock.Mock(), None)
+        )
+
+    def test_handle_put(self):
+        self.controller._find_controller = mock.Mock()
+        self.controller._find_controller.return_value = mock.Mock()
+
+        self.assertEqual(
+            (mock.ANY, []), self.controller._handle_put(mock.Mock(), None)
+        )
+
+    def test_handle_delete(self):
+        self.controller._find_controller = mock.Mock()
+        self.controller._find_controller.return_value = mock.Mock()
+
+        self.assertEqual(
+            (mock.ANY, []), self.controller._handle_delete(mock.Mock(), None)
+        )
+
+    def test_handle_post_method_not_allowed(self):
+        self.controller._find_controller = mock.Mock()
+        self.controller._find_controller.return_value = None
+
+        self.assertRaisesRegex(
+            exc.HTTPMethodNotAllowed,
+            'The server could not comply with the request since it is either '
+            'malformed or otherwise incorrect.',
+            self.controller._handle_post, mock.Mock(), None
+        )
+
+    def test_handle_patch_method_not_allowed(self):
+        self.controller._find_controller = mock.Mock()
+        self.controller._find_controller.return_value = None
+
+        self.assertRaisesRegex(
+            exc.HTTPMethodNotAllowed,
+            'The server could not comply with the request since it is either '
+            'malformed or otherwise incorrect.',
+            self.controller._handle_patch, mock.Mock(), None
+        )
+
+    def test_handle_put_method_not_allowed(self):
+        self.controller._find_controller = mock.Mock()
+        self.controller._find_controller.return_value = None
+
+        self.assertRaisesRegex(
+            exc.HTTPMethodNotAllowed,
+            'The server could not comply with the request since it is either '
+            'malformed or otherwise incorrect.',
+            self.controller._handle_put, mock.Mock(), None
+        )
+
+    def test_handle_delete_method_not_allowed(self):
+        self.controller._find_controller = mock.Mock()
+        self.controller._find_controller.return_value = None
+
+        self.assertRaisesRegex(
+            exc.HTTPMethodNotAllowed,
+            'The server could not comply with the request since it is either '
+            'malformed or otherwise incorrect.',
+            self.controller._handle_delete, mock.Mock(), None
+        )
+
+    def test_handle_post_controller_not_found(self):
+        self.controller._find_controller = mock.Mock()
+        self.controller._find_controller.return_value = None
+
+        self.assertRaisesRegex(
+            exc.HTTPMethodNotAllowed,
+            'The server could not comply with the request since it is either '
+            'malformed or otherwise incorrect.',
+            self.controller._handle_post, mock.Mock(), ['fake']
+        )
+
+    def test_handle_patch_controller_not_found(self):
+        self.controller._find_controller = mock.Mock()
+        self.controller._find_controller.return_value = None
+
+        self.assertRaisesRegex(
+            exc.HTTPMethodNotAllowed,
+            'The server could not comply with the request since it is either '
+            'malformed or otherwise incorrect.',
+            self.controller._handle_patch, mock.Mock(), ['fake']
+        )
+
+    def test_handle_put_controller_not_found(self):
+        self.controller._find_controller = mock.Mock()
+        self.controller._find_controller.return_value = None
+
+        self.assertRaisesRegex(
+            exc.HTTPMethodNotAllowed,
+            'The server could not comply with the request since it is either '
+            'malformed or otherwise incorrect.',
+            self.controller._handle_put, mock.Mock(), ['fake']
+        )
+
+    def test_handle_delete_controller_not_found(self):
+        self.controller._find_controller = mock.Mock()
+        self.controller._find_controller.return_value = None
+
+        self.assertRaisesRegex(
+            exc.HTTPMethodNotAllowed,
+            'The server could not comply with the request since it is either '
+            'malformed or otherwise incorrect.',
+            self.controller._handle_delete, mock.Mock(), ['fake']
+        )
diff --git a/designate/tests/unit/api/v2/test_zones.py b/designate/tests/unit/api/v2/test_zones.py
new file mode 100644
index 000000000..851aff827
--- /dev/null
+++ b/designate/tests/unit/api/v2/test_zones.py
@@ -0,0 +1,83 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+from unittest import mock
+
+import oslotest.base
+
+from designate.api.v2.controllers import zones
+from designate.central import rpcapi
+from designate import objects
+
+
+class TestZonesAPI(oslotest.base.BaseTestCase):
+    def setUp(self):
+        super().setUp()
+        self.central_api = mock.Mock()
+        self.zone = objects.Zone(
+            id='1e8952a5-e5a4-426a-afab-4cd10131a351',
+            name='example.com.',
+            email='example@example.com',
+            masters=objects.ZoneMasterList(),
+            attributes=objects.ZoneAttributeList(),
+        )
+        mock.patch.object(rpcapi.CentralAPI, 'get_instance',
+                          return_value=self.central_api).start()
+
+        self.controller = zones.ZonesController()
+
+    @mock.patch('pecan.response')
+    @mock.patch('pecan.request')
+    def test_post_all_zone_error(self, mock_request, mock_response):
+        mock_response.headers = {}
+
+        mock_request.environ = {'context': mock.Mock()}
+        mock_request.body_dict = {
+            'name': 'example.com.',
+            'type': 'PRIMARY',
+            'email': 'example@example.com',
+        }
+
+        zone = objects.Zone(
+            name='example.com.',
+            type='PRIMARY',
+            email='example@example.com',
+            status='ERROR',
+            masters=objects.ZoneMasterList(),
+            attributes=objects.ZoneAttributeList(),
+        )
+
+        self.central_api.create_zone.return_value = zone
+
+        self.controller.post_all()
+
+        self.assertEqual(201, mock_response.status_int)
+
+    @mock.patch('pecan.response')
+    @mock.patch('pecan.request')
+    def test_patch_one_zone_error(self, mock_request, mock_response):
+        mock_response.headers = {}
+
+        mock_request.environ = {'context': mock.Mock()}
+        mock_request.body_dict = {
+            'name': 'example.com.',
+            'type': 'PRIMARY',
+            'email': 'example@example.com',
+        }
+
+        self.central_api.get_zone.return_value = self.zone
+        self.central_api.update_zone.return_value = self.zone
+
+        self.controller.patch_one(self.zone.id)
+
+        self.assertEqual(200, mock_response.status_int)