DEV Community

rodbv
rodbv

Posted on

Creating a To-Do app with HTMX and Django, part 5: Testing the views

Before we add more features to the Todo app, let's have a look at ways to test the urls, views and the data being returned by them to ensure we're not breaking anything. This is part 5 in our series on how to use HTMX with Django and template partials. You can find the whole list of posts on dev.to/rodbv.

Testing GET /tasks

Our /tasks route supports two methods, GET to return the whole tasks.html template and all the tasks of the user, and POST that creates one task and returns a partial view.

Let's start with the GET route, by adding a new file tests/test_view_tasks.py:

# core/tests/test_view_tasks.py

from http import HTTPStatus
import pytest
from django.urls import reverse
from pytest_django.asserts import assertTemplateUsed


@pytest.mark.django_db
def test_get_tasks_returns_tasks_template_and_todo_items_of_the_user(
    client, make_user, make_todo
):
    user = make_user()
    client.force_login(user)

    make_todo(_quantity=3, user=user)

    response = client.get(reverse("tasks"))

    assertTemplateUsed(response, "tasks.html")

    assert response.status_code == HTTPStatus.OK
    assert len(response.context["todos"]) == 3
    assert all(todo.user == user for todo in response.context["todos"])
Enter fullscreen mode Exit fullscreen mode

For these tests we opted to not wrap them around with a test class, since we don't need any specific fixtures or setup code. We're making use of the make_user and make_todo general fixtures we defined on conftest.py.

The first assertion we're making is using pytest_django's assertTemplateUsed, and we're also making sure we're returning all the Todo items of the user.

To run the test, we execute the uv command

❯ uv run pytest
Test session starts (platform: darwin, Python 3.12.8, pytest 8.3.4, pytest-sugar 1.0.0)
django: version: 5.1.4, settings: todomx.settings (from ini)
rootdir: /Users/rodrigo/code/opensource/todo-mx
configfile: pyproject.toml
plugins: sugar-1.0.0, django-4.9.0, mock-3.14.0
collected 2 items

 core/tests/test_todo_model.py ✓                                                                                                                                    50% █████
 core/tests/test_view_tasks.py ✓                                                                                                                                   100% ██████████

Results (0.33s):
       2 passed
Enter fullscreen mode Exit fullscreen mode

Testing POST /tasks to create a Todo

To test the POST url, which returns a partial template with a single todo item, we won't be able to leverage assertTemplateUsed. The way template partials work, the response object does not have the templates and context properties assigned, it just returns the resulting HTML in the content property. That's good enough for our needs though. Let's add one more test in the file:

# core/tests/test_view_tasks.py

... previous tests

# NEW 
@pytest.mark.django_db
def test_create_todo_view_stores_todo_and_returns_todo_on_partial(client, make_user):
    user = make_user()
    client.force_login(user)

    response = client.post(reverse("tasks"), {"title": "New Todo"})

    assert user.todos.filter(title="New Todo").exists()
    assert response.status_code == HTTPStatus.CREATED

    content = response.content.decode()

    assert "New Todo" in content
    assert '<li class="list-row">' in content
Enter fullscreen mode Exit fullscreen mode

While I was writing this test, I noticed that the POST /tasks route was not returning the expected 201 (resource created) response code; it was returning 200 OK instead. That's fixed in core/views.py:

def _create_todo(request):
    title = request.POST.get("title")
    if not title:
        raise ValueError("Title is required")

    todo = Todo.objects.create(title=title, user=request.user)
    return render(
        request,
        "tasks.html#todo-item-partial",
        {"todo": todo},
        status=HTTPStatus.CREATED, # <-- CHANGED
    )

Enter fullscreen mode Exit fullscreen mode

Testing the toggle todo view

Finally, let's test the PUT /tasks/:id route, that toggles a todo item from complete to incomplete, or vice-versa.

In this test we will use the parametrize feature of pytest to test both ways, setting a todo item as completed and not completed.

# core/tests/test_view_tasks.py

...previous tests

# NEW
@pytest.mark.parametrize("is_completed", [True, False])
@pytest.mark.django_db
def test_put_todo_toggles_todo_status_and_returns_todo_on_partial(
    client, make_todo, make_user, is_completed
):
    user = make_user()
    client.force_login(user)

    todo = make_todo(title="New Todo", user=user, is_completed=is_completed)

    response = client.put(reverse("toggle_todo", args=[todo.id]))

    todo.refresh_from_db()

    assert response.status_code == HTTPStatus.OK
    assert todo.is_completed is not is_completed

    content = response.content.decode()

    assert "New Todo" in content
    assert '<li class="list-row">' in content
Enter fullscreen mode Exit fullscreen mode

Let's run the tests again and ensure it's all good. Note that the number of tests went from 3 to 5, since our new test will run twice to cover both values of is_completed.

Terminal window with all tests passing

Now that we have a test for our PUT route, I'd like to change it a bit: a route like PUT /tasks/:id, with no extra parameters or payload, would be expected to support several types of updates on the todo with the given ID; toggling the completed status is unexpected and ultimately a bad API design decision.

There are two interesting solutions, one is to change the route to be tasks/:id/toggle, and the other to keep it as tasks/:id and pass the value of is_completed in the form payload.

We will implement the first solution, since we will have other opportunities to learn how to pass values using hx-vals in the near future.

The good news are that since we're using Django's url function in the template, as shown below, instead of hard-coding it, and also using the reverse function in the tests, we can just change the url definition in urls.py and everything will just work.

<input type="checkbox" hx-put="{% url 'toggle_todo' todo.id %}" ...
Enter fullscreen mode Exit fullscreen mode

The change is

# core/urls.py

from django.urls import path
from . import views

urlpatterns = [
    path("", views.index, name="index"),
    path("tasks/", views.tasks, name="tasks"),
    path(
        "tasks/<int:task_id>/toggle/",  # <-- CHANGED
        views.toggle_todo,
        name="toggle_todo",
    ),
]
Enter fullscreen mode Exit fullscreen mode

All tests are still passing, and when we run the application, we can check the new URL being used. Not bad for a single line of change!

Developer tools in the browser showing the /tasks/:id/toggle URL being used

You can find the version of the code so far in the part 05 branch.

In part 6, we will implement a DELETE route and add it to our frontend.

Top comments (0)