Directus Testing Guide: Flows, Extensions, and Access Policies
Directus is a headless CMS with a powerful automation layer (Flows), a first-class extension system, and a policy-based access control model. Testing it means validating your REST and GraphQL API behavior, ensuring Flows trigger correctly and produce expected side effects, and confirming that access policies enforce the right data restrictions. This guide walks through all three layers.
Key Takeaways
- Test access policies by creating users in each role and asserting which collections and fields they can read or write.
- Flows are event-driven pipelines — test them by triggering the event (HTTP webhook or collection mutation) and asserting the downstream state.
- Custom extensions (hooks, endpoints, operations) should have their own unit tests decoupled from the Directus runtime.
- Use Directus's SDK (@directus/sdk) in tests for type-safe API calls that catch schema drift early.
- Run Directus in Docker against a fresh SQLite or Postgres database for reproducible integration tests in CI.
Directus is increasingly popular as a headless CMS and data platform because it works with any SQL database and exposes every table through a clean REST and GraphQL API automatically. But its breadth — collections, relationships, Flows automation, extension hooks, fine-grained permission policies — means there is a lot to test. This guide provides a systematic approach.
Test Environment Setup
Directus ships as a Docker image, which makes it straightforward to spin up a clean instance for tests. Use SQLite for local development tests (fast, no external dependency) and Postgres for CI parity with production.
# docker-compose.test.yml
version: "3.8"
services:
directus:
image: directus/directus:10.13
ports:
- "8055:8055"
environment:
SECRET: test-secret-not-for-production
ADMIN_EMAIL: admin@test.com
ADMIN_PASSWORD: test-admin-password
DB_CLIENT: sqlite3
DB_FILENAME: /directus/database/test.db
EXTENSIONS_PATH: /directus/extensions
volumes:
- ./extensions:/directus/extensions
healthcheck:
test: wget --quiet --tries=1 --spider http://localhost:8055/server/health || exit 1
interval: 5s
timeout: 3s
retries: 10In your Jest global setup, wait for Directus to be ready before running any tests:
// tests/setup.ts
import { createDirectus, rest, authentication } from '@directus/sdk';
const client = createDirectus('http://localhost:8055').with(rest());
export async function waitForDirectus(maxWaitMs = 30000) {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
try {
const res = await fetch('http://localhost:8055/server/health');
if (res.ok) return;
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error('Directus did not become healthy in time');
}
export async function getAdminClient() {
const c = createDirectus('http://localhost:8055')
.with(authentication('json'))
.with(rest());
await c.login('admin@test.com', 'test-admin-password');
return c;
}Testing REST API Endpoints
Every Directus collection exposes standard REST endpoints. Use @directus/sdk for type-safe assertions:
// tests/collections/articles.test.ts
import { createDirectus, rest, authentication,
readItems, createItem, updateItem, deleteItem, readItem } from '@directus/sdk';
type Article = {
id: number;
title: string;
status: 'draft' | 'published' | 'archived';
slug: string;
};
describe('Articles collection', () => {
let client: ReturnType<typeof createDirectus>;
let createdId: number;
beforeAll(async () => {
client = createDirectus<{ articles: Article }>('http://localhost:8055')
.with(authentication('json'))
.with(rest());
await client.login('admin@test.com', 'test-admin-password');
});
it('creates an article', async () => {
const article = await client.request(
createItem('articles', { title: 'Test Article', status: 'draft', slug: 'test-article' })
);
expect(article.id).toBeDefined();
expect(article.title).toBe('Test Article');
createdId = article.id;
});
it('reads an article by id', async () => {
const article = await client.request(readItem('articles', createdId));
expect(article.slug).toBe('test-article');
});
it('updates the article status', async () => {
const updated = await client.request(
updateItem('articles', createdId, { status: 'published' })
);
expect(updated.status).toBe('published');
});
it('filters articles by status', async () => {
const results = await client.request(
readItems('articles', { filter: { status: { _eq: 'published' } } })
);
expect(results.every((a) => a.status === 'published')).toBe(true);
});
afterAll(async () => {
if (createdId) {
await client.request(deleteItem('articles', createdId));
}
});
});Testing GraphQL Queries
Directus exposes a GraphQL endpoint at /graphql. Test it with raw fetch calls to catch schema issues:
// tests/graphql/articles.test.ts
async function gql(query: string, variables = {}, token?: string) {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch('http://localhost:8055/graphql', {
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
});
return res.json();
}
describe('Articles GraphQL', () => {
let adminToken: string;
beforeAll(async () => {
const res = await fetch('http://localhost:8055/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@test.com', password: 'test-admin-password' }),
});
const data = await res.json();
adminToken = data.data.access_token;
});
it('queries published articles', async () => {
const result = await gql(
`query PublishedArticles($status: String!) {
articles(filter: { status: { _eq: $status } }) {
id
title
status
}
}`,
{ status: 'published' },
adminToken
);
expect(result.errors).toBeUndefined();
expect(Array.isArray(result.data.articles)).toBe(true);
});
it('returns null for unknown article id', async () => {
const result = await gql(
`query { articles_by_id(id: 999999) { id title } }`,
{},
adminToken
);
expect(result.data.articles_by_id).toBeNull();
});
});Testing Access Policies
Directus uses a role-based permission system. Each role defines which collections it can access and which operations (read, create, update, delete) are permitted. Permissions can also filter by field values (e.g., a user can only read their own records).
Test access policies by creating test users in each role and asserting the API response code:
// tests/access/policies.test.ts
describe('Access policies', () => {
let editorToken: string;
let viewerToken: string;
beforeAll(async () => {
// Assume test users are pre-seeded in the test database
const editorLogin = await fetch('http://localhost:8055/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'editor@test.com', password: 'editor-pass' }),
});
editorToken = (await editorLogin.json()).data.access_token;
const viewerLogin = await fetch('http://localhost:8055/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'viewer@test.com', password: 'viewer-pass' }),
});
viewerToken = (await viewerLogin.json()).data.access_token;
});
it('editor can create articles', async () => {
const res = await fetch('http://localhost:8055/items/articles', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${editorToken}`,
},
body: JSON.stringify({ title: 'Editor Article', status: 'draft' }),
});
expect(res.status).toBe(200);
});
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', status: 'published' }),
});
expect(res.status).toBe(403);
});
it('unauthenticated request returns 401 for private collections', async () => {
const res = await fetch('http://localhost:8055/items/articles');
expect(res.status).toBe(401);
});
it('viewer can read published articles but not draft ones', async () => {
const res = await fetch('http://localhost:8055/items/articles?filter[status][_eq]=published', {
headers: { Authorization: `Bearer ${viewerToken}` },
});
const data = await res.json();
expect(res.status).toBe(200);
// All returned articles should be published
expect(data.data.every((a: any) => a.status === 'published')).toBe(true);
});
});Testing Directus Flows
Flows are Directus's built-in automation system. A Flow consists of a trigger (webhook, schedule, event hook) and a chain of operations (run script, send email, transform data, call HTTP endpoint). Testing Flows requires triggering the event and then asserting the downstream state.
For a Flow triggered by a webhook that creates a notification record when a comment is posted:
// tests/flows/new-comment-notification.test.ts
describe('New comment notification Flow', () => {
let adminToken: string;
beforeAll(async () => {
const res = await fetch('http://localhost:8055/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@test.com', password: 'test-admin-password' }),
});
adminToken = (await res.json()).data.access_token;
});
it('creates a notification when a comment is posted via webhook', async () => {
// Count notifications before
const beforeRes = await fetch('http://localhost:8055/items/notifications', {
headers: { Authorization: `Bearer ${adminToken}` },
});
const beforeCount = (await beforeRes.json()).data.length;
// Trigger the Flow webhook
const flowRes = await fetch('http://localhost:8055/flows/trigger/your-flow-webhook-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment: 'Great article!', article_id: 1, author: 'reader@test.com' }),
});
expect(flowRes.status).toBe(200);
// Give the Flow time to process (Flows run asynchronously)
await new Promise((r) => setTimeout(r, 1000));
// Assert a notification was created
const afterRes = await fetch('http://localhost:8055/items/notifications', {
headers: { Authorization: `Bearer ${adminToken}` },
});
const afterCount = (await afterRes.json()).data.length;
expect(afterCount).toBe(beforeCount + 1);
});
});For event-triggered Flows (e.g., items.create on a collection), create the item and assert side effects:
it('sends a welcome email Flow when a member is created', async () => {
const emailServiceSpy = jest.spyOn(emailService, 'send');
await fetch('http://localhost:8055/items/members', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${adminToken}`,
},
body: JSON.stringify({ email: 'newmember@test.com', name: 'New Member' }),
});
await new Promise((r) => setTimeout(r, 500));
expect(emailServiceSpy).toHaveBeenCalledWith(
expect.objectContaining({ to: 'newmember@test.com', subject: expect.stringContaining('Welcome') })
);
});Testing Custom Extensions
Directus extensions — custom API endpoints, hooks, and operations — are Node.js modules that follow a defined interface. Because they are plain JavaScript/TypeScript functions, they are easy to unit test.
// extensions/endpoints/publish-article/index.ts
import type { EndpointConfig } from '@directus/extensions';
export default {
id: 'publish-article',
handler: (router, { services, exceptions }) => {
const { ItemsService } = services;
const { ForbiddenException } = exceptions;
router.post('/:id/publish', async (req, res) => {
if (!req.accountability?.admin) {
throw new ForbiddenException();
}
const service = new ItemsService('articles', { accountability: req.accountability, schema: req.schema });
await service.updateOne(req.params.id, { status: 'published', published_at: new Date() });
res.json({ success: true });
});
},
} satisfies EndpointConfig;Test the business logic extracted from the handler:
// tests/extensions/publish-article.test.ts
describe('publish article logic', () => {
it('rejects non-admin requests', async () => {
const mockReq = { accountability: { admin: false }, params: { id: '1' } };
// ... test that ForbiddenException is thrown
});
it('sets status to published and adds timestamp', async () => {
const mockService = { updateOne: jest.fn().mockResolvedValue({}) };
const id = '42';
await mockService.updateOne(id, expect.objectContaining({
status: 'published',
published_at: expect.any(Date),
}));
expect(mockService.updateOne).toHaveBeenCalledWith('42', expect.objectContaining({ status: 'published' }));
});
});Browser-Level Testing with HelpMeTest
API tests confirm your data layer is solid, but the Directus admin UI — custom interfaces, inline editing, Flow visualization — benefits from browser-level testing. HelpMeTest runs E2E tests written in plain English against a real browser, so your editorial team's workflows are verified on every deploy without anyone writing Playwright or Selenium scripts.
Summary
Testing Directus requires three complementary strategies: REST and GraphQL API tests to verify collection behavior and schema correctness, access policy tests to confirm role-based restrictions are enforced at the HTTP layer, and Flow tests that trigger events and assert downstream state changes. Custom extensions are best tested with extracted unit tests. Combined with a Dockerized test environment and a CI pipeline, these patterns give you a safety net that lets you evolve your Directus configuration and extensions confidently.