Skip to main content
npayload is launching soon.
npayloadDocs
Guides

Testing

Strategies for testing npayload integrations with mocking, local development, and end-to-end patterns

Testing messaging integrations requires strategies for both unit tests (where you mock npayload) and integration tests (where you test against a real instance). This guide covers both approaches.

Unit testing with mocks

Mock the npayload SDK in your unit tests to verify your application logic without making API calls.

Mocking the client

// tests/mocks/npayload.ts
import { vi } from 'vitest';

export const mockNpayload = {
  messages: {
    publish: vi.fn().mockResolvedValue({ gid: 'msg_test_123' }),
    publishBatch: vi.fn().mockResolvedValue({ succeeded: 3, failed: 0 }),
  },
  channels: {
    create: vi.fn().mockResolvedValue({ gid: 'ch_test_456' }),
    get: vi.fn().mockResolvedValue({ name: 'orders', status: 'active' }),
  },
  subscriptions: {
    create: vi.fn().mockResolvedValue({ gid: 'sub_test_789' }),
  },
  webhooks: {
    verify: vi.fn().mockReturnValue(true),
  },
};

Testing publish logic

import { describe, it, expect, vi } from 'vitest';
import { mockNpayload } from './mocks/npayload';
import { OrderService } from '../src/order-service';

vi.mock('@npayload/node', () => ({
  NPayloadClient: vi.fn(() => mockNpayload),
  NPayloadAuth: vi.fn(),
}));

describe('OrderService', () => {
  it('publishes order.created event when order is placed', async () => {
    const service = new OrderService();
    await service.placeOrder({ customerId: 'cust_1', total: 99.99 });

    expect(mockNpayload.messages.publish).toHaveBeenCalledWith({
      channel: 'orders',
      payload: expect.objectContaining({
        event: 'order.created',
        customerId: 'cust_1',
      }),
      idempotencyKey: expect.stringContaining('order-'),
    });
  });
});

Testing webhook handlers

import { describe, it, expect } from 'vitest';
import { mockNpayload } from './mocks/npayload';
import { handleWebhook } from '../src/webhook-handler';

describe('Webhook handler', () => {
  it('rejects invalid signatures', async () => {
    mockNpayload.webhooks.verify.mockReturnValue(false);

    const response = await handleWebhook({
      body: { payload: { event: 'test' } },
      signature: 'invalid',
    });

    expect(response.status).toBe(401);
  });

  it('processes valid webhooks', async () => {
    mockNpayload.webhooks.verify.mockReturnValue(true);

    const response = await handleWebhook({
      body: { payload: { event: 'order.created', orderId: 'ord_123' } },
      signature: 'valid-signature',
    });

    expect(response.status).toBe(200);
  });
});

Integration testing

For integration tests, use a dedicated npayload app and environment.

Set up a test environment

Create a separate app or environment for testing so test data does not interfere with production:

// tests/setup.ts
import { NPayloadAuth, NPayloadClient } from '@npayload/node';

const auth = new NPayloadAuth({
  clientId: process.env.NPAYLOAD_TEST_CLIENT_ID!,
  hmacSecret: process.env.NPAYLOAD_TEST_HMAC_SECRET!,
});

export const testClient = new NPayloadClient({
  auth,
  environment: 'test',
});

End-to-end publish and receive

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { testClient } from './setup';

describe('End-to-end messaging', () => {
  const channelName = `test-${Date.now()}`;

  beforeAll(async () => {
    await testClient.channels.create({
      name: channelName,
      description: 'Temporary test channel',
    });
  });

  afterAll(async () => {
    await testClient.channels.delete(channelName);
  });

  it('publishes and retrieves a message', async () => {
    const published = await testClient.messages.publish({
      channel: channelName,
      payload: { event: 'test', value: 42 },
    });

    const retrieved = await testClient.messages.get(published.gid);

    expect(retrieved.payload).toEqual({ event: 'test', value: 42 });
    expect(retrieved.channel).toBe(channelName);
  });

  it('batch publishes multiple messages', async () => {
    const result = await testClient.messages.publishBatch({
      channel: channelName,
      messages: Array.from({ length: 10 }, (_, i) => ({
        payload: { index: i },
      })),
    });

    expect(result.succeeded).toBe(10);
    expect(result.failed).toBe(0);
  });
});

Testing webhook delivery

To test webhook delivery locally, use a tunnel service to expose your local server:

# Using ngrok or similar
ngrok http 3000

Then create a subscription pointing to the tunnel URL:

await testClient.subscriptions.create({
  channel: channelName,
  name: 'local-test',
  type: 'webhook',
  endpoint: {
    url: 'https://your-tunnel-id.ngrok.io/webhooks/test',
    method: 'POST',
  },
});

For CI/CD pipelines where tunnels are not practical, use queue subscriptions instead of webhooks. Pull messages from the queue to verify delivery without needing an HTTP endpoint.

Queue-based testing (CI-friendly)

it('delivers messages to queue subscribers', async () => {
  // Create a queue subscription
  await testClient.subscriptions.create({
    channel: channelName,
    name: 'test-queue',
    type: 'queue',
  });

  // Publish a message
  await testClient.messages.publish({
    channel: channelName,
    payload: { event: 'test.delivery' },
  });

  // Poll the queue (with timeout)
  const messages = await testClient.subscriptions.pull('test-queue', {
    limit: 1,
    waitMs: 5000,
  });

  expect(messages).toHaveLength(1);
  expect(messages[0].payload.event).toBe('test.delivery');
});

Test cleanup

Always clean up test resources to avoid hitting quotas:

// tests/teardown.ts
import { testClient } from './setup';

export async function cleanup() {
  const channels = await testClient.channels.list({ prefix: 'test-' });

  for (const channel of channels.items) {
    // Delete subscriptions first
    const subs = await testClient.subscriptions.list({ channel: channel.name });
    for (const sub of subs.items) {
      await testClient.subscriptions.delete(sub.gid);
    }

    await testClient.channels.delete(channel.name);
  }
}

Best practices

PracticeWhy
Use unique channel names per test runPrevents test interference
Clean up after each test suiteAvoids hitting resource limits
Use idempotency keys in testsMakes tests safe to retry
Prefer queue subscriptions in CINo tunnel or public URL needed
Keep a separate test environmentIsolates test data from production

Next steps

Was this page helpful?

On this page