Testing Background Jobs and Workers: Patterns for Queue-Based Systems
Background jobs power the asynchronous side of your application: sending emails, processing uploads, syncing data, sending webhooks. Testing them requires different strategies than testing HTTP handlers — you need to control job scheduling, execution, retries, and failure handling.
Testing Levels
Unit tests: Test the job's processing logic in isolation. Call the job handler function directly, assert on side effects (database writes, external API calls).
Integration tests: Test the full job lifecycle — enqueue, process, complete — against a real queue. Verify the job is scheduled correctly and executes the right logic end-to-end.
Failure tests: Test retry behavior, dead-letter queues, error handling.
Unit Testing Job Handlers
Extract job logic into a plain function you can test without a queue:
// jobs/send-invoice.js
export async function sendInvoiceHandler({ userId, orderId }) {
const order = await db.orders.findById(orderId)
if (!order) throw new Error(`Order ${orderId} not found`)
const user = await db.users.findById(userId)
const invoice = await generateInvoicePDF(order)
await emailService.sendInvoice({
to: user.email,
attachments: [{ filename: `invoice-${orderId}.pdf`, content: invoice }],
})
await db.orders.update(orderId, { invoiceSentAt: new Date() })
}
// Register with queue separately
export async function sendInvoiceJob(job) {
await sendInvoiceHandler(job.data)
}// jobs/send-invoice.test.js
import { vi, test, expect, beforeEach } from 'vitest'
import { sendInvoiceHandler } from './send-invoice'
vi.mock('../db')
vi.mock('../services/email-service')
vi.mock('../services/pdf')
beforeEach(() => {
vi.clearAllMocks()
})
test('sends invoice email with PDF attachment', async () => {
db.orders.findById.mockResolvedValue({ id: 'ord-1', total: 99.99, items: [] })
db.users.findById.mockResolvedValue({ email: 'user@example.com' })
generateInvoicePDF.mockResolvedValue(Buffer.from('pdf-content'))
emailService.sendInvoice.mockResolvedValue(undefined)
await sendInvoiceHandler({ userId: 'user-1', orderId: 'ord-1' })
expect(emailService.sendInvoice).toHaveBeenCalledWith({
to: 'user@example.com',
attachments: expect.arrayContaining([
expect.objectContaining({ filename: 'invoice-ord-1.pdf' })
])
})
expect(db.orders.update).toHaveBeenCalledWith('ord-1', {
invoiceSentAt: expect.any(Date)
})
})
test('throws when order not found', async () => {
db.orders.findById.mockResolvedValue(null)
await expect(sendInvoiceHandler({ userId: 'u1', orderId: 'missing' }))
.rejects.toThrow('Order missing not found')
expect(emailService.sendInvoice).not.toHaveBeenCalled()
})Testing Retry Logic
// jobs/webhook-delivery.js
export async function deliverWebhookHandler({ url, payload, attempt = 1 }) {
try {
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(5000),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return { delivered: true, attempt }
} catch (error) {
if (attempt >= 5) {
await db.webhooks.markFailed(payload.webhookId, error.message)
return { delivered: false, exhausted: true }
}
throw error // Queue will retry
}
}
// webhook-delivery.test.js
test('marks webhook failed after max retries', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))
db.webhooks.markFailed = vi.fn()
const result = await deliverWebhookHandler({
url: 'https://example.com/webhook',
payload: { webhookId: 'wh-123', event: 'order.created' },
attempt: 5, // Max attempts
})
expect(result.exhausted).toBe(true)
expect(db.webhooks.markFailed).toHaveBeenCalledWith('wh-123', 'ECONNREFUSED')
})
test('throws on failure before max retries (queue will retry)', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 })
await expect(deliverWebhookHandler({
url: 'https://example.com/webhook',
payload: { webhookId: 'wh-123', event: 'order.created' },
attempt: 2,
})).rejects.toThrow('HTTP 503')
})Integration Testing with Real Redis
For integration tests, use a real Redis instance in Docker:
# docker-compose.test.yml
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"// jobs/integration.test.js
import { Queue, Worker } from 'bullmq'
import Redis from 'ioredis'
const connection = new Redis({ host: 'localhost', port: 6379, maxRetriesPerRequest: null })
let emailQueue
let worker
beforeAll(async () => {
emailQueue = new Queue('emails', { connection })
})
afterAll(async () => {
await worker?.close()
await emailQueue.close()
await connection.quit()
})
beforeEach(async () => {
await emailQueue.drain()
})
test('job completes successfully', async () => {
const completed = new Promise((resolve, reject) => {
worker = new Worker('emails', async (job) => {
// Real handler
await sendWelcomeEmail(job.data)
resolve(job.data)
}, { connection })
worker.on('failed', (job, err) => reject(err))
})
await emailQueue.add('welcome', { userId: 'u1', email: 'alice@example.com' })
const result = await Promise.race([
completed,
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
])
expect(result.email).toBe('alice@example.com')
})Testing Job Scheduling
test('schedules invoice job after successful order', async () => {
const addMock = vi.fn().mockResolvedValue({ id: 'job-123' })
invoiceQueue.add = addMock
await createOrder({ userId: 'u1', items: [{ productId: 'p1', qty: 1 }] })
expect(addMock).toHaveBeenCalledWith(
'send-invoice',
expect.objectContaining({ userId: 'u1' }),
expect.objectContaining({ delay: 0, attempts: 5 })
)
})
test('does not schedule invoice for free orders', async () => {
const addMock = vi.fn()
invoiceQueue.add = addMock
await createOrder({ userId: 'u1', items: [], total: 0 })
expect(addMock).not.toHaveBeenCalled()
})Testing Idempotency
Background jobs should be idempotent — running them twice should not cause double effects:
test('running job twice does not send duplicate email', async () => {
const sendMock = vi.fn().mockResolvedValue(undefined)
emailService.send = sendMock
// Process same job data twice
await sendInvoiceHandler({ userId: 'u1', orderId: 'ord-1' })
await sendInvoiceHandler({ userId: 'u1', orderId: 'ord-1' })
// Should only send once (idempotency key or DB check)
expect(sendMock).toHaveBeenCalledTimes(1)
})Testing Dead Letter Queues
test('failed jobs land in DLQ after max retries', async () => {
// Simulate job failing all retries
const job = { data: { orderId: 'ord-1' }, attemptsMade: 4 }
processingService.process = vi.fn().mockRejectedValue(new Error('permanent failure'))
await expect(processOrderJob(job)).rejects.toThrow()
// Job should be moved to DLQ
const dlqJobs = await deadLetterQueue.getJobs(['failed'])
expect(dlqJobs.some(j => j.data.orderId === 'ord-1')).toBe(true)
})CI Configuration
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Run job tests
env:
REDIS_URL: redis://localhost:6379
run: npm test -- --testPathPattern=jobsSummary
Test job handlers as plain functions — mock dependencies, assert on side effects. Use integration tests with real Redis for queue lifecycle tests. Always test failure paths: missing data, network errors, max retries, dead letter queues. And test idempotency — jobs that can be safely replayed are much easier to operate in production.