Testing PayloadCMS Hooks, Custom Fields, and Validators

Testing PayloadCMS Hooks, Custom Fields, and Validators

PayloadCMS hooks let you intercept data at every lifecycle stage — beforeValidate, beforeChange, afterChange, afterRead, and more. Custom fields can carry their own validators. This guide shows you how to test both reliably using Jest and the Payload local API, without touching a browser.

Key Takeaways

  1. Hooks are plain async functions — unit-test them by calling them directly with mock arguments.
  2. Use the local API's overrideAccess option in tests to isolate hook behavior from access control.
  3. afterRead hooks are often overlooked but critical — they transform data that consumers actually receive.
  4. Custom field validators are synchronous or async functions; test every validation rule including async remote checks.
  5. Virtual fields computed at read time need integration tests that exercise the full afterRead pipeline.

One of PayloadCMS's most powerful features is its hook system. Hooks allow you to intercept every stage of a document's lifecycle: before and after validation, before and after database writes, and after reads. Combined with custom field validators, hooks let you enforce complex business rules at the CMS layer rather than scattering that logic across your application. But because hooks and validators are function references wired into a configuration object, developers often skip testing them — and that is where subtle bugs live.

This guide covers a systematic approach to testing PayloadCMS hooks, custom field validators, and virtual (computed) fields.

Understanding the Hook Execution Order

Before writing tests, it helps to understand when each hook fires:

beforeValidate → beforeChange → [database write] → afterChange → afterRead

For reads:

[database read] → afterRead

Each hook receives an args object with relevant context. The shape varies by hook type, but always includes req (the Payload request), collection (the collection config), and data or doc depending on the stage.

Unit Testing Hooks in Isolation

The simplest and fastest tests call the hook function directly with a crafted arguments object. No database, no HTTP — just pure function testing.

Consider a beforeChange hook that slugifies a title field:

// hooks/slugify.ts
import type { CollectionBeforeChangeHook } from 'payload/types';

export const slugifyTitle: CollectionBeforeChangeHook = async ({ data }) => {
  if (data.title) {
    data.slug = data.title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/^-|-$/g, '');
  }
  return data;
};

Test it without starting Payload at all:

// tests/hooks/slugify.test.ts
import { slugifyTitle } from '../../hooks/slugify';

describe('slugifyTitle hook', () => {
  const baseArgs = {
    req: {} as any,
    collection: {} as any,
    context: {},
    operation: 'create' as const,
    previousDoc: {},
  };

  it('converts a title to a lowercase slug', async () => {
    const result = await slugifyTitle({
      ...baseArgs,
      data: { title: 'Hello World Post' },
    });
    expect(result.slug).toBe('hello-world-post');
  });

  it('strips leading and trailing hyphens', async () => {
    const result = await slugifyTitle({
      ...baseArgs,
      data: { title: '  Edge Case!  ' },
    });
    expect(result.slug).toBe('edge-case');
  });

  it('collapses consecutive special characters', async () => {
    const result = await slugifyTitle({
      ...baseArgs,
      data: { title: 'Hello --- World' },
    });
    expect(result.slug).toBe('hello-world');
  });

  it('does nothing when title is absent', async () => {
    const result = await slugifyTitle({
      ...baseArgs,
      data: { content: 'no title here' },
    });
    expect(result.slug).toBeUndefined();
  });
});

This test suite runs in milliseconds and catches regressions in the slugification logic immediately.

Integration Testing Hooks via the Local API

Unit tests confirm the hook logic, but integration tests confirm that Payload actually calls the hook at the right time and that the result is persisted. Use the Payload local API with overrideAccess: true so you can focus on hook behavior without fighting access control.

// tests/hooks/slugify-integration.test.ts
import payload from 'payload';

describe('slugifyTitle hook — integration', () => {
  it('auto-generates a slug when a post is created', async () => {
    const post = await payload.create({
      collection: 'posts',
      data: { title: 'My Integration Test Post', status: 'draft' },
      overrideAccess: true,
    });

    expect(post.slug).toBe('my-integration-test-post');
  });

  it('updates the slug when the title changes', async () => {
    const post = await payload.create({
      collection: 'posts',
      data: { title: 'Original Title', status: 'draft' },
      overrideAccess: true,
    });

    const updated = await payload.update({
      collection: 'posts',
      id: post.id,
      data: { title: 'Revised Title' },
      overrideAccess: true,
    });

    expect(updated.slug).toBe('revised-title');
  });
});

