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:
How session-based auth usually works
Section titled “How session-based auth usually works”- Client sends credentials (e.g., username & password) to
/login. - Server verifies credentials and sets a session cookie (e.g.,
session_idoraccess_tokenin an HTTP-only cookie). - Client stores the cookie automatically (browser does this).
- Client sends subsequent requests with the cookie for authenticated access.
- Server checks the session cookie on each protected route.
How to test session auth with TestClient
Section titled “How to test session auth with TestClient”- Use the
TestClientwhich handles cookies automatically. - First, send a POST to
/loginwith valid credentials. - The response sets a cookie —
TestClientstores 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, Cookiefrom 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!"}Example test for session auth
Section titled “Example test for session auth”from fastapi.testclient import TestClientfrom 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 == 401Summary:
Section titled “Summary:”- Use the same
TestClientinstance 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 sentOption 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 hereOption 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 cookiesExample showing clearing cookies and testing auth
Section titled “Example showing clearing cookies and testing auth”from fastapi.testclient import TestClientfrom 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 yourTestClient - Or create a new
TestClientinstance 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.
How to use classes with pytest
Section titled “How to use classes with pytest”- Prefix the class name with
Test(e.g.,TestAuth) - Define test methods inside, each method name starting with
test_ - You can use
selfin methods but do not define__init__(pytest creates instances) - Use
@pytest.fixturewithselfor@classmethodif needed
Example: session auth tests using a class with pytest
Section titled “Example: session auth tests using a class with pytest”import pytestfrom fastapi.testclient import TestClientfrom 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 == 401Why use classes?
Section titled “Why use classes?”- 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 name | When it runs | What to use it for |
|---|---|---|
setup_class(cls) | Runs once before all tests in the class | Setup things shared by all tests (e.g., DB connection) |
teardown_class(cls) | Runs once after all tests in the class | Cleanup shared resources |
setup_method(self, method) | Runs before each test method | Setup fresh state before every test |
teardown_method(self, method) | Runs after each test method | Cleanup 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 TestClientfrom 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 == 200Notes:
Section titled “Notes:”- Use
clsfor class methods likesetup_classto set class-level attributes (likecls.client). - Use
selfin instance methods (setup_method, tests). setup_methodruns 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.
What is a pytest fixture?
Section titled “What is a pytest fixture?”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.
What does scope="class" mean?
Section titled “What does scope="class" mean?”- 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.
Example usage:
Section titled “Example usage:”import pytestfrom fastapi.testclient import TestClientfrom 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 == 200What happens here?
Section titled “What happens here?”- The first test that uses
clienttriggers the fixture. TestClient(app)is created once.- The same
clientinstance is passed to all test methods inTestMyAPI. - After all test methods finish, the fixture’s code after
yieldruns (teardown).
Why use scope="class"?
Section titled “Why use scope="class"?”- 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:
pytest fixture scopes explained
Section titled “pytest fixture scopes explained”| Scope | When fixture is created & destroyed | Use case example |
|---|---|---|
function (default) | Created fresh for every test function or method | Isolated tests, no shared state |
class | Created once per test class, shared by all methods | Sharing resources across related tests |
module | Created once per test module (.py file) | Share setup across multiple classes/functions in one file |
session | Created once per test session (entire test run) | Expensive global setup, e.g. DB, web server |
Examples
Section titled “Examples”1. function scope (default)
Section titled “1. function scope (default)”@pytest.fixturedef client(): # runs before every test function/method return TestClient(app)- Fresh client for each test.
- Isolated environment.
- Good if tests modify state.
2. class scope
Section titled “2. class scope”@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.
3. module scope
Section titled “3. module scope”@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.
4. session scope
Section titled “4. session scope”@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.
When to choose which?
Section titled “When to choose which?”- If tests can run isolated → use default
functionscope. - If setup is expensive but tests are related →
classormodule. - For global heavy setup →
session.
If you want, I can help you write fixtures with teardown code, or combine multiple fixtures for complex setups!