At this stage of development, the blog application includes authentication and authorization, PostgreSQL with Alembic migrations, AWS S3 image uploads, and async patterns throughout. This represents a production-ready application rather than a simple demo project. However, the complexity introduces numerous potential failure points, especially when adding features or making frequent updates. Testing provides confidence that the application functions as intended, catches hidden bugs, enables safe refactoring, and prevents silent breakage during updates.
Testing becomes non-negotiable when working professionally on team projects. The patterns demonstrated here reflect practical approaches used in real FastAPI projects rather than theoretical examples.
Installing Test Dependencies
The application requires two primary testing dependencies. For standard Python setups using pip, install pytest directly. Since this project uses uv throughout, the installation uses uv’s development dependency flag to separate testing tools from production requirements:
uv add --dev pytestThis installation adds pytest to the dev-dependencies section of pyproject.toml rather than the production dependencies list.
The second required package is Moto, a library that mocks AWS services. Only the S3 portion is needed, installed with bracket extras:
uv add --dev "moto[s3]"Both pytest and moto now appear in the development dependencies section of pyproject.toml.
Dependency Notes
httpx does not require separate installation. When FastAPI was installed with standard extras in the initial tutorial, it included httpx, which provides the async client for test requests. httpx includes AnyIO, which has a built-in pytest plugin for running async tests, eliminating the need for a separate pytest-anyio package.
Critical warning: If pytest-asyncio happens to be installed, check pyproject.toml or pytest.ini for a setting called asyncio_mode set to auto. This setting must be removed or pytest-asyncio must be uninstalled entirely. Auto mode conflicts with anyio’s built-in pytest plugin, and pytest-asyncio is unnecessary since anyio’s plugin handles everything required.
Organizing Test Files
Create a tests directory in the project root with the following structure:
tests/
├── __init__.py
├── conftest.py
├── test_posts.py
├── test_users.py
└── test_image.jpeg
The __init__.py file assists with imports. The conftest.py file contains shared test fixtures—reusable pieces of test setup that pytest automatically injects into tests. The separate test files mirror the routers structure: test_posts.py corresponds to the posts router, and test_users.py corresponds to the users router. The test_ prefix is critical—this naming convention allows pytest to automatically discover test files.
Since the application handles file uploads and image processing, include a test image. A minimal one-pixel JPEG serves this purpose perfectly. Place test_image.jpeg in the tests directory to keep test assets together with test code.
Understanding Synchronous vs. Async Testing
Before implementing the async setup used throughout this application, understanding the synchronous approach provides useful context. FastAPI’s documentation and many tutorials demonstrate a TestClient imported from fastapi.test_client, which works well for simple applications.
A synchronous testing example:
from fastapi import FastAPI
from fastapi.testclient import TestClient
demo_app = FastAPI()
@demo_app.get("/")
def demo_home():
return {"message": "hello"}
client = TestClient(demo_app)
def test_homepage():
response = client.get("/")
assert response.status_code == 200This approach uses a regular def function (not async). The test makes a GET request and uses Python’s built-in assert statement to verify the response status code. When an assertion expression evaluates to true, nothing happens and the test continues. When false, it raises an AssertionError, which pytest catches and marks as a test failure.
This differs from Python’s built-in unittest module, which uses methods like self.assertEqual() or self.assertTrue(). Pytest’s plain assert statement reads like regular Python, improving test clarity and readability.
For many applications, this synchronous approach works perfectly fine. However, this project uses an async database stack with async engine, async sessions, and async route handlers. Building database setup around a synchronous client creates awkward bridging between synchronous tests and asynchronous infrastructure. Instead, using AsyncClient from httpx keeps everything in the async world and maintains clean architecture.
Building conftest.py
The conftest.py file is special—pytest recognizes it automatically as the location for fixtures and shared setup available to all tests in that directory. Imports from this file are unnecessary; pytest handles them automatically.
Build this file incrementally to understand how each piece connects.
Setting Environment Variables
This step is extremely important. Pay close attention to the order.
The settings class uses Pydantic settings and reads environment variables when instantiated. If the application is imported first, it loads production settings from the .env file, including the real database URL and real AWS credentials. Testing must never touch production resources.
At the very top of conftest.py, before any application imports, set environment variables:
import os
from collections.abc import AsyncGenerator
os.environ["DATABASE_URL"] = (
"postgresql+psycopg://blog_user:blog_pass@localhost/test_blog"
)
os.environ["S3_BUCKET_NAME"] = "test-bucket"
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only"
os.environ["S3_ACCESS_KEY_ID"] = "testing"
os.environ["S3_SECRET_ACCESS_KEY"] = "testing"
os.environ["S3_REGION"] = "us-east-1"
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
os.environ["AWS_DEFAULT_REGION"] = "us-east-1"The database URL points to a completely separate test database called test_blog (the production database is simply blog). The S3 bucket name is fake, designed to work with Moto mocking. The secret key is a test value, acceptable since this runs only in testing environments.
Why set both S3 and AWS versions of credentials? The application uses S3 variables through Pydantic settings for configuration. However, boto3 (the AWS SDK) has its own credential chain that searches for AWS environment variables. When creating a boto3 client directly in test fixtures, it uses those AWS variables. Setting both ensures everything stays inside the mocked test environment, preventing any accidental real AWS service calls.
Now that environment variables are set, importing from the application is safe:
import boto3
import pytest
from httpx import ASGITransport, AsyncClient
from moto import mock_aws
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from database import Base, get_db
from main import appThese imports cover:
- boto3 for interacting with mocked S3 in tests
- pytest for test fixtures
- ASGITransport and AsyncClient for making async test requests
- mock_aws from moto to intercept AWS calls and redirect them to in-memory mocks
- SQLAlchemy imports for test database engine and session setup
- Base and get_db from the database module
- app from the main module
The import order is critical. The os.environ lines must precede the database and main imports. This is a common gotcha when testing with Pydantic settings—import order matters significantly.
Enabling the anyio Plugin
At the module level, register the anyio plugin:
pytest_plugins = ["anyio"]This is the standard way to register pytest plugins (alternatives include pyproject.toml or command-line flags, but placing it in conftest.py keeps configuration adjacent to the code using it).
The plugin enables async test functions. Normally, pytest runs only regular synchronous functions, but since the application is async, tests should be async as well. The plugin provides a @pytest.mark.anyio decorator for test functions, signaling pytest to run them on an event loop.
The anyio plugin supports multiple async backends (asyncio and trio). Since the application uses asyncio, specify that backend with a fixture:
@pytest.fixture(scope="session")
def anyio_backend():
return "asyncio"The @pytest.fixture decorator transforms a regular function into a fixture. The scope="session" parameter means this fixture runs once for the entire test session rather than once per test—scope significance becomes clear when setting up the database engine.
Test Database Strategy
Critical principle: Tests must never touch development or production databases. Tests create and delete substantial data, frequently wiping everything clean. A separate test database is essential.
Use a separate PostgreSQL database, not SQLite. Using the same database type as production ensures tests behave identically to the real application. SQLite exhibits different behavior in certain areas (as demonstrated earlier in the series), meaning tests could pass locally but break in production. A PostgreSQL test database prevents this discrepancy.
Create the test database (ensure the PostgreSQL server is running first—if the application has been running with PostgreSQL, it should already be active):
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=secret postgres
docker exec -it <container_name_ir_id> psql -U postgres
CREATE USER blog_user WITH PASSWORD 'blog_pass';
CREATE DATABASE test_blog OWNER blog_user;This creates the test_blog database specified in the test environment variables, with blog_user as the owner (the user created in the PostgreSQL tutorial—substitute a different username if necessary).
Transactional Rollback Pattern
The second part of the testing strategy employs the transactional rollback pattern, the industry-standard approach for fast test isolation:
- Create all database tables once at the start of the test session
- Each individual test runs inside a database transaction
- After each test completes, roll back that transaction, instantly undoing everything
- At the end of the test session, drop all database tables
This approach is significantly faster than creating and dropping tables for every single test, and eliminates concerns about tracking table order for foreign key constraints during cleanup.
Database Fixtures
Create a session-scoped test engine:
@pytest.fixture(scope="session")
async def test_engine():
engine = create_async_engine(
os.environ["DATABASE_URL"],
poolclass=NullPool,
)
return engineThis creates the test database engine using create_async_engine (identical to the approach in database.py), but pointing to the test database set in environment variables. Session scope means it’s created only once.
Why must the anyio_backend fixture also be session-scoped? Since anyio_backend is session-scoped, anyio creates one event loop that lives for the entire test session. If it were function-scoped, a new event loop would be created for each test. This creates a problem: the engine would be created on the first test’s event loop, then that loop closes after the first test. Subsequent tests would receive a new loop, but the engine remains bound to the old, closed one. If “event loop is closed” errors appear during test runs, mismatched scopes are likely the cause.
NullPool disables connection pooling entirely. Without it, pooled connections cause issues between tests, including “connection already closed” errors or stale connection problems.
The next fixture creates and drops tables:
@pytest.fixture(scope="session")
async def setup_database(test_engine):
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await test_engine.dispose()This session-scoped fixture runs at the start, creating all tables with connection.run_sync(Base.metadata.create_all). The run_sync call is necessary because SQLAlchemy’s create_all is a synchronous operation—it must be bridged through run_sync.
After creating tables, the fixture yields, allowing all tests to run. When tests complete, execution resumes, dropping all tables and disposing of the engine to clean up connection resources.
Database Session Fixture
This fixture implements the transactional rollback pattern:
@pytest.fixture
async def db_session(
test_engine,
setup_database,
) -> AsyncGenerator[AsyncSession]:
conn = await test_engine.connect()
trans = await conn.begin()
test_async_session = async_sessionmaker(
bind=conn,
class_=AsyncSession,
expire_on_commit=False,
join_transaction_mode="create_savepoint",
)
async with test_async_session() as session:
try:
yield session
finally:
await session.close()
await trans.rollback()
await conn.close()No scope is specified—this fixture is function-scoped by default, running for each test. It takes test_engine and setup_database as parameters.
The fixture manually creates a connection and begins a transaction. It then creates a session bound to this specific connection, not to the engine. This ensures all operations the test performs go through this one connection and this one transaction.
The critical line: join_transaction_mode="create_savepoint". This setting enables fake commit magic. When application code calls session.commit(), SQLAlchemy intercepts that call. Instead of performing a real commit, it creates a savepoint. The data appears committed to the application code—everything works as expected—but nothing has actually committed to the database. This allows rolling back everything at the end because the real transaction was never committed.
The async with test_async_session opens a session from the session maker, yielding it to the test. When a test requests the db_session fixture, it receives this session. After the test runs and completes its database operations, execution reaches the finally block, closing the session, explicitly rolling back the transaction (undoing everything the test did), and closing the connection. Explicit rollback is crucial to prevent data leaking between tests.
Mocking S3
The application uploads profile pictures to AWS, and tests must never touch real Amazon Web Services. Moto intercepts all calls to boto3 and redirects them to a virtual in-memory AWS environment:
@pytest.fixture
def mocked_aws():
with mock_aws():
s3 = boto3.client("s3", region_name="us-east-1")
s3.create_bucket(Bucket=os.environ["S3_BUCKET_NAME"])
yield s3This is a regular def function, not asynchronous. Moto’s mock_aws is a synchronous context manager that patches boto3 globally—async application code still works inside the mock.
The fixture creates the test bucket the application expects using the mocked environment variable, then yields the S3 client so tests can verify side effects (checking that files were actually uploaded). Since this is function-scoped, each test gets a fresh mock state with an empty bucket.
Client Fixture with Dependency Overrides
This fixture ties everything together using FastAPI’s dependency overrides feature:
@pytest.fixture
async def client(
db_session: AsyncSession,
mocked_aws,
) -> AsyncGenerator[AsyncClient]:
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as ac:
yield ac
app.dependency_overrides.clear()Inside this function-scoped fixture, create an asynchronous function override_get_db that yields the db_session created earlier. Then use app.dependency_overrides[get_db] = override_get_db.
The application’s get_db function provides database sessions to route handlers through the Depends system. The app.dependency_overrides dictionary swaps out any depends function for testing. Instead of routes receiving normal database sessions, they receive the transactional test session that rolls back after each test. This is significantly cleaner than manual mocking—FastAPI’s dependency injection system makes testing elegant.
After overriding the dependency, create the async client AsyncClient: it is the standard httpx client normally used for real HTTP requests over the network. For testing, avoid starting an actual server and making real network calls—that would be slow and add complexity.
ASGITransport solves this. FastAPI is an ASGI (Asynchronous Server Gateway Interface) application—a standard defining how async web servers like Uvicorn communicate with async Python web apps like FastAPI. ASGITransport takes the app and lets AsyncClient send requests directly to it in memory without touching the network.
When calling client.get("/api/posts") in a test, that request goes straight into the FastAPI app, runs through all middleware and route handlers, and returns a response—all without a real HTTP server. This is simple, fast, and provides realistic behavior by running actual application code.
The base_url is required to prevent odd behavior with relative URLs and redirects. The URL itself doesn’t matter (no actual network calls occur)—it simply needs a value to function properly.
After yielding the client for tests, clear dependency overrides to prevent leakage between tests.
Note on lifespan events: AsyncClient with ASGITransport doesn’t run the app’s lifespan events—startup and shutdown handlers won’t fire. This is acceptable because the lifespan only disposes of the production engine, which tests don’t use. If an application had important startup code (like populating a cache), use the asgi-lifespan package’s LifespanManager to run those events in tests.
Authentication Helpers
Many endpoints require authentication, necessitating frequent user creation and login. Helper functions streamline this:
async def create_test_user(
client: AsyncClient,
username: str = "testuser",
email: str = "test@example.com",
password: str = "testpassword123",
) -> dict:
response = await client.post(
"/api/users",
json={
"username": username,
"email": email,
"password": password,
},
)
assert response.status_code == 201, f"Failed to create user: {response.text}"
return response.json()
async def login_user(
client: AsyncClient,
email: str = "test@example.com",
password: str = "testpassword123",
) -> str:
response = await client.post(
"/api/users/token",
data={
"username": email,
"password": password,
},
)
assert response.status_code == 200, f"Failed to login: {response.text}"
return response.json()["access_token"]
def auth_header(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}create_test_user creates a test user through the API and returns the response data. Default values (test_user, test@example.com, test_password_123) allow calling without arguments for common cases, but custom values can be passed when multiple users are needed. The assertion includes a comma followed by a string—this second argument is an optional failure message. If the status code isn’t 201, the assertion error includes the actual response text, showing exactly what went wrong.
login_user logs in a user and returns the access token. Critical detail: the login request uses data, not json. OAuth2PasswordRequestForm expects form data (as established in the authentication video). Using JSON here causes login failure—a common mistake when testing auth endpoints.
auth_header creates the authorization header dictionary to pass to requests.
These helpers make tests cleaner and more readable. The setup is extensive, but properly mocking authentication, database transactions, and AWS services is essential for applications of this complexity. While ideally tests would be written incrementally as the application develops, consolidating testing into a single comprehensive tutorial demonstrates all unique patterns at once.
Testing Posts
Open test_posts.py and add imports:
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header, create_test_user, login_userImport pytest for the anyio marker, AsyncClient for type hinting, and the helper functions from conftest.
Testing Empty Posts List
Start with the simplest test—verifying a GET request to /api/posts returns an empty list when the database is fresh. Thanks to transactional rollback, each test starts with a clean database.
@pytest.mark.anyio
async def test_get_posts_empty(client: AsyncClient):
response = await client.get("/api/posts")
assert response.status_code == 200
data = response.json()
assert data["posts"] == []
assert data["total"] == 0
assert data["has_more"] is FalseThe @pytest.mark.anyio decorator allows running async test functions—without it, pytest wouldn’t handle async functions properly.
Notice the client parameter. It’s never imported or explicitly passed, yet it appears with a fully configured async client. This is pytest fixture injection in action. When pytest runs a test, it examines the function’s parameters and matches each parameter name to a fixture with the same name. It searches the current test file first, then conftest.py in the same directory, then any conftest.py files in parent directories. Finding the client fixture in conftest.py (specifically async def client(db_session: AsyncSession, mocked_aws) -> AsyncGenerator[AsyncClient]: ...), pytest runs that fixture to get its value and passes it into the test—no imports needed.
The client fixture also depends on db_session and mocked_aws, so pytest runs those in the correct order, wiring everything together automatically. This is what makes pytest pleasant to work with—declare what you need by name, and pytest provides it.
The test makes an HTTP request and asserts on the response. Simple, clean, and effective.
Run the test:
uv run pytest tests/test_posts.py -vThe test passes, confirming conftest.py, the test database, and the fixture chain are correctly wired. That green “passed” indicator validates the entire testing infrastructure.
/attachments/Pasted-image-20260504234923.png)
Testing 404 for Nonexistent Post
Test that fetching a nonexistent post returns 404:
@pytest.mark.anyio
async def test_get_post_not_found(client: AsyncClient):
response = await client.get("/api/posts/999")
assert response.status_code == 404
assert response.json()["detail"] == "Post not found"Even in a popular application with thousands of posts, the fresh test database ensures post 999 doesn’t exist.
Testing Post Creation
Test successful post creation when a user is authenticated:
@pytest.mark.anyio
async def test_create_post_success(client: AsyncClient):
user = await create_test_user(client)
token = await login_user(client)
headers = auth_header(token)
response = await client.post(
"/api/posts",
json={"title": "My First Post", "content": "This is the content"},
headers=headers,
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "My First Post"
assert data["content"] == "This is the content"
assert data["user_id"] == user["id"]
assert "id" in data
assert "date_posted" in data
assert data["author"]["username"] == "testuser"This three-line setup appears frequently in tests requiring authenticated requests:
- Call
create_test_userto create a user through the API - Call
login_userto log that user in and get an access token - Wrap the token in
auth_headerto build the authorization header
The helpers use default values (test_user, test@example.com, test_password_123), important to remember when checking assertions.
The test makes a POST request to /api/posts with title, content, and auth headers, verifying a 201 status code. It checks that title and content match what was sent, user_id matches the created user’s ID, and that id and date_posted exist in the response (generated by the database). It also verifies the author object is populated with the correct username, since the endpoint eager-loads the author relationship.
Transactional rollback means no cleanup is necessary. Even though the test created a user and post, the next test starts with a completely empty database.
Testing Unauthorized Post Creation
Test what happens when someone tries to create a post without authentication:
@pytest.mark.anyio
async def test_create_post_unauthorized(client: AsyncClient):
response = await client.post(
"/api/posts",
json={"title": "Test Post", "content": "Test content"},
)
assert response.status_code == 401
assert response.json()["detail"] == "Not authenticated"The test makes a POST request without creating a user or passing an authorization header, expecting a 401 Unauthorized response. 401 means unauthenticated—no token or bad token. The “Not authenticated” detail message is the default FastAPI’s OAuth2PasswordBearer returns when no token is provided.
Testing Post Updates
Test that a post owner can update their own post:
@pytest.mark.anyio
async def test_update_post_success(client: AsyncClient):
await create_test_user(client)
token = await login_user(client)
headers = auth_header(token)
response = await client.post(
"/api/posts",
json={"title": "Original Title", "content": "Original content"},
headers=headers,
)
post_id = response.json()["id"]
response = await client.patch(
f"/api/posts/{post_id}",
json={"title": "Updated Title"},
headers=headers,
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Updated Title"
assert data["content"] == "Original content"After the standard authentication setup, the test creates a post, then sends a PATCH request to update it with a new title. Since the user owns the post, the update succeeds with a 200 status, and the title reflects the change.
Testing Authorization and Ownership
Test that authentication alone doesn’t grant permission to update another user’s content:
@pytest.mark.anyio
async def test_update_post_wrong_user(client: AsyncClient):
await create_test_user(client, username="user1", email="user1@example.com")
token1 = await login_user(client, email="user1@example.com")
response = await client.post(
"/api/posts",
json={"title": "User 1's Post", "content": "Only user 1 can edit this"},
headers=auth_header(token1),
)
post_id = response.json()["id"]
await create_test_user(client, username="user2", email="user2@example.com")
token2 = await login_user(client, email="user2@example.com")
response = await client.patch(
f"/api/posts/{post_id}",
json={"title": "Hacked Title"},
headers=auth_header(token2),
)
assert response.status_code == 403
assert response.json()["detail"] == "Not authorized to update this post"This test creates two users. User 1 creates a post, then User 2 (using a different username and email but the same default password) attempts to update that post. Since User 2 doesn’t own the post, the response is 403 Forbidden.
The distinction between 401 and 403 matters in API design:
- 401 (Unauthorized): Not authenticated—no token or invalid token
- 403 (Forbidden): Authenticated, but not allowed to perform the action
Testing Pagination
Test that pagination parameters work correctly:
@pytest.mark.anyio
async def test_get_posts_with_pagination(client: AsyncClient):
await create_test_user(client)
token = await login_user(client)
headers = auth_header(token)
for i in range(5):
response = await client.post(
"/api/posts",
json={"title": f"Post {i}", "content": f"Content for post {i}"},
headers=headers,
)
assert response.status_code == 201
response = await client.get("/api/posts")
assert response.status_code == 200
data = response.json()
assert data["total"] == 5
assert len(data["posts"]) == 5
assert data["has_more"] is False
response = await client.get("/api/posts?limit=2")
assert response.status_code == 200
data = response.json()
assert data["total"] == 5
assert len(data["posts"]) == 2
assert data["has_more"] is True
response = await client.get("/api/posts?skip=2&limit=2")
assert response.status_code == 200
data = response.json()
assert data["total"] == 5
assert len(data["posts"]) == 2
assert data["skip"] == 2
assert data["limit"] == 2After authentication setup, a loop creates five posts with unique titles and content using f-strings. Inside the loop, assertions verify each creation returns 201—if any post creation fails, the test catches it immediately rather than producing confusing failures later.
The test then performs three checks:
- Default behavior (no query parameters): Should return all five posts, with
totalof 5,has_morefalse (nothing more to fetch) - Limit parameter:
limit=2means “give me at most two posts.” Total remains 5 (total posts in database, not number returned), but only two posts return, andhas_moreis true (more posts exist beyond what was returned). Thehas_moreflag tells the frontend whether to show a “next page” pagination button. - Skip and limit together:
skip=2&limit=2means “skip the first two, then give me two more” (returning posts three and four). Total is still 5, length is 2, and the response includes skip and limit values so the frontend knows its position in pagination.
This single test function covers multiple scenarios—default case, limit parameter, skip parameter, the has_more flag, and response shape.
Run all post tests:
uv run pytest tests/test_posts.py -vAll seven tests pass in under a second.
Testing Users
Open test_users.py. User tests require additional imports for file uploads and mocking:
from io import BytesIO
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header, create_test_user, login_userBytesIO handles file uploads, Path reads the test image, and AsyncMock and patch mock the email function.
Testing Validation Errors
Test that missing required fields return 422:
@pytest.mark.anyio
async def test_create_user_validation_error(client: AsyncClient):
response = await client.post(
"/api/users",
json={
"username": "testuser",
},
)
assert response.status_code == 422
assert "email" in response.text
assert "password" in response.textThe request includes only a username, missing both email and password. The response is 422 Unprocessable Entity—the data format is wrong, and Pydantic validation failed because required fields are missing.
The test asserts 422 status and checks that both “email” and “password” appear somewhere in response.text (the raw string version of the JSON response). Since Pydantic includes field names in error messages, a simple substring check suffices. While the JSON could be parsed to examine the error structure, this approach is simpler and more readable.
Testing Business Rule Violations
422 means data format is wrong (missing fields, wrong types). 400 means data is valid but breaks an application rule.
Test attempting to register with an already-taken email:
@pytest.mark.anyio
async def test_create_user_duplicate_email(client: AsyncClient):
await create_test_user(client)
response = await client.post(
"/api/users",
json={
"username": "different_user",
"email": "test@example.com",
"password": "password123",
},
)
assert response.status_code == 400
assert response.json()["detail"] == "Email already registered"The first user creation uses defaults (including test@example.com). The second attempt uses a different username and password but the same email. The response is 400 (not 422)—the data is perfectly valid with all required fields and correct types, but it violates the application rule that emails must be unique.
This distinction matters in API design: 422 for format errors, 400 for valid data that breaks business rules.
Testing Successful User Creation
Test successful user creation with custom values:
@pytest.mark.anyio
async def test_create_user_success(client: AsyncClient):
response = await client.post(
"/api/users",
json={
"username": "newuser",
"email": "newuser@example.com",
"password": "securepassword123",
},
)
assert response.status_code == 201
data = response.json()
assert data["username"] == "newuser"
assert data["email"] == "newuser@example.com"
assert "id" in data
assert "image_path" in data
assert "password" not in data
assert "password_hash" not in dataThis test makes the request directly instead of using the create_test_user helper, allowing custom values and detailed response verification.
The last two assertions are security checks. Verifying that password and password_hash are not in the response ensures no password leakage to clients. Tests excel at protecting against this type of issue—if a response schema accidentally changes later and starts leaking sensitive information, this test catches it.
Testing File Uploads
Test uploading a profile picture, the first file upload test and the first use of the mocked AWS fixture:
@pytest.mark.anyio
async def test_upload_profile_picture(client: AsyncClient, mocked_aws):
user = await create_test_user(client)
token = await login_user(client)
test_image_path = Path(__file__).parent / "test_image.jpg"
image_bytes = test_image_path.read_bytes()
response = await client.patch(
f"/api/users/{user['id']}/picture",
files={"file": ("profile.jpg", BytesIO(image_bytes), "image/jpeg")},
headers=auth_header(token),
)
assert response.status_code == 200
data = response.json()
assert data["image_file"] is not None
assert data["image_file"].endswith(".jpg")
assert "s3" in data["image_path"]
s3_objects = mocked_aws.list_objects_v2(Bucket="test-bucket")
assert "Contents" in s3_objects
assert len(s3_objects["Contents"]) == 1
assert s3_objects["Contents"][0]["Key"].endswith(data["image_file"])The parameters include both client and mocked_aws—the S3 client from the fixture, used to verify the upload actually occurred.
After authentication setup (creating a user and logging in), the test reads the test image. Path(__file__).parent gets the current test file’s path, then accesses its parent (the tests directory). Adding / "test_image.jpeg" gives the full path to the test image. read_bytes() returns raw file bytes.
The actual upload request uses files= instead of json=:
files={"file": ("profile.jpeg", BytesIO(image_bytes), "image/jpeg")}This tells httpx to send the data as multipart/form-data (like a browser file upload). The value is a tuple of three elements:
- Filename:
"profile.jpeg"(doesn’t need to match the actual disk filename—it’s just what gets sent to the server as part of the multipart form, simulating what a browser would send if a user picked a file called profile.jpeg) - File content:
BytesIO(image_bytes)(acts like an actual file object) - Content type:
"image/jpeg"
After the upload, verify the response has 200 status and expected image data—image_file is not None, ends with .jpeg, and image_path contains “s3” (indicating an S3 URL).
The crucial part: Use the mocked S3 client to verify the file actually exists in the mocked bucket:
objects = mocked_aws.list_objects_v2(Bucket=os.environ["S3_BUCKET_NAME"])
assert objects["KeyCount"] == 1
assert objects["Contents"][0]["Key"].startswith("profile_pics/")This lists objects in the bucket, checks that exactly one object exists, and verifies its key starts with "profile_pics/" (matching the image filename in the response).
By mocking S3, the test verifies not just the response, but the actual side effect of the upload—all without touching real Amazon Web Services. Even complex operations with multiple interactions and side effects can be properly tested with correct setup. This makes application updates easier by revealing if any part of the upload chain breaks.
Testing Background Tasks with Mocking
Test the background task for sending password reset emails without actually sending emails:
@pytest.mark.anyio
async def test_forgot_password_sends_email(client: AsyncClient):
await create_test_user(client)
with patch(
"routers.users.send_password_reset_email",
new_callable=AsyncMock,
) as mock_send:
response = await client.post(
"/api/users/forgot-password",
json={"email": "test@example.com"},
)
assert response.status_code == 202
mock_send.assert_awaited_once()
call_kwargs = mock_send.call_args.kwargs
assert call_kwargs["to_email"] == "test@example.com"
assert call_kwargs["username"] == "testuser"
assert "token" in call_kwargsAfter creating a test user (with default values), use patch as a context manager with as mock_send:
with patch("routers.users.send_password_reset_email", new_callable=AsyncMock) as mock_send:The patch function comes from unittest.mock. While using pytest, reaching for Python’s built-in unittest.mock for mocking is common. Pytest has its own monkeypatch fixture for simple swapping, but unittest.mock provides mock objects that track how they were called—necessary for verifying the function was awaited and with what arguments.
AsyncMock is required because the email function is asynchronous—a regular Mock wouldn’t work.
Understanding the patch path: Why patch routers.users.send_password_reset_email when send_password_reset_email actually lives in the email_utils module?
This is one of the most common gotchas with mocking in Python.
When routers/users.py does from email_utils import send_password_reset_email, it doesn’t create a live link back to email_utils. It grabs a reference to that function and creates a new name for it inside the routers.users namespace. Two names now point to the same function: the original in email_utils and a local reference in routers.users.
When the route handler runs and calls send_password_reset_email, it looks up that name in its own module namespace (routers.users). If email_utils.send_password_reset_email were patched, the original would be replaced, but the route handler would still use its local reference and call the real function—the mock would never get touched.
The rule: patch where the name is looked up, not where the function is defined. The route handler looks it up in routers.users, so that’s where to patch.
Mock this where it’s used, not where it’s defined.
With send_password_reset_email patched with AsyncMock, make a POST request to /api/users/forgot-password with the email of the created user. Verify a 202 Accepted response, then check that the mock was awaited once using assert_awaited_once().
How does this work with background tasks? BackgroundTasks runs tasks after the response is sent, but the test async client actually waits for background tasks to complete before returning. When BackgroundTasks calls an async function, it awaits it—so the async mock gets awaited, and assert_awaited_once() passes.
Verify call arguments to ensure the correct email, username, and token were passed to the function:
call_kwargs = mock_send.call_args.kwargs
assert call_kwargs["to_email"] == "test@example.com"
assert call_kwargs["username"] == "testuser"
assert "token" in call_kwargsThis is the most complex test, using mocking to intercept a function call. Review it multiple times if needed—understanding this pattern is valuable for testing background tasks and external service calls.
Running Tests
Run all tests together by pointing pytest at the entire test directory—it automatically finds files with the test_ prefix:
uv run pytest tests/ -vBoth test_posts.py and test_users.py run, with all tests passing.
Useful Pytest Options
Run a single test file by specifying the exact file:
uv run pytest tests/test_posts.py -vRun a single specific test using double colons after the filename:
uv run pytest tests/test_posts.py::test_get_posts_empty -vThe -v (verbose) flag provides detailed output. Without it, pytest displays dots for passing tests and a summary:
uv run pytest tests/To see print output from tests (useful for debugging), use the -s flag:
uv run pytest tests/ -sPrint statements won’t display by default—the -s option shows them in test output.
Coverage and Next Steps
This tutorial doesn’t achieve 100% test coverage, and not every endpoint has a test—that would make the tutorial excessively long. The goal was demonstrating unique patterns and techniques encountered when testing real FastAPI applications:
- Setting up test databases with transactional rollbacks
- Mocking external services like AWS
- Dependency overrides
- File uploads
- Background task mocking
- Authenticated requests
- Ownership checks
Understanding these patterns makes applying them to additional routes and endpoints mostly a matter of repetition.
Practice recommendation: Write tests for the remaining endpoints independently. This is the most effective way to internalize the material.
AI as a testing tool: AI can identify missing functional tests and edge cases. Even with 100% test coverage on paper, significant scenarios might be untested. Asking an AI assistant “Are there any test cases or edge cases I’m not properly testing that could break my application?” often yields valuable insights.
Recap
The conftest.py file establishes the testing foundation:
- Environment variables override production settings before any app imports
- Session-scoped fixtures create the engine and database setup
- The transactional rollback pattern provides fast, isolated tests
- A mocked AWS fixture supplies a virtual S3 environment
- An async client fixture with dependency overrides connects everything
- Helper functions streamline authentication
With solid setup in place, user and post route tests demonstrate various techniques and mocking approaches encountered in real-world testing.
End-to-end tests using tools like Playwright or Selenium could verify frontend display and button functionality, but that’s outside this scope. Dedicated Playwright and Selenium videos are planned for the future (though not part of the FastAPI series).
Testing coverage completes the foundation for a production-ready application. The next tutorial covers deploying this application to a server, making it accessible to everyone. Specifically, deployment to a VPS (virtual private server) will be demonstrated, followed by a subsequent video on using Docker to containerize the application and deploy it to a cloud service.