Testing Background Jobs and Workers: Patterns for Queue-Based Systems

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=jobs

Summary

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.

Read more