Testing
Testing strategies and patterns for Go Bananas!.
Testing Framework
Go Bananas! uses Vitest for testing:
bash
# Run all tests
npm test
# Run in watch mode
npm test -- --watch
# Run with coverage
npm test -- --coverage
# Run specific file
npm test -- tests/generate-image.test.tsTest Statistics
Backend (Vitest)
1,056 tests across 65 test files
| Category | Files | Description |
|---|---|---|
| API Routes | 12 | Characters, images, sessions, products, scenes, style-presets, reference-groups, webhooks, quota-check, search-presets, shared utils |
| Auth | 6 | OAuth authorize/authenticate, rate limiting, PBKDF2, session cookies, registration |
| MCP | 5 | Queue timeout, generation queue, agent props, tool rate limit, character generation |
| Tools | 20 | All tool handlers — generate, edit, batch, characters, products, search, schemas, coercion |
| Services | 3 | Webhooks, HMAC verification, video storage |
| Utils | 4 | Retry, circuit breaker, encryption, thumbnail resizer |
| Workflows | 3 | Character workflows, product generation, smart reference selection |
| Security | 1 | Tenant isolation |
| Integration | 1 | Upload and edit |
| Other | 10 | Search engine, scheduled handler, resolvers, benchmarks |
Frontend (Vitest)
122 tests across 16 test files — Component tests using @testing-library/react + jsdom.
E2E (Playwright)
70 tests across 6 spec files — Auth, navigation, gallery, composer, doc screenshots, doc GIFs.
Test Structure
Directory Organization
tests/
├── api/ # REST API endpoint tests
│ ├── character-videos-api.test.ts
│ ├── images-thumbnail.test.ts
│ ├── quota-check.test.ts
│ ├── shared.test.ts
│ └── webhooks-api.test.ts
├── auth/ # OAuth, rate limiting, sessions
│ ├── oauth-authorize.test.ts
│ ├── oauth-authenticate-request.test.ts
│ ├── auth-rate-limiter.test.ts
│ ├── register-api.test.ts
│ ├── session-cookie-refresh.test.ts
│ └── pbkdf2-iterations.test.ts
├── mcp/ # MCP server behavior
│ ├── queue-timeout.test.ts
│ ├── generation-queue.test.ts
│ ├── character-generation-queue.test.ts
│ ├── agent-props.test.ts
│ └── tool-rate-limit.test.ts
├── tools/ # Tool handlers (20 files)
│ ├── generate-image.test.ts
│ ├── edit-image.test.ts
│ ├── batch-generate.test.ts
│ ├── check-quota.test.ts
│ ├── search-images.test.ts
│ ├── schemas.test.ts
│ ├── parameter-coercion.test.ts
│ └── ... (13 more)
├── services/ # Service layer tests
│ ├── webhook-notifications.test.ts
│ ├── webhook-hmac-timestamp.test.ts
│ └── video-storage.test.ts
├── utils/ # Utility functions
│ ├── retry.test.ts
│ ├── circuit-breaker.test.ts
│ ├── encryption.test.ts
│ └── photon-thumbnail-resizer.test.ts
├── workflows/ # Character/product workflows
│ ├── character-workflows.test.ts
│ ├── product-generation-workflow.test.ts
│ └── smart-reference-selection.test.ts
├── security/ # Security tests
│ └── tenant-isolation.test.ts
├── integration/ # E2E workflows
│ └── upload-and-edit.test.ts
├── helpers/ # Shared test utilities
│ └── test-utils.ts
├── *.test.ts # Root-level API tests (characters, images, products, scenes, etc.)
└── benchmarks/ # Performance tests
└── mcp-timeout.test.tsUnit Tests
Schema Validation
typescript
import { describe, it, expect } from 'vitest';
import { GenerateImageInputSchema } from '../../src/tools/generate-image';
describe('GenerateImageInputSchema', () => {
describe('prompt validation', () => {
it('accepts valid prompt', () => {
const result = GenerateImageInputSchema.parse({
prompt: 'A beautiful sunset over the ocean',
});
expect(result.prompt).toBe('A beautiful sunset over the ocean');
});
it('rejects empty prompt', () => {
expect(() => GenerateImageInputSchema.parse({ prompt: '' }))
.toThrow('Prompt is required');
});
it('rejects prompt exceeding max length', () => {
const longPrompt = 'a'.repeat(20000);
expect(() => GenerateImageInputSchema.parse({ prompt: longPrompt }))
.toThrow();
});
});
describe('optional fields', () => {
it('applies default values', () => {
const result = GenerateImageInputSchema.parse({
prompt: 'test prompt',
});
expect(result.n).toBe(1);
expect(result.aspect_ratio).toBe('square');
});
it('accepts custom values', () => {
const result = GenerateImageInputSchema.parse({
prompt: 'test prompt',
n: 3,
aspect_ratio: 'landscape',
});
expect(result.n).toBe(3);
expect(result.aspect_ratio).toBe('landscape');
});
});
});Service Functions
typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { trackUsage } from '../../src/services/usage';
describe('trackUsage', () => {
let mockDb: any;
beforeEach(() => {
mockDb = {
prepare: vi.fn().mockReturnValue({
bind: vi.fn().mockReturnValue({
run: vi.fn().mockResolvedValue({ success: true }),
}),
}),
};
});
it('inserts usage record', async () => {
await trackUsage(mockDb, {
tenantId: 'test-tenant',
sessionId: 'sess_123',
operation: 'generate_image',
imagesGenerated: 2,
totalSizeBytes: 1024000,
durationMs: 3000,
});
expect(mockDb.prepare).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO usage_logs')
);
});
it('handles missing optional fields', async () => {
await trackUsage(mockDb, {
tenantId: 'test-tenant',
operation: 'generate_image',
});
// Should not throw
expect(mockDb.prepare).toHaveBeenCalled();
});
});Tool Handlers
typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generateImage } from '../../src/tools/generate-image';
import { createMockEnv } from '../fixtures/mocks';
describe('generateImage', () => {
let mockEnv: any;
let mockGeminiResponse: any;
beforeEach(() => {
mockEnv = createMockEnv();
mockGeminiResponse = {
candidates: [{
content: {
parts: [{
inlineData: {
data: 'base64ImageData',
mimeType: 'image/png',
},
}],
},
}],
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockGeminiResponse),
});
});
it('generates image successfully', async () => {
const result = await generateImage(
mockEnv,
'test-tenant',
'sess_123',
'fake-gemini-key',
{ prompt: 'A sunset' }
);
expect(result.publicUrl).toBeDefined();
expect(result.thumbnailUrl).toBeDefined();
expect(result.d1RecordId).toBeDefined();
});
it('handles Gemini API error', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
});
await expect(generateImage(
mockEnv,
'test-tenant',
'sess_123',
'fake-gemini-key',
{ prompt: 'A sunset' }
)).rejects.toThrow();
});
});Integration Tests
API Endpoints
typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { unstable_dev } from 'wrangler';
describe('Images API', () => {
let worker: any;
const apiKey = 'sk_test_integration';
beforeAll(async () => {
worker = await unstable_dev('src/index.ts', {
experimental: { disableExperimentalWarning: true },
local: true,
persist: false,
});
// Set up test tenant
await setupTestTenant(worker);
});
afterAll(async () => {
await worker.stop();
});
describe('GET /api/images', () => {
it('returns image list', async () => {
const response = await worker.fetch('/api/images', {
headers: { 'X-API-Key': apiKey },
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
expect(Array.isArray(data.data.items)).toBe(true);
});
it('requires authentication', async () => {
const response = await worker.fetch('/api/images');
expect(response.status).toBe(401);
});
it('supports pagination', async () => {
const response = await worker.fetch('/api/images?limit=5&offset=10', {
headers: { 'X-API-Key': apiKey },
});
const data = await response.json();
expect(data.data.pagination.limit).toBe(5);
expect(data.data.pagination.offset).toBe(10);
});
});
describe('POST /api/images', () => {
it('creates image', async () => {
const response = await worker.fetch('/api/images', {
method: 'POST',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: 'A test image for integration testing',
}),
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.data.images).toBeDefined();
});
});
});MCP Protocol
typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { unstable_dev } from 'wrangler';
describe('MCP Protocol', () => {
let worker: any;
const apiKey = 'sk_test_mcp';
beforeAll(async () => {
worker = await unstable_dev('src/index.ts', {
experimental: { disableExperimentalWarning: true },
local: true,
});
});
afterAll(async () => {
await worker.stop();
});
it('lists available tools', async () => {
const response = await worker.fetch('/mcp', {
method: 'POST',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/list',
id: 1,
}),
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.result.tools).toBeDefined();
expect(data.result.tools.length).toBeGreaterThan(0);
});
it('calls generate_image tool', async () => {
const response = await worker.fetch('/mcp', {
method: 'POST',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/call',
params: {
name: 'generate_image',
arguments: {
prompt: 'MCP integration test image',
},
},
id: 2,
}),
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.result.content).toBeDefined();
});
});Test Fixtures
Mock Environment
typescript
// tests/helpers/test-utils.ts
import { vi } from 'vitest';
export function createMockEnv() {
return {
DB: createMockD1(),
R2_IMAGES: createMockR2(),
API_KEYS: createMockKV(),
TENANT_CONFIG: createMockKV(),
ENCRYPTION_KEY: 'a'.repeat(64),
};
}
export function createMockD1() {
const mockStatement = {
bind: vi.fn().mockReturnThis(),
first: vi.fn().mockResolvedValue(null),
all: vi.fn().mockResolvedValue({ results: [] }),
run: vi.fn().mockResolvedValue({ success: true }),
};
return {
prepare: vi.fn().mockReturnValue(mockStatement),
batch: vi.fn().mockResolvedValue([]),
};
}
export function createMockR2() {
return {
put: vi.fn().mockResolvedValue(undefined),
get: vi.fn().mockResolvedValue({
arrayBuffer: () => Promise.resolve(new ArrayBuffer(100)),
}),
delete: vi.fn().mockResolvedValue(undefined),
};
}
export function createMockKV() {
const store = new Map();
return {
get: vi.fn((key) => Promise.resolve(store.get(key))),
put: vi.fn((key, value) => {
store.set(key, value);
return Promise.resolve();
}),
delete: vi.fn((key) => {
store.delete(key);
return Promise.resolve();
}),
};
}Test Data
typescript
// tests/fixtures/tenants.ts
export const testTenant = {
tenant_id: 'test-tenant',
name: 'Test Tenant',
encrypted_gemini_key: 'encrypted...',
iv: 'iv...',
monthly_quota_mb: 1024,
rate_limit_per_minute: 60,
is_active: 1,
};
export const testApiKey = {
id: 1,
tenant_id: 'test-tenant',
api_key: 'sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
label: 'Test Key',
is_active: 1,
};
// tests/fixtures/images.ts
export const testImage = {
id: 1,
tenant_id: 'test-tenant',
session_id: 'sess_test',
r2_key: 'test-tenant/2024-01-15/generate-abc123.png',
public_url: 'https://example.com/image.png',
width: 1024,
height: 1024,
prompt: 'A test image',
operation_type: 'generate',
};Mocking External Services
Gemini API
typescript
// Mock successful response
function mockGeminiSuccess(imageData = 'base64data') {
return vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
candidates: [{
content: {
parts: [{
inlineData: {
data: imageData,
mimeType: 'image/png',
},
}],
},
}],
}),
});
}
// Mock error response
function mockGeminiError(status = 500, message = 'Internal error') {
return vi.fn().mockResolvedValue({
ok: false,
status,
statusText: message,
});
}
// Mock safety filter
function mockGeminiBlocked() {
return vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
candidates: [],
promptFeedback: {
blockReason: 'SAFETY',
},
}),
});
}Test Coverage
Coverage Goals
| Category | Target | Notes |
|---|---|---|
| Tools | 80% | Core business logic |
| Services | 70% | Utility functions |
| API | 60% | Endpoint handlers |
| Types | N/A | Type-only files |
Running Coverage
bash
npm test -- --coverage
# Generate HTML report
npm test -- --coverage --reporter=htmlCoverage Configuration
In vitest.config.ts:
typescript
export default {
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: [
'node_modules/',
'tests/',
'*.config.ts',
],
},
},
};Reliability Tests
Tests for retry logic, circuit breaker, and queue management.
Retry Logic Tests
typescript
// tests/utils/retry.test.ts
import { describe, it, expect, vi } from 'vitest';
import { withRetry, GEMINI_RETRY_CONFIG } from '../../src/utils/retry';
describe('withRetry', () => {
it('should retry on retryable error and succeed', async () => {
const operation = vi.fn()
.mockRejectedValueOnce(new Error('rate limit exceeded'))
.mockResolvedValue('success');
const result = await withRetry(operation, {
...GEMINI_RETRY_CONFIG,
initialDelayMs: 10, // Fast for tests
});
expect(result).toBe('success');
expect(operation).toHaveBeenCalledTimes(2);
});
it('should not retry on non-retryable errors', async () => {
const operation = vi.fn()
.mockRejectedValue(new Error('invalid prompt'));
await expect(withRetry(operation, GEMINI_RETRY_CONFIG))
.rejects.toThrow('invalid prompt');
expect(operation).toHaveBeenCalledTimes(1);
});
});Circuit Breaker Tests
typescript
// tests/utils/circuit-breaker.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { CircuitBreaker, CircuitOpenError } from '../../src/utils/circuit-breaker';
describe('CircuitBreaker', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('should open after failure threshold', async () => {
const breaker = new CircuitBreaker({ failureThreshold: 2 });
const operation = vi.fn().mockRejectedValue(new Error('fail'));
// Trigger failures
for (let i = 0; i < 2; i++) {
try { await breaker.execute(operation); } catch {}
}
expect(breaker.getState()).toBe('open');
// Next call fails fast without calling operation
await expect(breaker.execute(operation))
.rejects.toBeInstanceOf(CircuitOpenError);
});
it('should recover after reset timeout', async () => {
const breaker = new CircuitBreaker({
failureThreshold: 1,
resetTimeoutMs: 5000,
successThreshold: 1,
});
// Open the circuit
try {
await breaker.execute(vi.fn().mockRejectedValue(new Error()));
} catch {}
// Advance past reset timeout
vi.advanceTimersByTime(6000);
// Should allow execution and close on success
const result = await breaker.execute(vi.fn().mockResolvedValue('ok'));
expect(result).toBe('ok');
expect(breaker.getState()).toBe('closed');
});
});Queue Timeout Tests
typescript
// tests/mcp/queue-timeout.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('Queue Timeout Behavior', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('should reject when queue is full', async () => {
const queue = new MockGenerationQueue();
const slowTask = () => new Promise(r => setTimeout(r, 60000));
// Fill queue
for (let i = 0; i < 5; i++) {
queue.queueGeneration(slowTask).catch(() => {});
}
// 6th task should be rejected
await expect(queue.queueGeneration(slowTask))
.rejects.toThrow('queue is full');
});
it('should timeout with queue position in error', async () => {
const queue = new MockGenerationQueue();
const slowTask = () => new Promise(r => setTimeout(r, 100000));
const p1 = queue.queueGeneration(slowTask);
const p2 = queue.queueGeneration(slowTask).catch(e => e);
await vi.advanceTimersByTimeAsync(200000);
await p1;
const error = await p2;
expect(error.message).toContain('Queue position was 2');
});
});Best Practices
Test Isolation
typescript
describe('feature', () => {
// Reset mocks between tests
beforeEach(() => {
vi.clearAllMocks();
});
// Clean up after all tests
afterAll(() => {
vi.restoreAllMocks();
});
});Descriptive Test Names
typescript
// ✅ Good - describes behavior
it('returns 401 when API key is missing', async () => {});
it('creates thumbnail at 200x200 pixels', async () => {});
// ❌ Bad - vague
it('works correctly', async () => {});
it('test case 1', async () => {});Arrange-Act-Assert
typescript
it('updates character usage count', async () => {
// Arrange
const character = { id: 1, times_used: 5 };
mockDb.prepare().bind().first.mockResolvedValue(character);
// Act
await generateWithCharacter(mockEnv, 'tenant', 'sess', 'key', {
character_id: 1,
scene_prompt: 'walking in forest',
});
// Assert
expect(mockDb.prepare).toHaveBeenCalledWith(
expect.stringContaining('UPDATE characters SET times_used')
);
});