Skip to content

Testing your AI logic

The logic around your model calls - prompt assembly, structured output handling, tool wiring, and your flow’s own business rules - is ordinary code, and you can test it like ordinary code. The genkit/testing module provides mock models that stand in for a real provider model, so your tests run deterministically with no live model, network access, or API key:

  • mockModel - a programmable mock. You script what the “model” returns on each call, and inspect exactly what your app sent it.
  • echoModel - a zero-config model that echoes the rendered request back as text, for asserting prompt and message assembly.
import { mockModel, echoModel } from 'genkit/testing';

These utilities work with any test runner (node:test, Vitest, Jest, and so on) because they are plain functions: they register a model on a Genkit instance and return it.

Your app doesn’t need any special structure to be testable. The standard Genkit setup - a module-level instance with the default model referenced by name - already is:

import { genkit, z } from 'genkit';
// In production, a provider plugin (e.g. googleAI) registers this model.
// In tests, a mock is registered under the same name.
export const ai = genkit({ model: 'menuModel' });
export const recommendDish = ai.defineFlow(
{
name: 'recommendDish',
inputSchema: z.object({
restaurant: z.string(),
mood: z.string(),
budgetUSD: z.number(),
}),
outputSchema: z.object({
dish: z.string(),
reason: z.string(),
withinBudget: z.boolean(),
}),
},
async (input) => {
const { output } = await ai.generate({
prompt: `Recommend a dish at ${input.restaurant} for someone feeling ${input.mood}.`,
output: {
schema: z.object({
dish: z.string(),
reason: z.string(),
priceUSD: z.number(),
}),
},
});
if (!output) {
throw new Error('Model did not return a structured recommendation.');
}
// Business logic the tests pin down - derived by the flow, not the model.
return {
dish: output.dish,
reason: output.reason,
withinBudget: output.priceUSD <= input.budgetUSD,
};
}
);

In a test file, register one mock under the app’s default model name, and the app resolves to it with no code change. Give each test its own behavior with respondWith(...), and call reset() in beforeEach so tests stay independent. genkit/testing is runner-agnostic - only the imports and assertion style differ:

import { mockModel } from 'genkit/testing';
import { beforeEach, expect, test } from 'vitest';
import { ai, recommendDish } from '../src/menu.js';
const model = mockModel(ai, { name: 'menuModel' });
beforeEach(() => model.reset());
test('marks a recommendation within budget', async () => {
model.respondWith({
text: JSON.stringify({
dish: 'Mushroom risotto',
reason: 'Comforting and in season.',
priceUSD: 18,
}),
});
const out = await recommendDish({
restaurant: 'Lumen',
mood: 'cozy',
budgetUSD: 30,
});
expect(out.dish).toBe('Mushroom risotto');
expect(out.withinBudget).toBe(true);
expect(model.requestCount).toBe(1);
});

Because the model’s response is fixed, the test exercises your logic: run the same response against a lower budget and assert withinBudget flips to false, or return a business-invalid price and assert your flow’s guard throws.

This register-once pattern is safe because node --test, Jest, and Vitest all run each test file in its own process or module graph - every file gets a fresh Genkit registry, so mock registrations in different files never collide. Within a file, reset() clears the mock’s recorded history and re-arms its original behavior, keeping tests order-independent.

  • model.respondWith(...) - replaces the respond behavior for subsequent calls. Recorded history is untouched.
  • model.reset() - clears recorded history (requests, requestCount, and so on) and restores the behavior given at construction, re-arming a queued respond from its first item.

The examples in the rest of this page follow this same setup, and reference tools (dailySpecial, confirmBooking), a prompt (recommendPrompt), and flows defined on the app in the ordinary way - see Tool calling, Prompt templating, and Flows.

Both the respond option and respondWith(...) accept, from lightest to fullest control:

  • a single response - returned on every call;
  • a callback (request, { sendChunk }) => response, invoked once per call - use it to branch on the request (for tool loops) or to stream chunks;
  • an array - a queue consumed one item per call, with the last item repeating once exhausted - use it to script multi-turn interactions without a branching callback.

Each response can be a string (shorthand for a text response), an object with any of text, toolRequests, content, finishReason, usage (assembled into a well-formed model message for you), or a full GenerateResponseData (used as-is).

// Same response every call:
model.respondWith('Hello!');
// A queue: first call gets 'first', every later call gets 'second':
model.respondWith(['first', 'second']);

The returned MockModel records every call it receives and exposes typed, read-only views over that history:

MemberWhat it gives you
lastRequestThe full GenerateRequest from the most recent call.
lastRequestMessageThe final message of the most recent request, wrapped as a Message (so you can read .text, .media, etc.).
lastRequestTextThe whole assembled conversation (system + every message) flattened to a single string.
toolResponsesThe tool results fed back to the model in the most recent request, in order.
requestsEvery request received, oldest first.
requestCountHow many times the model was called.
assert.match(model.lastRequestMessage!.text, /Recommend a dish at Lumen/);
assert.match(model.lastRequestText!, /system: You are a concise restaurant concierge/);

Request snapshots are deep-cloned when recorded, so later mutation - by the framework or by your test - cannot alter recorded history.

When your app requests structured output (output: { schema }), mockModel behaves like a modern provider model: it declares native constrained generation support by default, so a callback respond sees the schema on request.output.schema and no schema text is injected into the prompt. Return JSON text that conforms to the schema and Genkit parses and validates it as usual:

model.respondWith({
text: JSON.stringify({ dish: 'Mushroom risotto', reason: '...', priceUSD: 18 }),
});

