Skip to content

5.4 Testing

Testing session-based authentication with FastAPI involves simulating a login that sets a session cookie, then making subsequent requests using that cookie to access protected routes. Here’s a general approach and example:


  1. Client sends credentials (e.g., username & password) to /login.
  2. Server verifies credentials and sets a session cookie (e.g., session_id or access_token in an HTTP-only cookie).
  3. Client stores the cookie automatically (browser does this).
  4. Client sends subsequent requests with the cookie for authenticated access.
  5. Server checks the session cookie on each protected route.

  • Use the TestClient which handles cookies automatically.
  • First, send a POST to /login with valid credentials.
  • The response sets a cookie — TestClient stores it internally.
  • Subsequent requests made with the same client will include that cookie.
  • Call your protected endpoints and assert the expected authenticated responses.

Example FastAPI app with simple session auth

Section titled “Example FastAPI app with simple session auth”
from fastapi import FastAPI, Response, Request, Depends, HTTPException, status, Cookie
from fastapi.security import OAuth2PasswordRequestForm
app = FastAPI()
fake_users_db = {
"alice": "password123"
}
sessions = {}
@app.post("/login")
def login(form_data: OAuth2PasswordRequestForm = Depends(), response: Response = None):
username = form_data.username
password = form_data.password
if username in fake_users_db and fake_users_db[username] == password:
session_token = "fake-session-token-for-" + username
sessions[session_token] = username
response.set_cookie(key="session_token", value=session_token, httponly=True)
return {"message": "Logged in"}
raise HTTPException(status_code=400, detail="Invalid credentials")
def get_current_user(session_token: str = Cookie(None)):
if not session_token or session_token not in sessions:
raise HTTPException(status_code=401, detail="Not authenticated")
return sessions[session_token]
@app.get("/protected")
def protected_route(user: str = Depends(get_current_user)):
return {"message": f"Hello {user}, you are authenticated!"}

from fastapi.testclient import TestClient
from backend.main import app
client = TestClient(app)
def test_session_auth():
# Login with valid credentials
response = client.post("/login", data={"username": "alice", "password": "password123"})
assert response.status_code == 200
assert "session_token" in response.cookies
# The client stores the cookie automatically,
# now make an authenticated request
response = client.get("/protected")
assert response.status_code == 200
assert response.json() == {"message": "Hello alice, you are authenticated!"}
# Test unauthorized access (without login)
client_no_auth = TestClient(app)
response = client_no_auth.get("/protected")
assert response.status_code == 401

  • Use the same TestClient instance for login and subsequent requests so cookies persist.
  • Check that login sets the cookie.
  • Access protected routes and confirm auth behavior.
  • Test unauthorized access with a fresh client that has no cookie.

========================================

Great question!

With FastAPI’s TestClient, cookies are stored automatically in the client instance between requests. But if you want to simulate a request without any cookies (for example, after logging out or clearing session), you have a few options:


How to send a request with cleared cookies using TestClient

Section titled “How to send a request with cleared cookies using TestClient”

Option 1: Manually clear the cookies stored in the client

Section titled “Option 1: Manually clear the cookies stored in the client”
client.cookies.clear() # removes all stored cookies
response = client.get("/protected") # now no cookies are sent

Option 2: Use a new TestClient instance (fresh client with empty cookies)

Section titled “Option 2: Use a new TestClient instance (fresh client with empty cookies)”
new_client = TestClient(app)
response = new_client.get("/protected") # no cookies sent here

Option 3: Explicitly override cookies per request

Section titled “Option 3: Explicitly override cookies per request”

You can pass an empty cookie jar or headers that exclude cookies:

response = client.get("/protected", cookies={}) # sends no cookies

Example showing clearing cookies and testing auth

Section titled “Example showing clearing cookies and testing auth”
from fastapi.testclient import TestClient
from backend.main import app
client = TestClient(app)
def test_clearing_cookies():
# Login first (cookies are stored)
response = client.post("/login", data={"username": "alice", "password": "password123"})
assert response.status_code == 200
assert "session_token" in response.cookies
# Access protected route with cookie
response = client.get("/protected")
assert response.status_code == 200
# Clear cookies manually
client.cookies.clear()
# Now protected route without cookie → unauthorized
response = client.get("/protected")
assert response.status_code == 401

  • Use client.cookies.clear() to remove all cookies in your TestClient
  • Or create a new TestClient instance for a fresh state
  • Then requests will behave as if no session/auth cookie is sent

===============================

Absolutely! Using classes for grouping tests in pytest is totally fine and often recommended to organize related tests neatly.


  • Prefix the class name with Test (e.g., TestAuth)
  • Define test methods inside, each method name starting with test_
  • You can use self in methods but do not define __init__ (pytest creates instances)
  • Use @pytest.fixture with self or @classmethod if needed

Example: session auth tests using a class with pytest

Section titled “Example: session auth tests using a class with pytest”
import pytest
from fastapi.testclient import TestClient
from backend.main import app
@pytest.fixture(scope="class")
def client():
with TestClient(app) as c:
yield c
class TestSessionAuth:
def test_login(self, client):
response = client.post("/login", data={"username": "alice", "password": "password123"})
assert response.status_code == 200
assert "session_token" in response.cookies
def test_protected_with_auth(self, client):
# Login first
client.post("/login", data={"username": "alice", "password": "password123"})
# Access protected route with session cookie
response = client.get("/protected")
assert response.status_code == 200
assert "Hello alice" in response.json()["message"]
def test_protected_without_auth(self, client):
# Create a fresh client without login
with TestClient(app) as fresh_client:
response = fresh_client.get("/protected")
assert response.status_code == 401

  • Group related tests logically
  • Share fixtures easily with scope="class"
  • Keep your test files organized and readable

