Testing¶
django-absurd ships a pytest plugin, registered
automatically on install via a
pytest11 entry point.
It builds on pytest-django — install that
alongside django-absurd.
Run a task in a test¶
import pytest
pytestmark = pytest.mark.django_db(transaction=True)
def test_add_completes(dj_absurd):
add.enqueue(2, 3)
(run,) = dj_absurd.drain()
assert run.state == "completed"
assert run.result == 5
assert run.task_name == "myapp.tasks.add"
assert run.args == [2, 3]
assert run.attempt == 1
dj_absurd is the only fixture. drain() runs every claimable task to completion
in-process — no worker subprocess, no polling loop — and returns one
RunSnapshot per run.
transaction=Trueis required. Absurd works on its own connection, so under a plaindbtest the enqueued row is invisible to it.drain,emit, andget_resultraise rather than silently no-op.- Works unchanged from
async deftests — same names, nothing toawaiton the fixture. Enqueue withawait my_task.aenqueue(). - Multi-DB: declare the Absurd alias in the test's
databases, or committed state leaks into the next test.
Move durable time¶
import datetime as dt
def test_followup_sleeps_seven_days_then_completes(dj_absurd):
with dj_absurd.freeze_time(dt.datetime(2026, 1, 1, tzinfo=dt.UTC)) as frozen_time:
send_followup.enqueue() # enqueue INSIDE the block
(sleeping,) = dj_absurd.drain()
assert sleeping.state == "sleeping"
frozen_time.shift(dt.timedelta(days=7))
(woken,) = dj_absurd.drain()
assert woken.state == "completed"
assert woken.run_id == sleeping.run_id # the same run resumed...
assert woken.attempt == 1 # ...so it was never a retry
freeze_time(instant=None) pins durable time for the block (None = real now). Its
FrozenTime handle has the only two movers, move_to(datetime) and shift(timedelta),
and each moves Python's clock (via
time-machine) and Postgres's
absurd.fake_now together.
- Enter the block before the
enqueue()calls whose deadlines you want to control. Freezing to a past instant after rows already exist leaves their deadlines in the database's future, so nothing is claimable until a later move passes them. shift(Δ)is absolute elapsed time, not wall-clock arithmetic — seven days across a spring-forward morning is 7 × 24 hours, which is what a durable deadline measures.- Blocks don't nest, and a
FrozenTimeraises once its block has exited. Sequential blocks are fine. - Install time-machine yourself — it's
a test dependency of your project. Only
freeze_timeneeds it, and it raisesImproperlyConfigurednaming the install command if missing. - Don't enqueue across a savepoint rollback. The rollback reverts Django's session
clock, so a later
enqueue()stamps real time and won't look claimable. - A freeze doesn't reach pg_cron — its launcher runs in another database on its own clock. See below.
FrozenTime, AbsurdTestRuntime (what dj_absurd is typed as), TaskSnapshot, and
RunSnapshot are importable from django_absurd.test for annotating your own helpers.
Fixture API¶
dj_absurd.drain(queue="default")¶
Runs every currently-claimable task on queue to completion, one at a time, returning
one RunSnapshot per run executed, in claim order.
| Field | Meaning |
|---|---|
queue, task_id |
which task this run belongs to |
run_id |
this run's id — the same value appears twice for a re-armed await_event waiter |
task_name |
dotted task path |
args, kwargs |
decoded from the enqueued params |
attempt |
1-based attempt number |
state |
see the state vocabulary below |
result |
the task's return value, once completed |
failure |
{"message": str, "name"?: str, "traceback"?: str}, once failed |
| State | Meaning |
|---|---|
pending |
claimable, not yet run |
sleeping |
suspended — a durable sleep, an await_event wait, or a retry backoff (indistinguishable from a run alone) |
completed |
finished successfully |
failed |
raised, and out of retries |
cancelled |
cancelled before or during execution |
drainprovisions nothing. Building the test database runsmigrate, which provisions the queues declared at that moment. A queue declared after that — by overridingTASKSin one test, or by adding it to settings on a--reuse-dbrun — has no table: callsync_queues()first, or re-run with--create-db, ordrain()raisesQueueNotProvisionedError. Undeclared raisesQueueNotDeclaredError; see exceptions.
dj_absurd.emit(name, payload=None, queue="default")¶
Delivers an event, resolving a task suspended in await_event —
the waiter resumes on the next drain(). An unprovisioned queue raises
QueueNotProvisionedError, same as drain().
dj_absurd.get_result(task_id, queue=...)¶
result = reports_task.enqueue() # id is "reports:<uuid>"
dj_absurd.get_result(result.id) # queries the "reports" queue
Returns a TaskSnapshot, or raises TaskNotFoundError. Unlike
my_task.get_result() it reads Absurd's own states —
including sleeping, which TaskResult.status can't show.
| Field | Meaning |
|---|---|
queue, task_id |
which task this is (no queue prefix on task_id) |
task_name |
dotted task path |
args, kwargs |
decoded from the enqueued params |
state |
see the state vocabulary under drain() |
attempts |
attempts CREATED, not completed (see below) |
enqueued_at |
when enqueue() ran |
result |
the task's return value, once completed |
failure |
None except on a terminal failure (see below) |
task_idtakes a bare uuid or a prefixed"queue:uuid". The prefix wins overqueue's default; aqueue=that disagrees raisesTaskIdQueueMismatchError. An unprovisioned queue raisesQueueNotProvisionedError, same asdrain().- This view can't express an in-flight retry:
attemptsreads2before the second attempt runs,state="sleeping"covers a backoff as well as a durable sleep, andfailureisNonemid-backoff. Usedrain()'sRunSnapshotto tell them apart. - A deferred task's id names its wrapper. A
run_afterenqueue creates a<task path>:run_afterrow and this reports that row. Use Django's ownget_resultfor your task's status and return value.
dj_absurd.sync_queues()¶
Provisions every declared queue — the runtime counterpart of
manage.py absurd_sync_queues. Only needed when a test declares a queue the test
database was never built with — a settings override, or a new queue in settings under
--reuse-db.
dj_absurd.now¶
Virtual now, timezone-aware, as Postgres reports it — read over a fresh connection, not computed in Python.
Cleanup is automatic¶
pytest users do nothing — the plugin wires cleanup into Django's own test teardown. No fixture to request, no marker to add.
- Plain
TestCase/dbtests need none: theenqueue()rides the same uncommitted transaction Django rolls back. transaction=Truetests commit for real, so queue state is truncated after each — and withdjango_absurd.pg_croninstalled, its settings- and admin-authored jobs plus theOPTIONS["CLEANUP"]job are unscheduled too.- Multi-DB: cleanup only runs when the test's
databasesincludes the Absurd alias. - No DB access means no Absurd access —
enqueue()trips pytest-django's own blocking like any query.
Getting a SCHEDULE into pg_cron for a test¶
Every cron.* write is inert on a test database by
default, so a SCHEDULE can't fire for real against
test data. PG_CRON_ON_TEST_DB is the opt-in.
- Without it,
absurd_sync_cronsrefuses to run rather than silently doing nothing. - Using
migrate's automatic reconcile instead also needsSYNC_SCHEDULES_ON_TEST_DB = True. - Cleanup clears whatever ends up in
cron.job/ScheduledTaskeither way.
manage.py test¶
from django.test.runner import DiscoverRunner
from django_absurd.test import install_absurd_cleanup
class MyTestRunner(DiscoverRunner):
def setup_test_environment(self, **kwargs):
super().setup_test_environment(**kwargs)
install_absurd_cleanup()
Django's own
DiscoverRunner
has no equivalent auto-hook — pytest is django-absurd's primary test surface. Wire the
same public hook yourself and point TEST_RUNNER at your subclass.
install_absurd_cleanup() is idempotent.