In a Django test, how do you verify that an anonymous visitor to a login-protected dashboard is redirected to the login page?
answer
- status 302 and a Location
- the next query parameter
- one assertion checks both ends
- follow=True records every hop
basics
~10 sRequest the dashboard with the test Client and call assertRedirects(response, '/accounts/login/?next=/dashboard/'). It checks the 302, the target URL, and that the target loads with 200; follow=True with redirect_chain shows every hop.
solid answer
~30 sWith `self.client` anonymous, `response = self.client.get(reverse("dashboard"))` returns the redirect produced by `login_required`, `LoginRequiredMixin` or `LoginRequiredMiddleware`. `self.assertRedirects(response, f"{settings.LOGIN_URL}?next=/dashboard/")` then asserts the status is 302 (the `status_code` default), that the redirect URL matches, and, by default, fetches that URL with the same client and expects `target_status_code=200`. If the login page is not routed in the test URLconf or lives on another host, pass `fetch_redirect_response=False`. Alternatively `self.client.get(url, follow=True)` follows every hop and fills `response.redirect_chain` with `(url, status)` tuples; `assertRedirects` understands such responses too and checks the first hop's status and the final URL.
code
python · 6 linesresponse = self.client.get("/dashboard/?tab=billing")
self.assertRedirects(
response,
"/accounts/login/?next=/dashboard/%3Ftab%3Dbilling",
fetch_redirect_response=False,
)go deeper
Know the shape: get the dashboard anonymously, then assertRedirects to LOGIN_URL with the next parameter.
Explain what assertRedirects checks, including the target fetch and its defaults, and how follow=True and redirect_chain differ from a single response.
Write redirect tests that survive configuration changes: build URLs from settings and reverse(), choose fetch_redirect_response deliberately, and cover the signed-in path too.
Treat redirect and access tests as the guard on every protected route, and decide how the team keeps that coverage complete as URLs are added.
## The scenario A dashboard view must only be visible to signed-in users. Whether the protection comes from the `login_required` decorator, `LoginRequiredMixin` on a class-based view, or `LoginRequiredMiddleware` (added in Django 5.1), an anonymous request should end with an HTTP redirect to `settings.LOGIN_URL` carrying the original path in the `next` query parameter. `LOGIN_URL` defaults to `/accounts/login/` and the parameter name defaults to `next`. The **test Client** is the right tool here, because the redirect is produced by the authentication machinery around the view and the test should see exactly what a browser would. ## `assertRedirects` without following ```python from django.conf import settings from django.test import TestCase from django.urls import reverse class DashboardRedirectTests(TestCase): def test_anonymous_is_sent_to_login(self): url = reverse("dashboard") response = self.client.get(url) self.assertRedirects(response, f"{settings.LOGIN_URL}?next={url}") ``` `assertRedirects(response, expected_url, status_code=302, target_status_code=200, msg_prefix="", fetch_redirect_response=True)` performs several checks in one call: 1. The response status equals **`status_code`** (302 by default; pass 301, 303, 307 or 308 when that is what the view returns). 2. The redirect URL equals **`expected_url`**, compared with query-string parameter order ignored. 3. With **`fetch_redirect_response=True`** (the default) it issues a GET for the target with the same client and asserts it returns **`target_status_code`**. Step 3 is the part people forget. If the test URLconf does not include the login route, the fetch returns 404 and the assertion fails even though the redirect was correct. For an external target, the helper refuses and tells you to pass `fetch_redirect_response=False`. ## Following the chain with `follow=True` ```python response = self.client.get(reverse("dashboard"), follow=True) self.assertEqual(response.redirect_chain, [("/accounts/login/?next=/dashboard/", 302)]) self.assertTemplateUsed(response, "registration/login.html") ``` - With **`follow=True`** the Client keeps issuing requests until a non-redirect response arrives. - The final response carries **`redirect_chain`**: a list of `(url, status_code)` tuples, one per hop. - It raises `RedirectCycleError` if the chain loops, or once it grows past 20 hops. - For 307 and 308 the method and body are preserved, matching the HTTP semantics of those codes; other redirects are followed with GET. `assertRedirects` also accepts a followed response: it checks that the chain is non-empty, that the **first** hop had `status_code`, that the **final** response has `target_status_code`, and that the **last** redirect URL matches `expected_url`. ## Which form to use | Need | Use | |---|---| | Exact status and target of one redirect | `get()` + `assertRedirects` | | The landing page's content or template | `get(..., follow=True)` then `assertContains` / `assertTemplateUsed` | | A multi-hop flow such as login then back to `next` | `follow=True` and inspect `redirect_chain` | | Target not served by this project | `assertRedirects(..., fetch_redirect_response=False)` | ## Testing the other side A complete test pair also covers the signed-in case, where the same URL must not redirect: ```python def test_signed_in_user_is_not_redirected(self): self.client.force_login(self.user) response = self.client.get(reverse("dashboard")) self.assertEqual(response.status_code, 200) ``` ## Common mistakes - Asserting only `response.status_code == 302`: that passes for a redirect to the wrong page. - Hard-coding `/accounts/login/` when the project overrides `LOGIN_URL`; build the expected URL from the setting. - Forgetting that `next` holds the full path including the query string when the dashboard URL had one. - Using `follow=True` and then asserting `status_code == 302`: the followed response is the final page, usually 200. - Checking `response["Location"]` by string equality, which breaks if query parameters reorder; `assertRedirects` compares URLs properly. ## Where the redirect comes from The same assertion works whichever mechanism produces the redirect, which is exactly why it is valuable: you can move a dashboard from the `login_required` decorator to `LoginRequiredMixin` or to `LoginRequiredMiddleware` and the test keeps guarding the behaviour. The details differ slightly: - `login_required` and `LoginRequiredMixin` build the `next` value from the request path, keeping the query string. - `LoginRequiredMiddleware` does the same from its `process_view`, and honours a `login_url` or `redirect_field_name` set on the view through the `login_required` decorator. - A custom `LOGIN_URL` (a named URL pattern is allowed) changes the expected target, which is another reason to derive it from `settings.LOGIN_URL` or `reverse()` rather than hard-coding it. If a test needs the exact `Location` header string, read `response.url`, which the redirect response class exposes.
- Why might assertRedirects fail with a 404 even though the dashboard returned the right 302?By default `assertRedirects` also fetches the redirect target with the same client and expects `target_status_code=200`. If the login URL is not in the URLconf the test uses, that fetch returns 404. Include the auth URLs in the test URLconf, or pass `fetch_redirect_response=False` when only the redirect itself matters.
- After client.get(url, follow=True), what does response.status_code hold?The status of the final response after all redirects were followed, usually 200. The intermediate redirects live in `response.redirect_chain` as `(url, status_code)` tuples, so a 302 assertion belongs on the chain, not on `status_code`.
saying these in an interview costs you the question
- Asserting only status_code == 302 and calling the redirect tested
- Expecting status_code to be 302 after a request made with follow=True
- Believing assertRedirects never requests the target URL
- Hard-coding /accounts/login/ when the project sets its own LOGIN_URL
- Thinking redirect_chain exists on responses fetched without follow=True