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
The logic around your model calls — prompt assembly, structured output handling,
tool wiring, and your flow’s own business rules — is ordinary Go, and you test it
with ordinary go test. There is no special test runner and no separate testing
package: you register a fake model on a Genkit instance and run your flow
against it, so tests are deterministic and need no network or API key.
Make your flow testable
Section titled “Make your flow testable”The only structural requirement is that your flow takes the *genkit.Genkit
handle as a parameter instead of reading a package-level global. That is what
lets a test hand it an instance with a fake model registered.
package app
import ( "context"
"github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/genkit")
type Summary struct { Headline string `json:"headline"` Bullets []string `json:"bullets"`}
// NewSummarizeFlow takes g and the model name so a test can swap both.func NewSummarizeFlow(g *genkit.Genkit, model string) *core.Flow[string, Summary, struct{}] { return genkit.DefineFlow(g, "summarize", func(ctx context.Context, article string) (Summary, error) { out, _, err := genkit.GenerateData[Summary](ctx, g, ai.WithModelName(model), ai.WithSystem("Summarize the article. Three bullets, no more."), ai.WithPrompt("%s", article), ) if err != nil { return Summary{}, err } return *out, nil })}genkit.DefineFlow returns a *core.Flow[In, Out, Stream] from
github.com/firebase/genkit/go/core. That is the type to name when you store a
flow in a struct field or pass it to a helper.
Write a test model stub
Section titled “Write a test model stub”A test model is a model action registered on a test *genkit.Genkit instance that returns a predetermined response or error. genkit.DefineModelAction registers it under a name your flow can resolve, so your tests run deterministically with no network calls or API keys.
// defineTestModel registers a model action that returns a fixed response.func defineTestModel(g *genkit.Genkit, name string, response *ai.ModelResponse) { genkit.DefineModelAction(g, name, &ai.ModelOptions{ Supports: &ai.ModelSupports{Multiturn: true, Tools: true, SystemRole: true}, }, func(ctx context.Context, req *ai.ModelRequest, _ struct{}, cb ai.ModelStreamCallback) (*ai.ModelResponse, error) { return response, nil })}
// defineErrorModel registers a model action that returns a designated error.func defineErrorModel(g *genkit.Genkit, name string, err error) { genkit.DefineModelAction(g, name, &ai.ModelOptions{ Supports: &ai.ModelSupports{Multiturn: true, Tools: true, SystemRole: true}, }, func(ctx context.Context, req *ai.ModelRequest, _ struct{}, cb ai.ModelStreamCallback) (*ai.ModelResponse, error) { return nil, err })}genkit.Init(ctx) with no plugins initializes a test-friendly instance: it registers no external providers, reads no credentials, and starts no reflection server. Build a fresh instance per test to isolate registrations.
Test structured output
Section titled “Test structured output”Configure the test model to return JSON conforming to your output schema, and assert on the decoded Go value:
func TestStructuredOutput(t *testing.T) { ctx := context.Background() g := genkit.Init(ctx)
want := Summary{Headline: "Release notes", Bullets: []string{"a", "b", "c"}} body, _ := json.Marshal(want) defineTestModel(g, "mock/model", &ai.ModelResponse{ FinishReason: ai.FinishReasonStop, Message: ai.NewModelTextMessage(string(body)), })
flow := NewSummarizeFlow(g, "mock/model") got, err := flow.Run(ctx, "Sample release notes text.") if err != nil { t.Fatalf("flow failed: %v", err) } if got.Headline != want.Headline || len(got.Bullets) != 3 { t.Fatalf("got %+v, want %+v", got, want) }}Test tools directly
Section titled “Test tools directly”Because tools in Genkit wrap plain Go functions, you can unit-test tool business logic directly without running a model loop:
func TestWordCountTool(t *testing.T) { ctx := context.Background()
type wordCountInput struct { Text string `json:"text"` } toolFn := func(_ *ai.ToolContext, in wordCountInput) (int, error) { return len(strings.Fields(in.Text)), nil }
got, err := toolFn(&ai.ToolContext{Context: ctx}, wordCountInput{Text: "one two three"}) if err != nil { t.Fatalf("tool error: %v", err) } if got != 3 { t.Errorf("got %d, want 3", got) }}Test streaming
Section titled “Test streaming”To test streaming flows, pass chunks to the ModelStreamCallback inside a custom model action:
func TestStreaming(t *testing.T) { ctx := context.Background() g := genkit.Init(ctx)
genkit.DefineModelAction(g, "mock/stream", &ai.ModelOptions{ Supports: &ai.ModelSupports{Multiturn: true}, }, func(ctx context.Context, req *ai.ModelRequest, _ struct{}, cb ai.ModelStreamCallback) (*ai.ModelResponse, error) { chunks := []string{"Hello", ", ", "world"} if cb != nil { for _, c := range chunks { if err := cb(ctx, &ai.ModelResponseChunk{ Role: ai.RoleModel, Content: []*ai.Part{ai.NewTextPart(c)}, }); err != nil { return nil, err } } } return &ai.ModelResponse{ FinishReason: ai.FinishReasonStop, Message: ai.NewModelTextMessage("Hello, world"), }, nil })
var seen []string for chunk, err := range genkit.GenerateStream(ctx, g, ai.WithModelName("mock/stream"), ai.WithPrompt("greet"), ) { if err != nil { t.Fatalf("stream: %v", err) } if chunk.Done { break } seen = append(seen, chunk.Chunk.Text()) } if strings.Join(seen, "") != "Hello, world" { t.Fatalf("chunks = %q", seen) }}Test the HTTP layer
Section titled “Test the HTTP layer”genkit.Handler turns a flow into an http.HandlerFunc, and httptest.Server serves it in-process. This verifies the wire contract: the success envelope, status codes, and error formatting.
func TestHTTPBoundary(t *testing.T) { ctx := context.Background() g := genkit.Init(ctx) defineErrorModel(g, "mock/http", status.Errorf(status.ErrResourceExhausted, "quota exceeded"))
flow := NewSummarizeFlow(g, "mock/http")
mux := http.NewServeMux() mux.HandleFunc("POST /summarize", genkit.Handler(flow)) srv := httptest.NewServer(mux) defer srv.Close()
resp, err := http.Post(srv.URL+"/summarize", "application/json", strings.NewReader(`{"data":"an article"}`)) if err != nil { t.Fatalf("post: %v", err) } defer resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests { t.Fatalf("status = %d, want 429", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if strings.Contains(string(body), "quota exceeded") { t.Fatalf("internal message leaked: %q", body) }}The request body is {"data": <flow input>} and a successful response is {"result": <flow output>}. A failure body is plain text, and only a message built with status.PublicErrorf appears in it. See Error types for the rules the handler applies.
Test failure handling
Section titled “Test failure handling”Use defineErrorModel with a classified error to verify that the classification propagates to your handler without needing a live provider error:
func TestProviderFailureIsClassified(t *testing.T) { ctx := context.Background() g := genkit.Init(ctx) defineErrorModel(g, "mock/broken", status.Errorf(status.ErrResourceExhausted, "quota exceeded"))
_, err := genkit.Generate(ctx, g, ai.WithModelName("mock/broken"), ai.WithPrompt("anything"), ) if !errors.Is(err, status.ErrResourceExhausted) { t.Fatalf("error %v is not classified ResourceExhausted", err) }}See Error types for the full status set and how each one reaches a client.
Test a plugin
Section titled “Test a plugin”Three facts about genkit.Init shape how plugin tests are written:
- Each call builds an independent
*Genkitwith its own registry, so a per-testInitis safe and two tests cannot collide on the same action name. - The reflection server starts only under
GENKIT_ENV=dev. Leave that variable unset in tests and nothing listens on port 3100, so tests can run in parallel and in CI without a port conflict. - There is no
CloseorShutdown. Background work is released by cancelling the context you passed toInit, so uset.Context()or acontext.WithCancelyou defer.
The parts of a plugin worth unit-testing directly are its conversion functions:
Genkit request to provider request, provider response back to
*ai.ModelResponse. Those are ordinary functions and need no Genkit instance at
all.
For everything that goes over the wire, point the plugin at an httptest.Server
through its own endpoint field, and let the handler produce the failure you want
to test. That covers the cases a live provider will not reproduce on demand:
func TestProviderDown(t *testing.T) { // "Server down": a listener that is already closed. srv := httptest.NewServer(http.NotFoundHandler()) addr := srv.URL srv.Close()
g := genkit.Init(t.Context(), genkit.WithPlugins(&ollama.Ollama{ServerAddress: addr})) // ... assert the plugin's error is classified Unavailable ... _ = g}
func TestProviderDiesMidRequest(t *testing.T) { // "Dies mid-request": headers and a partial body, then the connection is // dropped without the rest. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"response":"partia`)) w.(http.Flusher).Flush() conn, _, err := w.(http.Hijacker).Hijack() if err != nil { t.Fatalf("hijack: %v", err) } conn.Close() })) defer srv.Close()
g := genkit.Init(t.Context(), genkit.WithPlugins(&ollama.Ollama{ServerAddress: srv.URL})) // ... assert the truncated response surfaces as an error, not a partial value ... _ = g}ollama.Ollama{ServerAddress: ...} is the example here because its endpoint is a
plain field. Other plugins expose the same hook under a different name, such as
compat_oai.OpenAICompatible{BaseURL: ...}.
Imports
Section titled “Imports”Every snippet above uses these:
import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "testing"
"github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/genkit" "github.com/firebase/genkit/go/plugins/ollama")Learn more
Section titled “Learn more”- Flows — defining the units you’ll be testing
- Tool calling — how tool round-trips work
- Error types — the status codes your tests assert on
- Writing plugins —
ai.ModelOptionsandai.ModelSupports, used when defining test models - Evaluation — assessing real model output quality