Repository: pallets/werkzeug

Issue:
Expose redirect chain from test client
I am currently with a project, `splinter`, which emulates browser-like interactions via the Flask/Werkzeug test client. Their current URL behavior is broken redirects as its currently serving the originally requested URL, not the final one.

Currently, the test client has no way of telling what the final request location was (nor its hops along the way). I was wondering if we could return the `redirect_chain` somehow (e.g. as an attribute of the Response object).

https://github.com/mitsuhiko/werkzeug/blob/0.10.4/werkzeug/test.py#L744-L760



Discussion:
The final location lives on the response object.

I don't see any attributes for it. What property are we talking about?

http://werkzeug.pocoo.org/docs/0.10/wrappers/#werkzeug.wrappers.BaseResponse

Ah sorry, I misremembered.

No worries. I felt like it should exist as well.  What is the interface you would consider adding? I am open to writing a PR for this.

I don't really know. I think keeping the API similar to requests is useful.

On Wed, Aug 19, 2015 at 01:49:13PM -0700, Todd Wolfson wrote:

> No worries. I felt like it should exist as well.  What is the interface you would consider adding? I am open to writing a PR for this.
> 
> ---
> 
> Reply to this email directly or view it on GitHub:
> https://github.com/mitsuhiko/werkzeug/issues/763#issuecomment-132779426

