HTMX + FastAPI Testing Guide: Functional and E2E Tests
FastAPI is popular for APIs, but with Jinja2 templates it's a great HTMX backend too. Testing HTMX with FastAPI means using FastAPI's TestClient (Starlette-based) to send HX-Request headers and assert on HTML fragment responses, plus Playwright for browser-level interaction tests.
Key Takeaways
Use TestClient with headers={'HX-Request': 'true'}. FastAPI's test client wraps Starlette's ASGI test client. Headers are passed as a dict — match the exact header name HTMX sends.
Return HTMLResponse for partials. When HX-Request is present, return HTMLResponse or render a Jinja2 partial template directly. Full pages should use the base layout.
Check request.headers.get('HX-Request') in route handlers. This boolean check drives the partial-vs-full response logic.
422 for form validation errors. FastAPI returns 422 for Pydantic validation errors by default. For HTMX form submissions, catch these and return a form-with-errors partial at 422.
conftest.py with TestClient fixture is the foundation. Create the client once and share it across test modules — don't recreate the ASGI app for each test.
FastAPI + HTMX Architecture
# main.py
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
app = FastAPI()
templates = Jinja2Templates(directory='templates')
@app.get('/tasks', response_class=HTMLResponse)
async def task_list(request: Request):
tasks = await get_all_tasks()
if request.headers.get('HX-Request'):
return templates.TemplateResponse(
'tasks/partials/list.html',
{'request': request, 'tasks': tasks}
)
return templates.TemplateResponse(
'tasks/index.html',
{'request': request, 'tasks': tasks}
)<!-- templates/tasks/partials/list.html -->
<ul id="task-list">
{% for task in tasks %}
<li id="task-{{ task.id }}"
hx-delete="/tasks/{{ task.id }}"
hx-target="this"
hx-swap="outerHTML">
{{ task.title }}
</li>
{% endfor %}
</ul>Test Setup
pip install pytest pytest-asyncio httpx fastapi# conftest.py
import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture(scope='module')
def client():
return TestClient(app)
@pytest.fixture
def htmx_headers():
return {'HX-Request': 'true'}Testing Partial vs Full Responses
# tests/test_task_routes.py
import pytest
class TestTaskList:
def test_full_page_without_htmx_header(self, client):
"""Non-HTMX request returns full page with layout."""
response = client.get('/tasks')
assert response.status_code == 200
assert '<!DOCTYPE html>' in response.text
assert 'task-list' in response.text
def test_partial_with_htmx_header(self, client, htmx_headers):
"""HTMX request returns only the fragment."""
response = client.get('/tasks', headers=htmx_headers)
assert response.status_code == 200
assert '<!DOCTYPE html>' not in response.text
assert 'id="task-list"' in response.text
def test_partial_includes_hx_attributes(self, client, htmx_headers):
"""Each task in the list has HTMX delete attributes."""
response = client.get('/tasks', headers=htmx_headers)
assert 'hx-delete' in response.text
assert 'hx-target' in response.text
def test_content_type_is_html(self, client, htmx_headers):
response = client.get('/tasks', headers=htmx_headers)
assert 'text/html' in response.headers['content-type']Testing Task Creation (POST with Form Data)
# routes/tasks.py
@app.post('/tasks', response_class=HTMLResponse)
async def create_task(
request: Request,
title: str = Form(...)
):
if not title.strip():
# Return form with error for HTMX to swap
return templates.TemplateResponse(
'tasks/partials/form.html',
{'request': request, 'error': 'Title is required'},
status_code=422
)
task = await Task.create(title=title)
return templates.TemplateResponse(
'tasks/partials/task_item.html',
{'request': request, 'task': task},
status_code=200
)class TestTaskCreate:
def test_creates_task_with_valid_data(self, client, htmx_headers):
response = client.post(
'/tasks',
data={'title': 'New task'},
headers=htmx_headers
)
assert response.status_code == 200
assert 'New task' in response.text
assert '<!DOCTYPE html>' not in response.text
def test_returns_422_for_empty_title(self, client, htmx_headers):
response = client.post(
'/tasks',
data={'title': ''},
headers=htmx_headers
)
assert response.status_code == 422
assert 'Title is required' in response.text
def test_error_response_contains_form(self, client, htmx_headers):
"""Error response should still have the form for HTMX to swap in."""
response = client.post(
'/tasks',
data={'title': ''},
headers=htmx_headers
)
# Form should still have htmx post attribute
assert 'hx-post="/tasks"' in response.text
def test_non_htmx_create_redirects(self, client):
"""Standard form submission redirects after create."""
response = client.post(
'/tasks',
data={'title': 'New task'},
allow_redirects=False
)
assert response.status_code == 303
assert response.headers['location'] == '/tasks'Testing Task Deletion
@app.delete('/tasks/{task_id}', response_class=HTMLResponse)
async def delete_task(task_id: int, request: Request):
task = await Task.get_or_404(task_id)
await task.delete()
return HTMLResponse(content='', status_code=200)class TestTaskDelete:
def test_deletes_task_returns_empty_200(self, client, htmx_headers, task):
response = client.delete(
f'/tasks/{task.id}',
headers=htmx_headers
)
assert response.status_code == 200
assert response.text == ''
def test_delete_nonexistent_returns_404(self, client, htmx_headers):
response = client.delete('/tasks/99999', headers=htmx_headers)
assert response.status_code == 404
def test_task_removed_from_database(self, client, htmx_headers, task, db_session):
client.delete(f'/tasks/{task.id}', headers=htmx_headers)
result = await db_session.get(Task, task.id)
assert result is NoneTesting HTMX Response Headers
from fastapi.responses import HTMLResponse
@app.post('/tasks/{task_id}/complete')
async def complete_task(task_id: int, request: Request):
task = await Task.get_or_404(task_id)
task.completed = True
await task.save()
response = templates.TemplateResponse(
'tasks/partials/task_item.html',
{'request': request, 'task': task}
)
response.headers['HX-Trigger'] = '{"taskCompleted": {"taskId": ' + str(task_id) + '}}'
return response
@app.post('/login')
async def login(request: Request, email: str = Form(...), password: str = Form(...)):
user = await authenticate(email, password)
if user and request.headers.get('HX-Request'):
response = HTMLResponse(content='', status_code=200)
response.headers['HX-Redirect'] = '/dashboard'
return response
return RedirectResponse('/dashboard', status_code=303)class TestHTMXHeaders:
def test_hx_trigger_on_task_complete(self, client, htmx_headers, task):
response = client.post(
f'/tasks/{task.id}/complete',
headers=htmx_headers
)
assert 'HX-Trigger' in response.headers
import json
trigger = json.loads(response.headers['HX-Trigger'])
assert 'taskCompleted' in trigger
def test_hx_redirect_after_login(self, client, htmx_headers):
response = client.post(
'/login',
data={'email': 'alice@example.com', 'password': 'secret'},
headers=htmx_headers
)
assert response.status_code == 200
assert response.headers.get('HX-Redirect') == '/dashboard'Async Route Testing
For truly async routes with SQLAlchemy async or async HTTP calls:
# conftest.py (async setup)
import pytest
import pytest_asyncio
from httpx import AsyncClient
@pytest_asyncio.fixture
async def async_client():
async with AsyncClient(app=app, base_url='http://test') as client:
yield client@pytest.mark.asyncio
async def test_task_list_async(async_client):
response = await async_client.get(
'/tasks',
headers={'HX-Request': 'true'}
)
assert response.status_code == 200
assert '<!DOCTYPE html>' not in response.textJinja2 Template Testing
Test that templates render correctly in isolation:
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
from starlette.testclient import TestClient
templates = Jinja2Templates(directory='templates')
def test_task_partial_template(client):
"""Test that the partial template renders expected HTML."""
# Use a simple route that just renders the template
@app.get('/test-partial')
async def test_route(request: Request):
return templates.TemplateResponse(
'tasks/partials/task_item.html',
{'request': request, 'task': {'id': 1, 'title': 'Test task', 'completed': False}}
)
response = client.get('/test-partial')
assert 'Test task' in response.text
assert 'hx-delete="/tasks/1"' in response.textE2E Testing with Playwright
# tests/e2e/test_htmx_interactions.py
import pytest
from playwright.sync_api import Page, expect
BASE_URL = 'http://localhost:8000'
def test_add_task_via_htmx(page: Page):
page.goto(f'{BASE_URL}/tasks')
navigations = []
page.on('framenavigated', lambda frame: navigations.append(frame.url))
page.fill('[name="title"]', 'Test HTMX task')
page.click('[type="submit"]')
# New task appears without page reload
expect(page.locator('#task-list')).to_contain_text('Test HTMX task')
assert len(navigations) == 0 # No navigation
def test_delete_task_removes_element(page: Page):
page.goto(f'{BASE_URL}/tasks')
# Find and delete the first task
first_task = page.locator('#task-list li').first
task_text = first_task.inner_text()
first_task.hover()
first_task.locator('button.delete').click()
# Element is removed (HTMX outerHTML swap)
expect(page.locator('#task-list')).not_to_contain_text(task_text)
def test_htmx_loading_state(page: Page):
"""Verify HTMX adds/removes htmx-request class during requests."""
page.goto(f'{BASE_URL}/tasks')
# Intercept and slow down the HTMX request
page.route('/tasks', lambda route: (
page.wait_for_timeout(100),
route.continue_()
))
page.fill('[name="title"]', 'Slow task')
page.click('[type="submit"]')
# During request, the form has htmx-request class
expect(page.locator('form.htmx-request')).to_be_visible()HelpMeTest for HTMX FastAPI Apps
The TestClient covers your server-side logic. HelpMeTest covers the browser-side:
When the user submits an empty form
Then the form shows an inline error without a page reload
And the error message says "Title is required"
And the form is still interactive for another submission attemptSummary
Testing HTMX with FastAPI:
- Use
TestClientwithheaders={'HX-Request': 'true'}to test partial responses - Assert
'<!DOCTYPE html>' not in response.textto verify partials - Return 422 status with error partials for form validation failures
- Assert
response.headers.get('HX-Trigger')for event-driven HTMX patterns - Use
httpx.AsyncClientfor async route tests - Use Playwright for browser-level HTMX interaction tests