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"])
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
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
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
)
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
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
.
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 %}" ...
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",
),
]
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!
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)