Repository: falconry/falcon

Issue:
Missing Content-Type on 404 Response
Greetings,

I've started [testing my webapps with wsgiref.validate](http://blog.dscpl.com.au/2015/05/returning-string-as-iterable-from-wsgi.html), and discovered that Falcon's 404 responses (and possible others) don't conform to the WSGI spec.  The `Content-Type` header is required but not present.

This script demonstrates:

    import falcon
    from webtest import TestApp
    import wsgiref.validate

    # the app -- note that it has no routes so all GETs will return 404
    app = falcon.API()

    # wrap in validator middleware
    app = wsgiref.validate.validator(app)

    # wrap in test middleware
    app = TestApp(app)

    # provoke a 404
    resp = app.get('/doesntexist', status=[404])

Output is:

    Traceback (most recent call last):
        <...snip...>
      File "/usr/local/lib/python2.7/dist-packages/falcon/api.py", line 247, in __call__
        start_response(resp.status, headers)
      File "/usr/lib/python2.7/wsgiref/validate.py", line 167, in start_response_wrapper
        check_content_type(status, headers)
      File "/usr/lib/python2.7/wsgiref/validate.py", line 419, in check_content_type
        assert_(0, "No Content-Type header found in headers (%s)" % headers)
      File "/usr/lib/python2.7/wsgiref/validate.py", line 129, in assert_
        raise AssertionError(*args)
    AssertionError: No Content-Type header found in headers ([('content-length', '0')])

FYI, as a sanity check, I ran the same test using Bottle in place of Falcon, and it conforms (sends Content-type with the 404 response.)

I'm assuming that we want to be compliant with the WSGI spec, in which case this is a bug. Happy to submit a PR if you point me in a recommended direction to patch.  (Set Content-type in `falcon/responders.py:path_not_found` ?)



Discussion:
jmvrbanac: lgtm :+1: 

Patch:
diff --git a/doc/api/cookies.rst b/doc/api/cookies.rst
index 84d8669..69c89d8 100644
--- a/doc/api/cookies.rst
+++ b/doc/api/cookies.rst
@@ -84,7 +84,7 @@ You can also instruct the client to remove a cookie with the
             # Clear the bad cookie
             resp.unset_cookie('bad_cookie')
 
-.. _cookie-secure-atribute:
+.. _cookie-secure-attribute:
 
 The Secure Attribute
 ~~~~~~~~~~~~~~~~~~~~
diff --git a/doc/api/index.rst b/doc/api/index.rst
index b0f4b20..a389b91 100644
--- a/doc/api/index.rst
+++ b/doc/api/index.rst
@@ -14,3 +14,4 @@ Classes and Functions
    hooks
    routing
    util
+   testing
diff --git a/doc/api/util.rst b/doc/api/util.rst
index 46ab3b6..fa11b32 100644
--- a/doc/api/util.rst
+++ b/doc/api/util.rst
@@ -9,21 +9,6 @@ URI Functions
 .. automodule:: falcon.util.uri
     :members:
 
-Testing
--------
-
-.. autoclass:: falcon.testing.TestBase
-    :members:
-
-.. autoclass:: falcon.testing.TestResource
-    :members:
-
-.. autoclass:: falcon.testing.StartResponseMock
-    :members:
-
-.. automodule:: falcon.testing
-    :members: rand_string, create_environ
-
 Miscellaneous
 -------------
 
diff --git a/falcon/__init__.py b/falcon/__init__.py
index c0214b8..f6256b2 100644
--- a/falcon/__init__.py
+++ b/falcon/__init__.py
@@ -24,7 +24,7 @@ HTTP_METHODS = (
     'TRACE',
 )
 
-DEFAULT_MEDIA_TYPE = 'application/json; charset=utf-8'
+DEFAULT_MEDIA_TYPE = 'application/json; charset=UTF-8'
 
 
 # Hoist classes and functions into the falcon namespace
diff --git a/falcon/api.py b/falcon/api.py
index d05ec71..d10f452 100644
--- a/falcon/api.py
+++ b/falcon/api.py
@@ -230,15 +230,14 @@ class API(object):
             if length is not None:
                 resp._headers['content-length'] = str(length)
 
-        # Set content type if needed
-        use_content_type = (body or
-                            req.method == 'HEAD' or
-                            resp.status == status.HTTP_416)
-
-        if use_content_type:
-            media_type = self._media_type
-        else:
+        # NOTE(kgriffs): Based on wsgiref.validate's interpretation of
+        # RFC 2616, as commented in that module's source code. The
+        # presence of the Content-Length header is not similarly
+        # enforced.
+        if resp.status in (status.HTTP_204, status.HTTP_304):
             media_type = None
+        else:
+            media_type = self._media_type
 
         headers = resp._wsgi_headers(media_type)
 
diff --git a/falcon/api_helpers.py b/falcon/api_helpers.py
index 7fe1093..bf578b8 100644
--- a/falcon/api_helpers.py
+++ b/falcon/api_helpers.py
@@ -123,7 +123,7 @@ def default_serialize_error(req, resp, exception):
             representation = exception.to_xml()
 
         resp.body = representation
-        resp.content_type = preferred
+        resp.content_type = preferred + '; charset=UTF-8'
 
 
 def wrap_old_error_serializer(old_fn):
diff --git a/falcon/response.py b/falcon/response.py
index 6ff027d..dd9948e 100644
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -581,7 +581,7 @@ class Response(object):
             # it isn't needed.
             items = headers.items()
         else:
-            items = list(headers.items())  # pragma: no cover
+            items = list(headers.items())
 
         if self._cookies is not None:
             # PERF(tbug):
diff --git a/falcon/util/structures.py b/falcon/util/structures.py
index 82995e3..8d5347c 100644
--- a/falcon/util/structures.py
+++ b/falcon/util/structures.py
@@ -1,13 +1,13 @@
 # Copied from the Requests library by Kenneth Reitz et al.
-
+#
 # Copyright 2013 Kenneth Reitz
-
+#
 #    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.
diff --git a/falcon/util/uri.py b/falcon/util/uri.py
index 2359672..2f68ec9 100644
--- a/falcon/util/uri.py
+++ b/falcon/util/uri.py
@@ -129,7 +129,6 @@ Returns:
 
 """
 
-# NOTE(kgriffs): This is actually covered, but not in py33; hence the pragma
 if six.PY2:
 
     # This map construction is based on urllib
@@ -193,8 +192,6 @@ if six.PY2:
 
         return decoded_uri
 
-# NOTE(kgriffs): This is actually covered, but not in py2x; hence the pragma
-
 else:
 
     # This map construction is based on urllib
diff --git a/tox.ini b/tox.ini
index fbdeaa5..c7e30c0 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,8 +1,12 @@
 [tox]
-# NOTE(kgriffs): The py26, py27, and py34 evns are required when
-# checking combined coverage. After running all three envs, execute
-# "tools/combine_coverage.sh" to create a combined coverage report
-# that can be viewed by opening ".coverage_html/index.html".
+# NOTE(kgriffs): The py26, py27, and py34 envs are required when
+# checking combined coverage. To check coverage:
+#
+#   $ tox -e py26,py27,py34 && tools/combine_coverage.sh
+#
+# You can then drill down into coverage details by opening the HTML
+# report at ".coverage_html/index.html".
+
 envlist = py26,
           py27,
           py34,


Test patch:
diff --git a/doc/api/testing.rst b/doc/api/testing.rst
new file mode 100644
index 0000000..5f4d4b5
--- /dev/null
+++ b/doc/api/testing.rst
@@ -0,0 +1,28 @@
+.. _testing:
+
+Testing
+=======
+
+.. autoclass:: falcon.testing.TestCase
+    :members:
+
+.. autoclass:: falcon.testing.Result
+    :members:
+
+.. autoclass:: falcon.testing.SimpleTestResource
+    :members:
+
+.. autoclass:: falcon.testing.StartResponseMock
+    :members:
+
+.. automodule:: falcon.testing
+    :members: capture_responder_args, rand_string, create_environ
+
+Deprecated
+----------
+
+.. autoclass:: falcon.testing.TestBase
+    :members:
+
+.. autoclass:: falcon.testing.TestResource
+    :members:
diff --git a/falcon/testing/__init__.py b/falcon/testing/__init__.py
index 8990a87..966abe0 100644
--- a/falcon/testing/__init__.py
+++ b/falcon/testing/__init__.py
@@ -13,7 +13,9 @@
 # limitations under the License.
 
 # Hoist classes and functions into the falcon.testing namespace
+from falcon.testing.base import TestBase  # NOQA
 from falcon.testing.helpers import *  # NOQA
+from falcon.testing.resource import capture_responder_args  # NOQA
+from falcon.testing.resource import SimpleTestResource, TestResource  # NOQA
 from falcon.testing.srmock import StartResponseMock  # NOQA
-from falcon.testing.resource import TestResource  # NOQA
-from falcon.testing.base import TestBase  # NOQA
+from falcon.testing.test_case import Result, TestCase  # NOQA
diff --git a/falcon/testing/base.py b/falcon/testing/base.py
index 31aeecc..5fd92b3 100644
--- a/falcon/testing/base.py
+++ b/falcon/testing/base.py
@@ -26,14 +26,22 @@ from falcon.testing.helpers import create_environ
 
 
 class TestBase(unittest.TestCase):
-    """Extends ``testtools.TestCase`` to support WSGI integration testing.
+    """Extends :py:mod:`unittest` to support WSGI functional testing.
 
-    ``TestBase`` provides a base class that provides some extra plumbing to
-    help simulate WSGI calls without having to actually host your API
-    in a server.
+    Warning:
+        This class has been deprecated and will be removed in a future
+        release. Please use :py:class:`~.TestCase`
+        instead.
 
     Note:
-        If ``testtools`` is not available, ``unittest`` is used instead.
+        If available, uses :py:mod:`testtools` in lieu of
+        :py:mod:`unittest`.
+
+    This base class provides some extra plumbing for unittest-style
+    test cases, to help simulate WSGI calls without having to spin up
+    an actual web server. Simply inherit from this class in your test
+    case classes instead of :py:class:`unittest.TestCase` or
+    :py:class:`testtools.TestCase`.
 
     Attributes:
         api (falcon.API): An API instance to target when simulating
@@ -46,6 +54,7 @@ class TestBase(unittest.TestCase):
         test_route (str): A simple, generated path that a test
             can use to add a route to the API.
     """
+
     api_class = falcon.API
     srmock_class = StartResponseMock
 
diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py
index e6f8ca2..38d340e 100644
--- a/falcon/testing/helpers.py
+++ b/falcon/testing/helpers.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import cgi
 import random
 import io
 import sys
@@ -28,6 +29,33 @@ DEFAULT_HOST = 'falconframework.org'
 httpnow = http_now
 
 
