Repository: falconry/falcon

Issue:
Add example code & recipes to test suite
Need to do this in a DRY manner.


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/falconry/falcon/pull/2261?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`1b14414`)](https://app.codecov.io/gh/falconry/falcon/commit/1b144148c7e0acb6f62c55033d0a135cbfa69133?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry) to head [(`5184a25`)](https://app.codecov.io/gh/falconry/falcon/commit/5184a256e4c0a231fe6c24385a9f8fb2bfb216dd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##            master     #2261   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           63        63           
  Lines         6849      6849           
  Branches      1260      1260           
=========================================
  Hits          6849      6849           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/falconry/falcon/pull/2261?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=falconry).

vytas7: > nice work!

Thanks!

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f5dbd5c..4748ace 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -133,6 +133,17 @@ $ gnome-open docs/_build/html/index.html
 $ xdg-open docs/_build/html/index.html
 ```
 
+### Recipes and code snippets
+
+If you are adding new recipes (in `docs/user/recipes`), try to break out code
+snippets into separate files inside `examples/recipes`.
+This allows `ruff` to format these snippets to conform to our code style, as
+well as check for trivial errors.
+Then simply use `literalinclude` to embed these snippets into your `.rst` recipe.
+
+If possible, try to implement tests for your recipe in `tests/test_recipes.py`.
+This helps to ensure that our recipes stay up-to-date as the framework's development progresses!
+
 ### VS Code Dev Container development environment
 
 When opening the project using the [VS Code](https://code.visualstudio.com/) IDE, if you have [Docker](https://www.docker.com/) (or some drop-in replacement such as [Podman](https://podman.io/) or [Colima](https://github.com/abiosoft/colima) or [Rancher Desktop](https://rancherdesktop.io/)) installed, you can leverage the [Dev Containers](https://code.visualstudio.com/docs/devcontainers/containers) feature to start a container in the background with all the dependencies required to test and debug the Falcon code. VS Code integrates with the Dev Container seamlessly, which can be configured via [devcontainer.json](.devcontainer/devcontainer.json). Once you open the project in VS Code, you can execute the "Reopen in Container" command to start the Dev Container which will run the headless VS Code Server process that the local VS Code app will connect to via a [published port](https://docs.docker.com/config/containers/container-networking/#published-ports).
diff --git a/docs/user/recipes/header-name-case.rst b/docs/user/recipes/header-name-case.rst
index 98460dc..e09de25 100644
--- a/docs/user/recipes/header-name-case.rst
+++ b/docs/user/recipes/header-name-case.rst
@@ -11,51 +11,12 @@ clients, it is possible to override HTTP headers using
 `generic WSGI middleware
 <https://www.python.org/dev/peps/pep-3333/#middleware-components-that-play-both-sides>`_:
 
-.. code:: python
-
-    class CustomHeadersMiddleware:
-
-        def __init__(self, app, title_case=True, custom_capitalization=None):
-            self._app = app
-            self._title_case = title_case
-            self._capitalization = custom_capitalization or {}
-
-        def __call__(self, environ, start_response):
-            def start_response_wrapper(status, response_headers, exc_info=None):
-                if self._title_case:
-                    headers = [
-                        (self._capitalization.get(name, name.title()), value)
-                        for name, value in response_headers]
-                else:
-                    headers = [
-                        (self._capitalization.get(name, name), value)
-                        for name, value in response_headers]
-                start_response(status, headers, exc_info)
-
-            return self._app(environ, start_response_wrapper)
+.. literalinclude:: ../../../examples/recipes/header_name_case_mw.py
+    :language: python
 
 We can now use this middleware to wrap a Falcon app:
 
-.. code:: python
-
-    import falcon
-
-    # Import or define CustomHeadersMiddleware from the above snippet...
-
-
-    class FunkyResource:
-
-        def on_get(self, req, resp):
-            resp.set_header('X-Funky-Header', 'test')
-            resp.media = {'message': 'Hello'}
-
-
-    app = falcon.App()
-    app.add_route('/test', FunkyResource())
-
-    app = CustomHeadersMiddleware(
-        app,
-        custom_capitalization={'x-funky-header': 'X-FuNkY-HeADeR'},
-    )
+.. literalinclude:: ../../../examples/recipes/header_name_case_app.py
+    :language: python
 
 As a bonus, this recipe applies to non-Falcon WSGI applications too.
diff --git a/docs/user/recipes/multipart-mixed.rst b/docs/user/recipes/multipart-mixed.rst
index 53b4754..713141f 100644
--- a/docs/user/recipes/multipart-mixed.rst
+++ b/docs/user/recipes/multipart-mixed.rst
@@ -22,14 +22,8 @@ Let us extend the multipart form parser :attr:`media handlers
 <falcon.media.multipart.MultipartParseOptions.media_handlers>` to recursively
 parse embedded forms of the ``multipart/mixed`` content type:
 
-.. code:: python
-
-    import falcon
-    import falcon.media
-
-    parser = falcon.media.MultipartFormHandler()
-    parser.parse_options.media_handlers['multipart/mixed'] = (
-        falcon.media.MultipartFormHandler())
+.. literalinclude:: ../../../examples/recipes/multipart_mixed_intro.py
+    :language: python
 
 .. note::
     Here we create a new parser (with default options) for nested parts,
@@ -40,29 +34,8 @@ parse embedded forms of the ``multipart/mixed`` content type:
 
 Let us now use the nesting-aware parser in an app:
 
-.. code:: python
-
-    import falcon
-    import falcon.media
-
-    class Forms:
-        def on_post(self, req, resp):
-            example = {}
-            for part in req.media:
-                if part.content_type.startswith('multipart/mixed'):
-                    for nested in part.media:
-                        example[nested.filename] = nested.text
-
-            resp.media = example
-
-
-    parser = falcon.media.MultipartFormHandler()
-    parser.parse_options.media_handlers['multipart/mixed'] = (
-        falcon.media.MultipartFormHandler())
-
-    app = falcon.App()
-    app.req_options.media_handlers[falcon.MEDIA_MULTIPART] = parser
-    app.add_route('/forms', Forms())
+.. literalinclude:: ../../../examples/recipes/multipart_mixed_main.py
+    :language: python
 
 We should now be able to consume a form containing a nested ``multipart/mixed``
 part (the example is adapted from the now-obsolete
diff --git a/docs/user/recipes/output-csv.rst b/docs/user/recipes/output-csv.rst
index a48b0f3..4255625 100644
--- a/docs/user/recipes/output-csv.rst
+++ b/docs/user/recipes/output-csv.rst
@@ -13,37 +13,13 @@ and then assign its value to :attr:`resp.text <falcon.Response.text>`:
 
     .. group-tab:: WSGI
 
-        .. code:: python
-
-            class Report:
-
-                def on_get(self, req, resp):
-                    output = io.StringIO()
-                    writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC)
-                    writer.writerow(('fruit', 'quantity'))
-                    writer.writerow(('apples', 13))
-                    writer.writerow(('oranges', 37))
-
-                    resp.content_type = 'text/csv'
-                    resp.downloadable_as = 'report.csv'
-                    resp.text = output.getvalue()
+        .. literalinclude:: ../../../examples/recipes/output_csv_text_wsgi.py
+            :language: python
 
     .. group-tab:: ASGI
 
