Part 7: Testing¶
So far you've checked every feature by hand, with curl and the shell. In this part you'll turn those checks into a pytest suite that runs in about half a second. Django Ninja's TestClient calls your operations directly, so tests send JSON, form data, files and tokens without a running server.
What you'll learn
- How to set up pytest and pytest-django for a Django Ninja project
- How to write fixtures for users, projects with memberships, and logged-in clients
- How
TestClientcalls an API, and how to send tokens, query strings and files with it - How to test validation errors, permissions and filters, and cover many cases with
parametrize - How to keep uploads and password hashing from slowing down or littering your tests
Time: about 15 minutes
Setting up pytest¶
The tests need two packages. They're only used in development, so they don't join Django and Django Ninja as runtime dependencies:
pytest-django creates a test database for every run, applies your migrations to it, and wraps each test in a transaction that is rolled back afterwards. It needs to know your settings module. Create pytest.ini next to manage.py:
Part 1 deleted the tests.py files from the apps. The tests go into a top-level tests/ folder instead, because most of them cross app boundaries: a task test needs users from accounts and a project from projects. testpaths tells pytest to look there. The folder needs no __init__.py. pytest-django puts the folder with manage.py on the import path, so from taskflow.api import api works in tests.
With SQLite, the test database lives in memory. Your db.sqlite3 and the data from earlier parts are never touched.
Go deeper: Testing
Fixtures¶
Almost every test needs the same things: a few users, a project with an owner and a member, and a client that sends a token. pytest fixtures build them on demand. Fixtures in tests/conftest.py are available to every test file without an import:
import pytest
from django.contrib.auth.models import User
from ninja.testing import TestClient
from accounts.tokens import create_token_pair
from projects.models import Project, Role
from taskflow.api import api
@pytest.fixture(autouse=True)
def test_settings(settings, tmp_path):
settings.MEDIA_ROOT = tmp_path # uploads go to a temporary folder
settings.PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
@pytest.fixture
def alice(db):
return User.objects.create_user("alice", password="wonderland-42")
@pytest.fixture
def bob(db):
return User.objects.create_user("bob", password="builder-42")
@pytest.fixture
def carol(db):
return User.objects.create_user("carol", password="sunflower-77")
@pytest.fixture
def project(alice, bob):
"""Alice owns the project, bob is a member."""
project = Project.objects.create(name="Website redesign", description="New marketing site")
project.memberships.create(user=alice, role=Role.OWNER)
project.memberships.create(user=bob, role=Role.MEMBER)
return project
@pytest.fixture
def client_for():
"""Return a TestClient that is logged in as the given user."""
def make_client(user):
token = create_token_pair(user.id)["access"]
return TestClient(api, headers={"Authorization": f"Bearer {token}"})
return make_client
test_settingsruns for every test because ofautouse=True. It uses pytest-django'ssettingsfixture, which restores the original values after each test.MEDIA_ROOTpoints totmp_path, a fresh temporary folder per test, so upload tests don't write into yourmedia/folder.- Django's default password hasher is slow on purpose. Every
create_user()and every login pays for it. The MD5 hasher is unsafe for real passwords, but fine for test users. On the machine used for this tutorial, the suite takes 51 seconds without that line and half a second with it. - The user fixtures take pytest-django's
dbfixture, which allows database access.projectdepends onaliceandbob, so asking forprojectcreates all three. client_foris a factory fixture. A test can call it for any user, even several times, as inclient_for(alice)andclient_for(bob). It creates the token withcreate_token_pair()from Part 4 instead of calling/auth/token, which keeps the tests of other endpoints independent of the login endpoint.TestClient(api, headers=...)sends those headers with every request. You used the same client in the shell in Part 5.
TestClient doesn't go through urls.py or Django's middleware. It finds the operation in the NinjaAPI and calls it with a fake request. Paths are therefore relative to the API: /projects/, not /api/projects/. Everything that is part of the operation still runs: authentication, validation, your view, the response schema and the error handlers. A path that matches no operation raises an exception instead of returning 404, so a typo in a test fails loudly.
Go deeper: Testing
Testing authentication¶
The auth tests use a plain client without a token, because getting one is what they test. Create tests/test_auth.py:
from ninja.testing import TestClient
from accounts.tokens import create_token
from taskflow.api import api
client = TestClient(api)
def test_obtain_token(alice):
response = client.post("/auth/token", json={"username": "alice", "password": "wonderland-42"})
assert response.status_code == 200
token = response.json()["access"]
response = client.get("/projects/", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
def test_wrong_password(alice):
response = client.post("/auth/token", json={"username": "alice", "password": "nope"})
assert response.status_code == 401
assert response.json() == {"detail": "Invalid username or password"}
def test_missing_token():
response = client.get("/projects/")
assert response.status_code == 401
def test_expired_token(alice):
token = create_token(alice.id, "access", lifetime=-1)
response = client.get("/projects/", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 401
def test_refresh_token_is_not_an_access_token(alice):
response = client.post("/auth/token", json={"username": "alice", "password": "wonderland-42"})
refresh = response.json()["refresh"]
response = client.get("/projects/", headers={"Authorization": f"Bearer {refresh}"})
assert response.status_code == 401
json=serializes the body, like a client sendingContent-Type: application/json.response.json()decodes the answer.headers=on a single request adds to the client's default headers, or overrides them.- Waiting 15 minutes for a token to expire isn't an option.
create_token()takes the lifetime as an argument, so a negative one creates a token that expired a second ago. test_missing_tokenneeds no database.HttpBearerrejects a request without anAuthorizationheader beforeauthenticate()runs.
Go deeper: Testing
Projects and permissions¶
Most of the value of an API test suite is in the permission rules, because a missing check doesn't show up when you try the happy path. Create tests/test_projects.py:
from projects.models import Role
def test_create_project(alice, client_for):
response = client_for(alice).post(
"/projects/", json={"name": "Launch", "description": "Product launch"}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Launch"
assert data["task_count"] == 0
assert alice.memberships.get(project_id=data["id"]).role == Role.OWNER
def test_list_only_my_projects(alice, carol, project, client_for):
client_for(carol).post(
"/projects/", json={"name": "Carol's garden", "description": "Vegetables"}
)
response = client_for(alice).get("/projects/")
assert [p["name"] for p in response.json()] == ["Website redesign"]
def test_non_member_gets_404(carol, project, client_for):
response = client_for(carol).get(f"/projects/{project.id}")
assert response.status_code == 404
def test_member_cannot_delete(bob, project, client_for):
response = client_for(bob).delete(f"/projects/{project.id}")
assert response.status_code == 403
assert response.json() == {"detail": "This needs the owner role"}
def test_owner_can_update(alice, project, client_for):
response = client_for(alice).put(
f"/projects/{project.id}", json={"name": "Website v2", "description": "Round two"}
)
assert response.status_code == 200
project.refresh_from_db()
assert project.name == "Website v2"
def test_add_member_twice(alice, bob, project, client_for):
response = client_for(alice).post(f"/projects/{project.id}/members", json={"username": "bob"})
assert response.status_code == 409
assert response.json() == {"detail": "bob is already a member"}
- The highlighted tests pin down the two sides of
get_project_or_404from Part 4: a non-member gets404, a member without the right role gets403. - Tests can check the database as well as the response.
test_create_projectconfirms that the creator became the owner, andtest_owner_can_updatereloads the project withrefresh_from_db(). - Each test starts with an empty database. Carol's project in
test_list_only_my_projectsis gone before the next test runs.
Tasks: validation, workflows and filters¶
The task tests share one more fixture, a task in the project. Create tests/test_tasks.py:
from datetime import date
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from tasks.models import Priority, Task, TaskStatus
@pytest.fixture
def task(project, alice):
return project.tasks.create(title="Draft the sitemap", assignee=alice)
def test_create_task(alice, bob, project, client_for):
label = project.labels.create(name="design")
response = client_for(alice).post(
f"/projects/{project.id}/tasks/",
json={"title": " Pick fonts ", "assignee_id": bob.id, "label_ids": [label.id]},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "Pick fonts"
assert data["assignee"]["username"] == "bob"
assert data["labels"] == [{"id": label.id, "name": "design", "color": "#808080"}]
assert data["status"] == "todo"
def test_blank_title(alice, project, client_for):
response = client_for(alice).post(f"/projects/{project.id}/tasks/", json={"title": " "})
assert response.status_code == 422
assert response.json()["detail"] == [
{"type": "blank", "loc": ["body", "payload", "title"], "msg": "Title can't be blank"}
]
def test_urgent_task_needs_due_date(alice, project, client_for):
response = client_for(alice).post(
f"/projects/{project.id}/tasks/", json={"title": "Fix login", "priority": Priority.URGENT}
)
assert response.status_code == 422
assert response.json()["detail"][0]["msg"] == "Value error, Urgent tasks need a due date"
def test_assignee_must_be_member(alice, carol, project, client_for):
response = client_for(alice).post(
f"/projects/{project.id}/tasks/", json={"title": "Pick fonts", "assignee_id": carol.id}
)
assert response.status_code == 400
def test_non_member_cannot_see_tasks(carol, task, client_for):
response = client_for(carol).get(f"/projects/{task.project_id}/tasks/{task.id}")
assert response.status_code == 404
def test_patch_changes_only_sent_fields(alice, task, client_for):
response = client_for(alice).patch(
f"/projects/{task.project_id}/tasks/{task.id}", json={"due_date": "2026-10-15"}
)
assert response.status_code == 200
task.refresh_from_db()
assert task.due_date == date(2026, 10, 15)
assert task.title == "Draft the sitemap"
- Fixtures can live in a test file too.
taskis only used here, so it doesn't need to be inconftest.py. test_blank_titlecompares the wholedetaillist. It holds the sameblankerror you saw in Part 3. A change to the error's type, location or message breaks the test, which is what you want once clients depend on it.test_create_taskalso checks that the title was stripped and that the nestedassigneeandlabelsfrom Part 2 are in the response.Priority.URGENTcan go straight intojson=.IntegerChoicesmembers are integers, so it's sent as4.
Many cases, one test¶
The status workflow from Part 3 has a table of allowed moves. @pytest.mark.parametrize turns a table of cases into one test each. Add it to tests/test_tasks.py:
@pytest.mark.parametrize(
"old, new, status_code",
[
(TaskStatus.TODO, TaskStatus.IN_PROGRESS, 200),
(TaskStatus.IN_PROGRESS, TaskStatus.DONE, 200),
(TaskStatus.TODO, TaskStatus.DONE, 409),
(TaskStatus.DONE, TaskStatus.TODO, 409),
],
)
def test_status_transitions(alice, task, client_for, old, new, status_code):
Task.objects.filter(id=task.id).update(status=old)
response = client_for(alice).post(
f"/projects/{task.project_id}/tasks/{task.id}/status", json={"status": new}
)
assert response.status_code == status_code
The test sets the starting status directly in the database. Going through the API would make a test for done depend on two earlier moves working.
Filters work the same way: one fixture with a few tasks, and a table of query strings with the titles each should return. Add both:
@pytest.fixture
def sample_tasks(project, bob):
bug = project.labels.create(name="bug")
project.tasks.create(
title="Fix mobile menu", assignee=bob, priority=Priority.URGENT, due_date=date(2026, 10, 1)
).labels.add(bug)
project.tasks.create(
title="Set up analytics",
description="Page views",
status=TaskStatus.IN_PROGRESS,
due_date=date(2026, 10, 20),
)
project.tasks.create(
title="Compress images",
assignee=bob,
priority=Priority.HIGH,
due_date=date(2026, 10, 3),
status=TaskStatus.DONE,
)
@pytest.mark.parametrize(
"query, titles",
[
("", ["Fix mobile menu", "Set up analytics", "Compress images"]),
("status=todo&status=in_progress", ["Fix mobile menu", "Set up analytics"]),
("assignee=bob&label=bug", ["Fix mobile menu"]),
("due_before=2026-10-05", ["Fix mobile menu", "Compress images"]),
("search=VIEWS", ["Set up analytics"]),
("ordering=-priority", ["Fix mobile menu", "Compress images", "Set up analytics"]),
],
)
def test_filter_tasks(alice, project, sample_tasks, client_for, query, titles):
response = client_for(alice).get(f"/projects/{project.id}/tasks/?{query}")
assert response.status_code == 200
assert [t["title"] for t in response.json()["items"]] == titles
assert response.json()["count"] == len(titles)
- The query string goes straight into the path, including a repeated
statusparameter.TestClientalso takesquery_params={"status": ["todo", "in_progress"]}if you prefer a dict. - The titles are compared as a list, so each case also checks the order:
created_atby default, or theorderingparameter from Part 5. search=VIEWSfinds a match in the description and checks that the search ignores case.- The paginated response wraps the tasks in
items. Checkingcountas well makes sure the total that pagination reports agrees with the filtered list.
Go deeper: Testing
Comments and attachments¶
The last tests cover the object-level rules from Parts 4 and 6, and file uploads. Add them to the end of tests/test_tasks.py:
def test_only_author_can_edit_comment(alice, bob, task, client_for):
comment = task.comments.create(author=bob, body="Looks good")
url = f"/projects/{task.project_id}/tasks/{task.id}/comments/{comment.id}"
response = client_for(alice).put(url, json={"body": "Changed"})
assert response.status_code == 403
response = client_for(alice).delete(url) # the owner may delete it
assert response.status_code == 204
def test_upload_attachment(alice, task, client_for, settings):
file = SimpleUploadedFile("notes.txt", b"Remember the footer")
response = client_for(alice).post(
f"/projects/{task.project_id}/tasks/{task.id}/attachments",
data={"name": "Meeting notes"},
FILES={"file": file},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Meeting notes"
assert data["url"].startswith("http://testlocation/media/attachments/")
attachment = task.attachments.get()
assert (settings.MEDIA_ROOT / attachment.file.name).read_bytes() == b"Remember the footer"
def test_upload_rejects_file_type(alice, task, client_for):
file = SimpleUploadedFile("setup.exe", b"MZ")
response = client_for(alice).post(
f"/projects/{task.project_id}/tasks/{task.id}/attachments", FILES={"file": file}
)
assert response.status_code == 422
assert response.json()["detail"][0]["type"] == "file_type"
assert not task.attachments.exists()
def test_delete_attachment(alice, bob, task, client_for, settings):
file = SimpleUploadedFile("plan.pdf", b"%PDF-1.4")
attachment = task.attachments.create(file=file, name="plan.pdf", uploaded_by=alice)
url = f"/projects/{task.project_id}/tasks/{task.id}/attachments/{attachment.id}"
response = client_for(bob).delete(url)
assert response.status_code == 403
response = client_for(alice).delete(url)
assert response.status_code == 204
assert not (settings.MEDIA_ROOT / attachment.file.name).exists()
SimpleUploadedFileis Django's in-memory upload. It has a name, a size and content, which is all thatcheck_uploadand theFileFieldneed.data=fills the form fields andFILES=the files, the two halves of themultipart/form-datarequest thatcurl -Fsent in Part 6.TestClient's fake request answersbuild_absolute_uri()with the hosttestlocation, so the resolver from Part 6 returnshttp://testlocation/media/....- The
settingsfixture gives the test theMEDIA_ROOTthattest_settingsset, so it can check the stored file on disk, and thatdelete_attachmentremoved it.
Go deeper: Testing
Running the tests¶
Run pytest from the folder with manage.py:
$ pytest
============================= test session starts ==============================
...
django: version: 6.1.1, settings: taskflow.settings (from ini)
...
collected 31 items
tests/test_auth.py .... [ 12%]
tests/test_projects.py ...... [ 32%]
tests/test_tasks.py .................... [ 96%]
tests/test_auth.py . [100%]
============================== 31 passed in 0.52s ==============================
tests/test_auth.py shows up twice because pytest-django runs the tests that don't use the database last. That's test_missing_token. Each parametrized case is a test of its own, so the 12 test functions in test_tasks.py make 20 tests.
-k picks tests by name, and -v lists them. Each parametrized case gets an id built from its parameters:
$ pytest tests/test_tasks.py -k filter -v
...
collecting ... collected 20 items / 14 deselected / 6 selected
tests/test_tasks.py::test_filter_tasks[-titles0] PASSED [ 16%]
tests/test_tasks.py::test_filter_tasks[status=todo&status=in_progress-titles1] PASSED [ 33%]
tests/test_tasks.py::test_filter_tasks[assignee=bob&label=bug-titles2] PASSED [ 50%]
tests/test_tasks.py::test_filter_tasks[due_before=2026-10-05-titles3] PASSED [ 66%]
tests/test_tasks.py::test_filter_tasks[search=VIEWS-titles4] PASSED [ 83%]
tests/test_tasks.py::test_filter_tasks[ordering=-priority-titles5] PASSED [100%]
======================= 6 passed, 14 deselected in 0.31s =======================
When a check goes missing¶
A test suite proves its worth when someone breaks a rule by accident. Delete the author check from update_comment in tasks/api.py:
if comment.author != request.auth:
raise HttpError(403, "Only the author can edit a comment")
Every other test still passes, and the endpoint still works for its author. Only the permission test notices:
$ pytest
...
tests/test_tasks.py ................F... [ 96%]
tests/test_auth.py . [100%]
=================================== FAILURES ===================================
______________________ test_only_author_can_edit_comment _______________________
alice = <User: alice>, bob = <User: bob>, task = <Task: Draft the sitemap>
client_for = <function client_for.<locals>.make_client at 0x7f6059af7a60>
def test_only_author_can_edit_comment(alice, bob, task, client_for):
comment = task.comments.create(author=bob, body="Looks good")
url = f"/projects/{task.project_id}/tasks/{task.id}/comments/{comment.id}"
response = client_for(alice).put(url, json={"body": "Changed"})
> assert response.status_code == 403
E assert 200 == 403
E + where 200 = <ninja.testing.client.NinjaResponse object at 0x7f6059b9a990>.status_code
tests/test_tasks.py:127: AssertionError
=========================== short test summary info ============================
FAILED tests/test_tasks.py::test_only_author_can_edit_comment - assert 200 ==...
========================= 1 failed, 30 passed in 0.59s =========================
pytest shows the fixtures the test received, the failing line, and the actual value: alice's edit of bob's comment returned 200. Put the two lines back before you continue.
Tip
TestClient skips urls.py and middleware to stay fast. To test the full stack, such as a custom middleware or the URL prefix, use Django's own django.test.Client with the /api/... URLs. Django Ninja views are normal Django views and work with it unchanged.
Go deeper: Testing
Recap¶
- pytest-django needs
DJANGO_SETTINGS_MODULEinpytest.ini. It creates a fresh test database and rolls back each test. - Fixtures in
conftest.pybuild users, a project with memberships, and aclient_for(user)factory that sends a JWT with every request. TestClient(api)calls operations directly, with paths relative to the API. Auth, validation and error handling still run.- Send bodies with
json=, form fields withdata=, files withFILES=andSimpleUploadedFile, and query strings in the path. parametrizeturns a table of cases, such as status moves or filter queries, into separate tests.- Point
MEDIA_ROOTattmp_pathand use a fast password hasher, so tests stay clean and take under a second.
Next¶
TaskFlow is complete and tested. In Part 8: OpenAPI polish & versioning, you'll improve the generated docs with summaries, descriptions and examples, and run a second API version next to the first.