Testing HTMX with Django: Integration Testing Server-Rendered Partials

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.content

Testing 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 == 404

Testing 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 attrs

Testing 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 trigger

Testing 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 response
def 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.headers

E2E 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) == 0

Testing 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 content

HelpMeTest 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 count

No 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-Refresh response headers
  • Use 422 for form validation errors (HTMX convention)
  • Test OOB swap content in the same response

Read more