Skip to content

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

Test Statistics ​

Backend (Vitest) ​

1,056 tests across 65 test files

CategoryFilesDescription
API Routes12Characters, images, sessions, products, scenes, style-presets, reference-groups, webhooks, quota-check, search-presets, shared utils
Auth6OAuth authorize/authenticate, rate limiting, PBKDF2, session cookies, registration
MCP5Queue timeout, generation queue, agent props, tool rate limit, character generation
Tools20All tool handlers — generate, edit, batch, characters, products, search, schemas, coercion
Services3Webhooks, HMAC verification, video storage
Utils4Retry, circuit breaker, encryption, thumbnail resizer
Workflows3Character workflows, product generation, smart reference selection
Security1Tenant isolation
Integration1Upload and edit
Other10Search 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.ts

Unit 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 ​

CategoryTargetNotes
Tools80%Core business logic
Services70%Utility functions
API60%Endpoint handlers
TypesN/AType-only files

Running Coverage ​

bash
npm test -- --coverage

# Generate HTML report
npm test -- --coverage --reporter=html

Coverage 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')
  );
});

Next Steps ​

Released under the MIT License.