Testing¶
InMemoryBackend, FakeClock, pytest fixtures, and test assertions.
Package surface (fakes, assertions, settings factories)¶
testing ¶
Test-only helpers for TaskQ: FakeClock, InMemoryBackend, and stub utilities.
Every symbol here lives in taskq.testing — never in taskq.backend — so
production code does not pull in test-only helpers.
run_until_drained, tick_cancel_polling, and register_cancel_event
are methods on the re-exported :class:InMemoryBackend class. The runner
logic lives in :mod:taskq.testing._runner.
Pytest fixtures are NOT re-exported here — they live in
:mod:taskq.testing.fixtures and are imported by
:mod:tests.conftest directly. This avoids importing pytest /
asyncpg at the taskq.testing top level.
JobContext is re-exported for convenience; the eventual public
JobContext will replace this test-scoped version.
OTel test utilities (ListSpanExporter, setup_tracer, setup_meter)
are NOT re-exported here — import them from taskq.testing.otel directly.
They require the [otel] extra (opentelemetry-sdk).
DEFAULT_ACTORS
module-attribute
¶
DEFAULT_ACTORS: tuple[str, ...] = (
"actor_a",
"actor_b",
"actor_c",
"A",
"C",
"X",
"test_actor",
"_progress_redis_hundred",
"_progress_redis_single",
"_progress_redis_three",
)
__all__
module-attribute
¶
__all__ = [
"DEFAULT_ACTORS",
"EmptyPayload",
"FakeBackend",
"FakeClock",
"InMemoryBackend",
"JobContext",
"JobTriple",
"StubActorConfig",
"WarningSpy",
"as_backend",
"assert_attempt",
"assert_has_event",
"assert_has_otel_event",
"assert_has_span",
"assert_job_status",
"assert_job_terminal",
"assert_transition_sequence",
"create_pending_job",
"create_running_job",
"create_worker",
"create_workered_running_job",
"default_actor_config",
"error_info",
"get_job_triple",
"make_enqueue_args",
"make_integration_settings",
"make_integration_settings_dict",
"make_job_row",
"parse_detail",
"pg_now",
"reset_schema",
"seed_actors",
"setup_running_job",
"truncate_schema",
"unique_health_sock_path",
"wait_for",
"wait_for_job_status",
"wait_for_leader",
]
EmptyPayload ¶
Bases: BaseModel
FakeBackend ¶
FakeBackend(
*,
mark_snoozed_return: Literal[
"scheduled", "failed", "noop"
] = "scheduled",
mark_retry_after_return: Literal[
"scheduled",
"failed:DeadlineExceeded",
"failed:MaxAttemptsExceeded",
"noop",
] = "scheduled",
)
Minimal backend recording method calls for assertions.
Source code in src/taskq/testing/actor.py
BACKEND_PROTOCOL_VERSION
class-attribute
instance-attribute
¶
supports_transactional_simulation
class-attribute
instance-attribute
¶
mark_succeeded_calls
instance-attribute
¶
mark_failed_or_retry_calls
instance-attribute
¶
enqueue
async
¶
enqueue_with_conn
async
¶
dispatch_batch
async
¶
heartbeat_jobs
async
¶
extend_reservation_leases
async
¶
mark_succeeded
async
¶
mark_succeeded(
job_id: UUID,
worker_id: UUID,
result: dict[str, object] | None,
progress_seq: int = 0,
progress_state: dict[str, object] | None = None,
fallback_result_ttl: timedelta | None = None,
) -> bool
Source code in src/taskq/testing/actor.py
mark_succeeded_with_conn
async
¶
mark_succeeded_with_conn(
conn: object,
job_id: UUID,
worker_id: UUID,
result: dict[str, object] | None,
progress_seq: int = 0,
progress_state: dict[str, object] | None = None,
fallback_result_ttl: timedelta | None = None,
) -> bool
Source code in src/taskq/testing/actor.py
mark_failed_or_retry
async
¶
mark_failed_or_retry(
job_id: UUID,
worker_id: UUID,
error_info: ErrorInfo,
retry_delay: timedelta | None,
progress_seq: int = 0,
progress_state: dict[str, object] | None = None,
) -> JobRow
Source code in src/taskq/testing/actor.py
mark_cancelled
async
¶
mark_cancelled(
job_id: UUID,
worker_id: UUID,
progress_seq: int = 0,
progress_state: dict[str, object] | None = None,
) -> bool
Source code in src/taskq/testing/actor.py
write_cancel_escalation
async
¶
mark_abandoned
async
¶
mark_snoozed
async
¶
mark_snoozed(
job_id: UUID,
worker_id: UUID,
delay: timedelta,
*,
metadata_update: dict[str, object] | None = None,
progress_seq: int = 0,
progress_state: dict[str, object] | None = None,
outcome: AttemptOutcome = "snoozed",
) -> Literal["scheduled", "failed", "noop"]
Source code in src/taskq/testing/actor.py
mark_retry_after
async
¶
mark_retry_after(
job_id: UUID,
worker_id: UUID,
delay: timedelta,
*,
consume_budget: bool = True,
progress_seq: int = 0,
progress_state: dict[str, object] | None = None,
) -> Literal[
"scheduled",
"failed:DeadlineExceeded",
"failed:MaxAttemptsExceeded",
"noop",
]
Source code in src/taskq/testing/actor.py
write_attempt
async
¶
get_attempts
async
¶
get_events
async
¶
write_cancel_request
async
¶
poll_cancel_flags
async
¶
scheduled_to_pending
async
¶
deadline_sweep
async
¶
reclaim_expired_locks
async
¶
get
async
¶
list_jobs
async
¶
count_pending_jobs
async
¶
count_active_jobs
async
¶
get_actor_max_pending
async
¶
enqueue_batch
async
¶
subscribe_wake ¶
create_schedule
async
¶
list_schedules
async
¶
update_schedule
async
¶
StubActorConfig
dataclass
¶
StubActorConfig(
retry: RetryPolicy,
non_retryable_exceptions: tuple[
type[Exception], ...
] = (),
retry_classifier: RetryClassifierHook | None = None,
on_retry_exhausted: OnRetryExhausted | None = None,
on_retry_exhausted_timeout: float = 3.0,
on_success: OnSuccess | None = None,
on_success_timeout: float = 3.0,
)
FakeClock ¶
Deterministic clock for tests.
Accepts a start datetime (typically
datetime(2025, 1, 1, tzinfo=UTC)). now() returns the current
internal time; move_to and advance let tests control the clock
explicitly. monotonic() returns elapsed seconds from _EPOCH
so that elapsed-time guards see a non-zero starting value.
Source code in src/taskq/testing/clock.py
InMemoryBackend ¶
InMemoryBackend(
clock: Clock,
cancellation_grace_period: timedelta = timedelta(
seconds=30
),
cleanup_grace_period: timedelta = timedelta(seconds=30),
rng: Random | None = None,
*,
actor_configs: Iterable[ActorConfig] | None = None,
result_max_bytes: int = MAX_RESULT_BYTES,
)
Deterministic, in-memory backend for unit tests.
All state is held as per-instance attributes: no module-level
mutable state, no class-level caches. Two InMemoryBackend instances
created in the same test session are fully isolated.
Single-threaded by contract — do not share across threads or event loops. Intra-coroutine re-entry within a single event loop is acceptable; cross-thread use is a caller bug.
Source code in src/taskq/testing/in_memory.py
advance_clock_to ¶
register_stub ¶
register_stub(
actor_name: str,
fn: StubFn,
*,
retry: RetryPolicy | None = None,
non_retryable_exceptions: tuple[
type[BaseException], ...
] = (),
retry_classifier: RetryClassifierHook | None = None,
on_retry_exhausted: OnRetryExhausted | None = None,
on_retry_exhausted_timeout: float = 3.0,
on_success: OnSuccess | None = None,
on_success_timeout: float = 3.0,
result_ttl: timedelta | None = None,
payload_type: type[BaseModel] | None = None,
) -> None
Source code in src/taskq/testing/in_memory.py
register_cancel_event ¶
get_events
async
¶
poll_reclaim_events
async
¶
poll_reclaim_events(
after_id: int,
limit: int = DEFAULT_RECLAIM_POLL_LIMIT,
*,
visibility_delay: timedelta | None = None,
) -> list[EventRow]
Source code in src/taskq/testing/in_memory.py
enqueue
async
¶
enqueue_with_conn
async
¶
enqueue_batch
async
¶
enqueue_batch_fast
async
¶
register_actor_config ¶
register_actor_config(
*,
actor: str,
max_concurrent: int | None = None,
max_pending: int | None = None,
queue: str = "default",
metadata: dict[str, object] | None = None,
) -> None
Source code in src/taskq/testing/in_memory.py
register_actor_configs ¶
set_queue_mode ¶
dispatch_batch
async
¶
heartbeat_jobs
async
¶
Source code in src/taskq/testing/in_memory.py
extend_reservation_leases
async
¶
Source code in src/taskq/testing/in_memory.py
mark_succeeded
async
¶
mark_succeeded(
job_id: JobId,
worker_id: UUID,
result: dict[str, object] | None,
progress_seq: int = 0,
progress_state: dict[str, object] | None = None,
fallback_result_ttl: timedelta | None = None,
) -> bool
Source code in src/taskq/testing/in_memory.py
mark_succeeded_with_conn
async
¶
mark_succeeded_with_conn(
conn: object,
job_id: JobId,
worker_id: UUID,
result: dict[str, object] | None,
progress_seq: int = 0,
progress_state: dict[str, object] | None = None,
fallback_result_ttl: timedelta | None = None,
) -> bool
Source code in src/taskq/testing/in_memory.py
mark_failed_or_retry
async
¶
mark_failed_or_retry(
job_id: JobId,
worker_id: UUID,
error_info: ErrorInfo,
retry_delay: timedelta | None,
progress_seq: int = 0,
progress_state: dict[str, object] | None = None,
) -> JobRow
Source code in src/taskq/testing/in_memory.py
mark_cancelled
async
¶
mark_cancelled(
job_id: JobId,
worker_id: UUID,
progress_seq: int = 0,
progress_state: dict[str, object] | None = None,
) -> bool
Source code in src/taskq/testing/in_memory.py
write_cancel_escalation
async
¶
mark_abandoned
async
¶
mark_snoozed
async
¶
mark_snoozed(
job_id: JobId,
worker_id: UUID,
delay: timedelta,
*,
metadata_update: dict[str, object] | None = None,
progress_seq: int = 0,
progress_state: dict[str, object] | None = None,
outcome: AttemptOutcome = "snoozed",
) -> Literal["scheduled", "failed", "noop"]
Source code in src/taskq/testing/in_memory.py
mark_retry_after
async
¶
mark_retry_after(
job_id: JobId,
worker_id: UUID,
delay: timedelta,
*,
consume_budget: bool = True,
progress_seq: int = 0,
progress_state: dict[str, object] | None = None,
) -> Literal[
"scheduled",
"failed:DeadlineExceeded",
"failed:MaxAttemptsExceeded",
"noop",
]
Source code in src/taskq/testing/in_memory.py
write_attempt
async
¶
get_attempts
async
¶
write_cancel_request
async
¶
Source code in src/taskq/testing/in_memory.py
poll_cancel_flags
async
¶
Source code in src/taskq/testing/in_memory.py
cancel_where
async
¶
retry_job
async
¶
Source code in src/taskq/testing/in_memory.py
scheduled_to_pending
async
¶
deadline_sweep
async
¶
reclaim_expired_locks
async
¶
archive_terminal_jobs ¶
archive_terminal_jobs(
retention: timedelta,
archive_retention: timedelta,
*,
statuses: frozenset[str] | None = None,
) -> PruneResult
Source code in src/taskq/testing/in_memory.py
expire_archived_jobs ¶
get_archived
async
¶
get
async
¶
list_jobs
async
¶
count_pending_jobs
async
¶
count_active_jobs
async
¶
get_actor_max_pending
async
¶
subscribe_wake ¶
subscribe_cancel_wake ¶
tick_cancel_polling
async
¶
run_until_drained
async
¶
create_schedule
async
¶
Source code in src/taskq/testing/in_memory.py
list_schedules
async
¶
Source code in src/taskq/testing/in_memory.py
update_schedule
async
¶
Source code in src/taskq/testing/in_memory.py
delete_schedule
async
¶
enqueue_batch_atomic
async
¶
enqueue_batch_atomic(
items: Iterable[EnqueueArgs],
*,
batch_id: UUID,
queue: str,
batch_row: BatchRow | None,
finalizer_args: EnqueueArgs | None,
chunk_size: int = DEFAULT_CHUNK_SIZE,
) -> list[JobRow]
Source code in src/taskq/testing/in_memory.py
create_batch
async
¶
create_batch(
batch_id: UUID,
queue: str,
expected_size: int,
failure_threshold: int | None,
finalizer_job_id: UUID | None,
originating_actor: str | None,
*,
connection: object = None,
) -> None
Source code in src/taskq/testing/in_memory.py
increment_batch_failures
async
¶
reset_batch_failures
async
¶
abort_batch
async
¶
complete_batch
async
¶
get_batch
async
¶
list_batches
async
¶
count_batch_non_terminal
async
¶
JobContext
dataclass
¶
JobContext(
job_id: JobId,
actor: str,
queue: str,
attempt: int,
payload: P,
cancel_event: Event,
worker_id: UUID,
jobs: SubJobEnqueuer,
log: BoundLogger,
deps: dict[str, object] | None = None,
abort_requested: Event = threading.Event(),
)
Test-scoped context with deps for fixture-injected dependencies.
Field shape mirrors :class:taskq.context.JobContext (the production
class) and adds deps. The bound on P matches production —
payload is always a :class:pydantic.BaseModel. Tests that pass raw
dicts as payload should validate them through a wrapper model
(:class:taskq.testing.in_memory._PassthroughPayload is the
permissive default for register_stub callers).
JobTriple ¶
WarningSpy ¶
Records how many times warning() was called, without sniffing args.
as_backend ¶
default_actor_config ¶
assert_attempt ¶
assert_attempt(
attempts: Sequence[object],
index: int,
*,
outcome: str | None = None,
error_class: str | None = None,
attempt_num: int | None = None,
) -> object
Assert on the attempt row at index.
Source code in src/taskq/testing/assertions.py
assert_has_event ¶
assert_has_event(
events: Sequence[Record],
kind: str,
*,
from_state: str | None = None,
to_state: str | None = None,
) -> asyncpg.Record
Find at least one event matching kind and optional state filters.
Source code in src/taskq/testing/assertions.py
assert_has_otel_event ¶
assert_has_otel_event(
exporter: _SpanExporter,
span_name: str,
event_name: str,
*,
from_state: str | None = None,
to_state: str | None = None,
) -> object
Find an OTel event by span and event name; assert state attributes if provided.
Source code in src/taskq/testing/assertions.py
assert_has_span ¶
assert_has_span(
exporter: _SpanExporter,
name: str,
*,
kind: object = None,
status: object = None,
) -> ReadableSpan
Find a span by name on the exporter; assert kind/status if provided.
Source code in src/taskq/testing/assertions.py
assert_job_status ¶
assert_job_status(
row: Record | None,
status: str,
*,
error_class: str | None = None,
attempt: int | None = None,
finished: bool | None = None,
) -> asyncpg.Record
Assert a job row has the expected status and optional fields.
Returns the row (guaranteed non-None on success) so callers can chain attribute/subscript access without pyright narrowing issues.
Source code in src/taskq/testing/assertions.py
assert_job_terminal ¶
assert_job_terminal(
row: Record | None,
status: str,
*,
error_class: str | None = None,
) -> asyncpg.Record
Assert a job is in a terminal status with finished_at set.
Source code in src/taskq/testing/assertions.py
assert_transition_sequence ¶
assert_transition_sequence(
events: Sequence[object],
expected: Sequence[tuple[str | None, str | None]],
) -> None
Assert the (from_state, to_state) sequence from state_change events matches expected.
Source code in src/taskq/testing/assertions.py
parse_detail ¶
Normalize a detail value (dict, JSON string, or other) to a dict.
Source code in src/taskq/testing/assertions.py
pg_now
async
¶
Return PG's clock_timestamp() — the realtime clock the server uses.
Use this instead of datetime.now(UTC) when a test needs to compute
cutoffs/margins that are compared against rows written via SQL: the
Python wall clock and PG's realtime clock can diverge enough under
parallel load to make Python-computed margins flaky.
Source code in src/taskq/testing/assertions.py
wait_for
async
¶
Wait for an asyncio.Event with test-failure semantics on timeout.
Source code in src/taskq/testing/assertions.py
wait_for_job_status
async
¶
wait_for_job_status(
backend: _AssertBackend,
job_id: JobId,
status: str,
*,
timeout: float = 2.0,
poll_interval: float = 0.05,
) -> JobRow
Poll backend.get until the job reaches the expected status.
Source code in src/taskq/testing/assertions.py
wait_for_leader
async
¶
Wait for the leader event on WorkerDeps with test-failure semantics.
Source code in src/taskq/testing/assertions.py
unique_health_sock_path ¶
Return a unique unix-socket path for one test's health server.
/tmp/tq-<module>-<pid>-<token>.sock: the pid scopes across xdist
workers, the random token across tests within a worker (stateless — no
shared counter, so uniqueness survives any pytest import mode), and the
module label identifies the owner when debugging stale files. The short
/tmp/tq- prefix keeps paths well under the 104-char AF_UNIX
sun_path limit on macOS.
Stale files are a resource-only leak: the per-run-unique token means a
leftover path can never collide with a future run. HealthServer.stop
unlinks on normal completion; suites wanting a crash backstop can sweep
/tmp/tq-*-<pid>-*.sock scoped to their own pid (see TaskQ's
tests/conftest.py::_sweep_health_sock_files).
:param module: label embedded in the path. Must not contain path
separators — / would point the socket at a nonexistent
directory and surface as a confusing ENOENT at bind time.
:raises ValueError: if module contains / or \.
Source code in src/taskq/testing/health.py
error_info ¶
Shorthand for ErrorInfo with error_traceback=None.
Source code in src/taskq/testing/jobs.py
make_enqueue_args ¶
make_enqueue_args(
*,
actor: str = "test_actor",
queue: str = "default",
payload: dict[str, object] | None = None,
idempotency_key: str | None = None,
idempotency_scope: str = "",
identity_key: str | None = None,
scheduled_at: datetime | None = None,
max_attempts: int = 3,
retry_kind: RetryKind = "transient",
priority: int = 0,
schedule_to_close: datetime | None = None,
metadata: dict[str, object] | None = None,
tags: tuple[str, ...] | None = None,
) -> EnqueueArgs
Build EnqueueArgs with sensible defaults.
scheduled_at defaults to 1 second in the past (relative to the
Python wall clock) so freshly-enqueued test jobs can never classify
as a future-"scheduled" job: the enqueue SQL compares against PG's
clock_timestamp(), and Python's monotonic/wall clock can diverge
from PG's realtime clock enough under parallel load that a "now"
computed here reads as still-future by the time the row lands.
Source code in src/taskq/testing/jobs.py
make_job_row ¶
make_job_row(
*,
attempt: int = 1,
max_attempts: int = 3,
retry_kind: RetryKind = "transient",
schedule_to_close: datetime | None = None,
identity_key: IdentityKey | None = None,
trace_id: str | None = None,
span_id: str | None = None,
start_to_close: timedelta | None = None,
heartbeat_timeout: timedelta | None = None,
cancel_phase: int | CancelPhase | None = None,
status: JobStatus = "running",
priority: int = 0,
error_class: str | None = None,
error_message: str | None = None,
payload: dict[str, object] | None = None,
queue: str = "default",
actor: str = "test_actor",
progress_seq: int = 0,
) -> JobRow
Build a JobRow with sensible defaults.
Source code in src/taskq/testing/jobs.py
create_pending_job
async
¶
create_pending_job(
conn: _Conn,
schema: str,
job_id: UUID | None = None,
*,
schedule_to_close: datetime | None = None,
status: str = "pending",
scheduled_at: datetime | None = None,
) -> UUID
Source code in src/taskq/testing/pg.py
create_running_job
async
¶
create_running_job(
conn: _Conn,
schema: str,
worker_id: UUID,
job_id: UUID | None = None,
*,
cancel_phase: int = 0,
max_attempts: int = 3,
retry_kind: str = "transient",
attempt: int = 1,
cancel_requested_at: datetime | None = None,
lock_expires_at: datetime | None = None,
schedule_to_close: datetime | None = None,
with_events: bool = True,
) -> UUID
Source code in src/taskq/testing/pg.py
create_workered_running_job
async
¶
create_workered_running_job(
conn: _Conn,
schema: str,
*,
worker_id: UUID | None = None,
**job_kwargs: Any,
) -> tuple[UUID, UUID]
Create a worker row and a running job row, returning (worker_id, job_id).
Passthrough wrapper: creates a worker (generating a UUID if none provided),
then creates a running job belonging to that worker. All extra keyword
arguments are forwarded to :func:create_running_job.
Source code in src/taskq/testing/pg.py
get_job_triple
async
¶
Source code in src/taskq/testing/pg.py
reset_schema
async
¶
Truncate all dynamic tables then seed default actor_config rows.
Tests needing a custom actor set can pass actors=[...];
tests that need an empty actor_config can pass actors=[].
Source code in src/taskq/testing/pg.py
seed_actors
async
¶
Insert actor_config rows for the given actors (or DEFAULT_ACTORS).
ON CONFLICT (actor) DO NOTHING makes this safe to call
alongside custom seed data — it never overwrites existing rows.
Source code in src/taskq/testing/pg.py
setup_running_job
async
¶
setup_running_job(
conn: _Conn,
schema: str,
*,
worker_id: UUID | None = None,
job_id: UUID | None = None,
attempt: int = 1,
max_attempts: int = 3,
retry_kind: str = "transient",
cancel_phase: int = 0,
cancel_requested_at: datetime | None = None,
lock_expires_at: datetime | None = None,
schedule_to_close: datetime | None = None,
with_events: bool = True,
) -> tuple[UUID, UUID]
Create a worker row and a running job row in one call.
Returns (worker_id, job_id). Delegates to
:func:create_workered_running_job.
Source code in src/taskq/testing/pg.py
truncate_schema
async
¶
Truncate all dynamic tables in FK-safe order using CASCADE.
Leaves schema_migrations intact. Safe to call repeatedly.
Source code in src/taskq/testing/pg.py
make_integration_settings ¶
Construct WorkerSettings with fast intervals for integration tests.
The schema defaults to a per-call unique tq_<token> name (see module
docstring); pass schema_name="..." to pin it.
Source code in src/taskq/testing/settings.py
make_integration_settings_dict ¶
Return the raw dict passed to WorkerSettings.load_from_dict.
Same per-call unique schema default as :func:make_integration_settings.
Source code in src/taskq/testing/settings.py
Pytest fixtures¶
The fixtures are not re-exported from taskq.testing.__init__ (importing
pytest/asyncpg at the package top level is deliberately avoided), so they
render from their defining module:
fixtures ¶
__all__
module-attribute
¶
__all__ = [
"ActorRunnerCallable",
"JobsApp",
"ModulePgSchema",
"_create_worker",
"actor_runner",
"backend_pair",
"clean_jobs_app",
"clean_pg_conn",
"clean_redis_client",
"clean_redis_url",
"jobs_app",
"killable_redis_container",
"memory_jobs",
"module_jobs_app",
"module_pg_pool",
"module_pg_schema",
"module_redis_url",
"redis_container",
"redis_url",
"redis_url_for",
"worker_with_running_job",
]
JobsApp ¶
ModulePgSchema ¶
ActorRunnerCallable ¶
Bases: Protocol
Protocol for the callable yielded by the actor_runner fixture.
payload is permissively typed: a :class:pydantic.BaseModel
(typed actor payload) or a dict[str, object] / object that
the runner wraps in a :class:PassthroughPayload model. The
JobContext.payload handed to the actor is always a
:class:pydantic.BaseModel per the locked architecture; the
coercion happens here so test authors don't have to declare a model
for ad-hoc payloads.
__call__
async
¶
__call__(
actor_fn: Callable[..., object],
payload: BaseModel | dict[str, object] | object,
*,
backend: InMemoryBackend,
job_id: JobId | UUID | None = ...,
attempt: int = ...,
cancel_event: Event | None = ...,
actor: str = ...,
queue: str = ...,
**deps: object,
) -> object
Source code in src/taskq/testing/fixtures.py
RedisContainerLike ¶
Bases: Protocol
The RedisContainer surface redis_url_for needs — structural, so both
the real testcontainers object (killable_redis_container) and the shared-pair
shim (redis_container) satisfy it.
memory_jobs
async
¶
Yield a fresh InMemoryBackend with a FakeClock starting at
datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC). Default cancellation
and cleanup grace from InMemoryBackend.__init__ (30s each).
No teardown beyond GC (fully isolated per fixture instance — each call
constructs a new backend and clock; nothing is shared at module level).
Source code in src/taskq/testing/fixtures.py
actor_runner ¶
Yield a callable that constructs a synthetic JobContext and
calls actor_fn(payload, ctx).
Accepts cancel_event to test cancellation paths and **deps
to forward ad-hoc keyword-injected collaborators (e.g. stub HTTP
clients or database sessions) directly to actor_fn without
wiring the full DI scope hierarchy.
Source code in src/taskq/testing/fixtures.py
jobs_app
async
¶
Yield a :class:JobsApp named tuple (deps, backend) against the
database named by the consumer-provided pg_dsn fixture.
Access the fields as jobs_app.deps and jobs_app.backend instead
of unpacking — the named-tuple interface is clearer and type-safe.
Per-test isolation: drops the schema CASCADE before each test (schema
name is hashed from the test's own node id via
:func:_schema_name_from_test, so distinct tests never share a schema),
applies migrations, opens pools, constructs the backend. Teardown via
AsyncExitStack unwind closes pools; the schema is dropped at the
next invocation's setup for the same test — same pattern as pg_conn.
Source code in src/taskq/testing/fixtures.py
backend_pair
async
¶
Yield a single Backend instance per parametrize id.
memory: same construction asmemory_jobs.pg: requires a consumer-providedpg_dsnfixture (see the module docstring) plus migrations; reuses the same settings / migration sequence via :func:_open_pg_backend. Returns thePostgresBackendinstance.
Tests using this fixture must be marked @pytest.mark.integration
so the PG branch does not run in the unit tier. The guard below
enforces this: the pg param is automatically skipped when the
test lacks @pytest.mark.integration, preventing testcontainers
from booting during unit-only runs.
pg_dsn is resolved lazily via request.getfixturevalue only
inside the pg branch (after the integration-marker check) so
the memory variant never triggers the pg_dsn fixture
chain and stays container-free.
Source code in src/taskq/testing/fixtures.py
redis_container ¶
One shared Dragonfly (Redis-compatible) container per pytest invocation — every xdist worker of it, and no other invocation of any repo.
Backs onto :func:taskq.testing._shared_containers.shared_service_pair (file
lock + per-invocation holder registry under the invocation's state dir — see
:func:taskq.testing._shared_containers.invocation_state_dir): the first
worker to take the lock boots the pair (--dbnum 1024 so every consumer —
module or test function, across ALL workers of the invocation — gets its own
logical DB; sharing would let one consumer's FLUSHDB wipe another's mid-run
state); later workers reuse it; the last worker to finish removes it. The
shim exposes get_container_host_ip / get_exposed_port so
redis_url_for(redis_container, db) keeps working unchanged.
Source code in src/taskq/testing/fixtures.py
killable_redis_container ¶
Function-scoped disposable Dragonfly for chaos tests that stop/restart Redis.
Chaos tests must NEVER stop the shared container (redis_container) —
every worker of the run shares it, and a slow restart leaks failures into
unrelated tests. Each kill test gets its own container (~1s boot). The
ownership labels (creator_labels) keep a crashed run's leftovers
sweepable — Ryuk is disabled process-wide by the shared-container machinery
(see :mod:taskq.testing._shared_containers), so labeling is what lets the
next run's stale sweep remove these once their owner pids are dead.
Source code in src/taskq/testing/fixtures.py
redis_url_for ¶
Build a redis://host:port/{db} URL for container.
Accepts anything with the RedisContainer host/port surface — the real
testcontainers object or the shared-pair shim.
Source code in src/taskq/testing/fixtures.py
redis_url ¶
Per-test Redis URL with a UNIQUE logical DB — a guaranteed clean slate.
Each test function gets its own DB (never reused within the invocation), so no
setup/teardown flushing is needed and no two tests can observe each other's
keys. DB indices are drawn from the invocation's counter (see
next_redis_logical_db) so consumers on different xdist workers can never
collide inside the ONE shared Dragonfly.
Source code in src/taskq/testing/fixtures.py
run_isolation_token ¶
The token mixed into every hashed per-module/per-test database and schema
name: the xdist worker id under xdist, the conftest-published per-invocation
token in serial runs, master as a last-resort fallback (direct library
use outside this repo's conftest).
The token's load-bearing job is WITHIN one invocation: the shared pair
serves every xdist worker of it, and two workers hashing the same module
or node id must not land the same database or schema on that one pair —
each worker's DROP DATABASE ... WITH (FORCE) / DROP SCHEMA ... CASCADE
would kill the other's live pools mid-test.
Invocation-uniqueness of the token (the conftest publishes the
invocation-unique basetemp dir name, e.g. pytest-41) and the
master fallback are defense-in-depth, not the isolation mechanism:
the pair itself is per-invocation
(:func:taskq.testing._shared_containers.invocation_state_dir), so two
invocations' names can never land in one cluster. If that pair isolation
ever regressed, per-invocation tokens would still keep the runs' names
distinct on whatever they ended up sharing — mutual clobbering could not
silently return.
Source code in src/taskq/testing/fixtures.py
module_pg_schema
async
¶
Module-scoped PG schema: create once per test file, truncate per test.
Derives a unique schema name from the test module, applies migrations, seeds default actor_config rows. Drops the schema CASCADE on module teardown.
All tests in the module share the same schema; per-function isolation
is provided by :func:clean_pg_conn and :func:clean_jobs_app which
truncate every table before each test.
Source code in src/taskq/testing/fixtures.py
module_redis_url ¶
Module-scoped Redis URL with a unique DB per test file.
Assigns a unique Redis DB id (1-1023, never reused, unique across ALL
workers of the invocation — see _next_redis_db) and returns
redis://host:port/{db}. FLUSHDB at setup (clean slate even after a
crashed run) and again on teardown.
Source code in src/taskq/testing/fixtures.py
module_pg_pool
async
¶
Module-scoped asyncpg pool for the module's PG schema.
Pool is shared by all tests in a module. Per-test isolation is handled by clean_pg_conn/clean_jobs_app which truncate tables between tests.
Source code in src/taskq/testing/fixtures.py
module_jobs_app
async
¶
Module-scoped JobsApp (WorkerDeps + PostgresBackend) on the module's PG schema.
Pools are opened once per test module. Per-test isolation is handled by clean_jobs_app which truncates tables between tests against the same schema. The backend instance is shared — callers should NOT cache or mutate backend state across test boundaries.
Source code in src/taskq/testing/fixtures.py
clean_pg_conn
async
¶
Per-test clean asyncpg connection on the module's PG schema.
Truncates every dynamic table (FK-safe CASCADE) then re-seeds default actor_config rows, guaranteeing a blank slate for each test while keeping migration state intact.
Source code in src/taskq/testing/fixtures.py
worker_with_running_job
async
¶
worker_with_running_job(
clean_pg_conn: Connection,
module_pg_schema: ModulePgSchema,
) -> AsyncIterator[tuple[UUID, UUID, asyncpg.Connection]]
Per-test clean connection with a pre-created worker + running job.
Yields (worker_id, job_id, conn) where conn is the same
asyncpg.Connection provided by :func:clean_pg_conn. The
connection lifecycle is managed by clean_pg_conn — this fixture
only inserts the worker and job rows.
Source code in src/taskq/testing/fixtures.py
clean_jobs_app
async
¶
Per-test clean JobsApp (WorkerDeps + PostgresBackend) on the
module's PG schema.
Truncates + re-seeds before each test, then opens WorkerDeps and
constructs a PostgresBackend. Faster than the jobs_app fixture
which drops/recreates the schema every test.
Source code in src/taskq/testing/fixtures.py
clean_redis_url ¶
Per-test clean Redis URL.
FLUSHDB on the module's Redis DB before each test, guaranteeing
no cross-test state within the module. Uses the synchronous
redis.Redis client so the flush is reliable regardless of
whether tests are sync or async.
Source code in src/taskq/testing/fixtures.py
clean_redis_client
async
¶
Per-test clean Redis async client against the module's DB.
Returns a fresh redis.asyncio.Redis client with decode_responses=False.
Source code in src/taskq/testing/fixtures.py
Health-socket helpers¶
health ¶
Health-socket path helpers for test suites.
Solves the EADDRINUSE-under-xdist problem with the worker health server:
WorkerSettings.health_socket_path defaults to the shared production path
/tmp/taskq_health.sock, and worker._main starts a real
HealthServer on it. Under pytest-xdist, two workers running _main
in-process concurrently race on that one filesystem path — the loser gets
EADDRINUSE (there is a TOCTOU window in create_unix_server's
stale-file removal), or silently steals the socket from the live winner.
Recommended consumer pattern — mint a unique path per test at settings construction time::
from taskq.testing.health import unique_health_sock_path
from taskq.testing.settings import make_integration_settings
settings = make_integration_settings(
pg_dsn, HEALTH_SOCKET_PATH=unique_health_sock_path("my_test_module")
)
Suites that build settings through the env cascade (WorkerSettings.load())
instead of a factory should additionally redirect HealthServer.start away
from the shared default via an autouse monkeypatch — see
tests/conftest.py::_isolate_health_server_socket in the TaskQ repo for the
reference implementation (it is repo-specific and deliberately not published).
unique_health_sock_path ¶
Return a unique unix-socket path for one test's health server.
/tmp/tq-<module>-<pid>-<token>.sock: the pid scopes across xdist
workers, the random token across tests within a worker (stateless — no
shared counter, so uniqueness survives any pytest import mode), and the
module label identifies the owner when debugging stale files. The short
/tmp/tq- prefix keeps paths well under the 104-char AF_UNIX
sun_path limit on macOS.
Stale files are a resource-only leak: the per-run-unique token means a
leftover path can never collide with a future run. HealthServer.stop
unlinks on normal completion; suites wanting a crash backstop can sweep
/tmp/tq-*-<pid>-*.sock scoped to their own pid (see TaskQ's
tests/conftest.py::_sweep_health_sock_files).
:param module: label embedded in the path. Must not contain path
separators — / would point the socket at a nonexistent
directory and surface as a confusing ENOENT at bind time.
:raises ValueError: if module contains / or \.