+# get_encoding_from_headers() is Copyright 2016 Kenneth Reitz, and is
+# used here under the terms of the Apache License, Version 2.0.
+def get_encoding_from_headers(headers):
+    """Returns encoding from given HTTP Header Dict.
+
+    Args:
+        headers(dict): Dictionary from which to extract encoding. Header
+            names must either be lowercase or the dict must support
+            case-insensitive lookups.
+    """
+
+    content_type = headers.get('content-type')
+
+    if not content_type:
+        return None
+
+    content_type, params = cgi.parse_header(content_type)
+
+    if 'charset' in params:
+        return params['charset'].strip("'\"")
+
+    if 'text' in content_type:
+        return 'ISO-8859-1'
+
+    return None
+
+
 def rand_string(min, max):
     """Returns a randomly-generated string, of a random length.
 
@@ -40,7 +68,7 @@ def rand_string(min, max):
     int_gen = random.randint
     string_length = int_gen(min, max)
     return ''.join([chr(int_gen(ord(' '), ord('~')))
-                    for i in range(string_length)])
+                    for __ in range(string_length)])
 
 
 def create_environ(path='/', query_string='', protocol='HTTP/1.1',
@@ -50,26 +78,28 @@ def create_environ(path='/', query_string='', protocol='HTTP/1.1',
 
     """Creates a mock PEP-3333 environ ``dict`` for simulating WSGI requests.
 
-    Args:
-        path (str, optional): The path for the request (default '/')
-        query_string (str, optional): The query string to simulate, without a
+    Keyword Args:
+        path (str): The path for the request (default '/')
+        query_string (str): The query string to simulate, without a
             leading '?' (default '')
-        protocol (str, optional): The HTTP protocol to simulate
+        protocol (str): The HTTP protocol to simulate
             (default 'HTTP/1.1'). If set to 'HTTP/1.0', the Host header
             will not be added to the environment.
         scheme (str): URL scheme, either 'http' or 'https' (default 'http')
         host(str): Hostname for the request (default 'falconframework.org')
-        port (str or int, optional): The TCP port to simulate. Defaults to
+        port (str): The TCP port to simulate. Defaults to
             the standard port used by the given scheme (i.e., 80 for 'http'
             and 443 for 'https').
-        headers (dict or list, optional): Headers as a ``dict`` or an
-            iterable collection of (*key*, *value*) ``tuple``'s
+        headers (dict): Headers as a ``dict`` or an iterable yielding
+            (*key*, *value*) ``tuple``'s
         app (str): Value for the ``SCRIPT_NAME`` environ variable, described in
             PEP-333: 'The initial portion of the request URL's "path" that
             corresponds to the application object, so that the application
             knows its virtual "location". This may be an empty string, if the
             application corresponds to the "root" of the server.' (default '')
-        body (str or unicode): The body of the request (default '')
+        body (str): The body of the request (default ''). Accepts both byte
+            strings and Unicode strings. Unicode strings are encoded as UTF-8
+            in the request.
         method (str): The HTTP method to use (default 'GET')
         wsgierrors (io): The stream to use as *wsgierrors*
             (default ``sys.stderr``)
