Directus Testing Guide: Flows, Extensions, and Access Policies

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

  1. Test access policies by creating users in each role and asserting which collections and fields they can read or write.
  2. Flows are event-driven pipelines — test them by triggering the event (HTTP webhook or collection mutation) and asserting the downstream state.
  3. Custom extensions (hooks, endpoints, operations) should have their own unit tests decoupled from the Directus runtime.
  4. Use Directus's SDK (@directus/sdk) in tests for type-safe API calls that catch schema drift early.
  5. 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: 10

In 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.

Read more