ทำไมต้องทำ Testing Strategy สำหรับ Directus CMS
Directus เป็น headless CMS แบบ open source ที่สร้าง REST และ GraphQL API จาก database โดยตรง เมื่อใช้ Directus เป็น backend ให้กับ production app ต้องมี testing strategy ที่ครอบคลุมทั้ง API layer, custom extensions, data validation และ integration กับ frontend เพราะ Directus ให้อิสระในการออกแบบ schema มาก ถ้าไม่มี test รองรับจะเจอปัญหา regression ทุกครั้งที่ upgrade version หรือเปลี่ยน schema
ติดตั้ง Directus สำหรับ Testing Environment
# docker-compose.yml สำหรับ test environment
version: '3.8'
services:
directus:
image: directus/directus:10.10.0
ports:
- "8055:8055"
environment:
KEY: "test-key-not-for-production"
SECRET: "test-secret-not-for-production"
ADMIN_EMAIL: "admin@test.local"
ADMIN_PASSWORD: "TestPassword123!"
DB_CLIENT: "pg"
DB_HOST: "postgres"
DB_PORT: "5432"
DB_DATABASE: "directus_test"
DB_USER: "directus"
DB_PASSWORD: "directus_test_pass"
CACHE_ENABLED: "false"
RATE_LIMITER_ENABLED: "false"
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: directus_test
POSTGRES_USER: directus
POSTGRES_PASSWORD: directus_test_pass
healthcheck:
test: ["CMD-SHELL", "pg_isready -U directus"]
interval: 5s
timeout: 3s
retries: 5
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
# รัน test environment
docker compose up -d
# รอจน Directus พร้อม
until curl -s http://localhost:8055/server/health | grep -q '"status":"ok"'; do
echo "Waiting for Directus..."
sleep 2
done
echo "Directus is ready"
API Testing ด้วย Jest และ Axios
ทดสอบ Directus API endpoints ทั้ง CRUD operations, filtering, permissions และ custom endpoints
# ติดตั้ง dependencies สำหรับ test
npm init -y
npm install --save-dev jest @types/jest ts-jest axios dotenv
npm install --save-dev @directus/sdk
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testTimeout: 30000,
setupFilesAfterSetup: ['./tests/setup.ts'],
testMatch: ['**/tests/**/*.test.ts'],
};
// tests/setup.ts
import axios from 'axios';
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://localhost:8055';
export async function getAdminToken(): Promise<string> {
const resp = await axios.post(`/auth/login`, {
email: 'admin@test.local',
password: 'TestPassword123!',
});
return resp.data.data.access_token;
}
export async function setupTestCollection(token: string) {
const api = axios.create({
baseURL: DIRECTUS_URL,
headers: { Authorization: `Bearer ` },
});
// สร้าง collection สำหรับ test
try {
await api.post('/collections', {
collection: 'test_articles',
meta: { icon: 'article', note: 'Test collection' },
schema: {},
fields: [
{ field: 'id', type: 'integer', meta: { hidden: true, interface: 'input' }, schema: { is_primary_key: true, has_auto_increment: true } },
{ field: 'title', type: 'string', meta: { interface: 'input' }, schema: { is_nullable: false } },
{ field: 'content', type: 'text', meta: { interface: 'input-rich-text-md' } },
{ field: 'status', type: 'string', meta: { interface: 'select-dropdown', options: { choices: [{ text: 'Draft', value: 'draft' }, { text: 'Published', value: 'published' }] } }, schema: { default_value: 'draft' } },
{ field: 'publish_date', type: 'timestamp', meta: { interface: 'datetime' } },
],
});
} catch (e: any) {
if (e.response?.status !== 400) throw e; // 400 = already exists
}
}
// tests/crud.test.ts
import axios, { AxiosInstance } from 'axios';
import { getAdminToken, setupTestCollection } from './setup';
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://localhost:8055';
let api: AxiosInstance;
let createdId: number;
beforeAll(async () => {
const token = await getAdminToken();
api = axios.create({
baseURL: DIRECTUS_URL,
headers: { Authorization: `Bearer ` },
});
await setupTestCollection(token);
});
describe('Directus CRUD Operations', () => {
test('CREATE - สร้าง article ใหม่', async () => {
const resp = await api.post('/items/test_articles', {
title: 'ทดสอบบทความ',
content: 'เนื้อหาทดสอบ',
status: 'draft',
});
expect(resp.status).toBe(200);
expect(resp.data.data.title).toBe('ทดสอบบทความ');
createdId = resp.data.data.id;
});
test('READ - อ่าน article ที่สร้าง', async () => {
const resp = await api.get(`/items/test_articles/`);
expect(resp.status).toBe(200);
expect(resp.data.data.id).toBe(createdId);
expect(resp.data.data.status).toBe('draft');
});
test('UPDATE - แก้ไข title', async () => {
const resp = await api.patch(`/items/test_articles/`, {
title: 'บทความที่แก้ไขแล้ว',
status: 'published',
});
expect(resp.status).toBe(200);
expect(resp.data.data.title).toBe('บทความที่แก้ไขแล้ว');
});
test('FILTER - ค้นหาด้วย filter', async () => {
const resp = await api.get('/items/test_articles', {
params: {
'filter[status][_eq]': 'published',
'fields': 'id, title, status',
'sort': '-id',
'limit': 10,
},
});
expect(resp.status).toBe(200);
expect(resp.data.data.length).toBeGreaterThan(0);
expect(resp.data.data[0].status).toBe('published');
});
test('DELETE - ลบ article', async () => {
const resp = await api.delete(`/items/test_articles/`);
expect(resp.status).toBe(204);
// ยืนยันว่าลบแล้ว
try {
await api.get(`/items/test_articles/`);
fail('Should have thrown 403/404');
} catch (e: any) {
expect([403, 404]).toContain(e.response.status);
}
});
});
Permission Testing — ทดสอบระบบ Access Control
Directus มี granular permission system ที่ต้องทดสอบให้ครบทุก role เพื่อป้องกัน data leak
// tests/permissions.test.ts
import axios, { AxiosInstance } from 'axios';
import { getAdminToken } from './setup';
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://localhost:8055';
let adminApi: AxiosInstance;
let editorToken: string;
let editorApi: AxiosInstance;
beforeAll(async () => {
const adminToken = await getAdminToken();
adminApi = axios.create({
baseURL: DIRECTUS_URL,
headers: { Authorization: `Bearer ` },
});
// สร้าง editor role
const roleResp = await adminApi.post('/roles', {
name: 'Editor',
icon: 'edit',
admin_access: false,
app_access: true,
});
const roleId = roleResp.data.data.id;
// กำหนด permissions - editor อ่านได้ทุก article แต่แก้ได้เฉพาะ draft
await adminApi.post('/permissions', {
role: roleId,
collection: 'test_articles',
action: 'read',
fields: ['*'],
});
await adminApi.post('/permissions', {
role: roleId,
collection: 'test_articles',
action: 'update',
fields: ['title', 'content'],
permissions: { status: { _eq: 'draft' } },
});
// สร้าง editor user
await adminApi.post('/users', {
email: 'editor@test.local',
password: 'EditorPass123!',
role: roleId,
});
// login เป็น editor
const loginResp = await axios.post(`/auth/login`, {
email: 'editor@test.local',
password: 'EditorPass123!',
});
editorToken = loginResp.data.data.access_token;
editorApi = axios.create({
baseURL: DIRECTUS_URL,
headers: { Authorization: `Bearer ` },
});
});
describe('Permission Tests', () => {
test('Editor ไม่สามารถสร้าง article ได้', async () => {
try {
await editorApi.post('/items/test_articles', { title: 'Hacked!' });
fail('Should not allow create');
} catch (e: any) {
expect(e.response.status).toBe(403);
}
});
test('Editor ไม่สามารถแก้ published article ได้', async () => {
// admin สร้าง published article
const resp = await adminApi.post('/items/test_articles', {
title: 'Published Article',
status: 'published',
});
const id = resp.data.data.id;
try {
await editorApi.patch(`/items/test_articles/`, {
title: 'Hacked Title',
});
fail('Should not allow editing published articles');
} catch (e: any) {
expect(e.response.status).toBe(403);
}
});
test('Editor สามารถแก้ draft article ได้', async () => {
const resp = await adminApi.post('/items/test_articles', {
title: 'Draft Article',
status: 'draft',
});
const id = resp.data.data.id;
const updateResp = await editorApi.patch(`/items/test_articles/`, {
title: 'Updated Draft',
});
expect(updateResp.status).toBe(200);
});
});
Load Testing ด้วย k6
ทดสอบว่า Directus API รับ load ได้เพียงพอก่อน deploy production
// load-test.js (k6 script)
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 20 }, // ramp up
{ duration: '1m', target: 50 }, // hold
{ duration: '30s', target: 100 }, // spike
{ duration: '30s', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% ต้องเร็วกว่า 500ms
http_req_failed: ['rate<0.01'], // error rate < 1%
},
};
const BASE = 'http://localhost:8055';
let TOKEN;
export function setup() {
const loginRes = http.post(`/auth/login`, JSON.stringify({
email: 'admin@test.local',
password: 'TestPassword123!',
}), { headers: { 'Content-Type': 'application/json' } });
return { token: loginRes.json().data.access_token };
}
export default function (data) {
const headers = {
Authorization: `Bearer `,
'Content-Type': 'application/json',
};
// อ่าน articles list
const listRes = http.get(`/items/test_articles?limit=20&sort=-id`, { headers });
check(listRes, {
'list status 200': (r) => r.status === 200,
'list has data': (r) => r.json().data.length > 0,
});
// อ่าน single article
const items = listRes.json().data;
if (items.length > 0) {
const id = items[Math.floor(Math.random() * items.length)].id;
const getRes = http.get(`/items/test_articles/`, { headers });
check(getRes, { 'get status 200': (r) => r.status === 200 });
}
sleep(0.5);
}
# รัน load test
k6 run load-test.js
# ตัวอย่าง output
# ✓ list status 200
# ✓ list has data
# ✓ get status 200
#
# http_req_duration..........: avg=45ms min=12ms med=38ms max=890ms p(90)=120ms p(95)=230ms
# http_req_failed............: 0.00% ✓ 0 ✗ 4521
# iterations.................: 4521 75.35/s
CI/CD Pipeline สำหรับ Directus Testing
# .github/workflows/test-directus.yml
name: Directus API Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: directus_test
POSTGRES_USER: directus
POSTGRES_PASSWORD: directus_test_pass
ports: ['5432:5432']
options: --health-cmd="pg_isready" --health-interval=5s --health-timeout=3s --health-retries=5
steps:
- uses: actions/checkout@v4
- name: Start Directus
run: |
docker run -d --name directus --network host \
-e KEY=test-key -e SECRET=test-secret \
-e ADMIN_EMAIL=admin@test.local \
-e ADMIN_PASSWORD=TestPassword123! \
-e DB_CLIENT=pg -e DB_HOST=localhost \
-e DB_PORT=5432 -e DB_DATABASE=directus_test \
-e DB_USER=directus -e DB_PASSWORD=directus_test_pass \
directus/directus:10.10.0
- name: Wait for Directus
run: |
for i in $(seq 1 30); do
curl -s http://localhost:8055/server/health && break
sleep 2
done
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
FAQ — คำถามที่พบบ่อย
Q: ควรใช้ Directus SDK หรือ Axios ในการเขียน test?
A: ถ้าต้องการทดสอบ API behavior ตรงๆ ให้ใช้ Axios เพราะเห็น request/response ชัดเจน ถ้าต้องการทดสอบ business logic ที่ใช้ SDK อยู่แล้วให้ใช้ SDK เพื่อให้ test ใกล้เคียง production code
Q: ทำ snapshot testing กับ Directus schema ได้ไหม?
A: ได้ ใช้คำสั่ง npx directus schema snapshot ./snapshot.yaml เพื่อ export schema แล้วเก็บใน git ตอน CI ให้เปรียบเทียบกับ snapshot ก่อนหน้าเพื่อตรวจจับ schema change ที่ไม่ตั้งใจ
Q: ทดสอบ Directus Flows (automation) อย่างไร?
A: สร้าง test ที่ trigger event แล้วตรวจ side effect เช่น สร้าง item แล้วเช็คว่า Flow ส่ง webhook หรือสร้าง related item ตามที่ตั้งไว้หรือไม่ ใช้ mock server อย่าง msw หรือ nock รับ webhook request ระหว่าง test
Q: ควร test migration ของ Directus อย่างไร?
A: ใช้ npx directus schema apply ./snapshot.yaml กับ test database ก่อน apply กับ production ตรวจสอบว่า migration ไม่ทำ data loss โดยสร้าง seed data ก่อน migrate แล้วเช็คว่า data ยังครบอยู่
