Skip to content

Testing & Mocking

Meloqui provides testing utilities to help you write reliable tests for your LLM-powered applications without making real API calls.

Quick Start

typescript
import { ChatClient, createMockProvider } from 'meloqui';

const provider = createMockProvider();
const client = new ChatClient({ provider, model: 'test' });

// Test your code
const response = await client.chat('Hello');
expect(response.content).toBe('Mock response');

// Verify calls
expect(provider.chat).toHaveBeenCalledTimes(1);

Mock Provider Functions

createMockProvider

Creates a fully-mocked provider with jest mock functions.

typescript
import { createMockProvider } from 'meloqui';

const provider = createMockProvider();

// All methods are jest mocks
expect(jest.isMockFunction(provider.chat)).toBe(true);
expect(jest.isMockFunction(provider.stream)).toBe(true);

// Default response
const response = await provider.chat([{ role: 'user', content: 'Hi' }]);
// { content: 'Mock response', role: 'assistant', metadata: { model: 'mock-model', tokensUsed: 10 } }

Options

typescript
interface MockProviderOptions {
  /** Provider name (default: 'mock') */
  name?: string;
  /** Provider capabilities */
  capabilities?: Partial<ProviderCapabilities>;
  /** Custom chat mock */
  chat?: jest.Mock;
  /** Custom stream mock */
  stream?: jest.Mock;
  /** Custom chatWithTools mock */
  chatWithTools?: jest.Mock;
  /** Custom streamWithTools mock */
  streamWithTools?: jest.Mock;
}

Custom Capabilities

typescript
// Disable specific capabilities for testing fallbacks
const provider = createMockProvider({
  capabilities: {
    vision: false,
    streaming: false
  }
});

expect(provider.capabilities.vision).toBe(false);
expect(provider.capabilities.chat).toBe(true); // Default preserved

createMockProviderWithResponse

Creates a provider that returns a specific response.

typescript
import { createMockProviderWithResponse } from 'meloqui';

const provider = createMockProviderWithResponse({
  content: 'Paris is the capital of France.',
  role: 'assistant',
  metadata: { model: 'gpt-4o', tokensUsed: 15 }
});

const client = new ChatClient({ provider, model: 'test' });
const response = await client.chat('What is the capital of France?');

expect(response.content).toBe('Paris is the capital of France.');

createMockProviderWithError

Creates a provider that throws an error. Useful for testing error handling.

typescript
import { createMockProviderWithError, RateLimitError } from 'meloqui';

const provider = createMockProviderWithError(
  new RateLimitError('Rate limit exceeded', 'openai')
);

const client = new ChatClient({ provider, model: 'test' });

await expect(client.chat('Hello')).rejects.toThrow(RateLimitError);

createMockProviderWithStream

Creates a provider that streams specific chunks.

typescript
import { createMockProviderWithStream } from 'meloqui';

const provider = createMockProviderWithStream([
  { content: 'Hello', role: 'assistant' },
  { content: ' world', role: 'assistant' },
  { content: '!', role: 'assistant' }
]);

const client = new ChatClient({ provider, model: 'test' });

const chunks: string[] = [];
for await (const chunk of client.stream('Hi')) {
  chunks.push(chunk.content);
}

expect(chunks).toEqual(['Hello', ' world', '!']);

Testing Patterns

Basic Chat Testing

typescript
import { ChatClient, createMockProvider } from 'meloqui';

describe('MyService', () => {
  let client: ChatClient;
  let provider: ReturnType<typeof createMockProvider>;

  beforeEach(() => {
    provider = createMockProvider();
    client = new ChatClient({ provider, model: 'test' });
  });

  it('should send user message', async () => {
    await client.chat('Hello, AI!');

    expect(provider.chat).toHaveBeenCalledWith(
      [{ role: 'user', content: 'Hello, AI!' }],
      undefined
    );
  });

  it('should include system prompt', async () => {
    const clientWithSystem = new ChatClient({
      provider,
      model: 'test',
      systemPrompt: 'You are helpful.'
    });

    await clientWithSystem.chat('Hi');

    expect(provider.chat).toHaveBeenCalledWith(
      expect.arrayContaining([
        { role: 'system', content: 'You are helpful.' }
      ]),
      undefined
    );
  });
});