Testing afterChange Hooks with Side Effects

afterChange hooks are commonly used to send notifications, invalidate caches, or trigger downstream workflows. These hooks must not block the response (they run after the database write), but their side effects still need testing.

Here is a hook that enqueues a revalidation job when a post is published:

// hooks/revalidate.ts
import type { CollectionAfterChangeHook } from 'payload/types';
import { revalidationQueue } from '../services/queue';

export const enqueueRevalidation: CollectionAfterChangeHook = async ({
  doc,
  previousDoc,
  operation,
}) => {
  if (
    operation === 'update' &&
    doc.status === 'published' &&
    previousDoc?.status !== 'published'
  ) {
    await revalidationQueue.add({ slug: doc.slug, type: 'post' });
  }
  return doc;
};

Test the hook's decision logic by mocking the queue:

// tests/hooks/revalidate.test.ts
import { enqueueRevalidation } from '../../hooks/revalidate';
import { revalidationQueue } from '../../services/queue';

jest.mock('../../services/queue', () => ({
  revalidationQueue: { add: jest.fn() },
}));

describe('enqueueRevalidation hook', () => {
  beforeEach(() => jest.clearAllMocks());

  const baseArgs = {
    req: {} as any,
    collection: {} as any,
    context: {},
    operation: 'update' as const,
  };

  it('enqueues when a draft is published', async () => {
    await enqueueRevalidation({
      ...baseArgs,
      doc: { slug: 'my-post', status: 'published' },
      previousDoc: { slug: 'my-post', status: 'draft' },
    });

    expect(revalidationQueue.add).toHaveBeenCalledWith({
      slug: 'my-post',
      type: 'post',
    });
  });

  it('does not enqueue when post was already published', async () => {
    await enqueueRevalidation({
      ...baseArgs,
      doc: { slug: 'my-post', status: 'published' },
      previousDoc: { slug: 'my-post', status: 'published' },
    });

    expect(revalidationQueue.add).not.toHaveBeenCalled();
  });

  it('does not enqueue on create operations', async () => {
    await enqueueRevalidation({
      ...baseArgs,
      operation: 'create',
      doc: { slug: 'new-post', status: 'published' },
      previousDoc: undefined,
    });

    expect(revalidationQueue.add).not.toHaveBeenCalled();
  });
});

Testing Custom Field Validators

Custom validators in PayloadCMS are functions attached to a field's validate property. They receive the current value and a context object, and return either true (valid) or an error string.

// fields/wordCount.ts
import type { Field } from 'payload/types';

export const validateMinWords = (min: number) =>
  (value: string): string | true => {
    if (!value) return `Content is required`;
    const wordCount = value.trim().split(/\s+/).length;
    if (wordCount < min) return `Minimum ${min} words required (got ${wordCount})`;
    return true;
  };

export const contentField: Field = {
  name: 'content',
  type: 'textarea',
  validate: validateMinWords(100),
};

Test validators as pure functions:

// tests/fields/wordCount.test.ts
import { validateMinWords } from '../../fields/wordCount';

describe('validateMinWords', () => {
  const validate = validateMinWords(100);

  it('returns true for content that meets the minimum', () => {
    const content = Array(100).fill('word').join(' ');
    expect(validate(content)).toBe(true);
  });

  it('returns an error message when content is too short', () => {
    const content = Array(50).fill('word').join(' ');
    const result = validate(content);
    expect(typeof result).toBe('string');
    expect(result).toContain('50');
  });

  it('returns an error for empty content', () => {
    expect(validate('')).toBe('Content is required');
  });
});

For async validators that call an external service (e.g., checking slug uniqueness), mock the dependency:

// fields/uniqueSlug.ts
import { checkSlugExists } from '../services/slugService';
import type { ValidateOptions } from 'payload/types';

