PayloadCMS vs Directus: Testing Strategies Compared
PayloadCMS and Directus are both excellent headless CMS choices, but they have fundamentally different architectures that lead to different testing strategies. PayloadCMS gives you a TypeScript-first local API you can call directly in tests; Directus is a standalone server you test over HTTP. Understanding these differences helps you choose the right tools and write tests that actually catch bugs.
Key Takeaways
- PayloadCMS tests use the local API (no HTTP overhead); Directus tests go through HTTP even in integration tests.
- PayloadCMS access control functions are pure TypeScript — unit-test them directly. Directus permissions are database-stored — test them via API calls.
- PayloadCMS lifecycle hooks are registered in config and called synchronously; Directus Flows run asynchronously and need a delay or polling in tests.
- Both support GraphQL, but Directus generates its schema from database tables while PayloadCMS generates it from your collection config — test both after schema changes.
- CI setup for PayloadCMS needs MongoDB (or Postgres); Directus needs its own Docker container with a database.
Choosing between PayloadCMS and Directus often comes down to your team's preference for code-first versus UI-first configuration. But from a testing perspective, the differences are more fundamental. PayloadCMS is a Node.js library you embed in your application — you get a local API that bypasses HTTP entirely. Directus is a standalone service you run separately — every test call goes through HTTP. This shapes everything from test setup to access control validation.
This guide compares both platforms side by side across the key testing scenarios you will encounter in production projects.
Architecture and Test Setup
PayloadCMS
PayloadCMS is embedded in your Node.js application. It initializes with your collection config and exposes a local API directly callable from your test suite:
// tests/setup.ts — PayloadCMS
import payload from 'payload';
import { mongooseAdapter } from '@payloadcms/db-mongodb';
import config from '../payload.config';
export default async function globalSetup() {
await payload.init({
...config,
db: mongooseAdapter({ url: 'mongodb://localhost:27017/test' }),
secret: 'test-secret',
local: true, // no Express server
});
}Tests call the local API directly:
const post = await payload.create({
collection: 'posts',
data: { title: 'Hello', status: 'draft' },
overrideAccess: true,
});No HTTP, no authentication tokens, no network latency. The local API is the fastest way to test CMS logic.
Directus
Directus is a standalone Docker container. Your tests call it over HTTP using the Directus SDK or raw fetch:
# docker-compose.test.yml — Directus
services:
directus:
image: directus/directus:10.13
ports:
- "8055:8055"
environment:
SECRET: test-secret
ADMIN_EMAIL: admin@test.com
ADMIN_PASSWORD: test-admin-password
DB_CLIENT: sqlite3
DB_FILENAME: /directus/database/test.db// tests/setup.ts — Directus
import { createDirectus, rest, authentication } from '@directus/sdk';
const client = createDirectus('http://localhost:8055')
.with(authentication('json'))
.with(rest());
await client.login('admin@test.com', 'test-admin-password');Every test call is an HTTP request. This is slower but tests the full stack including authentication middleware, request parsing, and response serialization — which is also what your production code sees.
CRUD Testing
PayloadCMS CRUD
// PayloadCMS — local API
describe('Posts CRUD', () => {
it('creates, reads, updates, and deletes a post', async () => {
const created = await payload.create({
collection: 'posts',
data: { title: 'Test Post', status: 'draft' },
overrideAccess: true,
});
expect(created.id).toBeDefined();
const read = await payload.findByID({
collection: 'posts',
id: created.id,
overrideAccess: true,
});
expect(read.title).toBe('Test Post');
const updated = await payload.update({
collection: 'posts',
id: created.id,
data: { title: 'Updated Post' },
overrideAccess: true,
});
expect(updated.title).toBe('Updated Post');
await payload.delete({ collection: 'posts', id: created.id, overrideAccess: true });
await expect(
payload.findByID({ collection: 'posts', id: created.id, overrideAccess: true })
).rejects.toThrow();
});
});Directus CRUD
// Directus — HTTP via SDK
import { createItem, readItem, updateItem, deleteItem } from '@directus/sdk';
describe('Articles CRUD', () => {
it('creates, reads, updates, and deletes an article', async () => {
const created = await client.request(
createItem('articles', { title: 'Test Article', status: 'draft' })
);
expect(created.id).toBeDefined();
const read = await client.request(readItem('articles', created.id));
expect(read.title).toBe('Test Article');
const updated = await client.request(
updateItem('articles', created.id, { title: 'Updated Article' })
);
expect(updated.title).toBe('Updated Article');
await client.request(deleteItem('articles', created.id));
await expect(client.request(readItem('articles', created.id))).rejects.toThrow();
});
});The patterns are similar. The main difference is overrideAccess: true in PayloadCMS (which bypasses access control for tests that should not be testing it) versus using an admin token in Directus (which always goes through the permission layer).
Access Control Testing
This is where the architectural difference is most pronounced.
PayloadCMS Access Control
Access control is a TypeScript function in your collection config. Unit-test it directly:
// collections/Posts.ts
export const isPublishedOrOwner: Access = ({ req: { user } }) => {
if (!user) return { status: { equals: 'published' } };
if (user.role === 'admin') return true;
return { or: [{ status: { equals: 'published' } }, { author: { equals: user.id } }] };
};
// tests/access/posts.test.ts
import { isPublishedOrOwner } from '../../collections/Posts';
it('returns a where clause for unauthenticated requests', () => {
expect(isPublishedOrOwner({ req: { user: null } } as any))
.toEqual({ status: { equals: 'published' } });
});
it('returns true for admin users', () => {
expect(isPublishedOrOwner({ req: { user: { role: 'admin' } } } as any))
.toBe(true);
});For HTTP-level testing, use supertest with JWT tokens:
it('unauthenticated GET only returns published posts', async () => {
const res = await supertest(app).get('/api/posts');
expect(res.body.docs.every((p: any) => p.status === 'published')).toBe(true);
});Directus Access Control
Directus permissions are stored in the database and applied at the API layer. You cannot unit-test them as functions — you must test them via HTTP with different user tokens:
// tests/access/directus-policies.test.ts
describe('Directus access policies', () => {
let editorToken: string;
let viewerToken: string;
beforeAll(async () => {
// Login as different roles
const editorRes = await fetch('http://localhost:8055/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'editor@test.com', password: 'pass' }),
});
editorToken = (await editorRes.json()).data.access_token;
const viewerRes = await fetch('http://localhost:8055/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'viewer@test.com', password: 'pass' }),
});
viewerToken = (await viewerRes.json()).data.access_token;
});
it('viewer cannot create articles', async () => {
const res = await fetch('http://localhost:8055/items/articles', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${viewerToken}`,
},
body: JSON.stringify({ title: 'Sneaky Article' }),
});
expect(res.status).toBe(403);
});
it('viewer only sees published articles', async () => {
const res = await fetch('http://localhost:8055/items/articles', {
headers: { Authorization: `Bearer ${viewerToken}` },
});
const data = await res.json();
expect(data.data.every((a: any) => a.status === 'published')).toBe(true);
});
});The Directus approach requires more setup (seeding roles and users into the test database) but tests the full permission evaluation path including any row-level filters defined in the Directus admin.
Lifecycle Hooks vs Flows
PayloadCMS Hooks
Hooks are TypeScript functions registered in your collection config. They are synchronous from the perspective of the API call — a beforeChange hook runs and returns before the response is sent.
// Unit test the hook function directly
import { slugifyTitle } from '../../hooks/slugify';
it('generates a slug from the title', async () => {
const result = await slugifyTitle({
data: { title: 'My Great Post' },
req: {} as any,
collection: {} as any,
context: {},
operation: 'create',
previousDoc: {},
});
expect(result.slug).toBe('my-great-post');
});
// Integration test: confirm the hook fires
it('hook runs on create and persists slug', async () => {
const post = await payload.create({
collection: 'posts',
data: { title: 'My Great Post' },
overrideAccess: true,
});
expect(post.slug).toBe('my-great-post');
});Directus Flows
Flows are visual automation pipelines configured in the Directus admin. They run asynchronously after the triggering event. Testing them requires triggering the event and then waiting for the side effect:
// Trigger a Flow via webhook and assert the result
it('notification Flow creates a record when triggered', async () => {
const beforeRes = await fetch('http://localhost:8055/items/notifications', {
headers: { Authorization: `Bearer ${adminToken}` },
});
const beforeCount = (await beforeRes.json()).data.length;
// Trigger the Flow
await fetch('http://localhost:8055/flows/trigger/your-webhook-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event: 'comment_posted', article_id: 1 }),
});
// Flows are async — poll until the side effect appears
let afterCount = beforeCount;
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 500));
const afterRes = await fetch('http://localhost:8055/items/notifications', {
headers: { Authorization: `Bearer ${adminToken}` },
});
afterCount = (await afterRes.json()).data.length;
if (afterCount > beforeCount) break;
}
expect(afterCount).toBe(beforeCount + 1);
});The async nature of Flows is the biggest testing challenge Directus has compared to PayloadCMS hooks. You need either polling or a deterministic wait based on your Flow's execution time.
GraphQL Testing
Both platforms expose a GraphQL API. The testing approach is the same — raw HTTP queries — but the schema generation differs.
PayloadCMS generates GraphQL from your TypeScript collection config. Directus generates it from database table introspection. This means a Directus schema change (adding a column) is immediately reflected in GraphQL without a code deploy.
// GraphQL test — works for both platforms
async function gql(url: string, query: string, token?: string) {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ query }),
});
return res.json();
}
// PayloadCMS
it('PayloadCMS GraphQL returns published posts', async () => {
const result = await gql('http://localhost:3000/api/graphql', `
query { Posts(where: { status: { equals: published } }) {
docs { id title status }
}}
`);
expect(result.errors).toBeUndefined();
expect(result.data.Posts.docs.every((p: any) => p.status === 'published')).toBe(true);
});
// Directus
it('Directus GraphQL returns published articles', async () => {
const result = await gql('http://localhost:8055/graphql', `
query { articles(filter: { status: { _eq: "published" } }) {
id title status
}}
`, adminToken);
expect(result.errors).toBeUndefined();
expect(result.data.articles.every((a: any) => a.status === 'published')).toBe(true);
});CI Setup Comparison
PayloadCMS CI
name: PayloadCMS Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
services:
mongo:
image: mongo:7
ports: ["27017:27017"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm test
env:
MONGO_TEST_URL: mongodb://localhost:27017/test
PAYLOAD_SECRET: ci-secretDirectus CI
name: Directus Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
services:
directus:
image: directus/directus:10.13
ports: ["8055:8055"]
env:
SECRET: ci-secret
ADMIN_EMAIL: admin@test.com
ADMIN_PASSWORD: ci-admin-pass
DB_CLIENT: sqlite3
DB_FILENAME: /directus/database/test.db
options: >-
--health-cmd "wget --quiet --tries=1 --spider http://localhost:8055/server/health || exit 1"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm test
env:
DIRECTUS_URL: http://localhost:8055
DIRECTUS_ADMIN_EMAIL: admin@test.com
DIRECTUS_ADMIN_PASSWORD: ci-admin-passDirectus CI setup is slightly more complex because you need the health check to ensure Directus is fully ready before tests begin. PayloadCMS initializes within the test process itself, so there is no race condition.
When to Choose Which Approach
Use PayloadCMS when your team prefers code-first configuration and wants fast, in-process test execution. Access control and hook logic are TypeScript functions you can unit-test in milliseconds.
Use Directus when your team prefers a UI-first approach and you want non-developers to configure the CMS. The trade-off is that all tests go over HTTP and permission changes require database-level seeding to test.
Both platforms benefit from the same higher-level testing tools. HelpMeTest works with any headless CMS by testing the frontend application that consumes the CMS API. Write your editorial and content delivery workflows in plain English — "editor creates a post, publisher approves it, reader sees it on the homepage" — and HelpMeTest runs those as real browser tests on every deploy, regardless of whether your CMS is PayloadCMS, Directus, or anything else.
Summary
PayloadCMS and Directus have different testing profiles driven by their architecture. PayloadCMS's embedded local API enables fast, in-process tests with direct function calls and overrideAccess bypasses. Directus's standalone server requires HTTP-based tests for everything, which is slower but more realistic. Access control is the sharpest difference: PayloadCMS access functions are testable in pure unit tests, while Directus permissions require role-based integration tests against a live instance. Flows require async polling in tests; PayloadCMS hooks are synchronous and easier to assert. Both fit neatly into GitHub Actions CI with minor differences in infrastructure setup.