Testing Different Responses

typescript
it('should handle dynamic responses', async () => {
  provider.chat
    .mockResolvedValueOnce({ content: 'First', role: 'assistant' })
    .mockResolvedValueOnce({ content: 'Second', role: 'assistant' });

  const first = await client.chat('1');
  const second = await client.chat('2');

  expect(first.content).toBe('First');
  expect(second.content).toBe('Second');
});

it('should respond based on input', async () => {
  provider.chat.mockImplementation(async (messages) => {
    const lastMessage = messages[messages.length - 1];
    if (lastMessage.content.includes('hello')) {
      return { content: 'Hi there!', role: 'assistant' };
    }
    return { content: 'I don\'t understand', role: 'assistant' };
  });

  expect((await client.chat('hello')).content).toBe('Hi there!');
  expect((await client.chat('xyz')).content).toBe('I don\'t understand');
});

Testing Error Handling

typescript
import {
  ChatClient,
  createMockProviderWithError,
  RateLimitError,
  AuthenticationError,
  ChatError
} from 'meloqui';

describe('error handling', () => {
  it('should handle rate limit errors', async () => {
    const provider = createMockProviderWithError(
      new RateLimitError('Too many requests', 'openai')
    );
    const client = new ChatClient({ provider, model: 'test' });

    try {
      await client.chat('Hello');
      fail('Expected error');
    } catch (error) {
      expect(error).toBeInstanceOf(RateLimitError);
      expect((error as ChatError).isRetryable).toBe(true);
    }
  });

  it('should handle authentication errors', async () => {
    const provider = createMockProviderWithError(
      new AuthenticationError('Invalid API key', 'openai')
    );
    const client = new ChatClient({ provider, model: 'test' });

    await expect(client.chat('Hello')).rejects.toThrow(AuthenticationError);
  });

  it('should retry on transient errors', async () => {
    const provider = createMockProvider();
    provider.chat
      .mockRejectedValueOnce(new RateLimitError('Rate limited', 'openai'))
      .mockResolvedValueOnce({ content: 'Success', role: 'assistant' });

    const client = new ChatClient({
      provider,
      model: 'test',
      retryConfig: { maxAttempts: 2, initialBackoffMs: 10 }
    });

    const response = await client.chat('Hello');

    expect(response.content).toBe('Success');
    expect(provider.chat).toHaveBeenCalledTimes(2);
  });
});

Testing Streaming

typescript
import { ChatClient, createMockProviderWithStream } from 'meloqui';

describe('streaming', () => {
  it('should stream response chunks', async () => {
    const provider = createMockProviderWithStream([
      { content: 'The ', role: 'assistant' },
      { content: 'answer ', role: 'assistant' },
      { content: 'is 42.', role: 'assistant' }
    ]);
    const client = new ChatClient({ provider, model: 'test' });

    const chunks: string[] = [];
    for await (const chunk of client.stream('Question')) {
      chunks.push(chunk.content);
    }

    expect(chunks.join('')).toBe('The answer is 42.');
  });

  it('should handle stream errors', async () => {
    const provider = createMockProviderWithError(new Error('Stream failed'));
    const client = new ChatClient({ provider, model: 'test' });

    await expect(async () => {
      for await (const _chunk of client.stream('Hello')) {
        // Should throw
      }
    }).rejects.toThrow('Stream failed');
  });
});

Testing with Tools

typescript
import { ChatClient, createMockProvider, ChatResponse } from 'meloqui';
import { z } from 'zod';