export const validateUniqueSlug = async (
  value: string,
  { id }: ValidateOptions
): Promise<string | true> => {
  if (!value) return 'Slug is required';
  const existing = await checkSlugExists(value);
  if (existing && existing.id !== id) return `Slug "${value}" is already in use`;
  return true;
};
// tests/fields/uniqueSlug.test.ts
import { validateUniqueSlug } from '../../fields/uniqueSlug';
import { checkSlugExists } from '../../services/slugService';

jest.mock('../../services/slugService');
const mockCheck = checkSlugExists as jest.MockedFunction<typeof checkSlugExists>;

describe('validateUniqueSlug', () => {
  it('returns true when no existing document has the slug', async () => {
    mockCheck.mockResolvedValue(null);
    const result = await validateUniqueSlug('my-post', { id: undefined } as any);
    expect(result).toBe(true);
  });

  it('returns an error when another document uses the slug', async () => {
    mockCheck.mockResolvedValue({ id: 'other-id' } as any);
    const result = await validateUniqueSlug('taken-slug', { id: 'current-id' } as any);
    expect(typeof result).toBe('string');
    expect(result).toContain('taken-slug');
  });

  it('allows the same document to keep its own slug', async () => {
    mockCheck.mockResolvedValue({ id: 'same-id' } as any);
    const result = await validateUniqueSlug('my-slug', { id: 'same-id' } as any);
    expect(result).toBe(true);
  });
});

Testing afterRead Hooks (Virtual Fields)

afterRead hooks run on every document returned from a query. They are often used to add computed or virtual fields that are not stored in the database.

// hooks/readingTime.ts
import type { CollectionAfterReadHook } from 'payload/types';

export const addReadingTime: CollectionAfterReadHook = ({ doc }) => {
  if (doc.content) {
    const wordCount = doc.content.trim().split(/\s+/).length;
    doc.readingTimeMinutes = Math.ceil(wordCount / 200);
  }
  return doc;
};

Unit test the computation logic directly:

// tests/hooks/readingTime.test.ts
import { addReadingTime } from '../../hooks/readingTime';

describe('addReadingTime hook', () => {
  it('adds a readingTimeMinutes field based on word count', () => {
    const content = Array(400).fill('word').join(' '); // 400 words → 2 minutes
    const result = addReadingTime({
      doc: { content },
      req: {} as any,
      collection: {} as any,
      context: {},
      query: {},
      findMany: false,
    });
    expect(result.readingTimeMinutes).toBe(2);
  });

  it('rounds up for fractional minutes', () => {
    const content = Array(201).fill('word').join(' '); // 201 words → 2 minutes (ceiling)
    const result = addReadingTime({
      doc: { content },
      req: {} as any,
      collection: {} as any,
      context: {},
      query: {},
      findMany: false,
    });
    expect(result.readingTimeMinutes).toBe(2);
  });
});

Integration-test it by reading a post through Payload:

it('returned documents include readingTimeMinutes', async () => {
  const content = Array(600).fill('word').join(' ');
  const post = await payload.create({
    collection: 'posts',
    data: { title: 'Long Article', content, status: 'published' },
    overrideAccess: true,
  });

  const fetched = await payload.findByID({
    collection: 'posts',
    id: post.id,
    overrideAccess: true,
  });

  expect(fetched.readingTimeMinutes).toBe(3);
});

E2E Coverage with HelpMeTest

Your hook and validator tests protect the data layer, but the editor experience — does the slug field auto-populate in the CMS admin UI? Does the word count warning display before save? — requires browser-level testing. HelpMeTest lets you write those E2E tests in plain English and runs them on a real browser against every deploy, so you catch UI regressions without writing Playwright scripts by hand.

Summary

PayloadCMS hooks and validators are pure or nearly-pure functions — they are highly testable. Start with unit tests that call hook functions directly and assert on their return values. Layer in integration tests via the Payload local API to confirm hooks are wired into the collection correctly and fire at the expected lifecycle stage. Use mocks liberally for side-effectful hooks like cache invalidation or queue enqueuing. The result is a fast, reliable test suite that gives you the confidence to evolve your CMS configuration without fear.

Read more