How to Test and Evaluate AI Agents with TypeScript and Genkit

When we build regular frontend and backend applications, testing is a normal routine. We write unit tests, check results with Vitest or Jest, and deploy with confidence.
When I started building AI agents, I did what almost every developer does at first: a manual vibe check.
I opened my terminal, sent two prompts to the agent, watched it respond, and thought: "Looks good to me!"
Then came the unexpected bugs. A few days later, I changed one sentence in my system prompt to improve a feature. Right after that, the agent started failing on tasks that worked fine yesterday. It called the wrong tools, got stuck in infinite loops, and wasted tokens.
In my previous post, What is Agent Observability?, I explained how to trace and inspect what an agent does in production. But before sending an agent to production, you need a repeatable way to measure its quality. You need Evals (evaluations).
In this article, I will show you how to test AI agents step by step in TypeScript using Google's open-source framework, Firebase Genkit. We will cover prompt validation, tool selection testing, and running evaluations.
📦 All the code from this article is available on GitHub: danywalls/genkit-agent-evals-demo. Clone it and run
pnpm run evalsto see the evaluations working in under 5 minutes.
Before we write code or install packages, we need a clear mental model. What does it actually mean to test an agent?
The 3 Questions You Must Ask Your Agent
When evaluating an agent, looking only at the final message is a mistake. An agent can sound polite while calling the wrong API or wasting twenty unnecessary requests.
To test an agent properly, we measure three main areas:
| Area | Question | What We Check |
|---|---|---|
| Tool Selection | Did it pick the right tool? | Tool name and arguments |
| Trajectory | Did it work efficiently? | Step count, loops, and token usage |
| Outcome | Did it solve the user problem? | Final answer accuracy and facts |
First, we check Tool Selection. If a user asks to search photos, the agent must call your search tool, not an unrelated database query.
Second, we check Trajectory. If an agent takes eight attempts to call a single tool, something is wrong with your tool description or schema.
Third, we check the Outcome. The final answer must be accurate and directly answer the user prompt.
With this mental model in place, let's set up our project to test these three areas step by step.
Setting Up Your Testing Environment
To build our agent and write tests, we use Firebase Genkit with TypeScript.
Run the following command to install the required packages:
pnpm add @genkit-ai/ai @genkit-ai/flow @genkit-ai/dotprompt @genkit-ai/googleai zodNext, add your Gemini API key to your .env file:
GOOGLE_GENAI_API_KEY=your_api_key_hereYou can get a free API key from Google AI Studio.
With our environment ready, let's look at the first place where agents fail: invalid prompt inputs.
Step 1: Validating Prompts with Dotprompt and Zod
Many agent failures happen before the model even starts generating an answer. They happen because a template variable was undefined, empty, or formatted incorrectly.
What is Dotprompt?
Most developers write prompts as plain strings in their code, like this:
const prompt = `Help the user: ${userQuery}`;The problem is that there is no validation. If userQuery is undefined, your model receives a broken prompt and you only find out when it gives a wrong answer.
Dotprompt is a feature of Firebase Genkit that lets you write prompts in dedicated .prompt files with a YAML frontmatter section. That frontmatter declares exactly what variables the prompt needs, what types they are, and which model to use.
Think of it as a typed function signature for your prompt: if you call it with the wrong arguments, Genkit stops it before it reaches the model.
In Firebase Genkit, we use Dotprompt (.prompt files) combined with Zod schema validation to catch these errors early:
---
# prompts/assistant.prompt
model: googleai/gemini-2.5-flash
input:
schema:
userQuery: string
preferredOrientation: string
maxResults: number
---
You are a photo assistant. Help the user find photography assets.
User Request: {{userQuery}}
Orientation: {{preferredOrientation}}
Maximum Results: {{maxResults}}This .prompt file acts as a contract. It tells Genkit which template variables are required before creating the prompt.
Now, in our TypeScript code, Genkit automatically validates inputs against this schema before calling the model:
// promptValidator.ts
import { dotprompt } from '@genkit-ai/dotprompt';
import { z } from 'zod';
const SearchInputSchema = z.object({
userQuery: z.string().min(2, "Query is too short"),
preferredOrientation: z.enum(["landscape", "portrait", "squarish"]),
maxResults: z.number().min(1).max(10),
});
export async function renderValidatedPrompt(input: unknown) {
// 1. Validate inputs before touching the model
const validated = SearchInputSchema.parse(input);
const prompt = await dotprompt('assistant');
return prompt.render(validated);
}The SearchInputSchema.parse call validates the incoming data with Zod at runtime. If any variable is missing or has the wrong type, execution stops immediately with a clear error before calling the Gemini API. This saves money and avoids broken agent runs.
Now that our prompt inputs are safely validated, our agent needs tools to take action. Let's create our search tool and build the agent flow.
Step 2: Defining the Agent Flow and Tools
In Genkit, your agent is built from two core building blocks: Tools and Flows.
What is a Tool?
A Tool is a typed function that the model can decide to call on its own. You describe what the tool does in plain text, define its inputs and outputs with Zod schemas, and Genkit tells the model that this tool is available.
The model reads your description and decides when to use the tool. If the description is clear, the model picks the right tool. If the description is vague, the model guesses, and that is when evals catch the mistake.
You can read more about Genkit Tools in the official docs.
What is a Flow?
A Flow connects everything together. It is a typed, observable function that wraps your whole agent logic: it receives user input, calls the model (with the available tools), and returns the result.
Because flows are typed and registered with Genkit, you can run them from the terminal, the Developer UI, or your evaluation scripts, all consistently.
You can read more about Genkit Flows in the official docs.
Let's define a photo search tool inspired by my unsplashx project:
// tools.ts
import { defineTool } from '@genkit-ai/ai';
import { z } from 'zod';
export const searchPhotosTool = defineTool(
{
name: 'searchPhotos',
description: 'Search for high-quality photos by keyword and orientation.',
inputSchema: z.object({
query: z.string().describe('Search keyword'),
orientation: z.enum(['landscape', 'portrait', 'squarish']),
}),
outputSchema: z.object({
count: z.number(),
results: z.array(z.string()),
}),
},
async (input) => {
// Tool execution logic
return {
count: 2,
results: [`https://images.unsplash.com/photo-1?${input.orientation}`],
};
}
);The description property is very important because the LLM reads it to decide when to call this tool. The inputSchema uses Zod to limit arguments to valid search parameters, so the model never passes unexpected keys.
Now, let's create the agent flow that uses this tool. Notice that we reuse renderValidatedPrompt from Step 1 instead of an inline string:
// agentFlow.ts
import { configureGenkit } from '@genkit-ai/core';
import { googleAI } from '@genkit-ai/googleai';
import { defineFlow } from '@genkit-ai/flow';
import { generate } from '@genkit-ai/ai';
import { dotprompt } from '@genkit-ai/dotprompt';
import { searchPhotosTool } from './tools.js';
import { renderValidatedPrompt } from './promptValidator.js';
// Initialize Genkit once, at the entry point of your app.
// The API key is read from GOOGLE_GENAI_API_KEY in your .env file.
configureGenkit({
plugins: [googleAI(), dotprompt()],
enableTracingAndMetrics: true,
});
export const photoAgentFlow = defineFlow(
{ name: 'photoAgent' },
async (input: { userQuery: string; preferredOrientation: string; maxResults: number }) => {
// Reuse the validated prompt (never pass raw strings to the model)
const messages = await renderValidatedPrompt(input);
const response = await generate({
model: 'googleai/gemini-2.5-flash',
messages,
tools: [searchPhotosTool],
});
return {
text: response.text(),
toolCalls: response.toolCalls(),
};
}
);Notice that photoAgentFlow returns response.toolCalls() together with the text. This allows our test runner to check the exact tools and arguments the model selected instead of parsing raw text with complex regular expressions.
Our agent flow can run and call tools. But testing it once manually in the terminal is just another vibe check. To make sure our agent does not break when we change code or prompts, we need a Golden Dataset.
Step 3: Building a Golden Dataset
A Golden Dataset is a collection of real test scenarios with their expected behaviors.
What is a Golden Dataset?
The name comes from the idea of a "golden standard". These are the test cases that you know are correct and that your agent must always pass.
Think of it like a regression test suite for your agent. In normal software, you write unit tests that check if add(2, 3) returns 5. In agent testing, you write eval cases that check if the agent calls the right tool with the right arguments when a user says "Find me a landscape photo of a desk".
Without a Golden Dataset, every prompt change is a risk. You change a sentence, you test it once manually, it looks fine, and you ship. Two days later, a user reports the agent is using the wrong tool. With a Golden Dataset, you run all your known scenarios after every change and you catch that bug immediately.
Each test case in the dataset has four parts:
| Field | Purpose |
|---|---|
id | A unique name to identify the test in reports |
input | The real user message you send to the agent |
expectedTool | The tool the agent must call |
expectedParams | The arguments the tool must receive |
Let's define our evaluation dataset in evalDataset.json:
[
{
"id": "search-minimal-desk",
"input": {
"userQuery": "Find a landscape photo of a minimal desk setup.",
"preferredOrientation": "landscape",
"maxResults": 3
},
"expectedTool": "searchPhotos",
"expectedParams": { "orientation": "landscape" }
},
{
"id": "search-vertical-nature",
"input": {
"userQuery": "I need portrait wallpaper photos of mountain trees.",
"preferredOrientation": "portrait",
"maxResults": 5
},
"expectedTool": "searchPhotos",
"expectedParams": { "orientation": "portrait" }
}
]Each entry in this file defines a real user prompt and the exact tool and parameters we expect. If we update our prompt instructions tomorrow, running this dataset immediately warns us if the model chooses the wrong tool.
With our test cases ready, the final step is writing an automated evaluation runner in TypeScript to execute them and verify the results.
Step 4: Running Evaluations with Code Assertions
To test our agent locally, we run each test case and check two things:
- Did the agent call the expected tool?
- Did it pass the expected arguments?
Here is our evaluation runner written in TypeScript. We split it into two focused parts.
First, let's define the types and the parameter matching helper:
// runEvals.ts: Part 1, types and helpers
import fs from 'fs';
import { photoAgentFlow } from './agentFlow.js';
interface TestCase {
id: string;
input: { userQuery: string; preferredOrientation: string; maxResults: number };
expectedTool: string;
expectedParams: Record<string, string>;
}
function checkParams(
actual: Record<string, unknown>,
expected: Record<string, string>
): boolean {
return Object.entries(expected).every(([key, val]) => actual[key] === val);
}The TestCase interface models each scenario from our dataset. The checkParams helper ensures every expected argument matches what the agent actually passed to the tool.
Now let's write the execution loop that runs each test case and reports the results:
// runEvals.ts: Part 2, evaluation loop
async function runEvaluation() {
const cases: TestCase[] = JSON.parse(fs.readFileSync('evalDataset.json', 'utf8'));
let passed = 0;
console.log('Running Agent Evaluations...\n');
for (const test of cases) {
const output = await photoAgentFlow(test.input);
const firstCall = output.toolCalls?.[0];
const calledTool = firstCall?.name;
const calledParams = (firstCall?.input ?? {}) as Record<string, unknown>;
const toolOk = calledTool === test.expectedTool;
const paramsOk = checkParams(calledParams, test.expectedParams);
if (toolOk && paramsOk) {
console.log(`✅ [PASS] ${test.id}: ${calledTool} with correct params`);
passed++;
} else if (!toolOk) {
console.log(`❌ [FAIL] ${test.id}: Expected tool ${test.expectedTool}, got ${calledTool}`);
} else {
console.log(`❌ [FAIL] ${test.id}: Tool ok, but wrong params. Expected:`, test.expectedParams);
}
}
const passRate = Math.round((passed / cases.length) * 100);
console.log(`\nResults: ${passed}/${cases.length} passed (${passRate}%)\n`);
}
runEvaluation();The runEvaluation function loops over every scenario in evalDataset.json, runs photoAgentFlow, and checks both the tool called and the arguments sent. It prints a pass or fail indicator for each case and computes the final pass rate.
If you are using the companion repo, run:
pnpm run evalsOr if you are building your own project from scratch:
npx tsx src/runEvals.tsYou will see output like this:
🧪 Running Agent Evaluations...
Running: search-minimal-desk
✅ [PASS] search-minimal-desk: searchPhotos with correct params
Running: search-vertical-nature
✅ [PASS] search-vertical-nature: searchPhotos with correct params
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Results: 2/2 passed (100%)You can also run Genkit's local Developer UI by executing:
npx genkit startThis opens a dashboard on http://localhost:4000 where you can inspect every trace, view token costs, and run flows interactively for free.
Running evaluations locally gives you fast feedback while developing on your laptop. But when you build agents with a team, local scripts start to have limits.
When Local Scripts Fall Short: Scaling to Enterprise Evals
Building your own test scripts in TypeScript with Genkit is the best way to understand how evaluations work. It is free, fast, and runs directly on your machine.
However, as your project and team grow, you will run into common challenges:
Managing Golden Datasets: A JSON file with 10 cases is fine. With 200 cases and 5 engineers editing it, you get merge conflicts every day. There is no UI to add or filter scenarios without opening a code editor.
Historical regression tracking: Your terminal output tells you if today's run passed. It does not tell you that your pass rate dropped from 95% to 78% over the last two weeks because of a prompt change.
Testing across model providers: To compare Gemini 2.5 Flash with Claude 3.5 Sonnet on the same dataset, you need to write custom runner code for each provider. That is extra code you have to maintain.
Team visibility: Your product manager cannot read terminal logs. Team members and managers need visual dashboards with pass rate trends, cost per run, and failure breakdowns by scenario type.
Moving from local developer tests to a centralized evaluation platform is the natural next step for growing teams. In my next article, we will look at how enterprise evaluation platforms solve this scaling problem.
Whether you run tests through local scripts or a centralized platform, moving away from manual testing is what makes your agents reliable in production.
Summary
Testing AI agents requires moving beyond manual vibe checks:
- Validate prompts first: Use Genkit Dotprompt and Zod to check template variables before calling the model.
- Evaluate the full trajectory: Check tool selection and step counts, not just the final message.
- Build a golden dataset: Test real user scenarios to catch bugs before shipping.
- Use open-source tools locally: Firebase Genkit gives you free local flows, traces, and Developer UI tools.
If you are already collecting traces with Agent Observability, you have the data ready to build your first evaluation dataset. Start testing your agents today!
Bridging the gap between AI demos and real-world production applications.
Frequently Asked Questions
What is an evaluation (eval) in AI agents?
An eval is an automated test that measures how well an AI agent performs a task, verifying prompt inputs, tool selection, step efficiency, and answer accuracy.
How do you validate prompts before calling an LLM?
You can use Genkit Dotprompt and Zod to validate template variables, input types, and token limits before sending requests to the language model.
Why use Firebase Genkit to test AI agents in TypeScript?
Genkit is an open-source TypeScript framework with built-in support for schema-validated tools, flows, prompt templates, and a free local Developer UI for inspecting traces and running evaluations.
What are the limitations of local evaluation scripts?
Local evaluations are great during early development, but managing large test datasets across team members, tracking historical regressions, and enterprise dashboards require dedicated evaluation platforms.
Related Articles
What is Agent Observability? Why and When It Matters
A practical guide to Agent Observability, OpenTelemetry, spans, 3-layer tracing architecture, and why debugging AI requires a new approach.
How to Build an MCP Server in TypeScript: Connect My Own Tools with AI Agents
Learn how I built unsplashx, an open-source Model Context Protocol server in TypeScript that gives AI assistants direct access to authentic Unsplash photos and credits real photographers.
Prompt Injection in Agentic Apps: 4 Layers of Defense
Learn how to protect your AI agents using a defense-in-depth strategy with these 4 essential security layers.
How to Create One-Click MCP Installation Deep Links for Cursor and VS Code
Stop making users manually edit JSON files to install your MCP server. Learn how to automate one-click installation deep links for Cursor and VS Code.
Share this article
If you found this guide helpful, consider sharing it with your team or fellow developers.
Real Software. Real Lessons.
I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.
Join 13,800+ developers and readers.
No spam ever. Unsubscribe at any time.