How to mock the OpenAI SDK in Jest
Tests that call the real OpenAI API are slow, flaky, need an API key in CI and cost money. Hand-written mocks (jest.fn() returning a fake completion) have to fake the whole response shape, and they drift every time the SDK changes.
llmao is a fake LLM that behaves like the real API: same client, same response objects, same errors. You swap it in with one line and your app code stays untouched.
Install
npm install --save-dev llmaoMock the module
Say your app has a function that uses the official SDK:
// src/support.ts
import OpenAI from 'openai';
const client = new OpenAI();
export async function classify(ticket: string) {
const completion = await client.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: 'Classify the ticket as billing, shipping or other.' },
{ role: 'user', content: ticket },
],
});
return completion.choices[0].message.content;
}In the test, replace openai with llmao/openai and script the answers:
// src/support.test.ts
jest.mock('openai', () => require('llmao/openai'));
import OpenAI from 'openai'; // this is llmao now
import * as llmao from 'llmao/testing';
import { classify } from './support';
beforeEach(() => llmao.reset());
test('classifies shipping tickets', async () => {
llmao.configure({ script: [{ when: /never arrived/i, text: 'shipping' }] });
expect(await classify('My order never arrived')).toBe('shipping');
});
test('sends the ticket with the right system prompt', async () => {
llmao.configure({ script: [{ text: 'billing' }] });
await classify('I was charged twice');
expect(llmao.lastCall()).toMatchObject({
model: 'gpt-4o',
system: 'Classify the ticket as billing, shipping or other.',
prompt: 'I was charged twice',
});
});
test('surfaces rate limits', async () => {
llmao.configure({ failures: { rateLimit: 1 } });
await expect(classify('hi')).rejects.toBeInstanceOf(OpenAI.RateLimitError);
});What you get
- Scripted answers: rules match on a substring, a regex or a function. Use
unscripted: 'error'to fail the test on any prompt you didn't script. - Recorded calls:
llmao.callsandllmao.lastCall()give you the provider, model, prompt, system prompt, tools, the original request and the answer. - Real errors:
OpenAI.RateLimitErrorwithstatus: 429and aretry-afterheader,OpenAI.InternalServerError, timeouts. The client retries like the real SDK (maxRetries, default 2). - Streaming, tool calls and structured output (
response_format,chat.completions.parse()) work like the real thing. - Fast: importing
llmao/testingmakes every answer instant and deterministic. It creates no timers, so it works withjest.useFakeTimers().
Scripting a whole conversation
llmao.configure({
unscripted: 'error',
script: [
{ when: 'weather', toolCalls: [{ name: 'get_weather', args: { city: 'Lima' } }] },
{ when: 'weather', afterToolResults: true, text: 'It is sunny in Lima.' },
{ error: 'server_error', once: true }, // fail once, then fall through
],
});