Testing and Autoreload¶
Goal¶
This final part covers the development workflow.
You install pytest and write end-to-end tests against the Notes application with NextClient and SignalRecorder.
You also learn how the autoreloader picks up file router changes without a server restart.
Prerequisites¶
You have finished Forms and Actions. The application creates, edits, and deletes notes through registered actions. The patterns below mirror Testing. Keep that page open if you want the full helper catalog.
Walkthrough¶
Install Test Dependencies¶
next.dj ships the next.testing package, and its helpers work with Django TestCase, stdlib unittest, and pytest alike.
This tutorial drives it with pytest and pytest-django, which you install separately.
uv add --dev pytest pytest-django
Add the pytest configuration.
[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py
addopts = --tb=short
Add a conftest.py at the project root.
The conftest imports every page.py so actions register before the tests run.
The PAGES_DIR path must match the actual app and page-root names, so a project that did not name its app notes adjusts the path accordingly.
from pathlib import Path
import pytest
from next.testing.isolation import reset_registries
from next.testing.loaders import eager_load_pages
PAGES_DIR = Path(__file__).resolve().parent / "notes" / "pages"
@pytest.fixture(autouse=True)
def _next_dj_isolation():
reset_registries()
eager_load_pages(PAGES_DIR)
yield
reset_registries()
reset_registries() runs first to create fresh form-action and component backends, so every @action and @component.context registration that follows lands on a clean slate.
eager_load_pages then walks notes/pages and imports every page.py, which runs the decorators and registers them on those fresh backends.
The teardown reset_registries() clears all state before the next test runs.
Database access uses the standard db fixture from pytest-django, no extra fixture is needed.
Write the First End-to-End Test¶
Create tests/test_notes_e2e.py.
import pytest
from notes.models import Note
from next.testing.client import NextClient
@pytest.fixture
def client() -> NextClient:
return NextClient()
def test_index_lists_notes(client, db) -> None:
Note.objects.create(title="First", body="hello")
response = client.get("/")
assert response.status_code == 200
assert "First" in response.content.decode()
def test_detail_renders_note(client, db) -> None:
note = Note.objects.create(title="Second", body="world")
response = client.get(f"/notes/{note.id}/")
assert response.status_code == 200
assert "Second" in response.content.decode()
NextClient extends Django’s test client with two shortcuts for form actions.
post_action resolves an action name to its URL and POSTs in one call.
get_action_url returns that URL without dispatching.
The router itself is built lazily through Django’s URL resolver, exactly as in production.
Use the same client.get and client.post calls you already know.
Run the tests.
uv run pytest
Test the Create Action¶
The framework gives each action a stable URL.
from django.urls import reverse
from notes.models import Note
from next.testing.client import NextClient
def test_create_note_action(db) -> None:
client = NextClient()
response = client.post_action("create_note", {"title": "From test", "body": "body"})
assert response.status_code == 302
assert Note.objects.filter(title="From test").exists()
assert response["Location"] == reverse("next:page_")
post_action looks the action name up through resolve_action_url and posts the data to the dispatch endpoint.
The redirect target matches next:page_ because the handler returns HttpResponseRedirect(reverse("next:page_")).
Capture Action Signals¶
Every dispatch fires the action_dispatched signal.
SignalRecorder collects events so the test can assert what happened.
from next.signals import action_dispatched
from next.testing.client import NextClient
from next.testing.signals import SignalRecorder
def test_create_emits_action_dispatched(db) -> None:
with SignalRecorder(action_dispatched) as recorder:
NextClient().post_action("create_note", {"title": "Signal", "body": ""})
assert len(recorder) == 1
event = recorder.first_for(action_dispatched)
assert event.kwargs["action_name"] == "create_note"
assert event.kwargs["form"].cleaned_data["title"] == "Signal"
action_dispatched is re-exported from next.signals and also lives on its owning module next.forms.signals.
SignalRecorder is a context manager that subscribes to the signal on entry and unsubscribes on exit.
It accepts several signals at once and exposes first_for, last_for, and events_for to query the captured events per signal.
Each captured event is a SignalEvent with signal, sender, and kwargs attributes.
The action_dispatched payload carries action_name, form, url_kwargs, duration_ms, response_status, and dep_cache.
Test Validation Failure¶
A failed validation does not produce a redirect. The pipeline re-renders the origin page with the bound form and a non-zero error count.
def test_create_with_blank_title_rerenders(db) -> None:
client = NextClient()
response = client.post_action("create_note", {"title": "", "body": "x"})
assert response.status_code == 200
assert b"This field is required" in response.content
The response status is 200 because the index page rendered.
This time the failing form replaces the unbound one in the template context.
Use the Autoreloader¶
The development server already reloads on Python file changes. The file router has its own reloader for new pages. Run the server and create a new page in a separate terminal.
uv run python manage.py runserver
mkdir -p notes/pages/about
Add the two page files.
from next.pages import context
@context("body")
def about_body() -> str:
return "Hello from a new page."
<p>{{ body }}</p>
Within a second the server picks up the change.
Open http://127.0.0.1:8000/about/ and confirm that the new page is served without a manual restart.
Note
The framework emits action_dispatched after a successful handler run, recorded above through SignalRecorder.
The autoreloader emits a companion router_reloaded signal each time the route set changes, so a test that wants to react to filesystem-driven route changes can subscribe to it the same way.
See Observe Framework Signals for the full subscriber pattern.
Checkpoint¶
The project now has tests.
tests/
test_notes_e2e.py
test_notes_actions.py
test_notes_signals.py
conftest.py
pytest.ini
The full test suite covers the index, the detail page, the create action, the create validation failure, and the action_dispatched signal.
The development server reloads page and component changes without a manual restart.
Common Pitfalls¶
post_actionraises an unknown action error.An
@actionhandler registers only when itspage.pyis imported. Calleager_load_pagesin the test setup so every handler registers before the first dispatch.- Tests that rewrite page files on disk see stale handlers.
eager_load_pagesmemoises each directory it has already imported. Callclear_loaded_dirs()fromnext.testing.loadersto drop that memo when a test editspage.pyfiles between runs.- Autoreloader does not pick up a change.
Confirm that the changed file lives under one of the watched roots. The reloader watches
page.pyfiles under the page roots andcomponent.pyfiles under the component roots, so files elsewhere do not trigger a router reload.
Next Steps¶
The tutorial is complete.
See also
What to Read Next lists where to go next, by topic. Testing covers the full testing surface. Autoreload explains how the reloader watches the filesystem. Deployment covers production setup once the application is feature complete.