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.


Discussion:
While I wasn't thinking about this issue specifically in https://github.com/encode/starlette/pull/211, after going over it seems this would necessarily impact anything there - I'll look at updating that with something that could inform progress on this.
@tomchristie I updated the PR to include one potential pathway for the first part (assuming I understood the issue correctly) - though making the response details available on the `TestClient` appears to be a more difficult challenge.

Is that something that perhaps extensions could be used for? Or just custom message types? I found that if I send the template/context data through the response messages into the adapter that I was still faced with the problem of allowing the mock response to digest that information through the raw request.

Resolving patch:
diff --git a/starlette/responses.py b/starlette/responses.py
--- 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"
 
diff --git a/starlette/testclient.py b/starlette/testclient.py
--- a/starlette/testclient.py
+++ b/starlette/testclient.py
@@ -126,6 +126,7 @@ def send(  # type: ignore
             "headers": headers,
             "client": ["testclient", 50000],
             "server": [host, port],
+            "extensions": {"http.response.template": {}},
         }
 
         async def receive() -> Message:
@@ -147,7 +148,7 @@ async def receive() -> Message:
             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 @@ async def send(message: Message) -> None:
                 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 @@ async def send(message: Message) -> None:
             }
 
         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:


Test patch:
diff --git a/tests/test_responses.py b/tests/test_responses.py
--- a/tests/test_responses.py
+++ b/tests/test_responses.py
@@ -11,6 +11,7 @@
     RedirectResponse,
     Response,
     StreamingResponse,
+    TemplateResponse,
     UJSONResponse,
 )
 from starlette.testclient import TestClient
@@ -243,3 +244,34 @@ async def asgi(receive, send):
     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, {})