Executable handlers¶
By default drongo mocks the API surface: it records state and returns realistic responses, but it does not run your workload. That is enough for many tests, and it mirrors how moto behaves for most services.
Sometimes you want more: you want the mock to actually run your code when a job runs, a task dispatches, or a message is published, so a test exercises the full producer -> consumer path in-process. That is what executable handlers are for.
The idea is uniform across services: register a Python callable, and drongo invokes it when the corresponding action fires. Handlers are opt-in. If you do not register one, behavior is exactly as before.
Every service offers the same two registration styles:
- a decorator,
@<service>.<thing>_handler(name), and - a backend method,
get_backend("<service>")[project].register_handler(name, fn).
Handlers are bound to the in-memory backend, so they are cleared automatically
when the mock_gcp scope resets between tests.
| Service | Register | Invoked when | Handler receives |
|---|---|---|---|
| Cloud Run Jobs | cloudrun.job_handler(job) |
run_job |
nothing (zero-arg) |
| Cloud Tasks | cloudtasks.task_handler(queue) |
run_task; create_task on a running queue |
a TaskRequest |
| Pub/Sub | pubsub.subscription_handler(sub) |
publish fans out to the subscription |
a PushMessage |
| Cloud Scheduler | cloudscheduler.job_handler(job) |
run_job |
a SchedulerRequest |
| Vertex AI | vertexai.prediction_handler(endpoint) |
predict on the endpoint |
(instances, parameters) |
| Document AI | documentai.processor_handler(processor) |
process_document on the processor |
(content, mime_type) |
Cloud Run Jobs¶
run_job invokes the registered function. If it raises, the execution is marked
failed (Cloud Run reports the failure on the Execution, it does not fail the
operation), so you can assert on failed_count / conditions.
from drongo import mock_gcp, cloudrun
PARENT = "projects/p/locations/us-central1"
JOB = f"{PARENT}/jobs/nightly"
@mock_gcp
def test_job_runs_real_code():
from google.cloud import run_v2
ran = []
@cloudrun.job_handler(JOB)
def nightly():
ran.append("did the work") # your real logic
jobs = run_v2.JobsClient()
job = run_v2.Job(
template=run_v2.ExecutionTemplate(
template=run_v2.TaskTemplate(
containers=[run_v2.Container(image="gcr.io/p/img")]
)
)
)
jobs.create_job(
request={"parent": PARENT, "job": job, "job_id": "nightly"}
).result()
execution = jobs.run_job(request={"name": JOB}).result()
assert ran == ["did the work"]
assert execution.succeeded_count == 1
Cloud Tasks¶
The handler receives a TaskRequest describing the task's HTTP target (url,
method, headers, and body decoded to bytes). A running queue delivers
on create_task, so a producer-only test still exercises the consumer;
run_task always delivers. A raising handler is recorded on the task's
last_error rather than raised to the producer, exactly as a real queue does not
surface a consumer failure to the caller of create_task.
from drongo import mock_gcp, cloudtasks
PARENT = "projects/p/locations/us-central1"
QUEUE = f"{PARENT}/queues/emails"
@mock_gcp
def test_task_runs_consumer():
from google.cloud import tasks_v2
delivered = []
@cloudtasks.task_handler(QUEUE)
def handle(request):
assert request.method == "POST"
delivered.append(request.body)
client = tasks_v2.CloudTasksClient()
client.create_queue(request={"parent": PARENT, "queue": {"name": QUEUE}})
client.create_task(
request={
"parent": QUEUE,
"task": {
"http_request": {
"url": "https://example.com/handler",
"http_method": "POST",
"body": b"payload",
}
},
}
)
assert delivered == [b"payload"] # delivered on create (queue is running)
Pub/Sub¶
The handler receives a PushMessage (data, attributes, message_id,
ordering_key, publish_time). Returning normally acks the message; raising
or calling message.nack() returns it to the pullable backlog, so push and pull
coexist.
from drongo import mock_gcp, pubsub
TOPIC = "projects/p/topics/orders"
SUB = "projects/p/subscriptions/worker"
@mock_gcp
def test_publish_pushes_to_subscriber():
from google.cloud import pubsub_v1
seen = []
@pubsub.subscription_handler(SUB)
def on_message(message):
seen.append(message.data) # returning acks; raising redelivers
publisher = pubsub_v1.PublisherClient()
subscriber = pubsub_v1.SubscriberClient()
publisher.create_topic(request={"name": TOPIC})
subscriber.create_subscription(request={"name": SUB, "topic": TOPIC})
publisher.publish(TOPIC, b'{"id": 1}', kind="order").result()
assert seen == [b'{"id": 1}']
Registering programmatically¶
The @..._handler decorators are sugar over each backend's register_handler,
which you can call directly, for example to register dynamically or from a
fixture:
from drongo import get_backend
get_backend("cloudrun")["my-project"].register_handler(job_name, my_fn)
get_backend("cloudtasks")["my-project"].register_handler(queue_name, my_fn)
get_backend("pubsub")["my-project"].register_handler(sub_name, my_fn)
# Vertex AI's predict handler has its own name:
get_backend("vertexai")["my-project"].register_prediction_handler(endpoint_name, my_fn)
get_backend("documentai")["my-project"].register_processor_handler(
processor_name, my_fn
)