Testing and autoreload#
Goal#
This 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 the actions declared there 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.loaders import eager_load_pages
PAGES_DIR = Path(__file__).resolve().parent / "notes" / "pages"
@pytest.fixture(autouse=True, scope="session")
def _next_dj_registration():
eager_load_pages(PAGES_DIR)
yield
The forms in notes/forms.py need no conftest wiring.
The next app runs form autodiscovery at startup, and pytest-django boots Django before any fixture runs, so CreateNoteForm and DeleteNoteForm are already registered.
eager_load_pages walks notes/pages and imports every page.py, which runs the page decorators.
The call is idempotent, so the session-scoped fixture runs the registration once.
Database access uses the standard db fixture from pytest-django, no extra fixture is needed.
Note
Do not call reset_registries() in an autouse fixture for this project.
It rebuilds the form-action backend from settings and drops every form registered at import.
Re-importing notes/forms.py will not re-register them because Python caches the module, so the next post_action raises FormActionNotFoundError.
Reach for reset_registries() only in a test that deliberately swaps NEXT_FRAMEWORK backends, and re-register the affected forms inside that test.
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, and its origin keyword fills the hidden _next_form_origin field the {% form %} tag emits in the browser.
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 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_form", {"title": "From test", "body": "body"})
assert response.status_code == 302
assert Note.objects.filter(title="From test").exists()
assert response["Location"] == "/"
post_action looks the action name up through resolve_action_url and posts the data to the dispatch endpoint.
The action name create_note_form is derived automatically from the class name CreateNoteForm.
The redirect target is "/" because on_valid calls redirect_to_origin, which sends the visitor back to the page that rendered the form and falls back to "/".
Capture action signals#
Every successful dispatch fires the action_dispatched signal.
A validation failure fires form_validation_failed instead and never reaches action_dispatched.
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_form", {"title": "Signal", "body": ""})
assert len(recorder) == 1
event = recorder.first_for(action_dispatched)
assert event.kwargs["action_name"] == "create_note_form"
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, uid, request, 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.
The test passes origin="/", which fills the hidden _next_form_origin field a browser submission carries, so the dispatcher knows which page to re-render.
Append the failure test to tests/test_notes_actions.py.
def test_create_with_blank_title_rerenders(db) -> None:
client = NextClient()
response = client.post_action("create_note_form", {"title": "", "body": "x"}, origin="/")
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.
A failing POST without a resolvable origin returns HTTP 400 instead, because the dispatcher has no page to re-render.
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 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 router manager emits a companion router_reloaded signal each time it rebuilds its backends, for example on a settings reload.
A test that swaps NEXT_FRAMEWORK backends 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_actionraisesFormActionNotFoundError.A form class registers only when its module is imported. Call
eager_load_pagesin the test setup so every form and handler registers before the first dispatch. For forms informs.py, also import that module explicitly or rely onautodiscover_forms().- 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 Notes application works and is tested. The next part makes the index live with partial rendering, without leaving the patterns this tutorial already built.
See also
Live updates with partial rendering wraps the list in a zone and updates it in place, with a no-JavaScript fallback. 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.