To instead exercise Genkit’s simulated constrained-output path - where the framework injects schema instructions into the prompt - opt out of native support when defining the mock:

const model = mockModel(ai, {
name: 'menuModel',
info: { supports: { constrained: 'none' } },
});

On the simulated path the injected schema instructions are visible in lastRequestText, so you can assert on them.

A tool round-trip is two model turns: the model requests a tool, Genkit runs it and feeds the result back, and the model responds again. Script it either by branching on the request in a callback, or - often simpler - with a response queue. Declare tool support on the mock when you define it:

const model = mockModel(ai, {
name: 'menuModel',
info: { supports: { tools: true } },
});
beforeEach(() => model.reset());
test('runs dailySpecial, then recommends', async () => {
model.respondWith([
// Turn 1: ask for the tool.
{ toolRequests: [{ name: 'dailySpecial', input: { restaurant: 'Lumen' } }] },
// Turn 2 (after the tool ran): the final answer.
{ text: "Try the mushroom risotto - today's special." },
]);
const res = await ai.generate({
prompt: 'What should I eat at Lumen?',
tools: [dailySpecial],
});
assert.equal(model.requestCount, 2);
// The real tool ran, and its output was fed back to the model:
assert.equal(model.toolResponses[0]?.name, 'dailySpecial');
assert.match(String(model.toolResponses[0]?.output), /mushroom risotto/);
});

Note that the tool itself is your real tool implementation - only the model is mocked. toolResponses lets you assert which tools ran and what they returned without digging through message content yourself.

If you need to branch on conversation state instead of scripting turns, use the callback form:

model.respondWith((req) => {
const toolAnswered = req.messages.some((m) =>
m.content.some((c) => c.toolResponse)
);
return toolAnswered
? { text: 'Final answer using the tool result.' }
: { toolRequests: [{ name: 'dailySpecial', input: { restaurant: 'Lumen' } }] };
});

The callback form receives sendChunk, which streams chunks to the caller exactly as a real model would. Use it to test flows that forward model tokens through their own stream:

test('forwards model chunks through the flow stream', async () => {
model.respondWith((_req, { sendChunk }) => {
sendChunk('Try ');
sendChunk('the ');
sendChunk('risotto.');
return { text: 'Try the risotto.' };
});
const { stream, output } = streamRecommendation.stream({
restaurant: 'Lumen',
mood: 'cozy',
});
const chunks: string[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
assert.deepEqual(chunks, ['Try ', 'the ', 'risotto.']);
assert.equal(await output, 'Try the risotto.');
});

A bare string passed to sendChunk is shorthand for a single text part; pass a full GenerateResponseChunkData for anything richer.

A queued Error is thrown when its turn is reached, so you can test retry, fallback, and error-surfacing paths declaratively:

model.respondWith([new Error('model overloaded')]);
await assert.rejects(
recommendDish({ restaurant: 'Lumen', mood: 'cozy', budgetUSD: 30 }),
/model overloaded/
);

Mix errors into a longer queue to fail on a specific turn - for example, succeed once, then fail: respondWith(['ok', new Error('rate limited')]).

echoModel answers the question “what would the model have seen?” It echoes the fully rendered request - system instruction, rendered template, message history - back as the response text, so a single assertion covers your prompt assembly:

import { echoModel } from 'genkit/testing';
import { ai, recommendPrompt } from '../src/menu.js';
echoModel(ai, { name: 'menuModel' });
test('renders the system instruction and template variables', async () => {
const res = await recommendPrompt({
restaurant: 'Lumen',
mood: 'tired',
budgetUSD: 40,
});
assert.match(res.text, /system: You are a concise restaurant concierge/);
assert.match(
res.text,
/Recommend a dish at Lumen for someone feeling tired\. Their budget is 40 USD/
);
});

Put echoModel tests in their own test file when they claim the same default model name as your mockModel tests - per-file process isolation keeps the two registrations apart.

echoModel supports the same inspection members as mockModel.

Flows that pause for human input via interrupts need no special helpers: script the model’s tool request with a queue, assert the generation pauses, then resume it and assert completion:

test('pauses on confirmBooking, then resumes', async () => {
model.respondWith([
{ toolRequests: [{ name: 'confirmBooking', input: { dish: 'Mushroom risotto' } }] },
{ text: 'Enjoy your meal!' },
]);
// First pass: the tool interrupts, so generation pauses awaiting the human.
const paused = await ai.generate({
prompt: 'Book the risotto.',
tools: [confirmBooking],
});
assert.equal(paused.interrupts.length, 1);
// The human confirms; restart re-runs the tool with the decision.
const done = await ai.generate({
messages: paused.messages,
tools: [confirmBooking],
resume: {
restart: confirmBooking.restart(paused.interrupts[0], { confirmed: true }),
},
});
assert.equal(done.text, 'Enjoy your meal!');
assert.equal(model.requestCount, 2);
});

If you prefer each test (not just each file) to have a fully isolated Genkit registry - for example, when tests need mocks with different model info under the same name - construct a fresh instance per test with a factory function that builds your app, and register the mock on it in beforeEach. For most suites the register-once pattern above is simpler and sufficient.

genkit/testing also exports testModels, a conformance harness for model plugin authors - it runs a suite of behavioral checks against a real model implementation. It is unrelated to app-level unit testing; see Writing plugins for plugin development.

  • Flows - defining the units you’ll be testing
  • Tool calling - how tool round-trips work
  • Interrupts - pausing generation for human input
  • Evaluation - assessing real model output quality