Repository: encode/starlette

Issue:
Testing template reponses
This is going to require a bit of thinking about.

```python
app = Starlette(template_directory='templates')

@app.route('/')
async def homepage(request):
    template = app.get_template('index.html')
    content = template.render(request=request)
    return HTMLResponse(content)

client = TestClient(app)
response = client.get('/')
assert response.template = 'index.html'
assert 'request' in response.context
```

Two issues here. Firstly we haven't associated the template name or context with the response in any way, so we can't reference them from the template. We could do so, with something like...

```python
return app.render_reponse('index.html', context={'request': request})
```

Or...

```python
return TemplateResponse(template, context={'request': request}))  # I *guess* `template` has the "index.html" name string associated with it somewhere?
```

Secondly, we don't have access to the response instance itself, we've just been sent ASGI messages. We'd need to ensure that `TemplateResponse` included some extra info ("template", "context") in the messages that it sent back, and that `TestClient` was able to additionally annotate the response with that information.

Comments:

Patch:
diff --git a/docs/responses.md b/docs/responses.md
index 2072bb6..be23a3d 100644
--- a/docs/responses.md
+++ b/docs/responses.md
@@ -86,6 +86,51 @@ class App:
         await response(receive, send)
 ```
 
+### TemplateResponse
+
+The `TemplateResponse` class return plain text responses generated
+from a template instance, and a dictionary of context to render into the
+template.
+
+A `request` argument must always be included in the context. Responses default
+to `text/html` unless an alternative `media_type` is specified.
+
+```python
+from starlette.responses import TemplateResponse
+from starlette.requests import Request
+
+from jinja2 import Environment, FileSystemLoader
+
+
+env = Environment(loader=FileSystemLoader('templates'))
+
+
+class App:
+    def __init__(self, scope):
+        self.scope = scope
+
+    async def __call__(self, receive, send):
+        template = env.get_template('index.html')
+        context = {
+            'request': Request(self.scope),
+        }
+        response = TemplateResponse(template, context)
+        await response(receive, send)
+```
+
+The advantage with using `TemplateResponse` over `HTMLResponse` is that
+it will make `template` and `context` properties available on response instances
+returned by the test client.
+
+```python
+def test_app():
+    client = TestClient(App)
+    response = client.get("/")
+    assert response.status_code == 200
+    assert response.template.name == "index.html"
+    assert "request" in response.context
+```
+
 ### JSONResponse
 
 Takes some data and returns an `application/json` encoded response.
diff --git a/starlette/responses.py b/starlette/responses.py
index 35062ba..542e800 100644
--- a/starlette/responses.py
+++ b/starlette/responses.py
@@ -138,6 +138,39 @@ class PlainTextResponse(Response):
     media_type = "text/plain"
 
 
+class TemplateResponse(Response):
+    media_type = "text/html"
+
+    def __init__(
+        self,
+        template: typing.Any,
+        context: dict,
+        status_code: int = 200,
+        headers: dict = None,
+        media_type: str = None,
+        background: BackgroundTask = None,
+    ):
+        if "request" not in context:
+            raise ValueError('context must include a "request" key')
+        self.template = template
+        self.context = context
+        content = template.render(context)
+        super().__init__(content, status_code, headers, media_type, background)
+
+    async def __call__(self, receive: Receive, send: Send) -> None:
+        request = self.context["request"]
+        extensions = request.get("extensions", {})
+        if "http.response.template" in extensions:
+            await send(
+                {
+                    "type": "http.response.template",
+                    "template": self.template,
+                    "context": self.context,
+                }
+            )
+        await super().__call__(receive, send)
+
+
 class JSONResponse(Response):
     media_type = "application/json"
 


Test patch:
diff --git a/starlette/testclient.py b/starlette/testclient.py
index 2f901c1..53afb45 100644
--- a/starlette/testclient.py
+++ b/starlette/testclient.py
@@ -126,6 +126,7 @@ class _ASGIAdapter(requests.adapters.HTTPAdapter):
             "headers": headers,
             "client": ["testclient", 50000],
             "server": [host, port],
+            "extensions": {"http.response.template": {}},
         }
 
         async def receive() -> Message:
@@ -147,7 +148,7 @@ class _ASGIAdapter(requests.adapters.HTTPAdapter):
             return {"type": "http.request", "body": body_bytes}
 
         async def send(message: Message) -> None:
-            nonlocal raw_kwargs, response_started, response_complete
+            nonlocal raw_kwargs, response_started, response_complete, template, context
 
             if message["type"] == "http.response.start":
                 assert (
@@ -177,10 +178,15 @@ class _ASGIAdapter(requests.adapters.HTTPAdapter):
                 if not more_body:
                     raw_kwargs["body"].seek(0)
                     response_complete = True
+            elif message["type"] == "http.response.template":
+                template = message["template"]
+                context = message["context"]
 
         response_started = False
         response_complete = False
         raw_kwargs = {"body": io.BytesIO()}  # type: typing.Dict[str, typing.Any]
+        template = None
+        context = None
 
         try:
             loop = asyncio.get_event_loop()
@@ -209,7 +215,11 @@ class _ASGIAdapter(requests.adapters.HTTPAdapter):
             }
 
         raw = requests.packages.urllib3.HTTPResponse(**raw_kwargs)
-        return self.build_response(request, raw)
+        response = self.build_response(request, raw)
+        if template is not None:
+            response.template = template
+            response.context = context
+        return response
 
 
 class WebSocketTestSession:
diff --git a/tests/test_responses.py b/tests/test_responses.py
index e68958e..0b79051 100644
--- a/tests/test_responses.py
+++ b/tests/test_responses.py
@@ -11,6 +11,7 @@ from starlette.responses import (
     RedirectResponse,
     Response,
     StreamingResponse,
+    TemplateResponse,
     UJSONResponse,
 )
 from starlette.testclient import TestClient
@@ -243,3 +244,34 @@ def test_delete_cookie():
     assert response.cookies["mycookie"]
     response = client.get("/")
     assert not response.cookies.get("mycookie")
+
+
+def test_template_response():
+    def app(scope):
+        request = Request(scope)
+
+        class Template:
+            def __init__(self, name):
+                self.name = name
+
+            def render(self, context):
+                return "username: %s" % context["username"]
+
+        async def asgi(receive, send):
+            template = Template("index.html")
+            context = {"username": "tomchristie", "request": request}
+            response = TemplateResponse(template, context)
+            await response(receive, send)
+
+        return asgi
+
+    client = TestClient(app)
+    response = client.get("/")
+    assert response.text == "username: tomchristie"
+    assert response.template.name == "index.html"
+    assert response.context["username"] == "tomchristie"
+
+
+def test_template_response_requires_request():
+    with pytest.raises(ValueError):
+        TemplateResponse(None, {})