Testing
API Testing Guide
Comprehensive guide to testing applications built with the Cloudillo API.
Testing Strategy
Testing Pyramid
/\
/E2E\ Few, slow, expensive
/------\
/ API \ More, medium speed
/----------\
/ Unit Tests \ Many, fast, cheap
/--------------\Unit Tests: Test individual functions and components in isolation API/Integration Tests: Test API interactions and service integration E2E Tests: Test complete user workflows
Unit Testing
Testing API Client Functions
import { describe, test, expect, vi } from 'vitest'
import { ActionService } from './action-service'
describe('ActionService', () => {
test('formats action query parameters correctly', () => {
const service = new ActionService(mockClient)
const query = service.buildQuery({
type: 'POST',
status: 'A',
_limit: 20,
_offset: 0
})
expect(query.toString()).toBe('type=POST&status=A&_limit=20&_offset=0')
})
test('converts timestamps to Unix seconds', () => {
const service = new ActionService(mockClient)
const action = service.prepareAction({
type: 'POST',
content: { text: 'Hello' },
published: new Date('2025-01-01T00:00:00Z')
})
expect(action.published).toBe(1735689600)
})
test('validates action type', () => {
const service = new ActionService(mockClient)
expect(() => {
service.validateAction({ type: 'INVALID' })
}).toThrow('Invalid action type')
})
})Testing State Management
import { describe, test, expect } from 'vitest'
import { TokenManager } from './token-manager'
describe('TokenManager', () => {
test('detects expired tokens', () => {
const manager = new TokenManager()
// Token expired 1 hour ago
const expiredToken = createToken({ exp: Math.floor(Date.now() / 1000) - 3600 })
expect(manager.isExpired(expiredToken)).toBe(true)
})
test('schedules refresh before expiry', () => {
const manager = new TokenManager()
// Token expires in 20 minutes
const token = createToken({ exp: Math.floor(Date.now() / 1000) + 1200 })
const refreshTime = manager.getRefreshTime(token)
const now = Date.now()
// Should refresh in ~10 minutes (10 min before expiry)
expect(refreshTime).toBeGreaterThan(now)
expect(refreshTime).toBeLessThan(now + 11 * 60 * 1000)
})
test('caches tokens by scope', () => {
const manager = new TokenManager()
manager.setToken('read', 'token1')
manager.setToken('write', 'token2')
expect(manager.getToken('read')).toBe('token1')
expect(manager.getToken('write')).toBe('token2')
})
})Testing Utility Functions
import { describe, test, expect } from 'vitest'
import { validateIdTag, parseActionId, formatTimestamp } from './utils'
describe('Utility Functions', () => {
test('validates idTag format', () => {
expect(validateIdTag('alice@example.com')).toBe(true)
expect(validateIdTag('invalid')).toBe(false)
expect(validateIdTag('@example.com')).toBe(false)
})
test('parses action ID components', () => {
const parsed = parseActionId('act_abc123')
expect(parsed.prefix).toBe('act')
expect(parsed.id).toBe('abc123')
})
test('formats Unix timestamp to readable date', () => {
const timestamp = 1735689600 // 2025-01-01 00:00:00 UTC
const formatted = formatTimestamp(timestamp)
expect(formatted).toBe('2025-01-01 00:00:00')
})
})Integration Testing
Mocking Fetch Requests
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'
import { CloudilloClient } from './client'
describe('CloudilloClient Integration', () => {
let client: CloudilloClient
beforeEach(() => {
global.fetch = vi.fn()
client = new CloudilloClient('http://localhost:3000', tokenManager)
})
afterEach(() => {
vi.restoreAllMocks()
})
test('creates action via API', async () => {
const mockResponse = {
data: {
actionId: 'act_123',
type: 'POST',
content: { text: 'Hello' }
},
time: 1735689600,
reqId: 'req_abc'
}
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse
})
const result = await client.actions.create({
type: 'POST',
content: { text: 'Hello' },
audience: ['public'],
published: 1735689600
})
expect(fetch).toHaveBeenCalledWith(
'http://localhost:3000/api/action',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'Authorization': expect.stringContaining('Bearer ')
})
})
)
expect(result.data.actionId).toBe('act_123')
})
test('handles API errors', async () => {
;(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({
error: {
code: 'E-AUTH-UNAUTH',
message: 'Unauthorized access'
}
})
})
await expect(
client.actions.list()
).rejects.toThrow('Unauthorized access')
})
test('retries on 5xx errors', async () => {
;(global.fetch as any)
.mockResolvedValueOnce({
ok: false,
status: 503,
json: async () => ({ error: { message: 'Service unavailable' } })
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: [] })
})
const result = await client.actions.list()
expect(fetch).toHaveBeenCalledTimes(2)
expect(result.data).toEqual([])
})
})Testing with MSW (Mock Service Worker)
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { describe, test, expect, beforeAll, afterAll, afterEach } from 'vitest'
const server = setupServer(
// Mock login endpoint
rest.post('http://localhost:3000/api/auth/login', (req, res, ctx) => {
const { email, password } = req.body as any
if (email === 'test@example.com' && password === 'password') {
return res(
ctx.json({
data: {
token: 'mock-jwt-token',
tenant: { idTag: 'test@example.com' }
}
})
)
}
return res(
ctx.status(401),
ctx.json({
error: {
code: 'E-AUTH-BADCRED',
message: 'Invalid credentials'
}
})
)
}),
// Mock actions list endpoint
rest.get('http://localhost:3000/api/action', (req, res, ctx) => {
const type = req.url.searchParams.get('type')
const limit = parseInt(req.url.searchParams.get('_limit') || '50')
return res(
ctx.json({
data: [
{
actionId: 'act_1',
type: type || 'POST',
content: { text: 'Test post' },
createdAt: 1735689600
}
],
pagination: {
total: 1,
offset: 0,
limit,
hasMore: false
}
})
)
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('API Integration with MSW', () => {
test('login flow', async () => {
const result = await fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'test@example.com',
password: 'password'
})
})
const data = await result.json()
expect(data.data.token).toBe('mock-jwt-token')
})
test('fetch filtered actions', async () => {
const result = await fetch('http://localhost:3000/api/action?type=POST&_limit=20')
const data = await result.json()
expect(data.data).toHaveLength(1)
expect(data.data[0].type).toBe('POST')
expect(data.pagination.limit).toBe(20)
})
})E2E Testing
Testing with Playwright
import { test, expect } from '@playwright/test'
test.describe('Cloudillo Social Features', () => {
test.beforeEach(async ({ page }) => {
// Login before each test
await page.goto('http://localhost:3000/login')
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'password')
await page.click('button[type="submit"]')
await page.waitForURL('http://localhost:3000/feed')
})
test('create a post', async ({ page }) => {
// Navigate to create post
await page.click('[data-testid="create-post-button"]')
// Fill in post content
await page.fill('[data-testid="post-textarea"]', 'This is a test post')
// Submit post
await page.click('[data-testid="submit-post-button"]')
// Wait for post to appear
await page.waitForSelector('[data-testid="post-item"]')
// Verify post content
const postContent = await page.textContent('[data-testid="post-content"]')
expect(postContent).toContain('This is a test post')
})
test('comment on a post', async ({ page }) => {
// Find first post
const firstPost = page.locator('[data-testid="post-item"]').first()
// Click comment button
await firstPost.locator('[data-testid="comment-button"]').click()
// Fill comment
await page.fill('[data-testid="comment-textarea"]', 'Great post!')
// Submit comment
await page.click('[data-testid="submit-comment-button"]')
// Verify comment appears
await page.waitForSelector('[data-testid="comment-item"]')
const commentText = await page.textContent('[data-testid="comment-content"]')
expect(commentText).toContain('Great post!')
})
test('upload file', async ({ page }) => {
await page.goto('http://localhost:3000/files')
// Upload file
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles('./test-fixtures/image.jpg')
// Wait for upload to complete
await page.waitForSelector('[data-testid="upload-success"]')
// Verify file appears in list
const fileName = await page.textContent('[data-testid="file-name"]')
expect(fileName).toContain('image.jpg')
})
test('real-time updates via WebSocket', async ({ page, context }) => {
// Open two pages
const page2 = await context.newPage()
await page2.goto('http://localhost:3000/feed')
// Create post on first page
await page.click('[data-testid="create-post-button"]')
await page.fill('[data-testid="post-textarea"]', 'Real-time test')
await page.click('[data-testid="submit-post-button"]')
// Verify post appears on second page (via WebSocket)
await page2.waitForSelector(':has-text("Real-time test")', { timeout: 5000 })
const postOnPage2 = await page2.textContent(':has-text("Real-time test")')
expect(postOnPage2).toContain('Real-time test')
})
})Testing API Directly in E2E
import { test, expect } from '@playwright/test'
test.describe('API E2E Tests', () => {
let token: string
test.beforeAll(async ({ request }) => {
// Login to get token
const response = await request.post('http://localhost:3000/api/auth/login', {
data: {
email: 'test@example.com',
password: 'password'
}
})
const data = await response.json()
token = data.data.token
})
test('complete post lifecycle', async ({ request }) => {
// Create post
const createResponse = await request.post('http://localhost:3000/api/action', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: {
type: 'POST',
content: { text: 'E2E test post' },
audience: ['public'],
published: Math.floor(Date.now() / 1000)
}
})
expect(createResponse.ok()).toBeTruthy()
const createData = await createResponse.json()
const actionId = createData.data.actionId
// Fetch post
const getResponse = await request.get(
`http://localhost:3000/api/action/${actionId}`,
{
headers: { 'Authorization': `Bearer ${token}` }
}
)
const getData = await getResponse.json()
expect(getData.data.content.text).toBe('E2E test post')
// Delete post
const deleteResponse = await request.delete(
`http://localhost:3000/api/action/${actionId}`,
{
headers: { 'Authorization': `Bearer ${token}` }
}
)
expect(deleteResponse.ok()).toBeTruthy()
// Verify deleted
const verifyResponse = await request.get(
`http://localhost:3000/api/action/${actionId}`,
{
headers: { 'Authorization': `Bearer ${token}` }
}
)
expect(verifyResponse.status()).toBe(404)
})
test('pagination works correctly', async ({ request }) => {
// Fetch first page
const page1 = await request.get(
'http://localhost:3000/api/action?_limit=10&_offset=0',
{
headers: { 'Authorization': `Bearer ${token}` }
}
)
const page1Data = await page1.json()
expect(page1Data.data).toHaveLength(10)
expect(page1Data.pagination.offset).toBe(0)
// Fetch second page
const page2 = await request.get(
'http://localhost:3000/api/action?_limit=10&_offset=10',
{
headers: { 'Authorization': `Bearer ${token}` }
}
)
const page2Data = await page2.json()
expect(page2Data.pagination.offset).toBe(10)
// Verify different results
const page1Ids = page1Data.data.map((a: any) => a.actionId)
const page2Ids = page2Data.data.map((a: any) => a.actionId)
expect(page1Ids).not.toEqual(page2Ids)
})
})Component Testing
Testing React Components with API
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ActionFeed } from './ActionFeed'
describe('ActionFeed Component', () => {
test('renders loading state', () => {
render(<ActionFeed />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
test('renders actions after loading', async () => {
const mockActions = [
{
actionId: 'act_1',
type: 'POST',
content: { text: 'Test post 1' },
createdAt: 1735689600
}
]
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: mockActions })
})
render(<ActionFeed />)
await waitFor(() => {
expect(screen.getByText('Test post 1')).toBeInTheDocument()
})
})
test('loads more on scroll', async () => {
const page1 = [
{ actionId: 'act_1', type: 'POST', content: { text: 'Post 1' } }
]
const page2 = [
{ actionId: 'act_2', type: 'POST', content: { text: 'Post 2' } }
]
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: page1,
pagination: { hasMore: true }
})
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: page2,
pagination: { hasMore: false }
})
})
const { container } = render(<ActionFeed />)
// Wait for first page
await waitFor(() => {
expect(screen.getByText('Post 1')).toBeInTheDocument()
})
// Scroll to bottom
const scrollElement = container.querySelector('[data-testid="feed-container"]')
scrollElement?.scrollTo(0, scrollElement.scrollHeight)
// Wait for second page
await waitFor(() => {
expect(screen.getByText('Post 2')).toBeInTheDocument()
})
})
test('handles create post', async () => {
global.fetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: [] }) // Initial load
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
actionId: 'act_new',
type: 'POST',
content: { text: 'New post' }
}
})
})
render(<ActionFeed />)
// Fill in post
const textarea = screen.getByPlaceholderText('What\'s on your mind?')
await userEvent.type(textarea, 'New post')
// Submit
const submitButton = screen.getByRole('button', { name: 'Post' })
await userEvent.click(submitButton)
// Verify API call
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/action'),
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('New post')
})
)
})
})
})Load Testing
Testing with k6
import http from 'k6/http'
import { check, sleep } from 'k6'
export const options = {
stages: [
{ duration: '1m', target: 50 }, // Ramp up to 50 users
{ duration: '3m', target: 50 }, // Stay at 50 users
{ duration: '1m', target: 100 }, // Ramp up to 100 users
{ duration: '3m', target: 100 }, // Stay at 100 users
{ duration: '1m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests should be below 500ms
http_req_failed: ['rate<0.01'], // Error rate should be below 1%
},
}
const BASE_URL = 'http://localhost:3000'
let token = ''
export function setup() {
// Login to get token
const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({
email: 'test@example.com',
password: 'password'
}), {
headers: { 'Content-Type': 'application/json' }
})
const loginData = JSON.parse(loginRes.body)
return { token: loginData.data.token }
}
export default function (data) {
const headers = {
'Authorization': `Bearer ${data.token}`,
'Content-Type': 'application/json'
}
// Test 1: Fetch actions
const listRes = http.get(`${BASE_URL}/api/action?_limit=20`, { headers })
check(listRes, {
'status is 200': (r) => r.status === 200,
'has data array': (r) => JSON.parse(r.body).data !== undefined,
})
sleep(1)
// Test 2: Create action
const createRes = http.post(
`${BASE_URL}/api/action`,
JSON.stringify({
type: 'POST',
content: { text: 'Load test post' },
audience: ['public'],
published: Math.floor(Date.now() / 1000)
}),
{ headers }
)
check(createRes, {
'create status is 200': (r) => r.status === 200,
'has actionId': (r) => JSON.parse(r.body).data.actionId !== undefined,
})
const actionId = JSON.parse(createRes.body).data.actionId
sleep(1)
// Test 3: Get specific action
const getRes = http.get(`${BASE_URL}/api/action/${actionId}`, { headers })
check(getRes, {
'get status is 200': (r) => r.status === 200,
})
sleep(1)
// Test 4: Delete action
const delRes = http.del(`${BASE_URL}/api/action/${actionId}`, null, { headers })
check(delRes, {
'delete status is 200': (r) => r.status === 200,
})
sleep(1)
}Test Data Management
Fixtures and Factories
// test/fixtures/actions.ts
export const actionFixtures = {
post: {
actionId: 'act_post_1',
type: 'POST',
content: { text: 'Test post content' },
audience: ['public'],
issuerTag: 'alice@example.com',
createdAt: 1735689600,
published: 1735689600,
status: 'A'
},
comment: {
actionId: 'act_comment_1',
type: 'COMMENT',
content: { text: 'Test comment' },
parentId: 'act_post_1',
issuerTag: 'bob@example.com',
createdAt: 1735689700,
status: 'A'
}
}
// test/factories/action-factory.ts
export class ActionFactory {
static create(overrides: Partial<Action> = {}): Action {
return {
actionId: `act_${Math.random().toString(36).substr(2, 9)}`,
type: 'POST',
content: { text: 'Default post content' },
audience: ['public'],
issuerTag: 'test@example.com',
createdAt: Math.floor(Date.now() / 1000),
published: Math.floor(Date.now() / 1000),
status: 'A',
...overrides
}
}
static createMany(count: number, overrides: Partial<Action> = {}): Action[] {
return Array.from({ length: count }, () => this.create(overrides))
}
static createPost(text: string): Action {
return this.create({
type: 'POST',
content: { text }
})
}
static createComment(parentId: string, text: string): Action {
return this.create({
type: 'COMMENT',
parentId,
content: { text }
})
}
}
// Usage in tests
const post = ActionFactory.createPost('Hello world')
const comment = ActionFactory.createComment(post.actionId, 'Nice post!')
const manyPosts = ActionFactory.createMany(10)Database Seeding for Tests
// test/seed.ts
export async function seedTestData(token: string) {
const baseUrl = 'http://localhost:3000'
// Create test users
const users = [
{ email: 'alice@example.com', name: 'Alice' },
{ email: 'bob@example.com', name: 'Bob' }
]
// Create test posts
const posts = []
for (let i = 0; i < 10; i++) {
const response = await fetch(`${baseUrl}/api/action`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'POST',
content: { text: `Test post ${i}` },
audience: ['public'],
published: Math.floor(Date.now() / 1000)
})
})
const { data } = await response.json()
posts.push(data)
}
return { users, posts }
}
// Use in tests
beforeAll(async () => {
const { posts } = await seedTestData(testToken)
testData = { posts }
})Continuous Integration
GitHub Actions Workflow
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
- name: Upload coverage
uses: codecov/codecov-action@v3
integration-tests:
runs-on: ubuntu-latest
services:
cloudillo:
image: cloudillo/server:latest
ports:
- 3000:3000
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- name: Install dependencies
run: npm ci
- name: Wait for server
run: npx wait-on http://localhost:3000/api/auth/login
- name: Run integration tests
run: npm run test:integration
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-results
path: test-results/Best Practices
Test Organization
tests/
├── unit/
│ ├── services/
│ │ ├── action-service.test.ts
│ │ ├── profile-service.test.ts
│ │ └── file-service.test.ts
│ └── utils/
│ ├── validation.test.ts
│ └── formatting.test.ts
├── integration/
│ ├── auth-flow.test.ts
│ ├── action-crud.test.ts
│ └── file-upload.test.ts
├── e2e/
│ ├── social-features.spec.ts
│ ├── file-management.spec.ts
│ └── real-time.spec.ts
├── fixtures/
│ ├── actions.ts
│ ├── profiles.ts
│ └── files.ts
├── factories/
│ └── action-factory.ts
└── helpers/
├── setup.ts
└── teardown.tsWriting Maintainable Tests
// ✅ Good - descriptive test names
test('should return 401 when token is expired', async () => { ... })
// ❌ Bad - vague test name
test('auth test', async () => { ... })
// ✅ Good - test one thing
test('validates email format', () => {
expect(validateEmail('test@example.com')).toBe(true)
})
test('rejects invalid email format', () => {
expect(validateEmail('invalid')).toBe(false)
})
// ❌ Bad - tests multiple things
test('email validation', () => {
expect(validateEmail('test@example.com')).toBe(true)
expect(validateEmail('invalid')).toBe(false)
expect(validateEmail('')).toBe(false)
})
// ✅ Good - clear arrange-act-assert
test('creates action successfully', async () => {
// Arrange
const action = ActionFactory.createPost('Hello')
// Act
const result = await api.actions.create(action)
// Assert
expect(result.data.actionId).toBeDefined()
expect(result.data.type).toBe('POST')
})Summary
Key testing strategies for Cloudillo API:
- Unit Tests: Test business logic and utilities in isolation
- Integration Tests: Test API interactions with mocked services
- E2E Tests: Test complete user workflows
- Component Tests: Test UI components with API interactions
- Load Tests: Test performance under load
- CI/CD: Automate testing in CI pipeline
Aim for 80%+ code coverage while focusing on critical paths and edge cases.