@@ -108,6 +138,7 @@ def create_environ(path='/', query_string='', protocol='HTTP/1.1',
         'SERVER_NAME': host,
         'SERVER_PORT': port,
 
+        'wsgi.version': (1, 0),
         'wsgi.url_scheme': scheme,
         'wsgi.input': body,
         'wsgi.errors': wsgierrors or sys.stderr,
@@ -135,7 +166,7 @@ def create_environ(path='/', query_string='', protocol='HTTP/1.1',
     body.seek(0)
 
     if content_length != 0:
-        env['CONTENT_LENGTH'] = content_length
+        env['CONTENT_LENGTH'] = str(content_length)
 
     if headers is not None:
         _add_headers_to_environ(env, headers)
diff --git a/falcon/testing/resource.py b/falcon/testing/resource.py
index 0e11f32..1f80ed9 100644
--- a/falcon/testing/resource.py
+++ b/falcon/testing/resource.py
@@ -12,12 +12,103 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from falcon import HTTP_200
+from json import dumps as json_dumps
+
+import falcon
 from .helpers import rand_string
 
 
-class TestResource:
-    """Mock resource for integration testing.
+def capture_responder_args(req, resp, resource, params):
+    """Before hook for capturing responder arguments.
+
+    Adds the following attributes to the hooked responder's resource
+    class:
+
+        * captured_req
+        * captured_resp
+        * captured_kwargs
+    """
+
+    resource.captured_req = req
+    resource.captured_resp = resp
+    resource.captured_kwargs = params
+
+
+def set_resp_defaults(req, resp, resource, params):
+    """Before hook for setting default response properties."""
+
+    if resource._default_status is not None:
+        resp.status = resource._default_status
+
+    if resource._default_body is not None:
+        resp.body = resource._default_body
+
+    if resource._default_headers is not None:
+        resp.set_headers(resource._default_headers)
+
+
+class SimpleTestResource(object):
+    """Mock resource for functional testing of framework components.
+
+    This class implements a simple test resource that can be extended
+    as needed to test middleware, hooks, and the Falcon framework
+    itself.
+
+    Only the ``on_get()`` responder is implemented; when adding
+    additional responders in child classes, they can be decorated
+    with the :py:meth:`falcon.testing.capture_responder_args` hook in
+    order to capture the *req*, *resp*, and *params* arguments that
+    are passed to the responder. Responders may also be decorated with
+    the :py:meth:`falcon.testing.set_resp_defaults` hook in order to
+    set *resp* properties to default *status*, *body*, and *header*
+    values.
+
+    Keyword Arguments:
+        status (str): Default status string to use in responses
+        body (str): Default body string to use in responses
+        json (dict): Default JSON document to use in responses. Will
+            be serialized to a string and encoded as UTF-8. Either
+            *json* or *body* may be specified, but not both.
+        headers (dict): Default set of additional headers to include in
+            responses
+
+    Attributes:
+        captured_req (falcon.Request): The last Request object passed
+            into any one of the responder methods.
+        captured_resp (falcon.Response): The last Response object passed
+            into any one of the responder methods.
+        captured_kwargs (dict): The last dictionary of kwargs, beyond
+            ``req`` and ``resp``, that were passed into any one of the
+            responder methods.
+    """
+
+    def __init__(self, status=None, body=None, json=None, headers=None):
+        self._default_status = status
+        self._default_headers = headers
+
+        if json is not None:
+            if body is not None:
+                msg = 'Either json or body may be specified, but not both'
+                raise ValueError(msg)
+
+            self._default_body = json_dumps(json, ensure_ascii=False)
+
+        else:
+            self._default_body = body
+
+    @falcon.before(capture_responder_args)
+    @falcon.before(set_resp_defaults)
+    def on_get(self, req, resp, **kwargs):
+        pass
+
+
+class TestResource(object):
+    """Mock resource for functional testing.
+
+    Warning:
+        This class is deprecated and will be removed in a future
+        release. Please use :py:class:`~.SimpleTestResource`
+        instead.
 
     This class implements the `on_get` responder, captures
     request data, and sets response body and headers.
@@ -38,14 +129,12 @@ class TestResource:
             responder, if any.
         called (bool): ``True`` if `on_get` was ever called; ``False``
             otherwise.
-
-
     """
 
     sample_status = "200 OK"
     sample_body = rand_string(0, 128 * 1024)
     resp_headers = {
-        'Content-Type': 'text/plain; charset=utf-8',
+        'Content-Type': 'text/plain; charset=UTF-8',
         'ETag': '10d4555ebeb53b30adf724ca198b32a2',
         'X-Hello': 'OH HAI'
     }
@@ -73,6 +162,6 @@ class TestResource:
         self.req, self.resp, self.kwargs = req, resp, kwargs
 
         self.called = True
-        resp.status = HTTP_200
+        resp.status = falcon.HTTP_200
         resp.body = self.sample_body
         resp.set_headers(self.resp_headers)
diff --git a/falcon/testing/srmock.py b/falcon/testing/srmock.py
index 21e7544..916c662 100644
--- a/falcon/testing/srmock.py
+++ b/falcon/testing/srmock.py
@@ -15,7 +15,7 @@
 from falcon import util
 
 
-class StartResponseMock:
+class StartResponseMock(object):
     """Mock object representing a WSGI `start_response` callable.
 
     Attributes:
diff --git a/falcon/testing/test_case.py b/falcon/testing/test_case.py
new file mode 100644
index 0000000..e809b8b
--- /dev/null
+++ b/falcon/testing/test_case.py
@@ -0,0 +1,332 @@
+# Copyright 2013 by Rackspace Hosting, Inc.
+#
+# 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.
+
+import json
+import wsgiref.validate
+
+try:
+    import testtools as unittest
+except ImportError:  # pragma: nocover
+    import unittest
+
+import falcon
+import falcon.request
+from falcon.util import CaseInsensitiveDict
+from falcon.testing.srmock import StartResponseMock
+from falcon.testing.helpers import create_environ, get_encoding_from_headers
+
+
+class Result(object):
+    """Encapsulates the result of a simulated WSGI request.
+
+    Args:
+        iterable (iterable): An iterable that yields zero or more
+            bytestrings, per PEP-3333
+        status (str): An HTTP status string, including status code and
+            reason string
+        headers (list): A list of (header_name, header_value) tuples,
+            per PEP-3333
+
+    Attributes:
+        status (str): HTTP status string given in the response
+        status_code (int): The code portion of the HTTP status string
+        headers (CaseInsensitiveDict): A case-insensitive dictionary
+            containing all the headers in the response
+        encoding (str): Text encoding of the response body, or ``None``
+            if the encoding can not be determined.
+        data (bytes): Raw response body, or ``bytes`` if the response
+            body was empty.
+        text (str): Decoded response body of type ``unicode``
+            under Python 2.6 and 2.7, and of type ``str`` otherwise.
+            Raises an error if the response encoding can not be
+            determined.
+        json (dict): Deserialized JSON body. Raises an error if the
+            response is not JSON.
+    """
+
+    def __init__(self, iterable, status, headers):
+        self._text = None
+
+        self._data = b''.join(iterable)
+        if hasattr(iterable, 'close'):
+            iterable.close()
+
+        self._status = status
+        self._status_code = int(status[:3])
+        self._headers = CaseInsensitiveDict(headers)
+
+        self._encoding = get_encoding_from_headers(self._headers)
+
+    @property
+    def status(self):
+        return self._status
+
+    @property
+    def status_code(self):
+        return self._status_code
+
+    @property
+    def headers(self):
+        return self._headers
+
+    @property
+    def encoding(self):
+        return self._encoding
+
+    @property
+    def data(self):
+        return self._data
+
+    @property
+    def text(self):
+        if self._text is None:
+            if not self.data:
+                self._text = u''
+            else:
+                if self.encoding is None:
+                    msg = 'Response did not specify a content encoding'
+                    raise RuntimeError(msg)
+
+                self._text = self.data.decode(self.encoding)
+
+        return self._text
+
+    @property
+    def json(self):
+        return json.loads(self.text)
+
+
+class TestCase(unittest.TestCase):
+    """Extends :py:mod:`unittest` to support WSGI functional testing.
+
+    Note:
+        If available, uses :py:mod:`testtools` in lieu of
+        :py:mod:`unittest`.
+
+    This base class provides some extra plumbing for unittest-style
+    test cases, to help simulate WSGI calls without having to spin up
+    an actual web server. Simply inherit from this class in your test
+    case classes instead of :py:class:`unittest.TestCase` or
+    :py:class:`testtools.TestCase`.
+
+    Attributes:
+        api_class (class): An API class to use when instantiating
+            the ``api`` instance (default: :py:class:`falcon.API`)
+        api (object): An API instance to target when simulating
+            requests (default: ``self.api_class()``)
+    """
+
+    api_class = None
+
+    def setUp(self):
+        super(TestCase, self).setUp()
+
+        if self.api_class is None:
+            self.api = falcon.API()
+        else:
+            self.api = self.api_class()  # pylint: disable=not-callable
+
+        # Reset to simulate "restarting" the WSGI container
+        falcon.request._maybe_wrap_wsgi_stream = True
+
+    # NOTE(warsaw): Pythons earlier than 2.7 do not have a
+    # self.assertIn() method, so use this compatibility function
+    # instead.
+    if not hasattr(unittest.TestCase, 'assertIn'):  # pragma: nocover
+        def assertIn(self, a, b):
+            self.assertTrue(a in b)
+
+    def simulate_get(self, path='/', **kwargs):
+        """Simulates a GET request to a WSGI application.
+
+        Equivalent to ``simulate_request('GET', ...)``
+
+        Args:
+            path (str): The URL path to request (default: '/')
+
+        Keyword Args:
+            query_string (str): A raw query string to include in the
+                request (default: ``None``)
+            headers (dict): Additional headers to include in the request
+                (default: ``None``)
+        """
+        return self.simulate_request('GET', path, **kwargs)
+
+    def simulate_head(self, path='/', **kwargs):
+        """Simulates a HEAD request to a WSGI application.
+
+        Equivalent to ``simulate_request('HEAD', ...)``
+
+        Args:
+            path (str): The URL path to request (default: '/')
+
+        Keyword Args:
+            query_string (str): A raw query string to include in the
+                request (default: ``None``)
+            headers (dict): Additional headers to include in the request
+                (default: ``None``)
+        """
+        return self.simulate_request('HEAD', path, **kwargs)
+
+    def simulate_post(self, path='/', **kwargs):
+        """Simulates a POST request to a WSGI application.
+
+        Equivalent to ``simulate_request('POST', ...)``
+
+        Args:
+            path (str): The URL path to request (default: '/')
+
+        Keyword Args:
+            query_string (str): A raw query string to include in the
+                request (default: ``None``)
+            headers (dict): Additional headers to include in the request
+                (default: ``None``)
+            body (str): A string to send as the body of the request.
+                Accepts both byte strings and Unicode strings
+                (default: ``None``). If a Unicode string is provided,
+                it will be encoded as UTF-8 in the request.
+        """
+        return self.simulate_request('POST', path, **kwargs)
+
+    def simulate_put(self, path='/', **kwargs):
+        """Simulates a PUT request to a WSGI application.
+
+        Equivalent to ``simulate_request('PUT', ...)``
+
+        Args:
+            path (str): The URL path to request (default: '/')
+
+        Keyword Args:
+            query_string (str): A raw query string to include in the
+                request (default: ``None``)
+            headers (dict): Additional headers to include in the request
+                (default: ``None``)
+            body (str): A string to send as the body of the request.
+                Accepts both byte strings and Unicode strings
+                (default: ``None``). If a Unicode string is provided,
+                it will be encoded as UTF-8 in the request.
+        """
+        return self.simulate_request('PUT', path, **kwargs)
+
+    def simulate_options(self, path='/', **kwargs):
+        """Simulates an OPTIONS request to a WSGI application.
+
+        Equivalent to ``simulate_request('OPTIONS', ...)``
+
+        Args:
+            path (str): The URL path to request (default: '/')
+
+        Keyword Args:
+            query_string (str): A raw query string to include in the
+                request (default: ``None``)
+            headers (dict): Additional headers to include in the request
+                (default: ``None``)
+        """
+        return self.simulate_request('OPTIONS', path, **kwargs)
+
+    def simulate_patch(self, path='/', **kwargs):
+        """Simulates a PATCH request to a WSGI application.
+
+        Equivalent to ``simulate_request('PATCH', ...)``
+
+        Args:
+            path (str): The URL path to request (default: '/')
+
+        Keyword Args:
+            query_string (str): A raw query string to include in the
+                request (default: ``None``)
+            headers (dict): Additional headers to include in the request
+                (default: ``None``)
+            body (str): A string to send as the body of the request.
+                Accepts both byte strings and Unicode strings
+                (default: ``None``). If a Unicode string is provided,
+                it will be encoded as UTF-8 in the request.
+        """
+        return self.simulate_request('PATCH', path, **kwargs)
+
+    def simulate_delete(self, path='/', **kwargs):
+        """Simulates a DELETE request to a WSGI application.
+
+        Equivalent to ``simulate_request('DELETE', ...)``
+
+        Args:
+            path (str): The URL path to request (default: '/')
+
+        Keyword Args:
+            query_string (str): A raw query string to include in the
+                request (default: ``None``)
+            headers (dict): Additional headers to include in the request
+                (default: ``None``)
+        """
+        return self.simulate_request('DELETE', path, **kwargs)
+
+    def simulate_request(self, method='GET', path='/', query_string=None,
+                         headers=None, body=None, file_wrapper=None):
+        """Simulates a request to a WSGI application.
+
+        Performs a WSGI request directly against ``self.api``.
+
+        Keyword Args:
+            method (str): The HTTP method to use in the request
+                (default: 'GET')
+            path (str): The URL path to request (default: '/')
+            query_string (str): A raw query string to include in the
+                request (default: ``None``)
+            headers (dict): Additional headers to include in the request
+                (default: ``None``)
+            body (str): A string to send as the body of the request.
+                Accepts both byte strings and Unicode strings
+                (default: ``None``). If a Unicode string is provided,
+                it will be encoded as UTF-8 in the request.
+            file_wrapper (callable): Callable that returns an iterable,
+                to be used as the value for *wsgi.file_wrapper* in the
+                environ (default: ``None``).
+
+        Returns:
+            :py:class:`~.Result`: The result of the request
+        """
+
+        if not path.startswith('/'):
+            raise ValueError("path must start with '/'")
+
+        if query_string and query_string.startswith('?'):
+            raise ValueError("query_string should not start with '?'")
+
+        if '?' in path:
+            # NOTE(kgriffs): We could allow this, but then we'd need
+            #   to define semantics regarding whether the path takes
+            #   precedence over the query_string. Also, it would make
+            #   tests less consistent, since there would be "more than
+            #   one...way to do it."
+            raise ValueError(
+                'path may not contain a query string. Please use the '
+                'query_string parameter instead.'
+            )
+
+        env = create_environ(
+            method=method,
+            path=path,
+            query_string=(query_string or ''),
+            headers=headers,
+            body=body,
+            file_wrapper=file_wrapper,
+        )
+
+        srmock = StartResponseMock()
+        validator = wsgiref.validate.validator(self.api)
+        iterable = validator(env, srmock)
+
+        result = Result(iterable, srmock.status, srmock.headers)
+
+        return result
diff --git a/tests/test_after_hooks.py b/tests/test_after_hooks.py
index b9b1307..c4b4ada 100644
--- a/tests/test_after_hooks.py
+++ b/tests/test_after_hooks.py
@@ -2,7 +2,12 @@ import functools
 import json
 
 import falcon
-import falcon.testing as testing
+from falcon import testing
+
+
+# --------------------------------------------------------------------
+# Hooks
+# --------------------------------------------------------------------
 
 
 def validate_output(req, resp):
@@ -71,6 +76,11 @@ def cuteness_in_the_head(req, resp):
     resp.set_header('X-Cuteness', 'cute')
 
 
+# --------------------------------------------------------------------
+# Resources
+# --------------------------------------------------------------------
+
+
 class WrappedRespondersResource(object):
 
     @falcon.after(serialize_body)
@@ -163,15 +173,18 @@ class FaultyResource(object):
         raise falcon.HTTPError(falcon.HTTP_743, 'Query failed')
 
 
-class TestHooks(testing.TestBase):
+# --------------------------------------------------------------------
+# Tests
+# --------------------------------------------------------------------
 
-    def simulate_request(self, *args, **kwargs):
-        return super(TestHooks, self).simulate_request(
-            *args, decode='utf-8', **kwargs)
 
-    def before(self):
+class TestHooks(testing.TestCase):
+
+    def setUp(self):
+        super(TestHooks, self).setUp()
+
         self.resource = WrappedRespondersResource()
-        self.api.add_route(self.test_route, self.resource)
+        self.api.add_route('/', self.resource)
 
         self.wrapped_resource = WrappedClassResource()
         self.api.add_route('/wrapped', self.wrapped_resource)
@@ -179,157 +192,145 @@ class TestHooks(testing.TestBase):
         self.wrapped_resource_aware = ClassResourceWithAwareHooks()
         self.api.add_route('/wrapped_aware', self.wrapped_resource_aware)
 
-    def test_global_hook(self):
-        self.assertRaises(TypeError, falcon.API, None, {})
-        self.assertRaises(TypeError, falcon.API, None, 0)
+    def test_output_validator(self):
+        result = self.simulate_get()
+        self.assertEqual(result.status_code, 723)
+        self.assertEqual(result.text, '{\n    "title": "Tricky"\n}')
 
-        self.api = falcon.API(after=fluffiness)
-        zoo_resource = ZooResource()
+    def test_serializer(self):
+        result = self.simulate_put()
+        self.assertEqual('{"animal": "falcon"}', result.text)
 
-        self.api.add_route(self.test_route, zoo_resource)
+    def test_hook_as_callable_class(self):
+        result = self.simulate_post()
+        self.assertEqual('smart', result.text)
 
-        result = self.simulate_request(self.test_route)
-        self.assertEqual(u'fluffy', result)
+    def test_wrapped_resource(self):
+        result = self.simulate_get('/wrapped')
+        self.assertEqual(result.status_code, 200)
+        self.assertEqual(result.text, 'fluffy and cute', )
 
-    def test_global_hook_is_resource_aware(self):
-        self.assertRaises(TypeError, falcon.API, None, {})
-        self.assertRaises(TypeError, falcon.API, None, 0)
+        result = self.simulate_head('/wrapped')
+        self.assertEqual(result.status_code, 200)
 
-        self.api = falcon.API(after=resource_aware_fluffiness)
-        zoo_resource = ZooResource()
+        result = self.simulate_post('/wrapped')
+        self.assertEqual(result.status_code, 405)
 
-        self.api.add_route(self.test_route, zoo_resource)
+        result = self.simulate_patch('/wrapped')
+        self.assertEqual(result.status_code, 405)
 
-        result = self.simulate_request(self.test_route)
-        self.assertEqual(u'fluffy', result)
+        # Decorator should not affect the default on_options responder
+        result = self.simulate_options('/wrapped')
+        self.assertEqual(result.status_code, 204)
+        self.assertFalse(result.text)
+
+    def test_wrapped_resource_with_hooks_aware_of_resource(self):
+        expected = 'fluffy and cute'
+
+        result = self.simulate_get('/wrapped_aware')
+        self.assertEqual(result.status_code, 200)
+        self.assertEqual(expected, result.text)
+
+        for test in (self.simulate_head, self.simulate_put, self.simulate_post):
+            result = test('/wrapped_aware')
+            self.assertEqual(result.status_code, 200)
+            self.assertEqual(self.wrapped_resource_aware.resp.body, expected)
+
+        result = self.simulate_patch('/wrapped_aware')
+        self.assertEqual(result.status_code, 405)
+
+        # Decorator should not affect the default on_options responder
+        result = self.simulate_options('/wrapped_aware')
+        self.assertEqual(result.status_code, 204)
+        self.assertFalse(result.text)
+
+
+class TestGlobalHooks(testing.TestCase):
+
+    def test_invalid_type(self):
+        self.assertRaises(TypeError, falcon.API, after={})
+        self.assertRaises(TypeError, falcon.API, after=0)
+
+    def test_global_hook(self):
+        self.api = falcon.API(after=fluffiness)
+        self.api.add_route('/', ZooResource())
+
+        result = self.simulate_get()
+        self.assertEqual(result.text, 'fluffy')
+
+    def test_global_hook_is_resource_aware(self):
+        self.api = falcon.API(after=resource_aware_fluffiness)
+        self.api.add_route('/', ZooResource())
+
+        result = self.simulate_get()
+        self.assertEqual(result.text, 'fluffy')
 
     def test_multiple_global_hook(self):
         self.api = falcon.API(after=[fluffiness, cuteness, Smartness()])
-        zoo_resource = ZooResource()
-
-        self.api.add_route(self.test_route, zoo_resource)
+        self.api.add_route('/', ZooResource())
 
-        result = self.simulate_request(self.test_route)
-        self.assertEqual(u'fluffy and cute and smart', result)
+        result = self.simulate_get()
+        self.assertEqual(result.text, 'fluffy and cute and smart')
 
     def test_global_hook_wrap_default_on_options(self):
         self.api = falcon.API(after=fluffiness_in_the_head)
-        zoo_resource = ZooResource()
+        self.api.add_route('/', ZooResource())
 
-        self.api.add_route(self.test_route, zoo_resource)
+        result = self.simulate_options()
 
-        self.simulate_request(self.test_route, method='OPTIONS')
-
-        self.assertEqual(falcon.HTTP_204, self.srmock.status)
-        self.assertEqual('fluffy', self.srmock.headers_dict['X-Fluffiness'])
+        self.assertEqual(result.status_code, 204)
+        self.assertEqual(result.headers['X-Fluffiness'], 'fluffy')
 
     def test_global_hook_wrap_default_405(self):
         self.api = falcon.API(after=fluffiness_in_the_head)
-        zoo_resource = ZooResource()
-
-        self.api.add_route(self.test_route, zoo_resource)
+        self.api.add_route('/', ZooResource())
 
-        self.simulate_request(self.test_route, method='POST')
+        result = self.simulate_post()
 
-        self.assertEqual(falcon.HTTP_405, self.srmock.status)
-        self.assertEqual('fluffy', self.srmock.headers_dict['X-Fluffiness'])
+        self.assertEqual(result.status_code, 405)
+        self.assertEqual(result.headers['X-Fluffiness'], 'fluffy')
 
     def test_multiple_global_hooks_wrap_default_on_options(self):
         self.api = falcon.API(after=[fluffiness_in_the_head,
                                      cuteness_in_the_head])
-        zoo_resource = ZooResource()
 
-        self.api.add_route(self.test_route, zoo_resource)
+        self.api.add_route('/', ZooResource())
 
-        self.simulate_request(self.test_route, method='OPTIONS')
+        result = self.simulate_options()
 
-        self.assertEqual(falcon.HTTP_204, self.srmock.status)
-        self.assertEqual('fluffy', self.srmock.headers_dict['X-Fluffiness'])
-        self.assertEqual('cute', self.srmock.headers_dict['X-Cuteness'])
+        self.assertEqual(result.status_code, 204)
+        self.assertEqual(result.headers['X-Fluffiness'], 'fluffy')
+        self.assertEqual(result.headers['X-Cuteness'], 'cute')
 
     def test_multiple_global_hooks_wrap_default_405(self):
         self.api = falcon.API(after=[fluffiness_in_the_head,
                                      cuteness_in_the_head])
-        zoo_resource = ZooResource()
 
-        self.api.add_route(self.test_route, zoo_resource)
+        self.api.add_route('/', ZooResource())
 
-        self.simulate_request(self.test_route, method='POST')
+        result = self.simulate_post()
 
-        self.assertEqual(falcon.HTTP_405, self.srmock.status)
-        self.assertEqual('fluffy', self.srmock.headers_dict['X-Fluffiness'])
-        self.assertEqual('cute', self.srmock.headers_dict['X-Cuteness'])
+        self.assertEqual(result.status_code, 405)
+        self.assertEqual(result.headers['X-Fluffiness'], 'fluffy')
+        self.assertEqual(result.headers['X-Cuteness'], 'cute')
 
     def test_global_after_hooks_run_after_exception(self):
         self.api = falcon.API(after=[fluffiness,
                                      resource_aware_cuteness,
                                      Smartness()])
 
-        self.api.add_route(self.test_route, FaultyResource())
-
-        actual_body = self.simulate_request(self.test_route)
-        self.assertEqual(falcon.HTTP_743, self.srmock.status)
-        self.assertEqual(u'fluffy and cute and smart', actual_body)
+        self.api.add_route('/', FaultyResource())
 
-    def test_output_validator(self):
-        actual_body = self.simulate_request(self.test_route)
-        self.assertEqual(falcon.HTTP_723, self.srmock.status)
-        self.assertEqual(u'{\n    "title": "Tricky"\n}', actual_body)
-
-    def test_serializer(self):
-        actual_body = self.simulate_request(self.test_route, method='PUT')
-
-        self.assertEqual(u'{"animal": "falcon"}', actual_body)
-
-    def test_hook_as_callable_class(self):
-        actual_body = self.simulate_request(self.test_route, method='POST')
-        self.assertEqual(u'smart', actual_body)
-
-    def test_wrapped_resource(self):
-        actual_body = self.simulate_request('/wrapped')
-        self.assertEqual(falcon.HTTP_200, self.srmock.status)
-        self.assertEqual(u'fluffy and cute', actual_body)
-
-        self.simulate_request('/wrapped', method='HEAD')
-        self.assertEqual(falcon.HTTP_200, self.srmock.status)
-
-        self.simulate_request('/wrapped', method='POST')
-        self.assertEqual(falcon.HTTP_405, self.srmock.status)
-
-        self.simulate_request('/wrapped', method='PATCH')
-        self.assertEqual(falcon.HTTP_405, self.srmock.status)
-
-        # decorator does not affect the default on_options
-        body = self.simulate_request('/wrapped', method='OPTIONS')
-        self.assertEqual(falcon.HTTP_204, self.srmock.status)
-        self.assertEqual(u'', body)
-
-    def test_wrapped_resource_with_hooks_aware_of_resource(self):
-        expected = u'fluffy and cute'
-
-        actual_body = self.simulate_request('/wrapped_aware')
-        self.assertEqual(falcon.HTTP_200, self.srmock.status)
-        self.assertEqual(expected, actual_body)
-
-        for method in ('HEAD', 'PUT', 'POST'):
-            self.simulate_request('/wrapped_aware', method=method)
-            self.assertEqual(falcon.HTTP_200, self.srmock.status)
-            self.assertEqual(expected, self.wrapped_resource_aware.resp.body)
-
-        self.simulate_request('/wrapped_aware', method='PATCH')
-        self.assertEqual(falcon.HTTP_405, self.srmock.status)
-
-        # decorator does not affect the default on_options
-        body = self.simulate_request('/wrapped_aware', method='OPTIONS')
-        self.assertEqual(falcon.HTTP_204, self.srmock.status)
-        self.assertEqual(u'', body)
+        result = self.simulate_get()
+        self.assertEqual(result.status_code, 743)
+        self.assertEqual(result.text, 'fluffy and cute and smart')
 
     def test_customized_options(self):
         self.api = falcon.API(after=fluffiness)
-
         self.api.add_route('/one', SingleResource())
 
-        body = self.simulate_request('/one', method='OPTIONS')
-        self.assertEqual(falcon.HTTP_501, self.srmock.status)
-        self.assertEqual(u'fluffy', body)
-        self.assertNotIn('allow', self.srmock.headers_dict)
+        result = self.simulate_options('/one')
+
+        self.assertEqual(result.status_code, 501)
+        self.assertEqual(result.text, 'fluffy')
+        self.assertNotIn(result.headers, 'allow')
diff --git a/tests/test_error_handlers.py b/tests/test_error_handlers.py
index c87500e..30d155a 100644
--- a/tests/test_error_handlers.py
+++ b/tests/test_error_handlers.py
@@ -1,7 +1,5 @@
-import json
-
 import falcon
-import falcon.testing as testing
+from falcon import testing
 
 
 def capture_error(ex, req, resp, params):
@@ -31,6 +29,7 @@ class CustomException(CustomBaseException):
 
 
 class ErroredClassResource(object):
+
     def on_get(self, req, resp):
         raise Exception('Plain Exception')
 
@@ -41,44 +40,35 @@ class ErroredClassResource(object):
         raise CustomException('CustomException')
 
 
-class TestErrorHandler(testing.TestBase):
+class TestErrorHandler(testing.TestCase):
+
+    def setUp(self):
+        super(TestErrorHandler, self).setUp()
+        self.api.add_route('/', ErroredClassResource())
 
     def test_caught_error(self):
         self.api.add_error_handler(Exception, capture_error)
 
-        self.api.add_route(self.test_route, ErroredClassResource())
+        result = self.simulate_get()
+        self.assertEqual(result.text, 'error: Plain Exception')
 
-        body = self.simulate_request(self.test_route)
-        self.assertEqual([b'error: Plain Exception'], body)
-
-        body = self.simulate_request(self.test_route, method='HEAD')
-        self.assertEqual(falcon.HTTP_723, self.srmock.status)
-        self.assertEqual([], body)
+        result = self.simulate_head()
+        self.assertEqual(result.status_code, 723)
+        self.assertFalse(result.data)
 
     def test_uncaught_error(self):
         self.api.add_error_handler(CustomException, capture_error)
-
-        self.api.add_route(self.test_route, ErroredClassResource())
-
-        self.assertRaises(Exception,
-                          self.simulate_request, self.test_route)
+        self.assertRaises(Exception, self.simulate_get)
 
     def test_uncaught_error_else(self):
-        self.api.add_route(self.test_route, ErroredClassResource())
-
-        self.assertRaises(Exception,
-                          self.simulate_request, self.test_route)
+        self.assertRaises(Exception, self.simulate_get)
 
     def test_converted_error(self):
         self.api.add_error_handler(CustomException)
 
-        self.api.add_route(self.test_route, ErroredClassResource())
-
-        body = self.simulate_request(self.test_route, method='DELETE')
-        self.assertEqual(falcon.HTTP_792, self.srmock.status)
-
-        info = json.loads(body[0].decode())
-        self.assertEqual('Internet crashed!', info['title'])
+        result = self.simulate_delete()
+        self.assertEqual(result.status_code, 792)
+        self.assertEqual(result.json[u'title'], u'Internet crashed!')
 
     def test_handle_not_defined(self):
         self.assertRaises(AttributeError,
@@ -87,17 +77,13 @@ class TestErrorHandler(testing.TestBase):
     def test_subclass_error(self):
         self.api.add_error_handler(CustomBaseException, capture_error)
 
-        self.api.add_route(self.test_route, ErroredClassResource())
-
-        body = self.simulate_request(self.test_route, method='DELETE')
-        self.assertEqual(falcon.HTTP_723, self.srmock.status)
-        self.assertEqual([b'error: CustomException'], body)
+        result = self.simulate_delete()
+        self.assertEqual(result.status_code, 723)
+        self.assertEqual(result.text, 'error: CustomException')
 
     def test_error_order(self):
         self.api.add_error_handler(Exception, capture_error)
         self.api.add_error_handler(Exception, handle_error_first)
 
-        self.api.add_route(self.test_route, ErroredClassResource())
-
-        body = self.simulate_request(self.test_route)
-        self.assertEqual([b'first error handler'], body)
+        result = self.simulate_get()
+        self.assertEqual(result.text, 'first error handler')
diff --git a/tests/test_headers.py b/tests/test_headers.py
index 7b2f82c..fda337c 100644
--- a/tests/test_headers.py
+++ b/tests/test_headers.py
@@ -1,25 +1,14 @@
 from collections import defaultdict
 from datetime import datetime
 
+import ddt
 import six
-from testtools.matchers import Contains, Not
 
 import falcon
-import falcon.testing as testing
+from falcon import testing
 
 
-class StatusTestResource:
-    sample_body = testing.rand_string(0, 128 * 1024)
-
-    def __init__(self, status):
-        self.status = status
-
-    def on_get(self, req, resp):
-        resp.status = self.status
-        resp.body = self.sample_body
-
-
-class XmlResource:
+class XmlResource(object):
     def __init__(self, content_type):
         self.content_type = content_type
 
@@ -27,16 +16,7 @@ class XmlResource:
         resp.set_header('content-type', self.content_type)
 
 
-class DefaultContentTypeResource:
-    def __init__(self, body=None):
-        self.body = body
-
-    def on_get(self, req, resp):
-        if self.body is not None:
-            resp.body = self.body
-
-
-class HeaderHelpersResource:
+class HeaderHelpersResource(object):
 
     def __init__(self, last_modified=None):
         if last_modified is not None:
@@ -106,7 +86,7 @@ class HeaderHelpersResource:
         self.resp = resp
 
 
-class LocationHeaderUnicodeResource:
+class LocationHeaderUnicodeResource(object):
 
     URL1 = u'/\u00e7runchy/bacon'
     URL2 = u'ab\u00e7' if six.PY3 else 'ab\xc3\xa7'
@@ -120,18 +100,25 @@ class LocationHeaderUnicodeResource:
         resp.content_location = self.URL1
 
 
-class UnicodeHeaderResource:
+class UnicodeHeaderResource(object):
 
     def on_get(self, req, resp):
         resp.set_headers([
             (u'X-auTH-toKEN', 'toomanysecrets'),
             ('Content-TYpE', u'application/json'),
-            (u'X-symBOl', u'\u0040'),
-            (u'X-symb\u00F6l', u'\u00FF'),
+            (u'X-symBOl', u'@'),
+
+            # TODO(kgriffs): This will cause the wsgiref validator
+            # to raise an error. Falcon itself does not currently
+            # check for non-ASCII chars to save some CPU cycles. The
+            # app is responsible for doing the right thing, and
+            # validating its own output as needed.
+            #
+            # (u'X-symb\u00F6l', u'\u00FF'),
         ])
 
 
-class VaryHeaderResource:
+class VaryHeaderResource(object):
 
     def __init__(self, vary):
         self.vary = vary
@@ -141,7 +128,7 @@ class VaryHeaderResource:
         resp.vary = self.vary
 
 
-class LinkHeaderResource:
+class LinkHeaderResource(object):
 
     def __init__(self):
         self._links = []
@@ -156,7 +143,7 @@ class LinkHeaderResource:
             resp.add_link(*args, **kwargs)
 
 
-class AppendHeaderResource:
+class AppendHeaderResource(object):
 
     def on_get(self, req, resp):
         resp.append_header('X-Things', 'thing-1')
@@ -172,33 +159,35 @@ class AppendHeaderResource:
         resp.append_header('X-Things', 'thing-1')
 
 
-class TestHeaders(testing.TestBase):
+@ddt.ddt
+class TestHeaders(testing.TestCase):
 
-    def before(self):
-        self.resource = testing.TestResource()
-        self.api.add_route(self.test_route, self.resource)
+    def setUp(self):
+        super(TestHeaders, self).setUp()
 
-    def test_content_length(self):
-        self.simulate_request(self.test_route)
+        self.sample_body = testing.rand_string(0, 128 * 1024)
+        self.resource = testing.SimpleTestResource(body=self.sample_body)
+        self.api.add_route('/', self.resource)
 
-        headers = self.srmock.headers
+    def test_content_length(self):
+        result = self.simulate_get()
 
-        # Test Content-Length header set
-        content_length = str(len(self.resource.sample_body))
-        content_length_header = ('content-length', content_length)
-        self.assertThat(headers, Contains(content_length_header))
+        content_length = str(len(self.sample_body))
+        self.assertEqual(result.headers['Content-Length'], content_length)
 
     def test_default_value(self):
-        self.simulate_request(self.test_route)
+        self.simulate_get()
 
-        value = self.resource.req.get_header('X-Not-Found') or '876'
+        req = self.resource.captured_req
+        value = req.get_header('X-Not-Found') or '876'
         self.assertEqual(value, '876')
 
     def test_required_header(self):
-        self.simulate_request(self.test_route)
+        self.simulate_get()
 
         try:
-            self.resource.req.get_header('X-Not-Found', required=True)
+            req = self.resource.captured_req
+            req.get_header('X-Not-Found', required=True)
             self.fail('falcon.HTTPMissingHeader not raised')
         except falcon.HTTPMissingHeader as ex:
             self.assertIsInstance(ex, falcon.HTTPBadRequest)
@@ -206,45 +195,13 @@ class TestHeaders(testing.TestBase):
             expected_desc = 'The X-Not-Found header is required.'
             self.assertEqual(ex.description, expected_desc)
 
-    def test_no_body_on_100(self):
-        self.resource = StatusTestResource(falcon.HTTP_100)
-        self.api.add_route('/1xx', self.resource)
-
-        body = self.simulate_request('/1xx')
-        self.assertThat(self.srmock.headers_dict,
-                        Not(Contains('Content-Length')))
+    @ddt.data(falcon.HTTP_204, falcon.HTTP_304)
+    def test_no_content_length(self, status):
+        self.api.add_route('/xxx', testing.SimpleTestResource(status=status))
 
-        self.assertEqual(body, [])
-
-    def test_no_body_on_101(self):
-        self.resource = StatusTestResource(falcon.HTTP_101)
-        self.api.add_route('/1xx', self.resource)
-
-        body = self.simulate_request('/1xx')
-        self.assertThat(self.srmock.headers_dict,
-                        Not(Contains('Content-Length')))
-
-        self.assertEqual(body, [])
-
-    def test_no_body_on_204(self):
-        self.resource = StatusTestResource(falcon.HTTP_204)
-        self.api.add_route('/204', self.resource)
-
-        body = self.simulate_request('/204')
-        self.assertThat(self.srmock.headers_dict,
-                        Not(Contains('Content-Length')))
-
-        self.assertEqual(body, [])
-
-    def test_no_body_on_304(self):
-        self.resource = StatusTestResource(falcon.HTTP_304)
-        self.api.add_route('/304', self.resource)
-
-        body = self.simulate_request('/304')
-        self.assertThat(self.srmock.headers_dict,
-                        Not(Contains('Content-Length')))
-
-        self.assertEqual(body, [])
+        result = self.simulate_get('/xxx')
+        self.assertNotIn('Content-Length', result.headers)
+        self.assertFalse(result.data)
 
     def test_content_header_missing(self):
         environ = testing.create_environ()
@@ -252,248 +209,228 @@ class TestHeaders(testing.TestBase):
         for header in ('Content-Type', 'Content-Length'):
             self.assertIs(req.get_header(header), None)
 
-    def test_passthrough_req_headers(self):
-        req_headers = {
+    def test_passthrough_request_headers(self):
+        request_headers = {
             'X-Auth-Token': 'Setec Astronomy',
             'Content-Type': 'text/plain; charset=utf-8'
         }
-        self.simulate_request(self.test_route, headers=req_headers)
+        self.simulate_get(headers=request_headers)
 
-        for name, expected_value in req_headers.items():
-            actual_value = self.resource.req.get_header(name)
+        for name, expected_value in request_headers.items():
+            actual_value = self.resource.captured_req.get_header(name)
             self.assertEqual(actual_value, expected_value)
 
-        self.simulate_request(self.test_route,
-                              headers=self.resource.req.headers)
+        self.simulate_get(headers=self.resource.captured_req.headers)
 
         # Compare the request HTTP headers with the original headers
-        for name, expected_value in req_headers.items():
-            actual_value = self.resource.req.get_header(name)
+        for name, expected_value in request_headers.items():
+            actual_value = self.resource.captured_req.get_header(name)
             self.assertEqual(actual_value, expected_value)
 
-    def test_get_raw_headers(self):
+    def test_headers_as_list(self):
         headers = [
             ('Client-ID', '692ba466-74bb-11e3-bf3f-7567c531c7ca'),
             ('Accept', 'audio/*; q=0.2, audio/basic')
         ]
 
+        # Unit test
         environ = testing.create_environ(headers=headers)
         req = falcon.Request(environ)
 
         for name, value in headers:
             self.assertIn((name.upper(), value), req.headers.items())
 
-    def test_passthrough_resp_headers(self):
-        self.simulate_request(self.test_route)
+        # Functional test
+        self.api.add_route('/', testing.SimpleTestResource(headers=headers))
+        result = self.simulate_get()
 
-        resp_headers = self.srmock.headers
-
-        for name, value in self.resource.resp_headers.items():
-            expected = (name.lower(), value)
-            self.assertThat(resp_headers, Contains(expected))
+        for name, value in headers:
+            self.assertEqual(result.headers[name], value)
 
     def test_default_media_type(self):
-        self.resource = DefaultContentTypeResource('Hello world!')
-        self.api.add_route(self.test_route, self.resource)
-        self.simulate_request(self.test_route)
+        resource = testing.SimpleTestResource(body='Hello world!')
+        self._check_header(resource, 'Content-Type', falcon.DEFAULT_MEDIA_TYPE)
 
-        content_type = falcon.DEFAULT_MEDIA_TYPE
-        self.assertIn(('content-type', content_type), self.srmock.headers)
+    @ddt.data(
+        ('text/plain; charset=UTF-8', u'Hello Unicode! \U0001F638'),
 
-    def test_custom_media_type(self):
-        self.resource = DefaultContentTypeResource('Hello world!')
-        self.api = falcon.API(media_type='application/atom+xml')
-        self.api.add_route(self.test_route, self.resource)
-        self.simulate_request(self.test_route)
+        # NOTE(kgriffs): This only works because the client defaults to
+        # ISO-8859-1 IFF the media type is 'text'.
+        ('text/plain', 'Hello ISO-8859-1!'),
+    )
+    @ddt.unpack
+    def test_override_default_media_type(self, content_type, body):
+        self.api = falcon.API(media_type=content_type)
+        self.api.add_route('/', testing.SimpleTestResource(body=body))
+        result = self.simulate_get()
 
-        content_type = 'application/atom+xml'
-        self.assertIn(('content-type', content_type), self.srmock.headers)
+        self.assertEqual(result.text, body)
+        self.assertEqual(result.headers['Content-Type'], content_type)
+
+    def test_override_default_media_type_missing_encoding(self):
+        body = b'{}'
+
+        self.api = falcon.API(media_type='application/json')
+        self.api.add_route('/', testing.SimpleTestResource(body=body))
+        result = self.simulate_get()
+
+        self.assertEqual(result.data, body)
+        self.assertRaises(RuntimeError, lambda: result.text)
+        self.assertRaises(RuntimeError, lambda: result.json)
 
     def test_response_header_helpers_on_get(self):
         last_modified = datetime(2013, 1, 1, 10, 30, 30)
-        self.resource = HeaderHelpersResource(last_modified)
-        self.api.add_route(self.test_route, self.resource)
-        self.simulate_request(self.test_route)
+        resource = HeaderHelpersResource(last_modified)
+        self.api.add_route('/', resource)
+        result = self.simulate_get()
 
-        resp = self.resource.resp
+        resp = resource.resp
 
         content_type = 'x-falcon/peregrine'
-        self.assertEqual(content_type, resp.content_type)
-        self.assertIn(('content-type', content_type), self.srmock.headers)
+        self.assertEqual(resp.content_type, content_type)
+        self.assertEqual(result.headers['Content-Type'], content_type)
 
         cache_control = ('public, private, no-cache, no-store, '
                          'must-revalidate, proxy-revalidate, max-age=3600, '
                          's-maxage=60, no-transform')
 
-        self.assertEqual(cache_control, resp.cache_control)
-        self.assertIn(('cache-control', cache_control), self.srmock.headers)
+        self.assertEqual(resp.cache_control, cache_control)
+        self.assertEqual(result.headers['Cache-Control'], cache_control)
 
         etag = 'fa0d1a60ef6616bb28038515c8ea4cb2'
-        self.assertEqual(etag, resp.etag)
-        self.assertIn(('etag', etag), self.srmock.headers)
+        self.assertEqual(resp.etag, etag)
+        self.assertEqual(result.headers['Etag'], etag)
 
-        last_modified_http_date = 'Tue, 01 Jan 2013 10:30:30 GMT'
-        self.assertEqual(last_modified_http_date, resp.last_modified)
-        self.assertIn(('last-modified', last_modified_http_date),
-                      self.srmock.headers)
+        lm_date = 'Tue, 01 Jan 2013 10:30:30 GMT'
+        self.assertEqual(resp.last_modified, lm_date)
+        self.assertEqual(result.headers['Last-Modified'], lm_date)
 
-        self.assertEqual('3601', resp.retry_after)
-        self.assertIn(('retry-after', '3601'), self.srmock.headers)
+        self.assertEqual(resp.retry_after, '3601')
+        self.assertEqual(result.headers['Retry-After'], '3601')
 
-        self.assertEqual('/things/87', resp.location)
-        self.assertIn(('location', '/things/87'), self.srmock.headers)
+        self.assertEqual(resp.location, '/things/87')
+        self.assertEqual(result.headers['Location'], '/things/87')
 
-        self.assertEqual('/things/78', resp.content_location)
-        self.assertIn(('content-location', '/things/78'), self.srmock.headers)
+        self.assertEqual(resp.content_location, '/things/78')
+        self.assertEqual(result.headers['Content-Location'], '/things/78')
 
-        self.assertEqual('bytes 0-499/10240', resp.content_range)
-        self.assertIn(('content-range', 'bytes 0-499/10240'),
-                      self.srmock.headers)
+        content_range = 'bytes 0-499/10240'
+        self.assertEqual(resp.content_range, content_range)
+        self.assertEqual(result.headers['Content-Range'], content_range)
 
-        resp.content_range = (0, 499, 10 * 1024, 'bytes')
-        self.assertEqual('bytes 0-499/10240', resp.content_range)
-        self.assertIn(('content-range', 'bytes 0-499/10240'),
-                      self.srmock.headers)
+        resp.content_range = (1, 499, 10 * 1024, 'bytes')
+        self.assertEqual(resp.content_range, 'bytes 1-499/10240')
 
-        req_headers = {
-            'Range': 'items=0-25',
-        }
-        self.simulate_request(self.test_route, headers=req_headers)
-
-        resp.content_range = (0, 25, 100, 'items')
-        self.assertEqual('items 0-25/100', resp.content_range)
-        self.assertIn(('content-range', 'items 0-25/100'),
-                      self.srmock.headers)
+        req_headers = {'Range': 'items=0-25'}
+        result = self.simulate_get(headers=req_headers)
+        self.assertEqual(result.headers['Content-Range'], 'items 0-25/100')
 
         # Check for duplicate headers
         hist = defaultdict(lambda: 0)
-        for name, value in self.srmock.headers:
+        for name, value in result.headers.items():
             hist[name] += 1
             self.assertEqual(1, hist[name])
 
     def test_unicode_location_headers(self):
-        self.api.add_route(self.test_route, LocationHeaderUnicodeResource())
-        self.simulate_request(self.test_route)
-
-        location = ('location', '/%C3%A7runchy/bacon')
-        self.assertIn(location, self.srmock.headers)
+        self.api.add_route('/', LocationHeaderUnicodeResource())
 
-        content_location = ('content-location', 'ab%C3%A7')
-        self.assertIn(content_location, self.srmock.headers)
+        result = self.simulate_get()
+        self.assertEqual(result.headers['Location'], '/%C3%A7runchy/bacon')
+        self.assertEqual(result.headers['Content-Location'], 'ab%C3%A7')
 
         # Test with the values swapped
-        self.simulate_request(self.test_route, method='HEAD')
-
-        location = ('location', 'ab%C3%A7')
-        self.assertIn(location, self.srmock.headers)
-
-        content_location = ('content-location', '/%C3%A7runchy/bacon')
-        self.assertIn(content_location, self.srmock.headers)
+        result = self.simulate_head()
+        self.assertEqual(result.headers['Content-Location'],
+                         '/%C3%A7runchy/bacon')
+        self.assertEqual(result.headers['Location'], 'ab%C3%A7')
 
     def test_unicode_headers(self):
-        self.api.add_route(self.test_route, UnicodeHeaderResource())
-        self.simulate_request(self.test_route)
-
-        expect = ('x-auth-token', 'toomanysecrets')
-        self.assertIn(expect, self.srmock.headers)
+        self.api.add_route('/', UnicodeHeaderResource())
 
-        expect = ('content-type', 'application/json')
-        self.assertIn(expect, self.srmock.headers)
+        result = self.simulate_get('/')
 
-        expect = ('x-symbol', '@')
-        self.assertIn(expect, self.srmock.headers)
-
-        expect = ('x-symb\xF6l', '\xFF')
-        self.assertIn(expect, self.srmock.headers)
+        self.assertEqual(result.headers['Content-Type'], 'application/json')
+        self.assertEqual(result.headers['X-Auth-Token'], 'toomanysecrets')
+        self.assertEqual(result.headers['X-Symbol'], '@')
 
     def test_response_set_and_get_header(self):
-        self.resource = HeaderHelpersResource()
-        self.api.add_route(self.test_route, self.resource)
+        resource = HeaderHelpersResource()
+        self.api.add_route('/', resource)
 
         for method in ('HEAD', 'POST', 'PUT'):
-            self.simulate_request(self.test_route, method=method)
+            result = self.simulate_request(method=method)
 
             content_type = 'x-falcon/peregrine'
-            self.assertIn(('content-type', content_type), self.srmock.headers)
-            self.assertEquals(self.resource.resp.get_header('content-TyPe'), content_type)
-            self.assertIn(('cache-control', 'no-store'), self.srmock.headers)
-            self.assertIn(('x-auth-token', 'toomanysecrets'),
-                          self.srmock.headers)
+            self.assertEqual(result.headers['Content-Type'], content_type)
+            self.assertEqual(resource.resp.get_header('content-TyPe'),
+                             content_type)
+
+            self.assertEqual(result.headers['Cache-Control'], 'no-store')
+            self.assertEqual(result.headers['X-Auth-Token'], 'toomanysecrets')
 
-            self.assertEqual(None, self.resource.resp.location)
-            self.assertEquals(self.resource.resp.get_header('not-real'), None)
+            self.assertEqual(resource.resp.location, None)
+            self.assertEqual(resource.resp.get_header('not-real'), None)
 
             # Check for duplicate headers
-            hist = defaultdict(lambda: 0)
-            for name, value in self.srmock.headers:
+            hist = defaultdict(int)
+            for name, value in result.headers.items():
                 hist[name] += 1
-                self.assertEqual(1, hist[name])
+                self.assertEqual(hist[name], 1)
 
     def test_response_append_header(self):
-        self.resource = AppendHeaderResource()
-        self.api.add_route(self.test_route, self.resource)
+        self.api.add_route('/', AppendHeaderResource())
 
         for method in ('HEAD', 'GET'):
-            self.simulate_request(self.test_route, method=method)
-            value = self.srmock.headers_dict['x-things']
-            self.assertEqual('thing-1,thing-2,thing-3', value)
+            result = self.simulate_request(method=method)
+            value = result.headers['x-things']
+            self.assertEqual(value, 'thing-1,thing-2,thing-3')
 
-        self.simulate_request(self.test_route, method='POST')
-        value = self.srmock.headers_dict['x-things']
-        self.assertEqual('thing-1', value)
+        result = self.simulate_request(method='POST')
+        self.assertEqual(result.headers['x-things'], 'thing-1')
 
     def test_vary_star(self):
-        self.resource = VaryHeaderResource(['*'])
-        self.api.add_route(self.test_route, self.resource)
-        self.simulate_request(self.test_route)
-
-        self.assertIn(('vary', '*'), self.srmock.headers)
-
-    def test_vary_header(self):
-        self.resource = VaryHeaderResource(['accept-encoding'])
-        self.api.add_route(self.test_route, self.resource)
-        self.simulate_request(self.test_route)
-
-        self.assertIn(('vary', 'accept-encoding'), self.srmock.headers)
-
-    def test_vary_headers(self):
-        self.resource = VaryHeaderResource(['accept-encoding', 'x-auth-token'])
-        self.api.add_route(self.test_route, self.resource)
-        self.simulate_request(self.test_route)
-
-        vary = 'accept-encoding, x-auth-token'
-        self.assertIn(('vary', vary), self.srmock.headers)
-
-    def test_vary_headers_tuple(self):
-        self.resource = VaryHeaderResource(('accept-encoding', 'x-auth-token'))
-        self.api.add_route(self.test_route, self.resource)
-        self.simulate_request(self.test_route)
-
-        vary = 'accept-encoding, x-auth-token'
-        self.assertIn(('vary', vary), self.srmock.headers)
-
-    def test_no_content_type(self):
-        self.resource = DefaultContentTypeResource()
-        self.api.add_route(self.test_route, self.resource)
-        self.simulate_request(self.test_route)
-
-        self.assertNotIn('content-type', self.srmock.headers_dict)
+        self.api.add_route('/', VaryHeaderResource(['*']))
+        result = self.simulate_get()
+        self.assertEqual(result.headers['vary'], '*')
+
+    @ddt.data(
+        (['accept-encoding'], 'accept-encoding'),
+        (['accept-encoding', 'x-auth-token'], 'accept-encoding, x-auth-token'),
+        (('accept-encoding', 'x-auth-token'), 'accept-encoding, x-auth-token'),
+    )
+    @ddt.unpack
+    def test_vary_header(self, vary, expected_value):
+        resource = VaryHeaderResource(vary)
+        self._check_header(resource, 'Vary', expected_value)
+
+    def test_content_type_no_body(self):
+        self.api.add_route('/', testing.SimpleTestResource())
+        result = self.simulate_get()
+
+        # NOTE(kgriffs): Even when there is no body, Content-Type
+        # should still be included per wsgiref.validate
+        self.assertIn('Content-Type', result.headers)
+        self.assertEqual(result.headers['Content-Length'], '0')
+
+    @ddt.data(falcon.HTTP_204, falcon.HTTP_304)
+    def test_no_content_type(self, status):
+        self.api.add_route('/', testing.SimpleTestResource(status=status))
+
+        result = self.simulate_get()
+        self.assertNotIn('Content-Type', result.headers)
 
     def test_custom_content_type(self):
         content_type = 'application/xml; charset=utf-8'
-        self.resource = XmlResource(content_type)
-        self.api.add_route(self.test_route, self.resource)
-
-        self.simulate_request(self.test_route)
-        self.assertIn(('content-type', content_type), self.srmock.headers)
+        resource = XmlResource(content_type)
+        self._check_header(resource, 'Content-Type', content_type)
 
     def test_add_link_single(self):
         expected_value = '</things/2842>; rel=next'
 
-        self.resource = LinkHeaderResource()
-        self.resource.add_link('/things/2842', 'next')
+        resource = LinkHeaderResource()
+        resource.add_link('/things/2842', 'next')
 
-        self._check_link_header(expected_value)
+        self._check_link_header(resource, expected_value)
 
     def test_add_link_multiple(self):
         expected_value = (
@@ -506,26 +443,26 @@ class TestHeaders(testing.TestBase):
 
         uri = u'ab\u00e7' if six.PY3 else 'ab\xc3\xa7'
 
-        self.resource = LinkHeaderResource()
-        self.resource.add_link('/things/2842', 'next')
-        self.resource.add_link(u'http://\u00e7runchy/bacon', 'contents')
-        self.resource.add_link(uri, 'http://example.com/ext-type')
-        self.resource.add_link(uri, u'http://example.com/\u00e7runchy')
-        self.resource.add_link(uri, u'https://example.com/too-\u00e7runchy')
-        self.resource.add_link('/alt-thing',
-                               u'alternate http://example.com/\u00e7runchy')
+        resource = LinkHeaderResource()
+        resource.add_link('/things/2842', 'next')
+        resource.add_link(u'http://\u00e7runchy/bacon', 'contents')
+        resource.add_link(uri, 'http://example.com/ext-type')
+        resource.add_link(uri, u'http://example.com/\u00e7runchy')
+        resource.add_link(uri, u'https://example.com/too-\u00e7runchy')
+        resource.add_link('/alt-thing',
+                          u'alternate http://example.com/\u00e7runchy')
 
-        self._check_link_header(expected_value)
+        self._check_link_header(resource, expected_value)
 
     def test_add_link_with_title(self):
         expected_value = ('</related/thing>; rel=item; '
                           'title="A related thing"')
 
-        self.resource = LinkHeaderResource()
-        self.resource.add_link('/related/thing', 'item',
-                               title='A related thing')
+        resource = LinkHeaderResource()
+        resource.add_link('/related/thing', 'item',
+                          title='A related thing')
 
-        self._check_link_header(expected_value)
+        self._check_link_header(resource, expected_value)
 
     def test_add_link_with_title_star(self):
         expected_value = ('</related/thing>; rel=item; '
@@ -533,54 +470,53 @@ class TestHeaders(testing.TestBase):
                           '</%C3%A7runchy/thing>; rel=item; '
                           "title*=UTF-8'en'A%20%C3%A7runchy%20thing")
 
-        self.resource = LinkHeaderResource()
-        self.resource.add_link('/related/thing', 'item',
-                               title_star=('', 'A related thing'))
+        resource = LinkHeaderResource()
+        resource.add_link('/related/thing', 'item',
+                          title_star=('', 'A related thing'))
 
-        self.resource.add_link(u'/\u00e7runchy/thing', 'item',
-                               title_star=('en', u'A \u00e7runchy thing'))
+        resource.add_link(u'/\u00e7runchy/thing', 'item',
+                          title_star=('en', u'A \u00e7runchy thing'))
 
-        self._check_link_header(expected_value)
+        self._check_link_header(resource, expected_value)
 
     def test_add_link_with_anchor(self):
         expected_value = ('</related/thing>; rel=item; '
                           'anchor="/some%20thing/or-other"')
 
-        self.resource = LinkHeaderResource()
-        self.resource.add_link('/related/thing', 'item',
-                               anchor='/some thing/or-other')
+        resource = LinkHeaderResource()
+        resource.add_link('/related/thing', 'item',
+                          anchor='/some thing/or-other')
 
-        self._check_link_header(expected_value)
+        self._check_link_header(resource, expected_value)
 
     def test_add_link_with_hreflang(self):
         expected_value = ('</related/thing>; rel=about; '
                           'hreflang=en')
 
-        self.resource = LinkHeaderResource()
-        self.resource.add_link('/related/thing', 'about',
-                               hreflang='en')
+        resource = LinkHeaderResource()
+        resource.add_link('/related/thing', 'about', hreflang='en')
 
-        self._check_link_header(expected_value)
+        self._check_link_header(resource, expected_value)
 
     def test_add_link_with_hreflang_multi(self):
         expected_value = ('</related/thing>; rel=about; '
                           'hreflang=en-GB; hreflang=de')
 
-        self.resource = LinkHeaderResource()
-        self.resource.add_link('/related/thing', 'about',
-                               hreflang=('en-GB', 'de'))
+        resource = LinkHeaderResource()
+        resource.add_link('/related/thing', 'about',
+                          hreflang=('en-GB', 'de'))
 
-        self._check_link_header(expected_value)
+        self._check_link_header(resource, expected_value)
 
     def test_add_link_with_type_hint(self):
         expected_value = ('</related/thing>; rel=alternate; '
                           'type="video/mp4; codecs=avc1.640028"')
 
-        self.resource = LinkHeaderResource()
-        self.resource.add_link('/related/thing', 'alternate',
-                               type_hint='video/mp4; codecs=avc1.640028')
+        resource = LinkHeaderResource()
+        resource.add_link('/related/thing', 'alternate',
+                          type_hint='video/mp4; codecs=avc1.640028')
 
-        self._check_link_header(expected_value)
+        self._check_link_header(resource, expected_value)
 
     def test_add_link_complex(self):
         expected_value = ('</related/thing>; rel=alternate; '
@@ -589,21 +525,24 @@ class TestHeaders(testing.TestBase):
                           'type="application/json"; '
                           'hreflang=en-GB; hreflang=de')
 
-        self.resource = LinkHeaderResource()
-        self.resource.add_link('/related/thing', 'alternate',
-                               title='A related thing',
-                               hreflang=('en-GB', 'de'),
-                               type_hint='application/json',
-                               title_star=('en', u'A \u00e7runchy thing'))
+        resource = LinkHeaderResource()
+        resource.add_link('/related/thing', 'alternate',
+                          title='A related thing',
+                          hreflang=('en-GB', 'de'),
+                          type_hint='application/json',
+                          title_star=('en', u'A \u00e7runchy thing'))
 
-        self._check_link_header(expected_value)
+        self._check_link_header(resource, expected_value)
 
     # ----------------------------------------------------------------------
     # Helpers
     # ----------------------------------------------------------------------
 
-    def _check_link_header(self, expected_value):
-        self.api.add_route(self.test_route, self.resource)
+    def _check_link_header(self, resource, expected_value):
+        self._check_header(resource, 'Link', expected_value)
+
+    def _check_header(self, resource, header, expected_value):
+        self.api.add_route('/', resource)
 
-        self.simulate_request(self.test_route)
-        self.assertEqual(expected_value, self.srmock.headers_dict['link'])
+        result = self.simulate_get()
+        self.assertEqual(result.headers[header], expected_value)
diff --git a/tests/test_hello.py b/tests/test_hello.py
index daff9b9..f191916 100644
--- a/tests/test_hello.py
+++ b/tests/test_hello.py
@@ -1,4 +1,4 @@
-from testtools.matchers import Contains
+import ddt
 
 import falcon
 import io
@@ -74,156 +74,106 @@ class NoStatusResource(object):
         pass
 
 
-class TestHelloWorld(testing.TestBase):
+@ddt.ddt
+class TestHelloWorld(testing.TestCase):
 
-    def before(self):
-        self.resource = HelloResource('body')
-        self.api.add_route(self.test_route, self.resource)
-
-        self.bytes_resource = HelloResource('body, bytes')
-        self.api.add_route('/bytes', self.bytes_resource)
-
-        self.data_resource = HelloResource('data')
-        self.api.add_route('/data', self.data_resource)
-
-        self.chunked_resource = HelloResource('stream')
-        self.api.add_route('/chunked-stream', self.chunked_resource)
-
-        self.stream_resource = HelloResource('stream, stream_len')
-        self.api.add_route('/stream', self.stream_resource)
-
-        self.filelike_resource = HelloResource('stream, stream_len, filelike')
-        self.api.add_route('/filelike', self.filelike_resource)
-
-        self.filelike_helper_resource = HelloResource(
-            'stream, stream_len, filelike, use_helper')
-        self.api.add_route('/filelike-helper', self.filelike_helper_resource)
-
-        self.no_status_resource = NoStatusResource()
-        self.api.add_route('/nostatus', self.no_status_resource)
-
-        self.root_resource = testing.TestResource()
-        self.api.add_route('/', self.root_resource)
-
-    def after(self):
-        pass
+    def setUp(self):
+        super(TestHelloWorld, self).setUp()
 
     def test_env_headers_list_of_tuples(self):
         env = testing.create_environ(headers=[('User-Agent', 'Falcon-Test')])
         self.assertEqual(env['HTTP_USER_AGENT'], 'Falcon-Test')
 
-    def test_empty_route(self):
-        self.simulate_request('')
-        self.assertTrue(self.root_resource.called)
-
-    def test_route_negative(self):
-        bogus_route = self.test_route + 'x'
-        self.simulate_request(bogus_route)
+    def test_root_route(self):
+        doc = {u"message": u"Hello world!"}
+        resource = testing.SimpleTestResource(json=doc)
+        self.api.add_route('/', resource)
 
-        # Ensure the request was NOT routed to resource
-        self.assertFalse(self.resource.called)
-        self.assertEqual(self.srmock.status, falcon.HTTP_404)
+        result = self.simulate_get()
+        self.assertEqual(result.json, doc)
 
-    def test_body(self):
-        body = self.simulate_request(self.test_route)
-        resp = self.resource.resp
+    def test_no_route(self):
+        result = self.simulate_get('/seenoevil')
+        self.assertEqual(result.status_code, 404)
 
-        content_length = int(self.srmock.headers_dict['content-length'])
-        self.assertEqual(content_length, len(self.resource.sample_utf8))
+    @ddt.data(
+        ('/body', HelloResource('body'), lambda r: r.body.encode('utf-8')),
+        ('/bytes', HelloResource('body, bytes'), lambda r: r.body),
+        ('/data', HelloResource('data'), lambda r: r.data),
+    )
+    @ddt.unpack
+    def test_body(self, path, resource, get_body):
+        self.api.add_route(path, resource)
 
-        self.assertEqual(self.srmock.status, self.resource.sample_status)
-        self.assertEqual(resp.status, self.resource.sample_status)
-        self.assertEqual(resp.body.encode('utf-8'), self.resource.sample_utf8)
-        self.assertEqual(body, [self.resource.sample_utf8])
+        result = self.simulate_get(path)
+        resp = resource.resp
 
-    def test_body_bytes(self):
-        body = self.simulate_request('/bytes')
-        resp = self.bytes_resource.resp
+        content_length = int(result.headers['content-length'])
+        self.assertEqual(content_length, len(resource.sample_utf8))
 
-        content_length = int(self.srmock.headers_dict['content-length'])
-        self.assertEqual(content_length, len(self.resource.sample_utf8))
-
-        self.assertEqual(self.srmock.status, self.resource.sample_status)
-        self.assertEqual(resp.status, self.resource.sample_status)
-        self.assertEqual(resp.body, self.resource.sample_utf8)
-        self.assertEqual(body, [self.resource.sample_utf8])
-
-    def test_data(self):
-        body = self.simulate_request('/data')
-        resp = self.data_resource.resp
-
-        content_length = int(self.srmock.headers_dict['content-length'])
-        self.assertEqual(content_length, len(self.resource.sample_utf8))
-
-        self.assertEqual(self.srmock.status, self.resource.sample_status)
-        self.assertEqual(resp.status, self.resource.sample_status)
-        self.assertEqual(resp.data, self.resource.sample_utf8)
-        self.assertEqual(body, [self.resource.sample_utf8])
+        self.assertEqual(result.status, resource.sample_status)
+        self.assertEqual(resp.status, resource.sample_status)
+        self.assertEqual(get_body(resp), resource.sample_utf8)
+        self.assertEqual(result.data, resource.sample_utf8)
 
     def test_no_body_on_head(self):
-        body = self.simulate_request(self.test_route, method='HEAD')
-        self.assertEqual(body, [])
-        self.assertEqual(self.srmock.status, falcon.HTTP_200)
+        self.api.add_route('/body', HelloResource('body'))
+        result = self.simulate_head('/body')
 
-    def test_stream_chunked(self):
-        src = self.simulate_request('/chunked-stream')
+        self.assertFalse(result.data)
+        self.assertEqual(result.status_code, 200)
 
-        dest = io.BytesIO()
-        for chunk in src:
-            dest.write(chunk)
+    def test_stream_chunked(self):
+        resource = HelloResource('stream')
+        self.api.add_route('/chunked-stream', resource)
 
-        self.assertEqual(dest.getvalue(), self.chunked_resource.sample_utf8)
+        result = self.simulate_get('/chunked-stream')
 
-        for header in self.srmock.headers:
-            self.assertNotEqual(header[0].lower(), 'content-length')
+        self.assertEqual(result.data, resource.sample_utf8)
+        self.assertNotIn('content-length', result.headers)
 
     def test_stream_known_len(self):
-        src = self.simulate_request('/stream')
-        self.assertTrue(self.stream_resource.called)
+        resource = HelloResource('stream, stream_len')
+        self.api.add_route('/stream', resource)
 
-        dest = io.BytesIO()
-        for chunk in src:
-            dest.write(chunk)
+        result = self.simulate_get('/stream')
+        self.assertTrue(resource.called)
 
-        expected_len = self.stream_resource.resp.stream_len
-        content_length = ('content-length', str(expected_len))
-        self.assertThat(self.srmock.headers, Contains(content_length))
-        self.assertEqual(dest.tell(), expected_len)
-
-        self.assertEqual(dest.getvalue(),
-                         self.chunked_resource.sample_utf8)
+        expected_len = resource.resp.stream_len
+        actual_len = int(result.headers['content-length'])
+        self.assertEqual(actual_len, expected_len)
+        self.assertEqual(len(result.data), expected_len)
+        self.assertEqual(result.data, resource.sample_utf8)
 
     def test_filelike(self):
-        for file_wrapper in (None, FileWrapper):
-            url = '/filelike'
-
-            src = self.simulate_request(url, file_wrapper=file_wrapper)
-            self.assertTrue(self.filelike_resource.called)
+        resource = HelloResource('stream, stream_len, filelike')
+        self.api.add_route('/filelike', resource)
 
-            dest = io.BytesIO()
-            for chunk in src:
-                dest.write(chunk)
+        for file_wrapper in (None, FileWrapper):
+            result = self.simulate_get('/filelike', file_wrapper=file_wrapper)
+            self.assertTrue(resource.called)
 
-            expected_len = self.filelike_resource.resp.stream_len
-            content_length = ('content-length', str(expected_len))
-            self.assertThat(self.srmock.headers, Contains(content_length))
-            self.assertEqual(dest.tell(), expected_len)
+            expected_len = resource.resp.stream_len
+            actual_len = int(result.headers['content-length'])
+            self.assertEqual(actual_len, expected_len)
+            self.assertEqual(len(result.data), expected_len)
 
     def test_filelike_using_helper(self):
-            src = self.simulate_request('/filelike-helper')
-            self.assertTrue(self.filelike_helper_resource.called)
+        resource = HelloResource('stream, stream_len, filelike, use_helper')
+        self.api.add_route('/filelike-helper', resource)
 
-            dest = io.BytesIO()
-            for chunk in src:
-                dest.write(chunk)
+        result = self.simulate_get('/filelike-helper')
+        self.assertTrue(resource.called)
 
-            expected_len = self.filelike_helper_resource.resp.stream_len
-            content_length = ('content-length', str(expected_len))
-            self.assertThat(self.srmock.headers, Contains(content_length))
-            self.assertEqual(dest.tell(), expected_len)
+        expected_len = resource.resp.stream_len
+        actual_len = int(result.headers['content-length'])
+        self.assertEqual(actual_len, expected_len)
+        self.assertEqual(len(result.data), expected_len)
 
     def test_status_not_set(self):
-        body = self.simulate_request('/nostatus')
+        self.api.add_route('/nostatus', NoStatusResource())
+
+        result = self.simulate_get('/nostatus')
 
-        self.assertEqual(body, [])
-        self.assertEqual(self.srmock.status, falcon.HTTP_200)
+        self.assertFalse(result.data)
+        self.assertEqual(result.status_code, 200)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index b082c38..9ba7e0c 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -3,6 +3,7 @@
 from datetime import datetime
 import functools
 import io
+import json
 import random
 import sys
 
@@ -10,7 +11,7 @@ import testtools
 import six
 
 import falcon
-import falcon.testing
+from falcon import testing
 from falcon import util
 from falcon.util import uri
 
@@ -297,25 +298,25 @@ class TestFalconUtils(testtools.TestCase):
                          ('falcon.example.com', 42))
 
 
-class TestFalconTesting(falcon.testing.TestBase):
+class TestFalconTesting(testing.TestBase):
     """Catch some uncommon branches not covered elsewhere."""
 
     def test_path_escape_chars_in_create_environ(self):
-        env = falcon.testing.create_environ('/hello%20world%21')
+        env = testing.create_environ('/hello%20world%21')
         self.assertEqual(env['PATH_INFO'], '/hello world!')
 
     def test_unicode_path_in_create_environ(self):
         if six.PY3:
             self.skip('Test does not apply to Py3K')
 
-        env = falcon.testing.create_environ(u'/fancy/unícode')
+        env = testing.create_environ(u'/fancy/unícode')
         self.assertEqual(env['PATH_INFO'], '/fancy/un\xc3\xadcode')
 
-        env = falcon.testing.create_environ(u'/simple')
+        env = testing.create_environ(u'/simple')
         self.assertEqual(env['PATH_INFO'], '/simple')
 
     def test_none_header_value_in_create_environ(self):
-        env = falcon.testing.create_environ('/', headers={'X-Foo': None})
+        env = testing.create_environ('/', headers={'X-Foo': None})
         self.assertEqual(env['HTTP_X_FOO'], '')
 
     def test_decode_empty_result(self):
@@ -323,4 +324,69 @@ class TestFalconTesting(falcon.testing.TestBase):
         self.assertEqual(body, '')
 
     def test_httpnow_alias_for_backwards_compat(self):
-        self.assertIs(falcon.testing.httpnow, util.http_now)
+        self.assertIs(testing.httpnow, util.http_now)
+
+
+class TestFalconTestCase(testing.TestCase):
+    """Verify some branches not covered elsewhere."""
+
+    def test_status(self):
+        resource = testing.SimpleTestResource(status=falcon.HTTP_702)
+        self.api.add_route('/', resource)
+
+        result = self.simulate_get()
+        self.assertEqual(result.status, falcon.HTTP_702)
+
+    def test_wsgi_iterable_not_closeable(self):
+        result = testing.Result([], falcon.HTTP_200, [])
+        self.assertFalse(result.data)
+
+    def test_path_must_start_with_slash(self):
+        self.assertRaises(ValueError, self.simulate_get, 'foo')
+
+    def test_cached_text_in_result(self):
+        self.api.add_route('/', testing.SimpleTestResource(body='test'))
+
+        result = self.simulate_get()
+        self.assertEqual(result.text, result.text)
+
+    def test_simple_resource_body_json_xor(self):
+        self.assertRaises(
+            ValueError,
+            testing.SimpleTestResource,
+            body='',
+            json={},
+        )
+
+    def test_query_string(self):
+        class SomeResource(object):
+            def on_get(self, req, resp):
+                doc = {}
+
+                doc['oid'] = req.get_param_as_int('oid')
+                doc['detailed'] = req.get_param_as_bool('detailed')
+
+                resp.body = json.dumps(doc)
+
+        self.api.add_route('/', SomeResource())
+
+        result = self.simulate_get(query_string='oid=42&detailed=no')
+        self.assertEqual(result.json['oid'], 42)
+        self.assertFalse(result.json['detailed'])
+
+    def test_query_string_no_question(self):
+        self.assertRaises(ValueError, self.simulate_get, query_string='?x=1')
+
+    def test_query_string_in_path(self):
+        self.assertRaises(ValueError, self.simulate_get, path='/thing?x=1')
+
+
+class FancyAPI(falcon.API):
+    pass
+
+
+class FancyTestCase(testing.TestCase):
+    api_class = FancyAPI
+
+    def test_something(self):
+        self.assertTrue(isinstance(self.api, FancyAPI))