Testing HTMX with Django: Integration Testing Server-Rendered Partials
HTMX turns Django into a hypermedia-first framework where server responses are HTML fragments (partials) instead of JSON. Testing HTMX with Django means testing two layers: server-side partial rendering (Django TestClient with HX-Request header) and browser-side interaction (Playwright E2E). This guide covers both.
Key Takeaways
Test partials with HX-Request: true header. Django views return different responses for HTMX vs non-HTMX requests. In tests, set HTTP_HX_REQUEST='true' in client.get() to simulate an HTMX request.
Partial responses should be HTML fragments, not full pages. A well-structured HTMX view returns render(request, 'partials/list.html', context) for HTMX, full page for direct navigation. Test both branches.
django-htmx middleware adds request.htmx boolean. Use is_htmx = request.htmx in views — and test both branches by sending requests with and without the header.
Integration tests via Django TestClient are fast and don't need a browser. Test status codes, HTML content, and response headers for partial endpoints using the standard test client.
E2E tests catch JavaScript glue. HTMX request/response cycles, CSS transitions, out-of-band swaps, and push-url behavior require a real browser. Use Playwright.
HTMX Django Architecture
A typical HTMX Django view:
# views.py
from django.shortcuts import render, get_object_or_404
from django.views.decorators.http import require_GET
from .models import Task
@require_GET
def task_list(request):
tasks = Task.objects.filter(completed=False).order_by('-created_at')
template = 'tasks/partials/list.html' if request.headers.get('HX-Request') else 'tasks/list.html'
return render(request, template, {'tasks': tasks})<!-- tasks/partials/list.html -->
<ul id="task-list">
{% for task in tasks %}
<li hx-delete="/tasks/{{ task.id }}/"
hx-target="closest li"
hx-swap="outerHTML swap:300ms">
{{ task.title }}
</li>
{% endfor %}
</ul>Setting Up Tests
pip install pytest pytest-django playwright
pytest --co # verify setup# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = myapp.settings.test# conftest.py
import pytest
@pytest.fixture
def client(client):
return client
@pytest.fixture
def task(db):
from tasks.models import Task
return Task.objects.create(title='Write tests', completed=False)Testing Partial Views with Django TestClient
# tests/test_task_views.py
import pytest
from django.urls import reverse
@pytest.mark.django_db
class TestTaskListView:
def test_full_page_for_direct_navigation(self, client):
"""Non-HTMX request returns full page with layout."""
url = reverse('task-list')
response = client.get(url)
assert response.status_code == 200
assert b'<!DOCTYPE html>' in response.content
assert b'task-list' in response.content
def test_partial_for_htmx_request(self, client, task):
"""HTMX request returns only the partial fragment."""
url = reverse('task-list')
response = client.get(url, HTTP_HX_REQUEST='true')
assert response.status_code == 200
# Should NOT include full page layout
assert b'<!DOCTYPE html>' not in response.content
# Should include task content
assert task.title.encode() in response.content
assert b'id="task-list"' in response.content
def test_partial_includes_hx_attributes(self, client, task):
"""Each task has HTMX delete attributes."""
url = reverse('task-list')
response = client.get(url, HTTP_HX_REQUEST='true')
assert b'hx-delete' in response.content
assert f'/tasks/{task.id}/'.encode() in response.contentTesting HTMX DELETE (Inline Deletion)
# views.py
from django.http import HttpResponse
def task_delete(request, pk):
if request.method == 'DELETE':
task = get_object_or_404(Task, pk=pk)
task.delete()
# Return empty response — HTMX swaps the element with nothing (removes it)
return HttpResponse(status=200)
return HttpResponse(status=405)@pytest.mark.django_db
class TestTaskDeleteView:
def test_delete_removes_task(self, client, task):
"""DELETE removes the task from database."""
url = reverse('task-delete', kwargs={'pk': task.pk})
response = client.delete(url, HTTP_HX_REQUEST='true')
assert response.status_code == 200
assert not Task.objects.filter(pk=task.pk).exists()
def test_delete_returns_empty_response(self, client, task):
"""Empty response triggers HTMX outerHTML removal."""
url = reverse('task-delete', kwargs={'pk': task.pk})
response = client.delete(url, HTTP_HX_REQUEST='true')
assert response.content == b''
def test_delete_nonexistent_returns_404(self, client):
url = reverse('task-delete', kwargs={'pk': 99999})
response = client.delete(url, HTTP_HX_REQUEST='true')
assert response.status_code == 404Testing HTMX POST (Form Submission)
# views.py
def task_create(request):
if request.method == 'POST':
form = TaskForm(request.POST)
if form.is_valid():
task = form.save()
# Return the new task partial for HTMX to prepend
return render(request, 'tasks/partials/task_item.html', {'task': task})
# Return form with errors for HTMX to swap
return render(request, 'tasks/partials/create_form.html', {'form': form}, status=422)
return render(request, 'tasks/partials/create_form.html', {'form': TaskForm()})@pytest.mark.django_db
class TestTaskCreateView:
def test_valid_form_creates_task(self, client):
url = reverse('task-create')
response = client.post(url, {'title': 'New task'}, HTTP_HX_REQUEST='true')
assert response.status_code == 200
assert Task.objects.filter(title='New task').exists()
def test_valid_form_returns_task_partial(self, client):
url = reverse('task-create')
response = client.post(url, {'title': 'New task'}, HTTP_HX_REQUEST='true')
assert b'New task' in response.content
assert b'<!DOCTYPE html>' not in response.content
def test_invalid_form_returns_422(self, client):
url = reverse('task-create')
response = client.post(url, {'title': ''}, HTTP_HX_REQUEST='true')
assert response.status_code == 422
assert b'This field is required' in response.content
def test_invalid_form_returns_form_with_errors(self, client):
"""HTMX will swap the form partial with error state."""
url = reverse('task-create')
response = client.post(url, {'title': ''}, HTTP_HX_REQUEST='true')
assert b'hx-post' in response.content # form still has htmx attrsTesting HTMX Response Headers
HTMX uses response headers for client-side directives:
# views.py — redirect after login
def login_view(request):
if request.method == 'POST':
# ... authenticate ...
if request.headers.get('HX-Request'):
response = HttpResponse()
response['HX-Redirect'] = '/dashboard/'
return response
return redirect('/dashboard/')def test_htmx_redirect_header(self, client):
response = client.post(reverse('login'), {
'username': 'alice', 'password': 'secret'
}, HTTP_HX_REQUEST='true')
assert response.status_code == 200
assert response['HX-Redirect'] == '/dashboard/'
def test_htmx_trigger_header(self, client, task):
"""View sends HX-Trigger to notify client of events."""
url = reverse('task-complete', kwargs={'pk': task.pk})
response = client.post(url, HTTP_HX_REQUEST='true')
assert 'HX-Trigger' in response
import json
trigger = json.loads(response['HX-Trigger'])
assert 'taskCompleted' in triggerTesting with django-htmx
If you use django-htmx:
# views.py
from django_htmx.http import trigger_client_event, HttpResponseClientRefresh
def task_complete(request, pk):
task = get_object_or_404(Task, pk=pk)
task.completed = True
task.save()
response = render(request, 'tasks/partials/task_item.html', {'task': task})
trigger_client_event(response, 'taskCompleted', {'taskId': task.pk})
return responsedef test_django_htmx_trigger(self, client, task):
response = client.post(
reverse('task-complete', kwargs={'pk': task.pk}),
HTTP_HX_REQUEST='true'
)
assert response.status_code == 200
assert 'HX-Trigger' in response.headersE2E Testing with Playwright
# tests/e2e/test_task_list.py
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture
def live_server_url(live_server):
return live_server.url
def test_add_task(page: Page, live_server_url):
page.goto(f'{live_server_url}/tasks/')
# Fill form and submit via HTMX
page.fill('[name="title"]', 'My new task')
page.click('[type="submit"]')
# HTMX should prepend the new task to the list
expect(page.locator('#task-list li').first).to_contain_text('My new task')
def test_delete_task(page: Page, live_server_url, task):
page.goto(f'{live_server_url}/tasks/')
# The task should be visible
task_item = page.locator(f'[data-task-id="{task.pk}"]')
expect(task_item).to_be_visible()
# Click delete button
task_item.locator('button[hx-delete]').click()
# HTMX removes the element via outerHTML swap
expect(task_item).not_to_be_visible()
def test_htmx_request_does_not_reload_page(page: Page, live_server_url):
"""Verify HTMX is intercepting requests, not doing full page reloads."""
page.goto(f'{live_server_url}/tasks/')
navigations = []
page.on('framenavigated', lambda frame: navigations.append(frame.url))
page.fill('[name="title"]', 'Test task')
page.click('[type="submit"]')
page.wait_for_selector('#task-list li')
# HTMX should not cause page navigation
assert len(navigations) == 0Testing Out-of-Band Swaps
# views.py
def task_create(request):
"""Creates task and OOB-updates the task counter in the nav."""
task = form.save()
count = Task.objects.filter(completed=False).count()
# Main response content
content = render_to_string('tasks/partials/task_item.html', {'task': task})
# OOB swap for the counter
oob_content = render_to_string('tasks/partials/task_counter.html', {'count': count})
return HttpResponse(content + oob_content)def test_oob_swap_updates_counter(self, client):
url = reverse('task-create')
response = client.post(url, {'title': 'Task 1'}, HTTP_HX_REQUEST='true')
content = response.content.decode()
# The response contains both the task item and the OOB counter update
assert 'hx-swap-oob="true"' in content
assert 'id="task-counter"' in contentHelpMeTest for HTMX + Django
The Django test client handles server-side logic perfectly. But HTMX UI behavior — swap animations, push-url, browser history — needs browser testing.
HelpMeTest writes E2E tests in plain English against your deployed app:
When the user adds a task via the form
Then the new task appears at the top of the list without a page reload
And the task counter in the nav updates to reflect the new countNo Playwright setup needed — HelpMeTest runs it for you.
Summary
Testing HTMX with Django requires two layers:
- Server layer (Django TestClient): Test partial vs full responses, HTMX headers, POST/DELETE handlers, form validation, OOB swaps
- Browser layer (Playwright): Test swap animations, page history, HTMX request interception, and UI state
Key Django testing patterns:
- Use
HTTP_HX_REQUEST='true'to simulate HTMX requests - Assert partial responses don't include
<!DOCTYPE html> - Check
HX-Redirect,HX-Trigger,HX-Refreshresponse headers - Use 422 for form validation errors (HTMX convention)
- Test OOB swap content in the same response