AI
·13 min read·↗

How to Test and Evaluate AI Agents with TypeScript and Genkit

Main cover illustration for article: How to Test and Evaluate AI Agents with TypeScript and Genkit
Summarize with AI:

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 evals to 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:

AreaQuestionWhat We Check
Tool SelectionDid it pick the right tool?Tool name and arguments
TrajectoryDid it work efficiently?Step count, loops, and token usage
OutcomeDid 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 zod

Next, add your Gemini API key to your .env file:

GOOGLE_GENAI_API_KEY=your_api_key_here

You 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:

FieldPurpose
idA unique name to identify the test in reports
inputThe real user message you send to the agent
expectedToolThe tool the agent must call
expectedParamsThe 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:

  1. Did the agent call the expected tool?
  2. 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 evals

Or if you are building your own project from scratch:

npx tsx src/runEvals.ts

You 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 start

This 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:

  1. Validate prompts first: Use Genkit Dotprompt and Zod to check template variables before calling the model.
  2. Evaluate the full trajectory: Check tool selection and step counts, not just the final message.
  3. Build a golden dataset: Test real user scenarios to catch bugs before shipping.
  4. 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!

Part of the AI & Engineering Series

Bridging the gap between AI demos and real-world production applications.

View Entire Series

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

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.

User avatar
User avatar
User avatar
User avatar
+13K

Join 13,800+ developers and readers.

No spam ever. Unsubscribe at any time.