HTMX + FastAPI Testing Guide: Functional and E2E Tests

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 None

Testing 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.text

Jinja2 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.text

E2E 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 attempt

Summary

Testing HTMX with FastAPI:

  • Use TestClient with headers={'HX-Request': 'true'} to test partial responses
  • Assert '<!DOCTYPE html>' not in response.text to 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.AsyncClient for async route tests
  • Use Playwright for browser-level HTMX interaction tests

Read more