describe('tool calling', () => {
  it('should handle tool calls', async () => {
    const provider = createMockProvider();

    // Mock a response with tool call
    const toolCallResponse: ChatResponse = {
      content: '',
      role: 'assistant',
      toolCalls: [{
        id: 'call_123',
        name: 'get_weather',
        arguments: { city: 'Paris' }
      }]
    };
    provider.chatWithTools.mockResolvedValue(toolCallResponse);

    const client = new ChatClient({ provider, model: 'test' });

    const getWeather = {
      name: 'get_weather',
      description: 'Get weather for a city',
      parameters: z.object({ city: z.string() }),
      execute: jest.fn().mockResolvedValue({ temp: 20, condition: 'sunny' })
    };

    await client.chat('What is the weather in Paris?', {
      tools: [getWeather]
    });

    expect(getWeather.execute).toHaveBeenCalledWith({ city: 'Paris' });
  });
});

Testing Conversation History

typescript
import { ChatClient, createMockProvider, InMemoryStorage } from 'meloqui';

describe('conversation history', () => {
  it('should maintain history across messages', async () => {
    const provider = createMockProvider();
    const client = new ChatClient({
      provider,
      model: 'test',
      storage: new InMemoryStorage(),
      conversationId: 'test-conv'
    });

    await client.chat('First message');
    await client.chat('Second message');

    // Second call should include first message in history
    expect(provider.chat).toHaveBeenLastCalledWith(
      expect.arrayContaining([
        expect.objectContaining({ content: 'First message' }),
        expect.objectContaining({ content: 'Mock response' }),
        expect.objectContaining({ content: 'Second message' })
      ]),
      undefined
    );
  });
});

Testing Capability Checks

typescript
import { ChatClient, createMockProvider, CapabilityError } from 'meloqui';

describe('capabilities', () => {
  it('should check vision capability', async () => {
    const provider = createMockProvider({
      capabilities: { vision: false }
    });
    const client = new ChatClient({ provider, model: 'test' });

    expect(client.capabilities.vision).toBe(false);
  });

  it('should throw on unsupported capability', async () => {
    const provider = createMockProvider({
      capabilities: { vision: false }
    });
    const client = new ChatClient({ provider, model: 'test' });

    await expect(
      client.chat({
        role: 'user',
        content: [{ type: 'image', image: './photo.jpg' }]
      })
    ).rejects.toThrow(CapabilityError);
  });
});

Integration Testing

For integration tests that need real API calls, use environment variables and skip conditions:

typescript
describe('OpenAI Integration', () => {
  const apiKey = process.env.OPENAI_API_KEY;

  beforeAll(() => {
    if (!apiKey) {
      console.log('Skipping integration tests: OPENAI_API_KEY not set');
    }
  });

  it('should complete chat', async () => {
    if (!apiKey) return;

    const client = new ChatClient({
      provider: 'openai',
      model: 'gpt-4o-mini',
      apiKey
    });

    const response = await client.chat('Say "test" and nothing else');
    expect(response.content.toLowerCase()).toContain('test');
  });
});

Best Practices

1. Use Fresh Mocks Per Test

typescript
beforeEach(() => {
  provider = createMockProvider();
  client = new ChatClient({ provider, model: 'test' });
});

2. Type Your Mocks

typescript
import { MockedProviderPlugin } from 'meloqui';

let provider: MockedProviderPlugin;
// provider.chat.mock is now typed

3. Test Edge Cases

typescript
it('should handle empty response', async () => {
  provider.chat.mockResolvedValue({ content: '', role: 'assistant' });
  const response = await client.chat('Hi');
  expect(response.content).toBe('');
});

it('should handle very long response', async () => {
  const longContent = 'x'.repeat(100000);
  provider.chat.mockResolvedValue({ content: longContent, role: 'assistant' });
  const response = await client.chat('Hi');
  expect(response.content.length).toBe(100000);
});

4. Verify Call Arguments

typescript
it('should pass options correctly', async () => {
  await client.chat('Hi', { temperature: 0.5, maxTokens: 100 });

  expect(provider.chat).toHaveBeenCalledWith(
    expect.any(Array),
    expect.objectContaining({
      temperature: 0.5,
      maxTokens: 100
    })
  );
});

Next Steps

Released under the MIT License.