Testing Tasks¶
TestQueue runs tasks synchronously in the test process without a separate worker or redis. It is designed for simple testing tasks in unit tests by providing deterministic synchronous execution.
Basic testing¶
Here is a simple task to test:
from sheppy import task
@task
async def add(x: int, y: int) -> int:
return x + y
And its test:
from sheppy import TestQueue
from tasks import add
def test_add():
q = TestQueue()
# instantiate task
t = add(1, 2)
# add to the test queue
q.add(t)
# process the next task in the queue
processed_task = q.process_next()
# verify the task result
assert processed_task.status == 'completed'
assert processed_task.exception is None
assert processed_task.result == 3
Differences from "full" Queue:
- Synchronous API, so no
awaitand event loops - Tasks only run when you call
process_next()orprocess_all() - Everything happens in the test process and stored in-memory
Testing task failures¶
When a task raises, the exception is captured in the exception attribute instead of propagating, so you can assert on it:
from sheppy import TestQueue, task
@task
def divide(x: int, y: int) -> float:
return x / y
def test_divide_by_zero():
q = TestQueue()
# instantiate two tasks
t1 = divide(1, 2)
t2 = divide(1, 0)
# add both tasks to the test queue
q.add([t1, t2])
# process all tasks in the queue (processed in order)
processed_tasks = q.process_all()
# verify the first task result
assert processed_tasks[0].status == 'completed'
assert processed_tasks[0].exception is None
assert processed_tasks[0].result == 0.5
# verify the second task result (should fail)
assert processed_tasks[1].status == 'failed'
assert str(processed_tasks[1].exception) == "ZeroDivisionError: division by zero"
Testing retry logic¶
Retries in TestQueue happen immediately, with no delay, so retry behavior is fast to test:
from sheppy import TestQueue, task
FAIL_COUNTER = 0
@task(retry=2, retry_delay=0)
def fail_once() -> str:
global FAIL_COUNTER
if FAIL_COUNTER < 1:
FAIL_COUNTER += 1
raise ValueError("task failed")
return "success"
def test_fail_once():
q = TestQueue()
# instantiate the task
t = fail_once()
# add the task to the test queue
q.add(t)
assert q.size() == 1 # one task in the queue
# process all tasks in the queue
processed = q.process_all()
# there should be two processed tasks: the original + one retry
assert len(processed) == 2
# verify the first attempt failed and is marked for retry
assert processed[0].status == 'retrying'
assert str(processed[0].exception) == "ValueError: task failed"
# retry should succeed
assert processed[1].status == 'completed'
assert processed[1].exception is None
assert processed[1].result == "success"
# both processed tasks should have the same id
assert processed[0].id == processed[1].id
Note the first processed attempt has status retrying, not failed. A task is only failed once no attempts remain. Both attempts share the same task id.
Overriding dependencies¶
Tasks that use Depends can swap their dependencies in tests with dependency_overrides:
from sheppy import Depends, TestQueue, task
def get_db():
return "PROD DATABASE"
@task
def my_task(db: str = Depends(get_db)):
print("my database is: ", db)
return db
def test_dependency_override():
def alternative_get_db():
return "TESTING DATABASE"
q = TestQueue(dependency_overrides={get_db: alternative_get_db})
q.add(my_task())
processed = q.process_next()
assert processed.status == 'completed'
assert processed.result == "TESTING DATABASE"
The task resolves get_db through the override map and receives "TESTING DATABASE". The same parameter exists on Worker and InMemoryBackend for integration tests.
examples/example_timeout.py shows the same style of test for timeout and retry_on_timeout.