Trigger.dev Job Testing: Task Mocking, Run Lifecycle, and Webhook Triggers
Trigger.dev v3 brings a task-centric model for background jobs: each task is a durable, retriable unit that can spawn subtasks, wait on external events, and execute long-running logic without timeouts. Testing these tasks requires understanding their lifecycle and the tools Trigger.dev provides for isolation.
Trigger.dev v3 Architecture
In Trigger.dev v3, tasks replace jobs as the primary unit:
import { task } from "@trigger.dev/sdk/v3";
export const processUpload = task({
id: "process-upload",
run: async (payload: { fileId: string; userId: string }) => {
const file = await fetchFile(payload.fileId);
const processed = await transformFile(file);
await saveResult(payload.userId, processed);
return { resultId: processed.id };
},
});Tasks run on Trigger.dev's managed infrastructure but can be tested locally using their testing utilities.
Unit Testing with @trigger.dev/testing
Trigger.dev provides @trigger.dev/testing for local task execution without a live server:
import { configure } from "@trigger.dev/testing";
import { processUpload } from "./tasks/processUpload";
configure({ timeout: 30_000 });
it("processes upload successfully", async () => {
const result = await processUpload.triggerAndWait({
fileId: "file_123",
userId: "usr_456",
});
expect(result.ok).toBe(true);
expect(result.output).toMatchObject({ resultId: expect.any(String) });
});triggerAndWait runs the task synchronously in the test process — no external infrastructure needed.
Mocking External Dependencies
Tasks that call external services need mocking for fast, reliable tests:
import { vi, it, expect } from "vitest";
import { configure } from "@trigger.dev/testing";
import { sendInvoice } from "./tasks/sendInvoice";
import * as emailService from "./services/email";
configure({ timeout: 15_000 });
it("sends invoice email and returns message ID", async () => {
const sendEmail = vi
.spyOn(emailService, "sendEmail")
.mockResolvedValueOnce({ messageId: "msg_abc" });
const result = await sendInvoice.triggerAndWait({
invoiceId: "inv_123",
recipientEmail: "customer@example.com",
});
expect(result.ok).toBe(true);
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: "customer@example.com",
subject: expect.stringContaining("Invoice"),
})
);
expect(result.output.messageId).toBe("msg_abc");
});Testing Task Failure Scenarios
Test what happens when tasks fail — both expected errors and retryable failures:
import { AbortTaskRunError } from "@trigger.dev/sdk/v3";
export const validateDocument = task({
id: "validate-document",
retry: { maxAttempts: 3, minTimeoutInMs: 1000 },
run: async (payload: { docId: string }) => {
const doc = await fetchDocument(payload.docId);
if (!doc) {
// Non-retryable: document doesn't exist
throw new AbortTaskRunError(`Document ${payload.docId} not found`);
}
if (doc.status === "processing") {
// Retryable: document not ready yet
throw new Error("Document still processing");
}
return { valid: true, checksum: doc.checksum };
},
});
// Test abort (non-retryable) path
it("aborts when document does not exist", async () => {
vi.spyOn(documentService, "fetchDocument").mockResolvedValueOnce(null);
const result = await validateDocument.triggerAndWait({ docId: "missing_doc" });
expect(result.ok).toBe(false);
expect(result.error?.message).toContain("not found");
});
// Test retryable failure (mock fails first time, succeeds on retry)
it("retries when document is still processing", async () => {
const fetchDoc = vi
.spyOn(documentService, "fetchDocument")
.mockResolvedValueOnce({ status: "processing" })
.mockResolvedValueOnce({ status: "ready", checksum: "abc123" });
const result = await validateDocument.triggerAndWait({ docId: "doc_456" });
expect(result.ok).toBe(true);
expect(fetchDoc).toHaveBeenCalledTimes(2);
expect(result.output.checksum).toBe("abc123");
});Testing Subtask Coordination
Trigger.dev supports spawning subtasks from a parent task. Test that the parent correctly orchestrates subtasks:
import { task } from "@trigger.dev/sdk/v3";
import { resizeImage } from "./tasks/resizeImage";
import { generateThumbnail } from "./tasks/generateThumbnail";
import { updateDatabase } from "./tasks/updateDatabase";
export const processImage = task({
id: "process-image",
run: async (payload: { imageId: string }) => {
// Run subtasks in parallel
const [resized, thumbnail] = await Promise.all([
resizeImage.triggerAndWait({ imageId: payload.imageId, width: 1920 }),
generateThumbnail.triggerAndWait({ imageId: payload.imageId, size: 200 }),
]);
await updateDatabase.triggerAndWait({
imageId: payload.imageId,
resizedUrl: resized.output.url,
thumbnailUrl: thumbnail.output.url,
});
return { processed: true };
},
});
it("orchestrates image processing subtasks", async () => {
// Mock the subtask implementations
vi.spyOn(resizeImageModule, "run").mockResolvedValueOnce({ url: "https://cdn.example.com/resized.jpg" });
vi.spyOn(generateThumbnailModule, "run").mockResolvedValueOnce({ url: "https://cdn.example.com/thumb.jpg" });
vi.spyOn(updateDatabaseModule, "run").mockResolvedValueOnce({ updated: true });
const result = await processImage.triggerAndWait({ imageId: "img_789" });
expect(result.ok).toBe(true);
expect(result.output.processed).toBe(true);
});Testing Webhook Triggers
Trigger.dev supports webhook-triggered tasks. Test the webhook handler logic without hitting real external services:
import { webhooks } from "@trigger.dev/sdk/v3";
export const stripeWebhookHandler = webhooks.on("stripe", {
event: "payment_intent.succeeded",
run: async (payload) => {
const { amount, currency, metadata } = payload.data.object;
await fulfillOrder.trigger({
orderId: metadata.orderId,
amount,
currency,
});
return { fulfilled: true };
},
});
// Test the webhook handler
it("fulfills order on successful payment", async () => {
const triggerSpy = vi.spyOn(fulfillOrder, "trigger").mockResolvedValueOnce({ id: "run_abc" });
const result = await stripeWebhookHandler.triggerAndWait({
type: "payment_intent.succeeded",
data: {
object: {
amount: 5000,
currency: "usd",
metadata: { orderId: "ord_123" },
},
},
});
expect(result.ok).toBe(true);
expect(triggerSpy).toHaveBeenCalledWith({
orderId: "ord_123",
amount: 5000,
currency: "usd",
});
});Testing Run Lifecycle with Metadata
Trigger.dev v3 allows attaching metadata to runs for progress tracking. Test that metadata is set correctly at each stage:
import { metadata } from "@trigger.dev/sdk/v3";
export const longRunningExport = task({
id: "long-running-export",
run: async (payload: { reportId: string }) => {
await metadata.set("stage", "fetching-data");
const data = await fetchReportData(payload.reportId);
await metadata.set("stage", "processing");
await metadata.set("recordCount", data.length);
const processed = await processData(data);
await metadata.set("stage", "uploading");
const url = await uploadToStorage(processed);
await metadata.set("stage", "complete");
return { url, recordCount: data.length };
},
});
it("tracks export progress through metadata", async () => {
const metadataHistory: Record<string, unknown>[] = [];
// Intercept metadata.set calls
vi.spyOn(metadata, "set").mockImplementation(async (key, value) => {
metadataHistory.push({ [key]: value });
});
vi.spyOn(reportService, "fetchReportData").mockResolvedValueOnce(
Array(500).fill({ row: "data" })
);
vi.spyOn(storageService, "uploadToStorage").mockResolvedValueOnce(
"https://storage.example.com/report.csv"
);
const result = await longRunningExport.triggerAndWait({ reportId: "rpt_123" });
expect(result.ok).toBe(true);
expect(metadataHistory).toContainEqual({ stage: "fetching-data" });
expect(metadataHistory).toContainEqual({ stage: "complete" });
expect(metadataHistory).toContainEqual({ recordCount: 500 });
});Integration Testing with the Trigger.dev CLI
For full integration tests, run the Trigger.dev dev server:
npx trigger.dev@latest devThen trigger tasks via the SDK in your test suite:
import { tasks } from "@trigger.dev/sdk/v3";
it("processes upload end-to-end", async () => {
const run = await tasks.triggerAndWait<typeof processUpload>(
"process-upload",
{ fileId: "file_e2e_001", userId: "usr_test" },
{ pollIntervalMs: 500 }
);
expect(run.ok).toBe(true);
expect(run.output).toMatchObject({ resultId: expect.any(String) });
}, 60_000); // 60s timeout for integration testsTesting Scheduled Tasks
Trigger.dev supports cron-scheduled tasks. Test the task body directly:
import { schedules } from "@trigger.dev/sdk/v3";
export const dailyDigest = schedules.task({
id: "daily-digest",
cron: "0 9 * * *",
run: async (payload) => {
const users = await getActiveUsers();
const results = await Promise.allSettled(
users.map((user) => sendDigestEmail(user))
);
const sent = results.filter((r) => r.status === "fulfilled").length;
const failed = results.filter((r) => r.status === "rejected").length;
return { sent, failed };
},
});
it("sends digest to all active users", async () => {
vi.spyOn(userService, "getActiveUsers").mockResolvedValueOnce([
{ id: "usr_1", email: "a@example.com" },
{ id: "usr_2", email: "b@example.com" },
]);
vi.spyOn(emailService, "sendDigestEmail").mockResolvedValue({ sent: true });
const result = await dailyDigest.triggerAndWait({ timestamp: new Date() });
expect(result.ok).toBe(true);
expect(result.output.sent).toBe(2);
expect(result.output.failed).toBe(0);
});Common Pitfalls
Not using AbortTaskRunError for non-retryable errors: Regular errors are retried. If the error is permanent (invalid input, resource not found), use AbortTaskRunError to skip retries.
Testing only the happy path: Background tasks are most valuable when they handle failures gracefully. Write tests for timeout, partial failure, and retry scenarios.
Skipping idempotency tests: Tasks can run multiple times if they're re-queued. Test that running a task twice with the same payload produces the same result without side effects.
Timing out integration tests: Background tasks take real time to complete. Set generous timeouts (60_000 ms) on integration tests and poll for completion rather than waiting fixed durations.
Monitoring Trigger.dev in Production
HelpMeTest provides continuous monitoring for your background job pipelines. Set up automated tests that trigger jobs and verify completion:
*** Settings ***
Library Browser
Library RequestsLibrary
*** Test Cases ***
Verify Daily Digest Runs Successfully
${response}= POST ${API_URL}/api/trigger/daily-digest
Status Should Be 200 ${response}
Wait Until Keyword Succeeds 2 min 5 sec
... Verify Job Completed ${response.json()['runId']}HelpMeTest's 24/7 monitoring catches failures in scheduled tasks before your users notice missing emails or stale reports.
Summary
Testing Trigger.dev tasks effectively:
- Use
@trigger.dev/testingwithtriggerAndWaitfor synchronous unit tests - Mock external services with Vitest spies — keep tests fast and deterministic
- Use
AbortTaskRunErrorfor non-retryable errors; test both abort and retry paths - Test subtask coordination by mocking individual task runs
- Verify webhook handler logic independently from the webhook routing
- Track and assert on metadata for long-running tasks
- Use the Trigger.dev CLI dev server for integration tests