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.
Testing your app
Section titled “Testing your app”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);});import { beforeEach, expect, test } from '@jest/globals';import { mockModel } from 'genkit/testing';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);});import { mockModel } from 'genkit/testing';import assert from 'node:assert/strict';import { beforeEach, test } from 'node:test';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, });
assert.equal(out.dish, 'Mushroom risotto'); assert.equal(out.withinBudget, true); assert.equal(model.requestCount, 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.
Scripting responses
Section titled “Scripting responses”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']);Inspecting what the model received
Section titled “Inspecting what the model received”The returned MockModel records every call it receives and exposes typed,
read-only views over that history:
| Member | What it gives you |
|---|---|
lastRequest | The full GenerateRequest from the most recent call. |
lastRequestMessage | The final message of the most recent request, wrapped as a Message (so you can read .text, .media, etc.). |
lastRequestText | The whole assembled conversation (system + every message) flattened to a single string. |
toolResponses | The tool results fed back to the model in the most recent request, in order. |
requests | Every request received, oldest first. |
requestCount | How 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.
Testing structured output
Section titled “Testing structured output”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.
Testing tool calling
Section titled “Testing tool calling”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' } }] };});Testing streaming
Section titled “Testing streaming”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.
Testing failure handling
Section titled “Testing failure handling”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')]).
Asserting prompt assembly with echoModel
Section titled “Asserting prompt assembly with echoModel”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.
Testing interrupts (human-in-the-loop)
Section titled “Testing interrupts (human-in-the-loop)”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);});Isolating tests further
Section titled “Isolating tests further”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.
For plugin authors: testModels
Section titled “For plugin authors: testModels”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.
Learn more
Section titled “Learn more”- 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