-        .. code:: python
-
-            class Report:
-
-                async def on_get(self, req, resp):
-                    output = io.StringIO()
-                    writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC)
-                    writer.writerow(('fruit', 'quantity'))
-                    writer.writerow(('apples', 13))
-                    writer.writerow(('oranges', 37))
-
-                    resp.content_type = 'text/csv'
-                    resp.downloadable_as = 'report.csv'
-                    resp.text = output.getvalue()
+        .. literalinclude:: ../../../examples/recipes/output_csv_text_asgi.py
+            :language: python
 
 Here we set the response ``Content-Type`` to ``"text/csv"`` as
 recommended by `RFC 4180 <https://tools.ietf.org/html/rfc4180>`_, and assign
@@ -66,74 +42,13 @@ accumulate the CSV data in a list. We will then set :attr:`resp.stream
 
     .. group-tab:: WSGI
 
-        .. code:: python
-
-            class Report:
-
-                class PseudoTextStream:
-                    def __init__(self):
-                        self.clear()
-
-                    def clear(self):
-                        self.result = []
-
-                    def write(self, data):
-                        self.result.append(data.encode())
-
-                def fibonacci_generator(self, n=1000):
-                    stream = self.PseudoTextStream()
-                    writer = csv.writer(stream, quoting=csv.QUOTE_NONNUMERIC)
-                    writer.writerow(('n', 'Fibonacci Fn'))
-
-                    previous = 1
-                    current = 0
-                    for i in range(n+1):
-                        writer.writerow((i, current))
-                        previous, current = current, current + previous
-
-                        yield from stream.result
-                        stream.clear()
-
-                def on_get(self, req, resp):
-                    resp.content_type = 'text/csv'
-                    resp.downloadable_as = 'report.csv'
-                    resp.stream = self.fibonacci_generator()
+        .. literalinclude:: ../../../examples/recipes/output_csv_stream_wsgi.py
+            :language: python
 
     .. group-tab:: ASGI
 
-        .. code:: python
-
-            class Report:
-
-                class PseudoTextStream:
-                    def __init__(self):
-                        self.clear()
-
-                    def clear(self):
-                        self.result = []
-
-                    def write(self, data):
-                        self.result.append(data.encode())
-
-                async def fibonacci_generator(self, n=1000):
-                    stream = self.PseudoTextStream()
-                    writer = csv.writer(stream, quoting=csv.QUOTE_NONNUMERIC)
-                    writer.writerow(('n', 'Fibonacci Fn'))
-
-                    previous = 1
-                    current = 0
-                    for i in range(n+1):
-                        writer.writerow((i, current))
-                        previous, current = current, current + previous
-
-                        for chunk in stream.result:
-                            yield chunk
-                        stream.clear()
-
-                async def on_get(self, req, resp):
-                    resp.content_type = 'text/csv'
-                    resp.downloadable_as = 'report.csv'
-                    resp.stream = self.fibonacci_generator()
+        .. literalinclude:: ../../../examples/recipes/output_csv_stream_wsgi.py
+            :language: python
 
         .. note::
             At the time of writing, Python does not support ``yield from`` here
diff --git a/docs/user/recipes/pretty-json.rst b/docs/user/recipes/pretty-json.rst
index 5faf59e..76a45eb 100644
--- a/docs/user/recipes/pretty-json.rst
+++ b/docs/user/recipes/pretty-json.rst
@@ -9,16 +9,8 @@ prettify the output. By default, Falcon's :class:`JSONHandler
 However, you can easily customize the output by simply providing the
 desired ``dumps`` parameters:
 
-.. code:: python
-
-    import functools
-    import json
-
-    from falcon import media
-
-    json_handler = media.JSONHandler(
-        dumps=functools.partial(json.dumps, indent=4, sort_keys=True),
-    )
+.. literalinclude:: ../../../examples/recipes/pretty_json_intro.py
+    :language: python
 
 You can now replace the default ``application/json``
 :attr:`response media handlers <falcon.ResponseOptions.media_handlers>`
@@ -50,35 +42,9 @@ functionality" as per `RFC 6836, Section 4.3
 Assuming we want to add JSON ``indent`` support to a Falcon app, this can be
 implemented with a :ref:`custom media handler <custom-media-handler-type>`:
 
-.. code:: python
-
-    import json
-
-    import falcon
-
-
-    class CustomJSONHandler(falcon.media.BaseHandler):
-        MAX_INDENT_LEVEL = 8
-
-        def deserialize(self, stream, content_type, content_length):
-            data = stream.read()
-            return json.loads(data.decode())
-
-        def serialize(self, media, content_type):
-            _, params = falcon.parse_header(content_type)
-            indent = params.get('indent')
-            if indent is not None:
-                try:
-                    indent = int(indent)
-                    # NOTE: Impose a reasonable indentation level limit.
-                    if indent < 0 or indent > self.MAX_INDENT_LEVEL:
-                        indent = None
-                except ValueError:
-                    # TODO: Handle invalid params?
-                    indent = None
+.. literalinclude:: ../../../examples/recipes/pretty_json_main.py
+    :language: python
 
-            result = json.dumps(media, indent=indent, sort_keys=bool(indent))
-            return result.encode()
 
 Furthermore, we'll need to implement content-type negotiation to accept the
 indented JSON content type for response serialization. The bare-minimum