If you want, I can also show you how to use setup_method or setup_class for setup/teardown inside test classes. Just let me know!

===========================

Cool! Here’s how you can use setup_method and setup_class in pytest test classes for setup and teardown tasks.


pytest setup/teardown methods in test classes

Section titled “pytest setup/teardown methods in test classes”
Method nameWhen it runsWhat to use it for
setup_class(cls)Runs once before all tests in the classSetup things shared by all tests (e.g., DB connection)
teardown_class(cls)Runs once after all tests in the classCleanup shared resources
setup_method(self, method)Runs before each test methodSetup fresh state before every test
teardown_method(self, method)Runs after each test methodCleanup after every test

Example using setup and teardown with FastAPI TestClient

Section titled “Example using setup and teardown with FastAPI TestClient”
from fastapi.testclient import TestClient
from backend.main import app
class TestSessionAuth:
@classmethod
def setup_class(cls):
print("Setup once before all tests")
cls.client = TestClient(app)
@classmethod
def teardown_class(cls):
print("Cleanup once after all tests")
# e.g. close db connections if any
def setup_method(self, method):
print(f"Setup before test: {method.__name__}")
# Clear cookies before each test for isolation
self.client.cookies.clear()
def teardown_method(self, method):
print(f"Teardown after test: {method.__name__}")
def test_login(self):
response = self.client.post("/login", data={"username": "alice", "password": "password123"})
assert response.status_code == 200
assert "session_token" in response.cookies
def test_access_protected_without_login(self):
response = self.client.get("/protected")
assert response.status_code == 401
def test_access_protected_with_login(self):
self.client.post("/login", data={"username": "alice", "password": "password123"})
response = self.client.get("/protected")
assert response.status_code == 200

  • Use cls for class methods like setup_class to set class-level attributes (like cls.client).
  • Use self in instance methods (setup_method, tests).
  • setup_method runs before each test, so clearing cookies here means each test starts fresh.
  • Print statements are just for demonstration—you can remove them.

===================================

Great question!

@pytest.fixture(scope="class") is a way to define a pytest fixture whose lifetime (scope) lasts for an entire test class.


A fixture is a reusable setup function that provides test data, objects, or state to your test functions or methods. Pytest handles the setup and teardown automatically.


  • The fixture is created once per test class, not for every test method.
  • The same fixture instance is shared by all test methods in that class.
  • After all tests in the class finish, pytest will tear down the fixture.

import pytest
from fastapi.testclient import TestClient
from backend.main import app
@pytest.fixture(scope="class")
def client():
print("Setup TestClient once per test class")
with TestClient(app) as c:
yield c
print("Teardown TestClient after all tests in class")
class TestMyAPI:
def test_one(self, client):
response = client.get("/")
assert response.status_code == 200
def test_two(self, client):
response = client.get("/health")
assert response.status_code == 200

  • The first test that uses client triggers the fixture.
  • TestClient(app) is created once.
  • The same client instance is passed to all test methods in TestMyAPI.
  • After all test methods finish, the fixture’s code after yield runs (teardown).

  • Improves test speed by avoiding repeated setup/teardown per test.
  • Useful when setup is expensive (e.g., starting a DB, creating a client).
  • Helps keep shared state consistent during a class’s tests.

If you want, I can also show how to do fixtures with other scopes like "function" (default), "module", or "session". Just ask!

=====================================

Awesome! Here’s a quick rundown of pytest fixture scopes and when to use them:


ScopeWhen fixture is created & destroyedUse case example
function (default)Created fresh for every test function or methodIsolated tests, no shared state
classCreated once per test class, shared by all methodsSharing resources across related tests
moduleCreated once per test module (.py file)Share setup across multiple classes/functions in one file
sessionCreated once per test session (entire test run)Expensive global setup, e.g. DB, web server

@pytest.fixture
def client():
# runs before every test function/method
return TestClient(app)
  • Fresh client for each test.
  • Isolated environment.
  • Good if tests modify state.

@pytest.fixture(scope="class")
def client():
with TestClient(app) as c:
yield c
  • Shared client for all tests in a class.
  • Faster when setup is expensive.
  • Use when tests don’t interfere with each other’s state or you clean up carefully.

@pytest.fixture(scope="module")
def db():
# set up DB connection once for all tests in this file
connection = create_db_connection()
yield connection
connection.close()
  • Shared across all test classes and functions in a file.
  • Useful for expensive resource setup in that module.

@pytest.fixture(scope="session")
def expensive_resource():
# runs once per entire pytest session (all tests)
resource = start_expensive_service()
yield resource
resource.shutdown()
  • Use for very expensive setup/teardown.
  • Shared globally across all tests.
  • Examples: starting a Docker container, spinning up a test database, etc.

  • If tests can run isolated → use default function scope.
  • If setup is expensive but tests are related → class or module.
  • For global heavy setup → session.

If you want, I can help you write fixtures with teardown code, or combine multiple fixtures for complex setups!