I was hoping there was a way to define `BaseResponse.redirect_chain` (that's how `splinter` is interacting with Django) but that might be a bit of a hack.

https://github.com/cobrateam/splinter/blob/0.7.3/splinter/driver/djangoclient.py#L94-L98

For my interim hack/patch, I have extended on `as_tuple` but I don't believe that's practical as it's not backwards compatible:

https://github.com/cobrateam/splinter/compare/cobrateam:5317b71...underdogio:f7e0da6#diff-df553efba1487ab0f96b4053dee86c25R87

Any word on this issue? It's been 2 months since we started talking about =(

I looked into this too, and it's tricky to fit this in to the existing behavior. The problem is that what we return isn't flexible. Either we return a `Response` class passed in by the user, which isn't going to have test-related attributes like `redirect_chain`. Or we return a tuple that users are probably unpacking, so adding another value would break existing tests without a clear way to have a deprecation window.

I understand why we allow returning a tuple, since that maps directly to WSGI `start_response`, but I don't think anyone should be doing serious testing with it any more. You're going to have a way easier time inspecting a `Response` object, it's the way other tools like `httpx` work.

I think I have a compromise, but someone would need to try to implement it to see if it's viable. I'd like to always return a `Response` object, using a new default `TestResponse` subclass. This class can provide new things like `redirect_chain` as well as backwards compatibility with unpacking by overriding `__iter__` or something to make it look unpackable. Since the user can pass in a class, and `TestResponse` would provide extra functionality, we would create a new subclass based on both. `UserTestResponse(TestResponse, response_class)`.

Speaking of [`httpx`](https://www.python-httpx.org/), if you need this and other features in the mean time, it has great support for [making requests to WSGI apps](https://www.python-httpx.org/advanced/#calling-into-python-web-apps) built-in. Requests and responses look exactly the same as they would if you were making an HTTP request.
@davidism For us to hook up the `TestResponse` with  `redirect_chain` we'd need to override werkzeug's Client since we need to tap into its `open` method. Let me see if I can come up with a snippet. 
This is a Werkzeug issue, you can change the client code directly. 😉
@davidism this is what I was thinking. It adds the redirect_chain to the response. Not a super elegant solution though:sweat_smile:
```python
....

class Client():

    def open(self, *args, **kwargs):
        as_tuple = kwargs.pop("as_tuple", False)
        buffered = kwargs.pop("buffered", False)
        follow_redirects = kwargs.pop("follow_redirects", False)
        environ = None
        if not kwargs and len(args) == 1:
            if isinstance(args[0], EnvironBuilder):
                environ = args[0].get_environ()
            elif isinstance(args[0], dict):
                environ = args[0]
        if environ is None:
            builder = EnvironBuilder(*args, **kwargs)
            try:
                environ = builder.get_environ()
            finally:
                builder.close()

        response = self.run_wsgi_app(environ.copy(), buffered=buffered)

        # handle redirects
        redirect_chain = []
        while 1:
            status_code = int(response[1].split(None, 1)[0])
            if (
                status_code not in {301, 302, 303, 305, 307, 308}
                or not follow_redirects
            ):
                break

            # Exhaust intermediate response bodies to ensure middleware
            # that returns an iterator runs any cleanup code.
            if not buffered:
                for _ in response[0]:
                    pass

            new_location = response[2]["location"]
            new_redirect_entry = (new_location, status_code)
            if new_redirect_entry in redirect_chain:
                raise ClientRedirectError("loop detected")
            redirect_chain.append(new_redirect_entry)
            environ, response = self.resolve_redirect(
                response, new_location, environ, buffered=buffered
            )

        if self.response_wrapper is not None:
            response = self.response_wrapper(*response)
            setattr(response, 'redirect_chain', redirect_chain) # This will enable us to access the redirect chain

        if as_tuple:
            return environ, response
        return response
```
Makes sense. My comment above was addressing how we return the chain. I want to avoid setting extra attributes (using `setattr` or regular assignment). I also want to phase out the three different return types that can happen. A `TestResponse` class could address both these issues.

Resolving patch:
diff --git a/src/werkzeug/wrappers/base_response.py b/src/werkzeug/wrappers/base_response.py
--- a/src/werkzeug/wrappers/base_response.py
+++ b/src/werkzeug/wrappers/base_response.py
@@ -1,9 +1,6 @@
 import warnings
-from io import BytesIO
-from itertools import chain
 from typing import Any
 from typing import Callable
-from typing import Dict
 from typing import Iterable
 from typing import Iterator
 from typing import List
@@ -33,16 +30,6 @@
     from werkzeug.wrappers.response import Response  # noqa: F401
 
 
-def _run_wsgi_app(*args) -> Tuple[chain, str, Headers]:
-    """This function replaces itself to ensure that the test module is not
-    imported unless required.  DO NOT USE!
-    """
-    global _run_wsgi_app
-    from ..test import run_wsgi_app as _run_wsgi_app  # type: ignore
-
-    return _run_wsgi_app(*args)
-
-
 def _warn_if_string(iterable: Any) -> None:
     """Helper for the response objects to check if the iterable returned
     to the WSGI server is not a string.
@@ -246,13 +233,7 @@ def __repr__(self) -> str:
         return f"<{type(self).__name__} {body_info} [{self.status}]>"
 
     @classmethod
-    def force_type(
-        cls: Type[T],
-        response: Any,
-        environ: Optional[
-            Union[Dict[str, Union[str, Tuple[int, int], BytesIO, bool]], "Request"]
-        ] = None,
-    ) -> T:
+    def force_type(cls: Type[T], response: Any, environ: WSGIEnvironment = None) -> T:
         """Enforce that the WSGI response is a response object of the current
         type.  Werkzeug will use the :class:`BaseResponse` internally in many
         situations like the exceptions.  If you call :meth:`get_response` on an
@@ -286,7 +267,11 @@ def force_type(
                     "cannot convert WSGI application into response"
                     " objects without an environ"
                 )
-            response = BaseResponse(*_run_wsgi_app(response, environ))
+
+            from ..test import run_wsgi_app
+
+            response = BaseResponse(*run_wsgi_app(response, environ))
+
         response.__class__ = cls
         return response
 
@@ -309,7 +294,9 @@ def from_app(
         :param buffered: set to `True` to enforce buffering.
         :return: a response object.
         """
-        return cls(*_run_wsgi_app(app, environ, buffered))  # type: ignore
+        from ..test import run_wsgi_app
+
+        return cls(*run_wsgi_app(app, environ, buffered))  # type: ignore
 
     @property
     def status_code(self):


Test patch:
diff --git a/docs/test.rst b/docs/test.rst
--- a/docs/test.rst
+++ b/docs/test.rst
@@ -1,166 +1,111 @@
-==============
-Test Utilities
-==============
-
 .. module:: werkzeug.test
 
-Quite often you want to unittest your application or just check the output
-from an interactive python session.  In theory that is pretty simple because
-you can fake a WSGI environment and call the application with a dummy
-`start_response` and iterate over the application iterator but there are
-argumentably better ways to interact with an application.
-
+Testing WSGI Applications
+=========================
 
-Diving In
-=========
 
-Werkzeug provides a `Client` object which you can pass a WSGI application (and
-optionally a response wrapper) which you can use to send virtual requests to
-the application.
+Test Client
+-----------
 
-A response wrapper is a callable that takes three arguments: the application
-iterator, the status and finally a list of headers.  The default response
-wrapper returns a tuple.  Because response objects have the same signature,
-you can use them as response wrapper, ideally by subclassing them and hooking
-in test functionality.
+Werkzeug provides a :class:`Client` to simulate requests to a WSGI
+application without starting a server. The client has methods for making
+different types of requests, as well as managing cookies across
+requests.
 
 >>> from werkzeug.test import Client
 >>> from werkzeug.testapp import test_app
->>> from werkzeug.wrappers import BaseResponse
->>> c = Client(test_app, BaseResponse)
->>> resp = c.get('/')
->>> resp.status_code
+>>> c = Client(test_app)
+>>> response = c.get("/")
+>>> response.status_code
 200
 >>> resp.headers
 Headers([('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', '6658')])
->>> resp.data.splitlines()[0]
-b'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"'
-
-Or without a wrapper defined:
-
->>> c = Client(test_app)
->>> app_iter, status, headers = c.get('/')
->>> status
-'200 OK'
->>> headers
-Headers([('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', '6658')])
->>> b''.join(app_iter).splitlines()[0]
-b'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"'
-
-
-Environment Building
-====================
-
-.. versionadded:: 0.5
-
-The easiest way to interactively test applications is using the
-:class:`EnvironBuilder`.  It can create both standard WSGI environments
-and request objects.
-
-The following example creates a WSGI environment with one uploaded file
-and a form field:
-
->>> from werkzeug.test import EnvironBuilder
->>> from io import BytesIO
->>> builder = EnvironBuilder(method='POST', data={'foo': 'this is some text',
-...      'file': (BytesIO('my file contents'.encode("utf8")), 'test.txt')})
->>> env = builder.get_environ()
-
-The resulting environment is a regular WSGI environment that can be used for
-further processing:
+>>> response.get_data(as_text=True)
+'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"...'
 
->>> from werkzeug.wrappers import Request
->>> req = Request(env)
->>> req.form['foo']
-'this is some text'
->>> req.files['file']
-<FileStorage: 'test.txt' ('text/plain')>
->>> req.files['file'].read()
-b'my file contents'
+The client's request methods return instances of :class:`TestResponse`.
+This provides extra attributes and methods on top of
+:class:`~werkzeug.wrappers.Response` that are useful for testing.
 
-The :class:`EnvironBuilder` figures out the content type automatically if you
-pass a dict to the constructor as `data`.  If you provide a string or an
-input stream you have to do that yourself.
 
-By default it will try to use ``application/x-www-form-urlencoded`` and only
-use ``multipart/form-data`` if files are uploaded:
+Request Body
+------------
 
->>> builder = EnvironBuilder(method='POST', data={'foo': 'bar'})
->>> builder.content_type
-'application/x-www-form-urlencoded'
->>> builder.files['foo'] = BytesIO('contents'.encode("utf8"))
->>> builder.content_type
-'multipart/form-data'
+By passing a dict to ``data``, the client will construct a request body
+with file and form data. It will set the content type to
+``application/x-www-form-urlencoded`` if there are no files, or
+``multipart/form-data`` there are.
 
-If a string is provided as data (or an input stream) you have to specify
-the content type yourself:
+.. code-block:: python
 
->>> builder = EnvironBuilder(method='POST', data='{"json": "this is"}')
->>> builder.content_type
->>> builder.content_type = 'application/json'
+    import io
 
+    response = client.post(data={
+        "name": "test",
+        "file": (BytesIO("file contents".encode("utf8")), "test.txt")
+    })
 
-Testing API
-===========
+Pass a string, bytes, or file-like object to ``data`` to use that as the
+raw request body. In that case, you should set the content type
+appropriately. For example, to post YAML:
 
-.. autoclass:: EnvironBuilder
-   :members:
-
-   .. attribute:: path
-
-      The path of the application.  (aka `PATH_INFO`)
-
-   .. attribute:: charset
+.. code-block:: python
 
-      The charset used to encode to bytes.
+    response = client.post(
+        data="a: value\nb: 1\n", content_type="application/yaml"
+    )
 
-   .. attribute:: headers
+A shortcut when testing JSON APIs is to pass a dict to ``json`` instead
+of using ``data``. This will automatically call ``json.dumps()`` and
+set the content type to ``application/json``. Additionally, if the
+app returns JSON, ``response.json`` will automatically call
+``json.loads()``.
 
-      A :class:`Headers` object with the request headers.
+.. code-block:: python
 
-   .. attribute:: errors_stream
+    response = client.post("/api", json={"a": "value", "b": 1})
+    obj = response.json()
 
-      The error stream used for the `wsgi.errors` stream.
 
-   .. attribute:: multithread
+Environment Builder
+-------------------
 
-      The value of `wsgi.multithread`
+:class:`EnvironBuilder` is used to construct a WSGI environ dict. The
+test client uses this internally to prepare its requests. The arguments
+passed to the client request methods are the same as the builder.
 
-   .. attribute:: multiprocess
+Sometimes, it can be useful to construct a WSGI environment manually.
+An environ builder or dict can be passed to the test client request
+methods in place of other arguments to use a custom environ.
 
-      The value of `wsgi.multiprocess`
+.. code-block:: Python
 
-   .. attribute:: environ_base
+    from werkzeug.test import EnvironBuilder
+    builder = EnvironBuilder(...)
+    # build an environ dict
+    environ = builder.get_environ()
+    # build an environ dict wrapped in a request
+    request = builder.get_request()
 
-      The dict used as base for the newly create environ.
+The test client responses make this available through
+:attr:`TestResponse.request` and ``response.request.environ``.
 
-   .. attribute:: environ_overrides
 
-      A dict with values that are used to override the generated environ.
+API
+---
 
 .. autoclass:: Client
+    :members:
+    :member-order: bysource
 
-   .. automethod:: open
-
-   Shortcut methods are available for many HTTP methods:
-
-   .. automethod:: get
-
-   .. automethod:: patch
-
-   .. automethod:: post
-
-   .. automethod:: head
-
-   .. automethod:: put
-
-   .. automethod:: delete
-
-   .. automethod:: options
-
-   .. automethod:: trace
+.. autoclass:: TestResponse
+    :members:
+    :member-order: bysource
 
+.. autoclass:: EnvironBuilder
+    :members:
+    :member-order: bysource
 
-.. autofunction:: create_environ([options])
+.. autofunction:: create_environ
 
 .. autofunction:: run_wsgi_app
diff --git a/src/werkzeug/test.py b/src/werkzeug/test.py
--- a/src/werkzeug/test.py
+++ b/src/werkzeug/test.py
@@ -1,5 +1,6 @@
 import mimetypes
 import sys
+import warnings
 from collections import defaultdict
 from http.cookiejar import CookieJar
 from io import BytesIO
@@ -10,10 +11,12 @@
 from typing import Any
 from typing import BinaryIO
 from typing import Callable
+from typing import cast
 from typing import Dict
 from typing import Generic
 from typing import Hashable
 from typing import IO
+from typing import Iterable
 from typing import Iterator
 from typing import List
 from typing import Mapping
@@ -37,6 +40,7 @@
 from .http import dump_cookie
 from .http import dump_options_header
 from .http import parse_options_header
+from .types import WSGIEnvironment
 from .urls import iri_to_uri
 from .urls import url_encode
 from .urls import url_fix
@@ -44,16 +48,12 @@
 from .urls import url_unparse
 from .urls import url_unquote
 from .utils import get_content_type
-from .wrappers import BaseRequest
+from .wrappers.base_request import BaseRequest
+from .wrappers.base_response import BaseResponse
+from .wrappers.request import Request
+from .wrappers.response import Response
 from .wsgi import ClosingIterator
 from .wsgi import get_current_url
-from werkzeug.debug import DebuggedApplication
-from werkzeug.middleware.http_proxy import ProxyMiddleware
-from werkzeug.types import WSGIEnvironment
-from werkzeug.wrappers.base_response import BaseResponse
-from werkzeug.wrappers.request import PlainRequest
-from werkzeug.wrappers.request import Request
-from werkzeug.wrappers.response import Response
 
 
 def stream_encode_multipart(
@@ -305,6 +305,10 @@ class EnvironBuilder:
         ``REQUEST_URI`` and ``RAW_URI`` is the full raw URI including
         the query string, not only the path.
 
+    .. versionchanged:: 2.0.0
+        The default :attr:`request_class` is ``Request`` instead of
+        ``BaseRequest``.
+
     .. versionadded:: 0.15
         The ``json`` param and :meth:`json_dumps` method.
 
@@ -324,8 +328,8 @@ class EnvironBuilder:
     #: the wsgi version to use.  defaults to (1, 0)
     wsgi_version = (1, 0)
 
-    #: the default request class for :meth:`get_request`
-    request_class = BaseRequest
+    #: The default request class used by :meth:`get_request`.
+    request_class = Request
 
     import json
 
@@ -691,7 +695,7 @@ def close(self) -> None:
                 pass
         self.closed = True
 
-    def get_environ(self) -> Dict[str, Any]:
+    def get_environ(self) -> WSGIEnvironment:
         """Return the built environ.
 
         .. versionchanged:: 0.15
@@ -781,16 +785,15 @@ def _path_encode(x):
 
         return result
 
-    def get_request(
-        self, cls: Optional[Union[Type[Request], Type[PlainRequest]]] = None
-    ) -> Union[Request, BaseRequest, PlainRequest]:
+    def get_request(self, cls=None):
         """Returns a request with the data.  If the request class is not
         specified :attr:`request_class` is used.
 
         :param cls: The request wrapper to use.
         """
         if cls is None:
-            cls = self.request_class  # type: ignore
+            cls = self.request_class
+
         return cls(self.get_environ())
 
 
@@ -822,30 +825,39 @@ class ClientResponse(BaseResponse):
     `allow_subdomain_redirects` to `True` as if not no external redirects
     are allowed.
 
-    .. versionadded:: 0.5
-       `use_cookies` is new in this version.  Older versions did not provide
-       builtin cookie support.
-
-    .. versionadded:: 0.14
-       The `mimetype` parameter was added.
+    .. versionchanged:: 2.0.0
+        ``response_wrapper`` is always a subclass of
+        :class:``TestResponse``.
 
-    .. versionadded:: 0.15
-        The ``json`` parameter.
+    .. versionchanged:: 0.5
+        Added the ``use_cookies`` parameter.
     """
 
     def __init__(
         self,
-        application: Union[ProxyMiddleware, DebuggedApplication, Callable, Response],
+        application: Callable[[WSGIEnvironment, Callable], Iterable[bytes]],
         response_wrapper: Optional[Union[Type[Response], Type[BaseResponse]]] = None,
         use_cookies: bool = True,
         allow_subdomain_redirects: bool = False,
     ) -> None:
         self.application = application
-        self.response_wrapper = response_wrapper
+
+        if response_wrapper in {None, BaseResponse, Response}:
+            response_wrapper = TestResponse
+        elif not isinstance(response_wrapper, TestResponse):
+            response_wrapper = type(
+                "WrapperTestResponse", (TestResponse, response_wrapper), {}
+            )
+
+        self.response_wrapper: Type[TestResponse] = cast(
+            Type[TestResponse], response_wrapper
+        )
+
         if use_cookies:
             self.cookie_jar = _TestCookieJar()
         else:
             self.cookie_jar = None
+
         self.allow_subdomain_redirects = allow_subdomain_redirects
 
     def set_cookie(
@@ -907,22 +919,32 @@ def delete_cookie(
         )
 
     def run_wsgi_app(
-        self, environ: WSGIEnvironment, buffered: bool = False,
-    ) -> Union[Tuple[ClosingIterator, str, Headers], Tuple[chain, str, Headers]]:
-        """Runs the wrapped WSGI app with the given environment."""
+        self, environ: WSGIEnvironment, buffered: bool = False
+    ) -> Tuple[Iterable[bytes], str, Headers]:
+        """Runs the wrapped WSGI app with the given environment.
+
+        :meta private:
+        """
         if self.cookie_jar is not None:
             self.cookie_jar.inject_wsgi(environ)
+
         rv = run_wsgi_app(self.application, environ, buffered=buffered)
+
         if self.cookie_jar is not None:
             self.cookie_jar.extract_wsgi(environ, rv[2])
-        return rv  # type: ignore
 
-    def resolve_redirect(self, response, new_location, environ, buffered=False):
+        return rv
+
+    def resolve_redirect(
+        self, response: "TestResponse", buffered: bool = False
+    ) -> "TestResponse":
         """Perform a new request to the location given by the redirect
         response to the previous request.
+
+        :meta private:
         """
-        scheme, netloc, path, qs, anchor = url_parse(new_location)
-        builder = EnvironBuilder.from_environ(environ, query_string=qs)
+        scheme, netloc, path, qs, anchor = url_parse(response.location)
+        builder = EnvironBuilder.from_environ(response.request.environ, query_string=qs)
 
         to_name_parts = netloc.split(":", 1)[0].split(".")
         from_name_parts = builder.server_name.split(".")
@@ -956,10 +978,8 @@ def resolve_redirect(self, response, new_location, environ, buffered=False):
             builder.path = path
             builder.script_root = ""
 
-        status_code = int(response[1].split(None, 1)[0])
-
         # Only 307 and 308 preserve all of the original request.
-        if status_code not in {307, 308}:
+        if response.status_code not in {307, 308}:
             # HEAD is preserved, everything else becomes GET.
             if builder.method != "HEAD":
                 builder.method = "GET"
@@ -970,129 +990,146 @@ def resolve_redirect(self, response, new_location, environ, buffered=False):
             builder.content_length = None
             builder.headers.pop("Transfer-Encoding", None)
 
-        # Disable the response wrapper while handling redirects. Not
-        # thread safe, but the client should not be shared anyway.
-        old_response_wrapper = self.response_wrapper
-        self.response_wrapper = None
+        return self.open(builder, buffered=buffered)
 
-        try:
-            return self.open(builder, as_tuple=True, buffered=buffered)
-        finally:
-            self.response_wrapper = old_response_wrapper
+    def open(
+        self,
+        *args,
+        as_tuple: bool = False,
+        buffered: bool = False,
+        follow_redirects: bool = False,
+        **kwargs,
+    ) -> "TestResponse":
+        """Generate an environ dict from the given arguments, make a
+        request to the application using it, and return the response.
+
+        :param args: Passed to :class:`EnvironBuilder` to create the
+            environ for the request. If a single arg is passed, it can
+            be an existing :class:`EnvironBuilder` or an environ dict.
+        :param buffered: Convert the iterator returned by the app into
+            a list. If the iterator has a ``close()`` method, it is
+            called automatically.
+        :param follow_redirects: Make additional requests to follow HTTP
+            redirects until a non-redirect status is returned.
+            :attr:`TestResponse.history` lists the intermediate
+            responses.
+
+        .. versionchanged:: 2.0.0
+            ``as_tuple`` is deprecated and will be removed in version
+            2.1. Use :attr:`TestResponse.request` and
+            ``request.environ`` instead.
 
-    def open(self, *args, **kwargs) -> Any:
-        """Takes the same arguments as the :class:`EnvironBuilder` class with
-        some additions:  You can provide a :class:`EnvironBuilder` or a WSGI
-        environment as only argument instead of the :class:`EnvironBuilder`
-        arguments and two optional keyword arguments (`as_tuple`, `buffered`)
-        that change the type of the return value or the way the application is
-        executed.
+        .. versionchanged:: 0.5
+            If a dict is provided as file in the dict for the ``data``
+            parameter the content type has to be called ``content_type``
+            instead of ``mimetype``. This change was made for
+            consistency with :class:`werkzeug.FileWrapper`.
 
         .. versionchanged:: 0.5
-           If a dict is provided as file in the dict for the `data` parameter
-           the content type has to be called `content_type` now instead of
-           `mimetype`.  This change was made for consistency with
-           :class:`werkzeug.FileWrapper`.
+            Added the ``follow_redirects`` parameter.
+        """
+        request = None
 
-            The `follow_redirects` parameter was added to :func:`open`.
+        if not kwargs and len(args) == 1:
+            arg = args[0]
 
-        Additional parameters:
+            if isinstance(arg, EnvironBuilder):
+                request = arg.get_request()
+            elif isinstance(arg, dict):
+                request = EnvironBuilder.from_environ(arg).get_request()
+            elif isinstance(arg, BaseRequest):
+                request = arg
 
-        :param as_tuple: Returns a tuple in the form ``(environ, result)``
-        :param buffered: Set this to True to buffer the application run.
-                         This will automatically close the application for
-                         you as well.
-        :param follow_redirects: Set this to True if the `Client` should
-                                 follow HTTP redirects.
-        """
-        as_tuple = kwargs.pop("as_tuple", False)
-        buffered = kwargs.pop("buffered", False)
-        follow_redirects = kwargs.pop("follow_redirects", False)
-        environ = None
-        if not kwargs and len(args) == 1:
-            if isinstance(args[0], EnvironBuilder):
-                environ = args[0].get_environ()
-            elif isinstance(args[0], dict):
-                environ = args[0]
-        if environ is None:
+        if request is None:
             builder = EnvironBuilder(*args, **kwargs)
+
             try:
-                environ = builder.get_environ()
+                request = builder.get_request()
             finally:
                 builder.close()
 
-        response = self.run_wsgi_app(environ.copy(), buffered=buffered)
+        response = self.run_wsgi_app(request.environ, buffered=buffered)
+        response = self.response_wrapper(*response, request=request)
 
-        # handle redirects
-        redirect_chain = []
-        while 1:
-            status_code = int(response[1].split(None, 1)[0])
-            if (
-                status_code not in {301, 302, 303, 305, 307, 308}
-                or not follow_redirects
-            ):
-                break
+        redirects = set()
+        history: List[TestResponse] = []
 
+        while follow_redirects and response.status_code in {
+            301,
+            302,
+            303,
+            305,
+            307,
+            308,
+        }:
             # Exhaust intermediate response bodies to ensure middleware
             # that returns an iterator runs any cleanup code.
             if not buffered:
-                for _ in response[0]:
-                    pass
-
-            new_location = response[2]["location"]
-            new_redirect_entry = (new_location, status_code)
-            if new_redirect_entry in redirect_chain:
-                raise ClientRedirectError("loop detected")
-            redirect_chain.append(new_redirect_entry)
-            environ, response = self.resolve_redirect(
-                response, new_location, environ, buffered=buffered
-            )
+                response.make_sequence()
+                response.close()
+
+            new_redirect_entry = (response.location, response.status_code)
+
+            if new_redirect_entry in redirects:
+                raise ClientRedirectError(
+                    f"Loop detected: A {response.status_code} redirect"
+                    f" to {response.location} was already made."
+                )
+
+            redirects.add(new_redirect_entry)
+            response.history = tuple(history)
+            history.append(response)
+            response = self.resolve_redirect(response, buffered=buffered)
+
+        response.history = tuple(history)
 
-        if self.response_wrapper is not None:
-            response = self.response_wrapper(*response)  # type: ignore
         if as_tuple:
-            return environ, response
+            warnings.warn(
+                "'as_tuple' is deprecated and will be removed in"
+                " version 2.1. Access 'response.request.environ'"
+                " instead."
+            )
+            return request.environ, response  # type: ignore
+
         return response
 
-    def get(
-        self, *args, **kw
-    ) -> Union[Tuple[ClosingIterator, str, Headers], BaseResponse, Response]:
-        """Like open but method is enforced to GET."""
+    def get(self, *args, **kw) -> "TestResponse":
+        """Call :meth:`open` with ``method`` set to ``GET``."""
         kw["method"] = "GET"
         return self.open(*args, **kw)
 
-    def patch(self, *args, **kw):
-        """Like open but method is enforced to PATCH."""
-        kw["method"] = "PATCH"
-        return self.open(*args, **kw)
-
-    def post(self, *args, **kw) -> Union[BaseResponse, Response]:
-        """Like open but method is enforced to POST."""
+    def post(self, *args, **kw) -> "TestResponse":
+        """Call :meth:`open` with ``method`` set to ``POST``."""
         kw["method"] = "POST"
         return self.open(*args, **kw)
 
-    def head(self, *args, **kw):
-        """Like open but method is enforced to HEAD."""
-        kw["method"] = "HEAD"
-        return self.open(*args, **kw)
-
-    def put(self, *args, **kw):
-        """Like open but method is enforced to PUT."""
+    def put(self, *args, **kw) -> "TestResponse":
+        """Call :meth:`open` with ``method`` set to ``PUT``."""
         kw["method"] = "PUT"
         return self.open(*args, **kw)
 
-    def delete(self, *args, **kw) -> Response:
-        """Like open but method is enforced to DELETE."""
+    def delete(self, *args, **kw) -> "TestResponse":
+        """Call :meth:`open` with ``method`` set to ``DELETE``."""
         kw["method"] = "DELETE"
         return self.open(*args, **kw)
 
-    def options(self, *args, **kw):
-        """Like open but method is enforced to OPTIONS."""
+    def patch(self, *args, **kw) -> "TestResponse":
+        """Call :meth:`open` with ``method`` set to ``PATCH``."""
+        kw["method"] = "PATCH"
+        return self.open(*args, **kw)
+
+    def options(self, *args, **kw) -> "TestResponse":
+        """Call :meth:`open` with ``method`` set to ``OPTIONS``."""
         kw["method"] = "OPTIONS"
         return self.open(*args, **kw)
 
-    def trace(self, *args, **kw):
-        """Like open but method is enforced to TRACE."""
+    def head(self, *args, **kw) -> "TestResponse":
+        """Call :meth:`open` with ``method`` set to ``HEAD``."""
+        kw["method"] = "HEAD"
+        return self.open(*args, **kw)
+
+    def trace(self, *args, **kw) -> "TestResponse":
+        """Call :meth:`open` with ``method`` set to ``TRACE``."""
         kw["method"] = "TRACE"
         return self.open(*args, **kw)
 
@@ -1100,7 +1137,7 @@ def __repr__(self):
         return f"<{type(self).__name__} {self.application!r}>"
 
 
-def create_environ(*args, **kwargs):
+def create_environ(*args, **kwargs) -> WSGIEnvironment:
     """Create a new WSGI environ dict based on the values passed.  The first
     parameter should be the path of the request which defaults to '/'.  The
     second one can either be an absolute path (in that case the host is
@@ -1116,6 +1153,7 @@ def create_environ(*args, **kwargs):
        and `charset` parameters were added.
     """
     builder = EnvironBuilder(*args, **kwargs)
+
     try:
         return builder.get_environ()
     finally:
@@ -1123,10 +1161,10 @@ def create_environ(*args, **kwargs):
 
 
 def run_wsgi_app(
-    app: Any, environ: WSGIEnvironment, buffered: bool = False
-) -> Union[
-    Tuple[Iterator[Any], str, Headers],
-]:
+    app: Callable[[WSGIEnvironment, Callable], Iterable[bytes]],
+    environ: WSGIEnvironment,
+    buffered: bool = False,
+) -> Tuple[Iterable[bytes], str, Headers]:
     """Return a tuple in the form (app_iter, status, headers) of the
     application output.  This works best if you pass it an application that
     returns an iterator all the time.
@@ -1143,17 +1181,22 @@ def run_wsgi_app(
     :param buffered: set to `True` to enforce buffering.
     :return: tuple in the form ``(app_iter, status, headers)``
     """
-    environ = _get_environ(environ)
-    response: List[Any] = []
-    buffer: List[Any] = []
+    # Copy environ to ensure any mutations by the app (ProxyFix, for
+    # example) don't affect subsequent requests (such as redirects).
+    environ = _get_environ(environ).copy()
+    response: Optional[Tuple[str, List[Tuple[str, str]]]] = None
+    buffer: List[bytes] = []
 
     def start_response(status, headers, exc_info=None):
+        nonlocal response
+
         if exc_info:
             try:
                 raise exc_info[1].with_traceback(exc_info[2])
             finally:
                 exc_info = None
-        response[:] = [status, headers]
+
+        response = (status, headers)
         return buffer.append
 
     app_rv = app(environ, start_response)
@@ -1176,11 +1219,74 @@ def start_response(status, headers, exc_info=None):
     else:
         for item in app_iter:
             buffer.append(item)
-            if response:
+
+            if response is not None:
                 break
+
         if buffer:
             app_iter = chain(buffer, app_iter)
+
         if close_func is not None and app_iter is not app_rv:
             app_iter = ClosingIterator(app_iter, close_func)
 
     return app_iter, response[0], Headers(response[1])
+
+
+class TestResponse(Response):
+    """:class:`~werkzeug.wrappers.Response` subclass that provides extra
+    information about requests made with the test :class:`Client`.
+
+    Test client requests will always return an instance of this class.
+    If a custom response class is passed to the client, it is
+    subclassed along with this to support test information.
+    """
+
+    request: Request
+    """A request object with the environ used to make the request that
+    resulted in this response.
+    """
+
+    history: Tuple["TestResponse", ...]
+    """A list of intermediate responses. Populated when the test request
+    is made with ``follow_redirects`` enabled.
+    """
+
+    def __init__(
+        self,
+        response: Iterable[bytes],
+        status: str,
+        headers: Headers,
+        request: Request,
+        history: Tuple["TestResponse", ...] = (),
+        **kwargs,
+    ):
+        super().__init__(response, status, headers, **kwargs)
+        self.request = request
+        self.history = history
+        self._compat_tuple = response, status, headers
+
+    def __iter__(self):
+        warnings.warn(
+            (
+                "The test client no longer returns a tuple, it returns"
+                " a 'TestResponse'. Tuple unpacking is deprecated and"
+                " will be removed in version 2.1. Access the attributes"
+                " 'data', 'status', and 'headers' instead."
+            ),
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return iter(self._compat_tuple)
+
+    def __getitem__(self, item):
+        warnings.warn(
+            (
+                "The test client no longer returns a tuple, it returns"
+                " a 'TestResponse'. Item indexing is deprecated and"
+                " will be removed in version 2.1. Access the attributes"
+                " 'data', 'status', and 'headers' instead."
+            ),
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return self._compat_tuple[item]
diff --git a/tests/middleware/test_http_proxy.py b/tests/middleware/test_http_proxy.py
--- a/tests/middleware/test_http_proxy.py
+++ b/tests/middleware/test_http_proxy.py
@@ -35,7 +35,7 @@ def app(request):
         },
     )
 
-    client = Client(app, response_wrapper=BaseResponse)
+    client = Client(app)
 
     rv = client.get("/")
     assert rv.data == b"ROOT"
diff --git a/tests/middleware/test_proxy_fix.py b/tests/middleware/test_proxy_fix.py
--- a/tests/middleware/test_proxy_fix.py
+++ b/tests/middleware/test_proxy_fix.py
@@ -3,10 +3,10 @@
 from werkzeug.middleware.proxy_fix import ProxyFix
 from werkzeug.routing import Map
 from werkzeug.routing import Rule
+from werkzeug.test import Client
 from werkzeug.test import create_environ
 from werkzeug.utils import redirect
 from werkzeug.wrappers import Request
-from werkzeug.wrappers import Response
 
 
 @pytest.mark.parametrize(
@@ -149,28 +149,24 @@ def app(request):
         assert request.url_root == url_root
 
         urls = url_map.bind_to_environ(request.environ)
+        parrot_url = urls.build("parrot")
         # build includes prefix
         assert urls.build("parrot") == "/".join((request.script_root, "parrot"))
         # match doesn't include prefix
         assert urls.match("/parrot")[0] == "parrot"
 
-        return Response("success")
+        # location header will start with url_root
+        return redirect(parrot_url)
 
     url_map = Map([Rule("/parrot", endpoint="parrot")])
     app = ProxyFix(app, **kwargs)
 
     base.setdefault("REMOTE_ADDR", "192.168.0.1")
     environ = create_environ(environ_overrides=base)
+
     # host is always added, remove it if the test doesn't set it
     if "HTTP_HOST" not in base:
         del environ["HTTP_HOST"]
 
-    # ensure app request has correct headers
-    response = Response.from_app(app, environ)
-    assert response.get_data() == b"success"
-
-    # ensure redirect location is correct
-    redirect_app = redirect(url_map.bind_to_environ(environ).build("parrot"))
-    response = Response.from_app(redirect_app, environ)
-    location = response.headers["Location"]
-    assert location == f"{url_root}parrot"
+    response = Client(app).open(Request(environ))
+    assert response.location == f"{url_root}parrot"
diff --git a/tests/test_debug.py b/tests/test_debug.py
--- a/tests/test_debug.py
+++ b/tests/test_debug.py
@@ -16,7 +16,6 @@
 from werkzeug.debug.tbtools import Traceback
 from werkzeug.test import Client
 from werkzeug.wrappers import Request
-from werkzeug.wrappers import Response
 
 
 class TestDebugRepr:
@@ -235,7 +234,7 @@ def do_something():
                 raise KeyError("outer")
 
         debugged = DebuggedApplication(app)
-        client = Client(debugged, Response)
+        client = Client(debugged)
         response = client.get("/")
         data = response.get_data(as_text=True)
         assert 'raise ValueError("inner")' in data
diff --git a/tests/test_formparser.py b/tests/test_formparser.py
--- a/tests/test_formparser.py
+++ b/tests/test_formparser.py
@@ -227,7 +227,7 @@ def test_parse_from_environ(self):
 class TestMultiPart:
     def test_basic(self):
         resources = join(dirname(__file__), "multipart")
-        client = Client(form_data_consumer, Response)
+        client = Client(form_data_consumer)
 
         repository = [
             (
@@ -301,7 +301,7 @@ def test_basic(self):
             assert response.get_data() == repr(text).encode("utf-8")
 
     def test_ie7_unc_path(self):
-        client = Client(form_data_consumer, Response)
+        client = Client(form_data_consumer)
         data_file = join(dirname(__file__), "multipart", "ie7_full_path_request.http")
         data = get_contents(data_file)
         boundary = "---------------------------7da36d1b4a0164"
diff --git a/tests/test_test.py b/tests/test_test.py
--- a/tests/test_test.py
+++ b/tests/test_test.py
@@ -76,47 +76,47 @@ def multi_value_post_app(environ, start_response):
 def test_cookie_forging():
     c = Client(cookie_app)
     c.set_cookie("localhost", "foo", "bar")
-    appiter, code, headers = c.open()
-    assert list(appiter) == [b"foo=bar"]
+    response = c.open()
+    assert response.data == b"foo=bar"
 
 
 def test_set_cookie_app():
     c = Client(cookie_app)
-    appiter, code, headers = c.open()
-    assert "Set-Cookie" in dict(headers)
+    response = c.open()
+    assert "Set-Cookie" in response.headers
 
 
 def test_cookiejar_stores_cookie():
     c = Client(cookie_app)
-    appiter, code, headers = c.open()
+    c.open()
     assert "test" in c.cookie_jar._cookies["localhost.local"]["/"]
 
 
 def test_no_initial_cookie():
     c = Client(cookie_app)
-    appiter, code, headers = c.open()
-    assert b"".join(appiter) == b"No Cookie"
+    response = c.open()
+    assert response.data == b"No Cookie"
 
 
 def test_resent_cookie():
     c = Client(cookie_app)
     c.open()
-    appiter, code, headers = c.open()
-    assert b"".join(appiter) == b"test=test"
+    response = c.open()
+    assert response.data == b"test=test"
 
 
 def test_disable_cookies():
     c = Client(cookie_app, use_cookies=False)
     c.open()
-    appiter, code, headers = c.open()
-    assert b"".join(appiter) == b"No Cookie"
+    response = c.open()
+    assert response.data == b"No Cookie"
 
 
 def test_cookie_for_different_path():
     c = Client(cookie_app)
     c.open("/path1")
-    appiter, code, headers = c.open("/path2")
-    assert b"".join(appiter) == b"test=test"
+    response = c.open("/path2")
+    assert response.data == b"test=test"
 
 
 def test_environ_builder_basics():
@@ -176,7 +176,7 @@ def app(request):
         assert request.content_type == "application/json"
         return Response(json.loads(request.get_data(as_text=True))["foo"])
 
-    c = Client(app, Response)
+    c = Client(app)
     response = c.post("/", json={"foo": "bar"})
     assert response.data == b"bar"
 
@@ -384,18 +384,18 @@ def close(self):
 def test_follow_redirect():
     env = create_environ("/", base_url="http://localhost")
     c = Client(redirect_with_get_app)
-    appiter, code, headers = c.open(environ_overrides=env, follow_redirects=True)
-    assert code == "200 OK"
-    assert b"".join(appiter) == b"current url: http://localhost/some/redirect/"
+    response = c.open(environ_overrides=env, follow_redirects=True)
+    assert response.status == "200 OK"
+    assert response.data == b"current url: http://localhost/some/redirect/"
 
     # Test that the :cls:`Client` is aware of user defined response wrappers
-    c = Client(redirect_with_get_app, response_wrapper=BaseResponse)
+    c = Client(redirect_with_get_app)
     resp = c.get("/", follow_redirects=True)
     assert resp.status_code == 200
     assert resp.data == b"current url: http://localhost/some/redirect/"
 
     # test with URL other than '/' to make sure redirected URL's are correct
-    c = Client(redirect_with_get_app, response_wrapper=BaseResponse)
+    c = Client(redirect_with_get_app)
     resp = c.get("/first/request", follow_redirects=True)
     assert resp.status_code == 200
     assert resp.data == b"current url: http://localhost/some/redirect/"
@@ -413,7 +413,7 @@ def local_redirect_app(environ, start_response):
             response = Response(f"current path: {req.path}")
         return response(environ, start_response)
 
-    c = Client(local_redirect_app, response_wrapper=BaseResponse)
+    c = Client(local_redirect_app)
     resp = c.get("/from/location", follow_redirects=True)
     assert resp.status_code == 200
     assert resp.data == b"current path: /to/location"
@@ -438,7 +438,7 @@ def app(request):
 
         return redirect("http://localhost/some/redirect/", code=code)
 
-    c = Client(app, response_wrapper=BaseResponse)
+    c = Client(app)
     response = c.post(
         "/", follow_redirects=True, data={"foo": "bar"}, headers={"X-Foo": "bar"}
     )
@@ -473,7 +473,7 @@ def test_follow_external_redirect_on_same_subdomain():
 
 
 def test_follow_redirect_loop():
-    c = Client(redirect_loop_app, response_wrapper=BaseResponse)
+    c = Client(redirect_loop_app)
     with pytest.raises(ClientRedirectError):
         c.get("/", follow_redirects=True)
 
@@ -486,7 +486,7 @@ def app(request):
 
         return Response(request.path)
 
-    c = Client(app, response_wrapper=Response)
+    c = Client(app)
     response = c.get(
         "/redirect", base_url="http://localhost/other", follow_redirects=True
     )
@@ -511,12 +511,40 @@ def __call__(self, environ, start_response):
                 self.active -= 1
 
     app = Middleware(redirect_with_get_app)
-    client = Client(Middleware(redirect_with_get_app), Response)
+    client = Client(Middleware(redirect_with_get_app))
     response = client.get("/", follow_redirects=True, buffered=False)
     assert response.data == b"current url: http://localhost/some/redirect/"
     assert not app.active
 
 
+def test_redirects_are_tracked():
+    @Request.application
+    def app(request):
+        if request.path == "/first":
+            return redirect("/second")
+
+        if request.path == "/second":
+            return redirect("/third")
+
+        return Response("done")
+
+    c = Client(app)
+    response = c.get("/first", follow_redirects=True)
+    assert response.data == b"done"
+    assert len(response.history) == 2
+
+    assert response.history[-1].request.path == "/second"
+    assert response.history[-1].status_code == 302
+    assert response.history[-1].location == "http://localhost/third"
+    assert len(response.history[-1].history) == 1
+    assert response.history[-1].history[-1] is response.history[-2]
+
+    assert response.history[-2].request.path == "/first"
+    assert response.history[-2].status_code == 302
+    assert response.history[-2].location == "http://localhost/second"
+    assert len(response.history[-2].history) == 0
+
+
 def test_cookie_across_redirect():
     @Request.application
     def app(request):
@@ -533,7 +561,7 @@ def app(request):
             rv.delete_cookie("auth")
             return rv
 
-    c = Client(app, Response)
+    c = Client(app)
     assert c.get("/").data == b"out"
     assert c.get("/in", follow_redirects=True).data == b"in"
     assert c.get("/").data == b"in"
@@ -554,7 +582,7 @@ def middleware(environ, start_response):
         pop_path_info(environ)
         return app(environ, start_response)
 
-    c = Client(middleware, Response)
+    c = Client(middleware)
     rv = c.get("/prefix/first", follow_redirects=True)
     # if modified environ was used by client, this would be /
     assert rv.data == b"/second"
@@ -565,20 +593,20 @@ def test_app(environ, start_response):
         start_response("200 OK", [("Content-Type", "text/plain")])
         return [f"{environ['PATH_INFO']}\n{environ['SCRIPT_NAME']}"]
 
-    c = Client(test_app, response_wrapper=BaseResponse)
+    c = Client(test_app)
     resp = c.get("/foo%40bar")
     assert resp.data == b"/foo@bar\n"
-    c = Client(test_app, response_wrapper=BaseResponse)
+    c = Client(test_app)
     resp = c.get("/foo%40bar", "http://localhost/bar%40baz")
     assert resp.data == b"/foo@bar\n/bar@baz"
 
 
 def test_multi_value_submit():
-    c = Client(multi_value_post_app, response_wrapper=BaseResponse)
+    c = Client(multi_value_post_app)
     data = {"field": ["val1", "val2"]}
     resp = c.post("/", data=data)
     assert resp.status_code == 200
-    c = Client(multi_value_post_app, response_wrapper=BaseResponse)
+    c = Client(multi_value_post_app)
     data = MultiDict({"field": ["val1", "val2"]})
     resp = c.post("/", data=data)
     assert resp.status_code == 200
@@ -723,7 +751,7 @@ def test_app(request):
         response.set_cookie("test2", b"bar")
         return response
 
-    client = Client(test_app, Response)
+    client = Client(test_app)
     resp = client.get("/")
     assert resp.data == b"[]"
     resp = client.get("/")
@@ -763,7 +791,7 @@ def test_full_url_requests_with_args():
     def test_app(request):
         return Response(request.args["x"])
 
-    client = Client(test_app, Response)
+    client = Client(test_app)
     resp = client.get("/?x=42", base)
     assert resp.data == b"42"
     resp = client.get("http://www.example.com/?x=23", base)
@@ -775,13 +803,13 @@ def test_delete_requests_with_form():
     def test_app(request):
         return Response(request.form.get("x", None))
 
-    client = Client(test_app, Response)
+    client = Client(test_app)
     resp = client.delete("/", data={"x": 42})
     assert resp.data == b"42"
 
 
 def test_post_with_file_descriptor(tmpdir):
-    c = Client(Response(), response_wrapper=Response)
+    c = Client(Response())
     f = tmpdir.join("some-file.txt")
     f.write("foo")
     with open(f.strpath, mode="rt") as data:
@@ -797,7 +825,7 @@ def test_content_type():
     def test_app(request):
         return Response(request.content_type)
 
-    client = Client(test_app, Response)
+    client = Client(test_app)
 
     resp = client.get("/", data=b"testing", mimetype="text/css")
     assert resp.data == b"text/css; charset=utf-8"
@@ -813,7 +841,7 @@ def app(request):
         request_uri = request.environ["REQUEST_URI"]
         return Response("\n".join((path_info, request_uri)))
 
-    client = Client(app, Response)
+    client = Client(app)
     response = client.get("/hello%2fworld")
     data = response.get_data(as_text=True)
     assert data == "/hello/world\n/hello%2fworld"
@@ -823,3 +851,15 @@ def app(request):
 
     response = client.get("/%3f?")  # escaped ? in path, and empty query string
     assert response.get_data(as_text=True) == "/?\n/%3f?"
+
+
+def test_deprecated_tuple():
+    app = Request.application(lambda r: Response())
+    client = Client(app)
+    response = client.get()
+
+    with pytest.deprecated_call():
+        assert len(list(response)) == 3
+
+    with pytest.deprecated_call():
+        assert response[1] == "200 OK"
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -250,7 +250,7 @@ def test_append_slash_redirect():
     def app(env, sr):
         return utils.append_slash_redirect(env)(env, sr)
 
-    client = Client(app, BaseResponse)
+    client = Client(app)
     response = client.get("foo", base_url="http://example.org/app")
     assert response.status_code == 301
     assert response.headers["Location"] == "http://example.org/app/foo/"
diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py
--- a/tests/test_wrappers.py
+++ b/tests/test_wrappers.py
@@ -1,13 +1,13 @@
 import contextlib
 import json
 import os
-import pickle
 from datetime import datetime
 from datetime import timedelta
 from io import BytesIO
 
 import pytest
 
+from werkzeug import Response
 from werkzeug import wrappers
 from werkzeug.datastructures import Accept
 from werkzeug.datastructures import CharsetAccept
@@ -31,47 +31,10 @@
 from werkzeug.wsgi import wrap_file
 
 
-class RequestTestResponse(wrappers.BaseResponse):
-    """Subclass of the normal response class we use to test response
-    and base classes.  Has some methods to test if things in the
-    response match.
-    """
-
-    def __init__(self, response, status, headers):
-        wrappers.BaseResponse.__init__(self, response, status, headers)
-        self.body_data = pickle.loads(self.get_data())
-
-    def __getitem__(self, key):
-        return self.body_data[key]
-
-
-def request_demo_app(environ, start_response):
-    request = wrappers.BaseRequest(environ)
-    assert "werkzeug.request" in environ
-    start_response("200 OK", [("Content-Type", "text/plain")])
-    return [
-        pickle.dumps(
-            {
-                "args": request.args,
-                "args_as_list": list(request.args.lists()),
-                "form": request.form,
-                "form_as_list": list(request.form.lists()),
-                "environ": prepare_environ_pickle(request.environ),
-                "data": request.get_data(),
-            }
-        )
-    ]
-
-
-def prepare_environ_pickle(environ):
-    result = {}
-    for key, value in iter(environ.items()):
-        try:
-            pickle.dumps((key, value))
-        except Exception:
-            continue
-        result[key] = value
-    return result
+@wrappers.Request.application
+def request_demo_app(request):
+    assert "werkzeug.request" in request.environ
+    return Response()
 
 
 def assert_environ(environ, method):
@@ -84,16 +47,15 @@ def assert_environ(environ, method):
 
 
 def test_base_request():
-    client = Client(request_demo_app, RequestTestResponse)
+    client = Client(request_demo_app)
 
     # get requests
     response = client.get("/?foo=bar&foo=hehe")
-    assert response["args"] == MultiDict([("foo", "bar"), ("foo", "hehe")])
-    assert response["args_as_list"] == [("foo", ["bar", "hehe"])]
-    assert response["form"] == MultiDict()
-    assert response["form_as_list"] == []
-    assert response["data"] == b""
-    assert_environ(response["environ"], "GET")
+    request = response.request
+    assert request.args == MultiDict([("foo", "bar"), ("foo", "hehe")])
+    assert request.form == MultiDict()
+    assert request.data == b""
+    assert_environ(request.environ, "GET")
 
     # post requests with form data
     response = client.post(
@@ -101,14 +63,14 @@ def test_base_request():
         data="foo=blub+hehe&blah=42",
         content_type="application/x-www-form-urlencoded",
     )
-    assert response["args"] == MultiDict([("blub", "blah")])
-    assert response["args_as_list"] == [("blub", ["blah"])]
-    assert response["form"] == MultiDict([("foo", "blub hehe"), ("blah", "42")])
-    assert response["data"] == b""
+    request = response.request
+    assert request.args == MultiDict([("blub", "blah")])
+    assert request.form == MultiDict([("foo", "blub hehe"), ("blah", "42")])
+    assert request.data == b""
     # currently we do not guarantee that the values are ordered correctly
     # for post data.
     # assert response['form_as_list'] == [('foo', ['blub hehe']), ('blah', ['42'])]
-    assert_environ(response["environ"], "POST")
+    assert_environ(request.environ, "POST")
 
     # patch requests with form data
     response = client.patch(
@@ -116,18 +78,19 @@ def test_base_request():
         data="foo=blub+hehe&blah=42",
         content_type="application/x-www-form-urlencoded",
     )
-    assert response["args"] == MultiDict([("blub", "blah")])
-    assert response["args_as_list"] == [("blub", ["blah"])]
-    assert response["form"] == MultiDict([("foo", "blub hehe"), ("blah", "42")])
-    assert response["data"] == b""
-    assert_environ(response["environ"], "PATCH")
+    request = response.request
+    assert request.args == MultiDict([("blub", "blah")])
+    assert request.form == MultiDict([("foo", "blub hehe"), ("blah", "42")])
+    assert request.data == b""
+    assert_environ(request.environ, "PATCH")
 
     # post requests with json data
     json = b'{"foo": "bar", "blub": "blah"}'
     response = client.post("/?a=b", data=json, content_type="application/json")
-    assert response["data"] == json
-    assert response["args"] == MultiDict([("a", "b")])
-    assert response["form"] == MultiDict()
+    request = response.request
+    assert request.data == json
+    assert request.args == MultiDict([("a", "b")])
+    assert request.form == MultiDict()
 
 
 def test_query_string_is_bytes():
diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py
--- a/tests/test_wsgi.py
+++ b/tests/test_wsgi.py
@@ -75,7 +75,7 @@ def test_responder():
     def foo(environ, start_response):
         return BaseResponse(b"Test")
 
-    client = Client(wsgi.responder(foo), BaseResponse)
+    client = Client(wsgi.responder(foo))
     response = client.get("/")
     assert response.status_code == 200
     assert response.data == b"Test"