diff --git a/docs/user/recipes/raw-url-path.rst b/docs/user/recipes/raw-url-path.rst
index c20bd70..75dcc4e 100644
--- a/docs/user/recipes/raw-url-path.rst
+++ b/docs/user/recipes/raw-url-path.rst
@@ -20,38 +20,8 @@ understands two such extensions, ``RAW_URI`` (Gunicorn, Werkzeug's dev server)
 and ``REQUEST_URI`` (uWSGI, Waitress, Werkzeug's dev server), and replaces
 ``req.path`` with a value extracted from the raw URL:
 
-.. code:: python
-
-    import falcon
-    import falcon.uri
-
-
-    class RawPathComponent:
-        def process_request(self, req, resp):
-            raw_uri = req.env.get('RAW_URI') or req.env.get('REQUEST_URI')
-
-            # NOTE: Reconstruct the percent-encoded path from the raw URI.
-            if raw_uri:
-                req.path, _, _ = raw_uri.partition('?')
-
-
-    class URLResource:
-        def on_get(self, req, resp, url):
-            # NOTE: url here is potentially percent-encoded.
-            url = falcon.uri.decode(url)
-
-            resp.media = {'url': url}
-
-        def on_get_status(self, req, resp, url):
-            # NOTE: url here is potentially percent-encoded.
-            url = falcon.uri.decode(url)
-
-            resp.media = {'cached': True}
-
-
-    app = falcon.App(middleware=[RawPathComponent()])
-    app.add_route('/cache/{url}', URLResource())
-    app.add_route('/cache/{url}/status', URLResource(), suffix='status')
+.. literalinclude:: ../../../examples/recipes/raw_url_path_wsgi.py
+    :language: python
 
 Running the above app with a supported server such as Gunicorn or uWSGI, the
 following response is rendered to
@@ -98,39 +68,8 @@ Similar to the WSGI snippet from the previous chapter, let us create a
 middleware component that replaces ``req.path`` with the value of ``raw_path``
 (provided the latter is present in the ASGI HTTP scope):
 
-.. code:: python
-
-    import falcon.asgi
-    import falcon.uri
-
-
-    class RawPathComponent:
-        async def process_request(self, req, resp):
-            raw_path = req.scope.get('raw_path')
-
-            # NOTE: Decode the raw path from the raw_path bytestring, disallowing
-            #   non-ASCII characters, assuming they are correctly percent-coded.
-            if raw_path:
-                req.path = raw_path.decode('ascii')
-
-
-    class URLResource:
-        async def on_get(self, req, resp, url):
-            # NOTE: url here is potentially percent-encoded.
-            url = falcon.uri.decode(url)
-
-            resp.media = {'url': url}
-
-        async def on_get_status(self, req, resp, url):
-            # NOTE: url here is potentially percent-encoded.
-            url = falcon.uri.decode(url)
-
-            resp.media = {'cached': True}
-
-
-    app = falcon.asgi.App(middleware=[RawPathComponent()])
-    app.add_route('/cache/{url}', URLResource())
-    app.add_route('/cache/{url}/status', URLResource(), suffix='status')
+.. literalinclude:: ../../../examples/recipes/raw_url_path_asgi.py
+    :language: python
 
 Running the above snippet with ``uvicorn`` (that supports ``raw_path``), the
 percent-encoded ``url`` field is now correctly handled for a
diff --git a/docs/user/recipes/request-id.rst b/docs/user/recipes/request-id.rst
index 83439ee..688d4fb 100644
--- a/docs/user/recipes/request-id.rst
+++ b/docs/user/recipes/request-id.rst
@@ -13,69 +13,20 @@ from within components that are deeply nested or otherwise live outside of the
 normal request context, you can use a `thread-local`_ context object to store
 the request ID:
 
-.. code:: python
-
-    # context.py
-
-    import threading
-
-    class _Context:
-        def __init__(self):
-            self._thread_local = threading.local()
-
-        @property
-        def request_id(self):
-            return getattr(self._thread_local, 'request_id', None)
-
-        @request_id.setter
-        def request_id(self, value):
-            self._thread_local.request_id = value
-
-    ctx = _Context()
+.. literalinclude:: ../../../examples/recipes/request_id_context.py
+    :language: python
 
 Then, you can create a :ref:`middleware <middleware>` class to generate a
 unique ID for each request, persisting it in the thread local:
 
-.. code:: python
-
-    # middleware.py
-
-    from uuid import uuid4
-    from context import ctx
-
-    class RequestIDMiddleware:
-        def process_request(self, req, resp):
-            ctx.request_id = str(uuid4())
-
-        # It may also be helpful to include the ID in the response
-        def process_response(self, req, resp, resource, req_succeeded):
-            resp.set_header('X-Request-ID', ctx.request_id)
+.. literalinclude:: ../../../examples/recipes/request_id_middleware.py
+    :language: python
 
 Alternatively, if all of your application logic has access to the :ref:`request
 <request>`, you can simply use the `context` object to store the ID:
 
-.. code:: python
-
-    # middleware.py
-
-    from uuid import uuid4
-
-    # Optional logging package (pip install structlog)
-    import structlog
-
-    class RequestIDMiddleware:
-        def process_request(self, req, resp):
-            request_id = str(uuid4())
-
-            # Using Falcon 2.0 syntax
-            req.context.request_id = request_id
-
-            # Or if your logger has built-in support for contexts
-            req.context.log = structlog.get_logger(request_id=request_id)
-
-        # It may also be helpful to include the ID in the response
-        def process_response(self, req, resp, resource, req_succeeded):
-            resp.set_header('X-Request-ID', req.context.request_id)
+.. literalinclude:: ../../../examples/recipes/request_id_structlog.py
+    :language: python
 
 .. note::
 
@@ -94,24 +45,7 @@ or by using a third-party logging library such as
 
 In a pinch, you can also output the request ID directly:
 
-.. code:: python
-
-    # some_other_module.py
-
-    import logging
-
-    from context import ctx
-
-    def create_widget_object(name: str) -> Any:
-        request_id = 'request_id={0}'.format(ctx.request_id)
-        logging.debug('%s going to create widget: %s', request_id, name)
-
-        try:
-            # create the widget
-        except:
-            logging.exception('%s something went wrong', request_id)
-
-        logging.debug('%s created widget: %s', request_id, name)
-
+.. literalinclude:: ../../../examples/recipes/request_id_log.py
+    :language: python
 
 .. _thread-local: https://docs.python.org/3.7/library/threading.html#thread-local-data
diff --git a/examples/recipes/header_name_case_app.py b/examples/recipes/header_name_case_app.py
new file mode 100644
index 0000000..dc60607
--- /dev/null
+++ b/examples/recipes/header_name_case_app.py
@@ -0,0 +1,20 @@
+import falcon
+
+
+# Import or copy CustomHeadersMiddleware from the above snippet
+class CustomHeadersMiddleware: ...
+
+
+class FunkyResource:
+    def on_get(self, req, resp):
+        resp.set_header('X-Funky-Header', 'test')
+        resp.media = {'message': 'Hello'}
+
+
+app = falcon.App()
+app.add_route('/test', FunkyResource())
+
+app = CustomHeadersMiddleware(
+    app,
+    custom_capitalization={'x-funky-header': 'X-FuNkY-HeADeR'},
+)
diff --git a/examples/recipes/header_name_case_mw.py b/examples/recipes/header_name_case_mw.py
new file mode 100644
index 0000000..e818145
--- /dev/null
+++ b/examples/recipes/header_name_case_mw.py
@@ -0,0 +1,21 @@
+class CustomHeadersMiddleware:
+    def __init__(self, app, title_case=True, custom_capitalization=None):
+        self._app = app
+        self._title_case = title_case
+        self._capitalization = custom_capitalization or {}
+
+    def __call__(self, environ, start_response):
+        def start_response_wrapper(status, response_headers, exc_info=None):
+            if self._title_case:
+                headers = [
+                    (self._capitalization.get(name, name.title()), value)
+                    for name, value in response_headers
+                ]
+            else:
+                headers = [
+                    (self._capitalization.get(name, name), value)
+                    for name, value in response_headers
+                ]
+            start_response(status, headers, exc_info)
+
+        return self._app(environ, start_response_wrapper)
diff --git a/examples/recipes/multipart_mixed_intro.py b/examples/recipes/multipart_mixed_intro.py
new file mode 100644
index 0000000..e38f110
--- /dev/null
+++ b/examples/recipes/multipart_mixed_intro.py
@@ -0,0 +1,7 @@
+import falcon
+import falcon.media
+
+parser = falcon.media.MultipartFormHandler()
+parser.parse_options.media_handlers['multipart/mixed'] = (
+    falcon.media.MultipartFormHandler()
+)
diff --git a/examples/recipes/multipart_mixed_main.py b/examples/recipes/multipart_mixed_main.py
new file mode 100644
index 0000000..7288362
--- /dev/null
+++ b/examples/recipes/multipart_mixed_main.py
@@ -0,0 +1,23 @@
+import falcon
+import falcon.media
+
+
+class Forms:
+    def on_post(self, req, resp):
+        example = {}
+        for part in req.media:
+            if part.content_type.startswith('multipart/mixed'):
+                for nested in part.media:
+                    example[nested.filename] = nested.text
+
+        resp.media = example
+
+
+parser = falcon.media.MultipartFormHandler()
+parser.parse_options.media_handlers['multipart/mixed'] = (
+    falcon.media.MultipartFormHandler()
+)
+
+app = falcon.App()
+app.req_options.media_handlers[falcon.MEDIA_MULTIPART] = parser
+app.add_route('/forms', Forms())
diff --git a/examples/recipes/output_csv_stream_asgi.py b/examples/recipes/output_csv_stream_asgi.py
new file mode 100644
index 0000000..967776f
--- /dev/null
+++ b/examples/recipes/output_csv_stream_asgi.py
@@ -0,0 +1,33 @@
+import csv
+
+
+class Report:
+    class PseudoTextStream:
+        def __init__(self):
+            self.clear()
+
+        def clear(self):
+            self.result = []
+
+        def write(self, data):
+            self.result.append(data.encode())
+
+    async def fibonacci_generator(self, n=1000):
+        stream = self.PseudoTextStream()
+        writer = csv.writer(stream, quoting=csv.QUOTE_NONNUMERIC)
+        writer.writerow(('n', 'Fibonacci Fn'))
+
+        previous = 1
+        current = 0
+        for i in range(n + 1):
+            writer.writerow((i, current))
+            previous, current = current, current + previous
+
+            for chunk in stream.result:
+                yield chunk
+            stream.clear()
+
+    async def on_get(self, req, resp):
+        resp.content_type = 'text/csv'
+        resp.downloadable_as = 'report.csv'
+        resp.stream = self.fibonacci_generator()
diff --git a/examples/recipes/output_csv_stream_wsgi.py b/examples/recipes/output_csv_stream_wsgi.py
new file mode 100644
index 0000000..fee8ae7
--- /dev/null
+++ b/examples/recipes/output_csv_stream_wsgi.py
@@ -0,0 +1,32 @@
+import csv
+
+
+class Report:
+    class PseudoTextStream:
+        def __init__(self):
+            self.clear()
+
+        def clear(self):
+            self.result = []
+
+        def write(self, data):
+            self.result.append(data.encode())
+
+    def fibonacci_generator(self, n=1000):
+        stream = self.PseudoTextStream()
+        writer = csv.writer(stream, quoting=csv.QUOTE_NONNUMERIC)
+        writer.writerow(('n', 'Fibonacci Fn'))
+
+        previous = 1
+        current = 0
+        for i in range(n + 1):
+            writer.writerow((i, current))
+            previous, current = current, current + previous
+
+            yield from stream.result
+            stream.clear()
+
+    def on_get(self, req, resp):
+        resp.content_type = 'text/csv'
+        resp.downloadable_as = 'report.csv'
+        resp.stream = self.fibonacci_generator()
diff --git a/examples/recipes/output_csv_text_asgi.py b/examples/recipes/output_csv_text_asgi.py
new file mode 100644
index 0000000..adebcfd
--- /dev/null
+++ b/examples/recipes/output_csv_text_asgi.py
@@ -0,0 +1,15 @@
+import csv
+import io
+
+
+class Report:
+    async def on_get(self, req, resp):
+        output = io.StringIO()
+        writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC)
+        writer.writerow(('fruit', 'quantity'))
+        writer.writerow(('apples', 13))
+        writer.writerow(('oranges', 37))
+
+        resp.content_type = 'text/csv'
+        resp.downloadable_as = 'report.csv'
+        resp.text = output.getvalue()
diff --git a/examples/recipes/output_csv_text_wsgi.py b/examples/recipes/output_csv_text_wsgi.py
new file mode 100644
index 0000000..1254966
--- /dev/null
+++ b/examples/recipes/output_csv_text_wsgi.py
@@ -0,0 +1,15 @@
+import csv
+import io
+
+
+class Report:
+    def on_get(self, req, resp):
+        output = io.StringIO()
+        writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC)
+        writer.writerow(('fruit', 'quantity'))
+        writer.writerow(('apples', 13))
+        writer.writerow(('oranges', 37))
+
+        resp.content_type = 'text/csv'
+        resp.downloadable_as = 'report.csv'
+        resp.text = output.getvalue()
diff --git a/examples/recipes/pretty_json_intro.py b/examples/recipes/pretty_json_intro.py
new file mode 100644
index 0000000..152cf10
--- /dev/null
+++ b/examples/recipes/pretty_json_intro.py
@@ -0,0 +1,8 @@
+import functools
+import json
+
+from falcon import media
+
+json_handler = media.JSONHandler(
+    dumps=functools.partial(json.dumps, indent=4, sort_keys=True),
+)
diff --git a/examples/recipes/pretty_json_main.py b/examples/recipes/pretty_json_main.py
new file mode 100644
index 0000000..bd1a02d
--- /dev/null
+++ b/examples/recipes/pretty_json_main.py
@@ -0,0 +1,27 @@
+import json
+
+import falcon
+
+
+class CustomJSONHandler(falcon.media.BaseHandler):
+    MAX_INDENT_LEVEL = 8
+
+    def deserialize(self, stream, content_type, content_length):
+        data = stream.read()
+        return json.loads(data.decode())
+
+    def serialize(self, media, content_type):
+        _, params = falcon.parse_header(content_type)
+        indent = params.get('indent')
+        if indent is not None:
+            try:
+                indent = int(indent)
+                # NOTE: Impose a reasonable indentation level limit.
+                if indent < 0 or indent > self.MAX_INDENT_LEVEL:
+                    indent = None
+            except ValueError:
+                # TODO: Handle invalid params?
+                indent = None
+
+        result = json.dumps(media, indent=indent, sort_keys=bool(indent))
+        return result.encode()
diff --git a/examples/recipes/raw_url_path_asgi.py b/examples/recipes/raw_url_path_asgi.py
new file mode 100644
index 0000000..4361908
--- /dev/null
+++ b/examples/recipes/raw_url_path_asgi.py
@@ -0,0 +1,31 @@
+import falcon.asgi
+import falcon.uri
+
+
+class RawPathComponent:
+    async def process_request(self, req, resp):
+        raw_path = req.scope.get('raw_path')
+
+        # NOTE: Decode the raw path from the raw_path bytestring, disallowing
+        #   non-ASCII characters, assuming they are correctly percent-coded.
+        if raw_path:
+            req.path = raw_path.decode('ascii')
+
+
+class URLResource:
+    async def on_get(self, req, resp, url):
+        # NOTE: url here is potentially percent-encoded.
+        url = falcon.uri.decode(url)
+
+        resp.media = {'url': url}
+
+    async def on_get_status(self, req, resp, url):
+        # NOTE: url here is potentially percent-encoded.
+        url = falcon.uri.decode(url)
+
+        resp.media = {'cached': True}
+
+
+app = falcon.asgi.App(middleware=[RawPathComponent()])
+app.add_route('/cache/{url}', URLResource())
+app.add_route('/cache/{url}/status', URLResource(), suffix='status')
diff --git a/examples/recipes/raw_url_path_wsgi.py b/examples/recipes/raw_url_path_wsgi.py
new file mode 100644
index 0000000..408d768
--- /dev/null
+++ b/examples/recipes/raw_url_path_wsgi.py
@@ -0,0 +1,30 @@
+import falcon
+import falcon.uri
+
+
+class RawPathComponent:
+    def process_request(self, req, resp):
+        raw_uri = req.env.get('RAW_URI') or req.env.get('REQUEST_URI')
+
+        # NOTE: Reconstruct the percent-encoded path from the raw URI.
+        if raw_uri:
+            req.path, _, _ = raw_uri.partition('?')
+
+
+class URLResource:
+    def on_get(self, req, resp, url):
+        # NOTE: url here is potentially percent-encoded.
+        url = falcon.uri.decode(url)
+
+        resp.media = {'url': url}
+
+    def on_get_status(self, req, resp, url):
+        # NOTE: url here is potentially percent-encoded.
+        url = falcon.uri.decode(url)
+
+        resp.media = {'cached': True}
+
+
+app = falcon.App(middleware=[RawPathComponent()])
+app.add_route('/cache/{url}', URLResource())
+app.add_route('/cache/{url}/status', URLResource(), suffix='status')
diff --git a/examples/recipes/request_id_context.py b/examples/recipes/request_id_context.py
new file mode 100644
index 0000000..d071c90
--- /dev/null
+++ b/examples/recipes/request_id_context.py
@@ -0,0 +1,19 @@
+# context.py
+
+import threading
+
+
+class _Context:
+    def __init__(self):
+        self._thread_local = threading.local()
+
+    @property
+    def request_id(self):
+        return getattr(self._thread_local, 'request_id', None)
+
+    @request_id.setter
+    def request_id(self, value):
+        self._thread_local.request_id = value
+
+
+ctx = _Context()
diff --git a/examples/recipes/request_id_log.py b/examples/recipes/request_id_log.py
new file mode 100644
index 0000000..ec71a25
--- /dev/null
+++ b/examples/recipes/request_id_log.py
@@ -0,0 +1,18 @@
+# some_other_module.py
+
+import logging
+
+from context import ctx
+
+
+def create_widget_object(name: str):
+    request_id = 'request_id={0}'.format(ctx.request_id)
+    logging.debug('%s going to create widget: %s', request_id, name)
+
+    try:
+        # create the widget
+        pass
+    except Exception:
+        logging.exception('%s something went wrong', request_id)
+
+    logging.debug('%s created widget: %s', request_id, name)
diff --git a/examples/recipes/request_id_middleware.py b/examples/recipes/request_id_middleware.py
new file mode 100644
index 0000000..e99109b
--- /dev/null
+++ b/examples/recipes/request_id_middleware.py
@@ -0,0 +1,14 @@
+# middleware.py
+
+from uuid import uuid4
+
+from context import ctx
+
+
+class RequestIDMiddleware:
+    def process_request(self, req, resp):
+        ctx.request_id = str(uuid4())
+
+    # It may also be helpful to include the ID in the response
+    def process_response(self, req, resp, resource, req_succeeded):
+        resp.set_header('X-Request-ID', ctx.request_id)
diff --git a/examples/recipes/request_id_structlog.py b/examples/recipes/request_id_structlog.py
new file mode 100644
index 0000000..6066670
--- /dev/null
+++ b/examples/recipes/request_id_structlog.py
@@ -0,0 +1,21 @@
+# middleware.py
+
+from uuid import uuid4
+
+# Optional logging package (pip install structlog)
+import structlog
+
+
+class RequestIDMiddleware:
+    def process_request(self, req, resp):
+        request_id = str(uuid4())
+
+        # Using Falcon 2.0 syntax
+        req.context.request_id = request_id
+
+        # Or if your logger has built-in support for contexts
+        req.context.log = structlog.get_logger(request_id=request_id)
+
+    # It may also be helpful to include the ID in the response
+    def process_response(self, req, resp, resource, req_succeeded):
+        resp.set_header('X-Request-ID', req.context.request_id)
diff --git a/examples/things_advanced.py b/examples/things_advanced.py
index ba3c875..e9d8b78 100644
--- a/examples/things_advanced.py
+++ b/examples/things_advanced.py
@@ -21,7 +21,7 @@ class StorageEngine:
 
 class StorageError(Exception):
     @staticmethod
