6. Testing Authenticated Routes

Public endpoints are the easy part. The endpoints you actually build in a real app are usually gated behind @login_required: they read the current user from the session and return different data for different users. Testing them means simulating a logged-in user, and most pytest tutorials don't show you how.

The wrong approach is to run the full login flow in every test. Every test becomes slow, brittle, and coupled to the login implementation. Change how login works and a hundred tests break. The right approach is to inject session state directly. Flask's test client makes this surprisingly easy once you know the pattern.

Add a protected route

We need a minimal @login_required decorator and an endpoint to guard. If there's no user in the session, redirect to /login; otherwise, run the view. Add these to your existing app.py (keep everything from Section 5, and append this):

app.py (additions)
from functools import wraps
from flask import session, redirect, url_for

def login_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        if "user_id" not in session:
            return redirect(url_for("login"))
        return f(*args, **kwargs)
    return decorated

@app.route("/login")
def login():
    return "login page", 200

@app.route("/dashboard")
@login_required
def dashboard():
    return jsonify({
        "user_id": session["user_id"],
        "username": session.get("username"),
        "message": "welcome to your dashboard",
    })

Now update the test fixture. Flask signs session cookies using SECRET_KEY, and it refuses to sign anything without one. Update tests/conftest.py so the test app has a key:

tests/conftest.py (updated)
import pytest
from app import app as flask_app

@pytest.fixture
def client():
    flask_app.config.update(
        TESTING=True,
        SECRET_KEY="test-secret-not-for-production",
        WEATHER_API_KEY="test_key",
    )
    with flask_app.test_client() as test_client:
        yield test_client

Test the unauthenticated case first

Start with the negative case. If nobody's logged in, hitting /dashboard should redirect to /login. Save this as tests/test_auth.py:

tests/test_auth.py
def test_dashboard_redirects_when_unauthenticated(client):
    response = client.get("/dashboard")

    assert response.status_code == 302
    assert "/login" in response.headers["Location"]

Run it from the project root:

Terminal
$ pytest tests/test_auth.py
tests/test_auth.py .                                                    [100%]

============================== 1 passed in 0.05s ==============================

This test is short because the redirect is short. client.get doesn't follow redirects by default, which is what you want here. The 302 status plus the Location header contains everything you need to verify.

If you wanted to test that the redirect chain ends at the login page, you'd pass follow_redirects=True and check the final response body. That's a different test about end-to-end navigation, and it's rarely worth writing. Stick with the direct redirect assertion.

Inject session state directly

Now the authenticated case. You need a logged-in user, but you don't want to run the real login flow. The test client exposes session_transaction(), a context manager that lets you modify the session directly. Add this test to the bottom of tests/test_auth.py:

tests/test_auth.py (continued)
def test_dashboard_returns_200_when_authenticated(client):
    with client.session_transaction() as sess:
        sess["user_id"] = 42
        sess["username"] = "testuser"

    response = client.get("/dashboard")

    assert response.status_code == 200
    body = response.get_json()
    assert body["user_id"] == 42
    assert body["username"] == "testuser"

Run the full file and you should now have two passing tests:

Terminal
$ pytest tests/test_auth.py
tests/test_auth.py ..                                                   [100%]

============================== 2 passed in 0.05s ==============================

Inside the with block, sess behaves like a normal Flask session. You set keys, they're signed, and the resulting cookie is attached to the test client automatically. The next request the client makes sees those session values as if a real login had placed them there.

That's the whole pattern. Two lines of setup, one request, three assertions. No login form to post to, no database to seed, no mocking of the OAuth provider. The test focuses on the authenticated behaviour of the dashboard, which is what you actually want to verify.

A reusable authenticated_client fixture

Copy-pasting those two setup lines into every authenticated test gets old fast. We'll wrap it in a fixture and get a test client that's already logged in. Add this to the bottom of tests/conftest.py:

tests/conftest.py (extended)
@pytest.fixture
def authenticated_client(client):
    with client.session_transaction() as sess:
        sess["user_id"] = 42
        sess["username"] = "testuser"
    return client

Fixtures can depend on other fixtures. authenticated_client takes client as an argument, which means pytest builds a fresh client first, then hands it to this fixture for the session setup. Tests that use authenticated_client never touch the raw client, which is exactly the right abstraction.

Now our tests get shorter. Add these to the bottom of tests/test_auth.py:

tests/test_auth.py (using the fixture)
def test_dashboard_with_fixture(authenticated_client):
    response = authenticated_client.get("/dashboard")

    assert response.status_code == 200
    assert response.get_json()["user_id"] == 42


def test_dashboard_includes_username(authenticated_client):
    response = authenticated_client.get("/dashboard")

    assert response.get_json()["username"] == "testuser"

Run the full file and you should now have four passing tests:

Terminal
$ pytest tests/test_auth.py
tests/test_auth.py ....                                                 [100%]

============================== 4 passed in 0.05s ==============================

Two tests, four lines of meaningful code each. The session setup is invisible, because it's supposed to be. You're testing what the dashboard returns for a logged-in user, not the mechanism of logging in.

Parameterising by user role

The moment you have more than two user types to test against (admin vs member, trial vs paid, owner vs editor vs viewer), stop making a new fixture per role. Instead, make a factory fixture that builds a client with whatever session you ask for:

tests/conftest.py (factory fixture)
@pytest.fixture
def logged_in_as(client):
    def _login(user_id, **extra_session):
        with client.session_transaction() as sess:
            sess["user_id"] = user_id
            sess.update(extra_session)
        return client
    return _login

The fixture returns a function. Tests call that function with whatever user shape they need, and get back a logged-in client. Here's what that looks like in practice:

tests/test_auth.py (role tests)
def test_admin_sees_admin_panel(logged_in_as):
    client = logged_in_as(1, role="admin")
    response = client.get("/admin")
    assert response.status_code == 200


def test_member_does_not_see_admin_panel(logged_in_as):
    client = logged_in_as(2, role="member")
    response = client.get("/admin")
    assert response.status_code == 403

One fixture, arbitrary user shape per test. That's the pattern to reach for the moment you need to test more than two roles against the same endpoint.

Next, we'll take the authenticated test client and layer a real OAuth flow on top: authorising against an upstream provider, exchanging a code for a token, and locking that entire multi-step dance down in tests.