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

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.

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.

summarize.go
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.

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.

mock_model_test.go
// 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.

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)
}
}

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)
}
}

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)
}
}

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.

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.

Three facts about genkit.Init shape how plugin tests are written:

  • Each call builds an independent *Genkit with its own registry, so a per-test Init is 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 Close or Shutdown. Background work is released by cancelling the context you passed to Init, so use t.Context() or a context.WithCancel you 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: ...}.

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"
)
  • 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 pluginsai.ModelOptions and ai.ModelSupports, used when defining test models
  • Evaluation — assessing real model output quality