-    def handle(ex, req, resp, params):
+    def handle(req, resp, ex, params):
         # TODO: Log the error, clean up, etc. before raising
         raise falcon.HTTPInternalServerError()
 
diff --git a/examples/things_advanced_asgi.py b/examples/things_advanced_asgi.py
index fa05bc9..f6fc6bf 100644
--- a/examples/things_advanced_asgi.py
+++ b/examples/things_advanced_asgi.py
@@ -21,7 +21,7 @@ class StorageEngine:
 
 class StorageError(Exception):
     @staticmethod
-    async def handle(ex, req, resp, params):
+    async def handle(req, resp, ex, params):
         # TODO: Log the error, clean up, etc. before raising
         raise falcon.HTTPInternalServerError()
 
@@ -202,7 +202,7 @@ class ThingsResource:
 # The app instance is an ASGI callable
 app = falcon.asgi.App(
     middleware=[
-        # AuthMiddleware(),
+        AuthMiddleware(),
         RequireJSON(),
         JSONTranslator(),
     ]
diff --git a/tox.ini b/tox.ini
index 61d20a7..66318bc 100644
--- a/tox.ini
+++ b/tox.ini
@@ -286,7 +286,7 @@ commands = ruff check \
 
 [testenv:pep8-examples]
 deps = ruff
-commands = ruff check examples --line-length=99 []
+commands = ruff check examples []
 
 # --------------------------------------------------------------------
 # For viewing environ dicts generated by various WSGI servers


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 25707f7..b021132 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,8 +1,15 @@
+import contextlib
+import importlib.util
 import os
+import pathlib
 
 import pytest
 
 import falcon
+import falcon.asgi
+
+HERE = pathlib.Path(__file__).resolve().parent
+FALCON_ROOT = HERE.parent
 
 _FALCON_TEST_ENV = (
     ('FALCON_ASGI_WRAP_NON_COROUTINES', 'Y'),
@@ -17,6 +24,90 @@ def asgi(request):
     return request.param
 
 
+@pytest.fixture()
+def app_kind(asgi):
+    # NOTE(vytas): Same as the above asgi fixture but as string.
+    return 'asgi' if asgi else 'wsgi'
+
+
+class _SuiteUtils:
+    """Assorted utilities that previously resided in the _util.py module."""
+
+    @staticmethod
+    def create_app(asgi, **app_kwargs):
+        App = falcon.asgi.App if asgi else falcon.App
+        app = App(**app_kwargs)
+        return app
+
+    @staticmethod
+    def create_req(asgi, options=None, **environ_or_scope_kwargs):
+        if asgi:
+            return falcon.testing.create_asgi_req(
+                options=options, **environ_or_scope_kwargs
+            )
+
+        return falcon.testing.create_req(options=options, **environ_or_scope_kwargs)
+
+    @staticmethod
+    def create_resp(asgi):
+        if asgi:
+            return falcon.asgi.Response()
+
+        return falcon.Response()
+
+    @staticmethod
+    def to_coroutine(callable):
+        async def wrapper(*args, **kwargs):
+            return callable(*args, **kwargs)
+
+        return wrapper
+
+    @staticmethod
+    @contextlib.contextmanager
+    def disable_asgi_non_coroutine_wrapping():
+        should_wrap = 'FALCON_ASGI_WRAP_NON_COROUTINES' in os.environ
+        if should_wrap:
+            del os.environ['FALCON_ASGI_WRAP_NON_COROUTINES']
+
+        yield
+
+        if should_wrap:
+            os.environ['FALCON_ASGI_WRAP_NON_COROUTINES'] = 'Y'
+
+    @staticmethod
+    def as_params(*values, prefix=None):
+        if not prefix:
+            prefix = ''
+        # NOTE(caselit): each value must be a tuple/list even when using one
+        #   single argument
+        return [
+            pytest.param(*value, id=f'{prefix}_{i}' if prefix else f'{i}')
+            for i, value in enumerate(values, 1)
+        ]
+
+    @staticmethod
+    def load_module(filename, parent_dir=None, suffix=None):
+        root = FALCON_ROOT
+        root = root / parent_dir if parent_dir is not None else root
+        path = root / filename
+        if suffix is not None:
+            path = path.with_name(f'{path.stem}_{suffix}.py')
+        prefix = '.'.join(path.parent.parts)
+        module_name = f'{prefix}.{path.stem}'
+
+        spec = importlib.util.spec_from_file_location(module_name, path)
+        assert spec is not None, f'could not load module from {path}'
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)
+        return module
+
+
+# TODO(vytas): Migrate all cases to use this fixture instead of _util.
+@pytest.fixture(scope='session')
+def util():
+    return _SuiteUtils()
+
+
 # NOTE(kgriffs): Some modules actually run a wsgiref server, so
 # to ensure we reset the detection for the other modules, we just
 # run this fixture before each one is tested.
diff --git a/tests/test_example.py b/tests/test_example.py
deleted file mode 100644
index 4004584..0000000
--- a/tests/test_example.py
+++ /dev/null
@@ -1,225 +0,0 @@
-import json
-import logging
-import uuid
-from wsgiref import simple_server
-
-import requests
-
-import falcon
-
-
-class StorageEngine:
-    def get_things(self, marker, limit):
-        return [{'id': str(uuid.uuid4()), 'color': 'green'}]
-
-    def add_thing(self, thing):
-        thing['id'] = str(uuid.uuid4())
-        return thing
-
-
-class StorageError(Exception):
-    @staticmethod
-    def handle(req, resp, ex, params):
-        # TODO: Log the error, clean up, etc. before raising
-        raise falcon.HTTPInternalServerError()
-
-
-class SinkAdapter:
-    engines = {
-        'ddg': 'https://duckduckgo.com',
-        'y': 'https://search.yahoo.com/search',
-    }
-
-    def __call__(self, req, resp, engine):
-        url = self.engines[engine]
-        params = {'q': req.get_param('q', True)}
-        result = requests.get(url, params=params)
-
-        resp.status = falcon.code_to_http_status(result.status_code)
-        resp.content_type = result.headers['content-type']
-        resp.text = result.text
-
-
-class AuthMiddleware:
-    def process_request(self, req, resp):
-        token = req.get_header('Authorization')
-        account_id = req.get_header('Account-ID')
-
-        challenges = ['Token type="Fernet"']
-
-        if token is None:
-            description = 'Please provide an auth token as part of the request.'
-
-            raise falcon.HTTPUnauthorized(
-                title='Auth token required',
-                description=description,
-                challenges=challenges,
-                href='http://docs.example.com/auth',
-            )
-
-        if not self._token_is_valid(token, account_id):
-            description = (
-                'The provided auth token is not valid. '
-                'Please request a new token and try again.'
-            )
-
-            raise falcon.HTTPUnauthorized(
-                title='Authentication required',
-                description=description,
-                challenges=challenges,
-                href='http://docs.example.com/auth',
-            )
-
-    def _token_is_valid(self, token, account_id):
-        return True  # Suuuuuure it's valid...
-
-
-class RequireJSON:
-    def process_request(self, req, resp):
-        if not req.client_accepts_json:
-            raise falcon.HTTPNotAcceptable(
-                title='406 Not Acceptable',
-                description='This API only supports responses encoded as JSON.',
-                href='http://docs.examples.com/api/json',
-            )
-
-        if req.method in ('POST', 'PUT'):
-            if 'application/json' not in req.content_type:
-                raise falcon.HTTPUnsupportedMediaType(
-                    title='415 Unsupported Media Type',
-                    description='This API only supports requests encoded as JSON.',
-                    href='http://docs.examples.com/api/json',
-                )
-
-
-class JSONTranslator:
-    def process_request(self, req, resp):
-        # req.stream corresponds to the WSGI wsgi.input environ variable,
-        # and allows you to read bytes from the request body.
-        #
-        # See also: PEP 3333
-        if req.content_length in (None, 0):
-            # Nothing to do
-            return
-
-        body = req.bounded_stream.read()
-        if not body:
-            raise falcon.HTTPBadRequest(
-                title='Empty request body',
-                description='A valid JSON document is required.',
-            )
-
-        try:
-            req.context.doc = json.loads(body.decode('utf-8'))
-
-        except (ValueError, UnicodeDecodeError):
-            description = (
-                'Could not decode the request body. The '
-                'JSON was incorrect or not encoded as '
-                'UTF-8.'
-            )
-
-            raise falcon.HTTPBadRequest(title='Malformed JSON', description=description)
-
-    def process_response(self, req, resp, resource, req_succeeded):
-        if not hasattr(resp.context, 'result'):
-            return
-
-        resp.text = json.dumps(resp.context.result)
-
-
-def max_body(limit):
-    def hook(req, resp, resource, params):
-        length = req.content_length
-        if length is not None and length > limit:
-            msg = (
-                'The size of the request is too large. The body must not '
-                'exceed ' + str(limit) + ' bytes in length.'
-            )
-
-            raise falcon.HTTPPayloadTooLarge(
-                title='Request body is too large', description=msg
-            )
-
-    return hook
-
-
-class ThingsResource:
-    def __init__(self, db):
-        self.db = db
-        self.logger = logging.getLogger('thingsapp.' + __name__)
-
-    def on_get(self, req, resp, user_id):
-        marker = req.get_param('marker') or ''
-        limit = req.get_param_as_int('limit') or 50
-
-        try:
-            result = self.db.get_things(marker, limit)
-        except Exception as ex:
-            self.logger.error(ex)
-
-            description = (
-                'Aliens have attacked our base! We will '
-                'be back as soon as we fight them off. '
-                'We appreciate your patience.'
-            )
-
-            raise falcon.HTTPServiceUnavailable(
-                title='Service Outage', description=description, retry_after=30
-            )
-
-        # NOTE: Normally you would use resp.media for this sort of thing;
-        # this example serves only to demonstrate how the context can be
-        # used to pass arbitrary values between middleware components,
-        # hooks, and resources.
-        resp.context.result = result
-
-        resp.set_header('Powered-By', 'Falcon')
-        resp.status = falcon.HTTP_200
-
-    @falcon.before(max_body(64 * 1024))
-    def on_post(self, req, resp, user_id):
-        try:
-            doc = req.context.doc
-        except AttributeError:
-            raise falcon.HTTPBadRequest(
-                title='Missing thing',
-                description='A thing must be submitted in the request body.',
-            )
-
-        proper_thing = self.db.add_thing(doc)
-
-        resp.status = falcon.HTTP_201
-        resp.location = '/%s/things/%s' % (user_id, proper_thing['id'])
-
-
-# Configure your WSGI server to load "things.app" (app is a WSGI callable)
-app = falcon.App(
-    middleware=[
-        AuthMiddleware(),
-        RequireJSON(),
-        JSONTranslator(),
-    ]
-)
-
-db = StorageEngine()
-things = ThingsResource(db)
-app.add_route('/{user_id}/things', things)
-
-# If a responder ever raises an instance of StorageError, pass control to
-# the given handler.
-app.add_error_handler(StorageError, StorageError.handle)
-
-# Proxy some things to another service; this example shows how you might
-# send parts of an API off to a legacy system that hasn't been upgraded
-# yet, or perhaps is a single cluster that all data centers have to share.
-sink = SinkAdapter()
-app.add_sink(sink, r'/search/(?P<engine>ddg|y)\Z')
-
-# Useful for debugging problems in your API; works with pdb.set_trace(). You
-# can also use Gunicorn to host your app. Gunicorn can be configured to
-# auto-restart workers when it detects a code change, and it also works
-# with pdb.
-if __name__ == '__main__':
-    httpd = simple_server.make_server('127.0.0.1', 8000, app)
-    httpd.serve_forever()
diff --git a/tests/test_examples.py b/tests/test_examples.py
new file mode 100644
index 0000000..d6d37ff
--- /dev/null
+++ b/tests/test_examples.py
@@ -0,0 +1,46 @@
+import pytest
+
+try:
+    import httpx
+except ImportError:
+    httpx = None  # type: ignore
+
+import falcon.testing as testing
+
+
+def test_things(asgi, util):
+    suffix = '_asgi' if asgi else ''
+    things = util.load_module(f'examples/things{suffix}.py')
+
+    resp = testing.simulate_get(things.app, '/things')
+
+    assert resp.status_code == 200
+    assert resp.text == (
+        '\nTwo things awe me most, the starry sky above me and the moral law within me.'
+        '\n\n    ~ Immanuel Kant\n\n'
+    )
+
+
+@pytest.mark.skipif(
+    httpx is None, reason='things_advanced_asgi.py requires httpx [not found]'
+)
+def test_things_advanced(asgi, util):
+    suffix = '_asgi' if asgi else ''
+    advanced = util.load_module(f'examples/things_advanced{suffix}.py')
+
+    # NOTE(vytas): The ASGI example explicitly requires Content-Length
+    #   (its middleware errors out otherwise with 400).
+    #   Should we change this?
+    resp1 = testing.simulate_get(
+        advanced.app, '/1337/things', headers={'Content-Length': '0'}
+    )
+    assert resp1.status_code == 401
+
+    resp2 = testing.simulate_get(
+        advanced.app,
+        '/1337/things',
+        headers={'Authorization': 'custom-token', 'Content-Length': '0'},
+    )
+    assert resp2.status_code == 200
+    assert len(resp2.json) == 1
+    assert resp2.json[0]['color'] == 'green'
diff --git a/tests/test_recipes.py b/tests/test_recipes.py
new file mode 100644
index 0000000..9b21600
--- /dev/null
+++ b/tests/test_recipes.py
@@ -0,0 +1,143 @@
+import pytest
+
+import falcon
+import falcon.testing
+
+
+class TestMultipartMixed:
+    """Test parsing example from the now-obsolete RFC 1867:
+
+    --AaB03x
+    Content-Disposition: form-data; name="field1"
+
+    Joe Blow
+    --AaB03x
+    Content-Disposition: form-data; name="docs"
+    Content-Type: multipart/mixed; boundary=BbC04y
+
+    --BbC04y
+    Content-Disposition: attachment; filename="file1.txt"
+
+    This is file1.
+
+    --BbC04y
+    Content-Disposition: attachment; filename="file2.txt"
+
+    Hello, World!
+
+    --BbC04y--
+
+    --AaB03x--
+    """
+
+    @classmethod
+    def prepare_form(cls):
+        lines = [line.strip() for line in cls.__doc__.splitlines()[2:]]
+
+        # NOTE(vytas): On CPython 3.13-rc1, the last newline was missing.
+        if lines[-1]:
+            lines.append('')
+
+        return '\r\n'.join(lines).encode()
+
+    def test_parse(self, util):
+        recipe = util.load_module('examples/recipes/multipart_mixed_main.py')
+
+        result = falcon.testing.simulate_post(
+            recipe.app,
+            '/forms',
+            body=self.prepare_form(),
+            content_type='multipart/form-data; boundary=AaB03x',
+        )
+        assert result.status_code == 200
+        assert result.json == {
+            'file1.txt': 'This is file1.\r\n',
+            'file2.txt': 'Hello, World!\r\n',
+        }
+
+
+class TestOutputCSV:
+    @pytest.mark.parametrize(
+        'recipe,expected_head',
+        [
+            ('output_csv_text', '"fruit","quantity"\r\n"apples",13\r\n'),
+            ('output_csv_stream', '"n","Fibonacci Fn"\r\n0,0\r\n1,1\r\n'),
+        ],
+        ids=['simple', 'stream'],
+    )
+    def test_csv_output(self, asgi, app_kind, util, recipe, expected_head):
+        module = util.load_module(
+            recipe, parent_dir='examples/recipes', suffix=app_kind
+        )
+        app = util.create_app(asgi)
+        app.add_route('/report', module.Report())
+
+        result = falcon.testing.simulate_get(app, '/report')
+        assert result.status_code == 200
+        assert result.text.startswith(expected_head)
+
+
+class TestPrettyJSON:
+    class QuoteResource:
+        def on_get(self, req, resp):
+            resp.media = {
+                'author': 'Grace Hopper',
+                'quote': (
+                    "I've always been more interested in "
+                    'the future than in the past.'
+                ),
+            }
+
+    class NegotiationMiddleware:
+        def process_request(self, req, resp):
+            resp.content_type = req.accept
+
+    def test_optional_indent(self, util):
+        recipe = util.load_module('examples/recipes/pretty_json_main.py')
+
+        app = falcon.App(middleware=[self.NegotiationMiddleware()])
+        app.add_route('/quote', self.QuoteResource())
+        app.resp_options.media_handlers.update(
+            {falcon.MEDIA_JSON: recipe.CustomJSONHandler()}
+        )
+
+        result = falcon.testing.simulate_get(
+            app, '/quote', headers={'Accept': 'application/json; indent=4'}
+        )
+        assert result.status_code == 200
+        assert result.text == (
+            '{\n'
+            '    "author": "Grace Hopper",\n'
+            '    "quote": "I\'ve always been more interested in the future '
+            'than in the past."\n'
+            '}'
+        )
+
+
+class TestRawURLPath:
+    def path_extras(self, asgi, url):
+        if asgi:
+            return {'raw_path': url.encode()}
+        return None
+
+    def test_raw_path(self, asgi, app_kind, util):
+        recipe = util.load_module(
+            'raw_url_path', parent_dir='examples/recipes', suffix=app_kind
+        )
+
+        # TODO(vytas): Improve TestClient to automatically add ASGI raw_path
+        #   (as it does for WSGI): GH #2262.
+
+        url1 = '/cache/http%3A%2F%2Ffalconframework.org'
+        result1 = falcon.testing.simulate_get(
+            recipe.app, url1, extras=self.path_extras(asgi, url1)
+        )
+        assert result1.status_code == 200
+        assert result1.json == {'url': 'http://falconframework.org'}
+
+        url2 = '/cache/http%3A%2F%2Ffalconframework.org/status'
+        result2 = falcon.testing.simulate_get(
+            recipe.app, url2, extras=self.path_extras(asgi, url2)
+        )
+        assert result2.status_code == 200
+        assert result2.json == {'cached': True}
diff --git a/tests/test_things_example.py b/tests/test_things_example.py
deleted file mode 100644
index 342fb6e..0000000
--- a/tests/test_things_example.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from examples.things import app
-import falcon.testing as testing
-
-
-def test_things_resource_response():
-    client = testing.TestClient(app)
-
-    resp = client.simulate_get('/things')
-
-    assert resp.status_code == 200
-    assert resp.text == (
-        '\nTwo things awe me most, the starry sky above me and the moral law within me.'
-        '\n\n    ~ Immanuel Kant\n\n'
-    )