n4nAI

Testing Gemini 3 function calling against a JSON schema

A hands-on tutorial for testing Gemini 3 function calling against a JSON schema: build a validation harness, write pytest tests, and catch schema drift.

n4n Team2 min read503 words

Audio narration

Coming soon — every post will get a voice note here.

Testing Gemini 3 function calling against a strict JSON schema is the only reliable way to know your agent’s tool use won’t blow up in production. This tutorial builds a minimal but rigorous harness that sends a function declaration to Gemini 3, captures the model’s function call, and validates the returned arguments against your schema before they ever reach your backend.

Prerequisites

  • Python 3.11 or newer
  • A Gemini API key with access to a Gemini 3 model (we use gemini-3-pro in the examples)
  • Network access to generativelanguage.googleapis.com
  • The requests, jsonschema, and pytest packages
pip install requests jsonschema pytest
export GEMINI_API_KEY="your-key-here"

If your organization routes traffic through an OpenAI-compatible gateway, the validation logic below transfers unchanged—only the request shape differs. We’ll note that at the end.

Define the function schema

Start by writing the contract. Gemini accepts a parameters object that is a standard JSON Schema (draft 2020-12 works). We test a create_event function with required strings, an array of emails, and a closed enum for timezone.

{
  "name": "create_event",
  "description": "Create a calendar event from a natural language request",
  "parameters": {
    "type": "object",
    "properties": {
      "title": { "type": "string", "minLength": 1 },
      "start_time": { "type": "string", "format": "date-time" },
      "attendees": {
        "type": "array",
        "items": { "type": "string", "format": "email" }
      },
      "timezone": {
        "type": "string",
        "enum": ["UTC", "America/New_York", "Europe/London"]
      }
    },
    "required": ["title", "start_time"]
  }
}

Save this as schema.json. Note that Gemini does not enforce format at the API boundary—jsonschema will, once we run validation locally.

Calling Gemini 3 with the schema

Gemini’s native generateContent endpoint takes tools.functionDeclarations. We map our file directly. The script below is runnable as call.py.

import os, json, requests

API_KEY = os.environ["GEMINI_API_KEY"]
MODEL = "gemini-3-pro"
URL = f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent?key={API_KEY}"

with open("schema.json") as f:
    func_decl = json.load(f)

def call_gemini(prompt: str) -> dict:
    payload = {
        "contents": [{
            "role": "user",
            "parts": [{"text": prompt}]
        }],
        "tools": [{
            "functionDeclarations": [{
                "name": func_decl["name"],
                "description": func_decl["description"],
                "parameters": func_decl["parameters"]
            }]
        }]
    }
    r = requests.post(URL, json=payload, timeout=30)
    r.raise_for_status()
    return r.json()

if __name__ == "__main__":
    data = call_gemini(
        "Schedule a sync with alex@acme.com tomorrow at 10am UTC titled 'Sprint Planning'"
    )
    print(json.dumps(data, indent=2))

Expected (abridged) response:

{
  "candidates": [{
    "content": {
      "parts": [{
        "functionCall": {
          "name": "create_event",
          "args": {
            "title": "Sprint Planning",
            "start_time": "2025-01-15T10:00:00Z",
            "attendees": ["alex@acme.com"],
            "timezone": "UTC"
          }
        }
      }]
    }
  }]
}

Extract the call with a small helper:

def extract_function_call(data: dict) -> dict | None:
    parts = data["candidates"][0]["content"]["parts"]
    for p in parts:
        if "functionCall" in p:
            return p["functionCall"]
    return None

Validating the response

The core of testing Gemini 3 function calling is rejecting arguments that drift from the schema. Use jsonschema to iterate errors instead of a boolean pass/fail.

from jsonschema import Draft202012Validator

schema = func_decl["parameters"]
validator = Draft202012Validator(schema)

def validate_args(args: dict) -> None:
    errors = sorted(validator.iter_errors(args), key=lambda e: list(e.path))
    if errors:
        msgs = [f"{'/'.join(map(str,e.path)) or '<root>'}: {e.message}" for e in errors]
        raise AssertionError("Schema violations:\n" + "\n".join(msgs))

# In practice:
fc = extract_function_call(data)
assert fc is not None
validate_args(fc["args"])  # raises if Gemini 3 emitted e.g. timezone: "Mars"

If you prefer typed access in Python, pydantic is a drop-in alternative:

from pydantic import BaseModel, EmailStr, Field
from typing import List, Optional

class EventArgs(BaseModel):
    title: str = Field(min_length=1)
    start_time: str  # pydantic v2 doesn't enforce date-time by default; add validator if needed
    attendees: Optional[List[EmailStr]] = None
    timezone: str  # add Literal["UTC", ...] for strict enum

    model_config = {"extra": "forbid"}

EventArgs(**fc["args"])

extra: forbid catches the all-too-common Gemini habit of adding a notes field you never declared.

Writing a repeatable test

Put the harness under tests/test_gemini_tools.py. Parametrize over prompts so one regression doesn’t hide behind another.

import pytest, requests, os, json
from jsonschema import Draft202012Validator

API_KEY = os.environ["GEMINI_API_KEY"]
URL = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro:generateContent?key={API_KEY}"

with open("schema.json") as f:
    func_decl = json.load(f)
validator = Draft202012Validator(func_decl["parameters"])

PROMPTS = [
    "Schedule a sync with alex@acme.com tomorrow at 10am UTC titled 'Sprint Planning'",
    "Book 'Design Review' with bob@acme.com and carol@acme.com at 2025-02-01T15:00:00Z",
    "Add an event titled 'Standup' with no attendees",
]

def extract_function_call(data):
    for p in data["candidates"][0]["content"]["parts"]:
        if "functionCall" in p:
            return p["functionCall"]

@pytest.mark.parametrize("prompt", PROMPTS)
def test_gemini_3_function_call(prompt):
    payload = {
        "contents": [{"role": "user", "parts": [{"text": prompt}]}],
        "tools": [{"functionDeclarations": [func_decl]}]
    }
    r = requests.post(URL, json=payload, timeout=30)
    r.raise_for_status()
    fc = extract_function_call(r.json())
    assert fc, "Model returned no function call"
    assert fc["name"] == "create_event"
    errors = list(validator.iter_errors(fc["args"]))
    assert not errors, [e.message for e in errors]

Run it:

pytest tests/test_gemini_tools.py -q

Expected output:

3 passed in 4.83s

Handling schema drift and model errors

Forcing strict mode

Gemini 3 has no OpenAI-style strict: true. You push compliance by adding a systemInstruction and by keeping your schema description crisp.

payload["systemInstruction"] = {
    "parts": [{"text": "You MUST call create_event. Arguments MUST match the supplied JSON schema exactly. Do not add fields."}]
}

Testing fallback behavior

If you call Gemini 3 through an OpenAI-compatible gateway such as n4n.ai, the tools payload uses the OpenAI functions/tools format and the response arrives as choices[0].message.tool_calls. The gateway honors your routing directives and forwards provider cache-control hints, so the same validate_args function works after a json.loads(tool_call.function.arguments). That lets you test degraded-provider fallback by sending a header that forces a backup route.

Running the suite in CI

Live model calls are flaky and rate-limited; run them nightly, not on every PR. A GitHub Actions workflow:

name: gemini-tool-tests
on:
  schedule:
    - cron: "0 3 * * *"
  workflow_dispatch:
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install requests jsonschema pytest
      - run: pytest tests/test_gemini_tools.py -q
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}

Keep a mocked unit test in the same file (or a separate test_validator.py) that runs on push.

Beyond happy paths

Negative tests matter. Add a prompt that should NOT trigger the tool:

def test_no_call_for_small_talk():
    payload = {"contents":[{"role":"user","parts":[{"text":"What's the weather?"}]}],
               "tools":[{"functionDeclarations":[func_decl]}]}
    r = requests.post(URL, json=payload, timeout=30)
    fc = extract_function_call(r.json())
    assert fc is None

And a pure validator test that needs no network:

def test_validator_rejects_bad_enum():
    with pytest.raises(AssertionError):
        validate_args({"title":"X","start_time":"2025-01-01T00:00:00Z","timezone":"Moon"})

Debugging failed calls

When extract_function_call returns None, print the raw candidate text—Gemini sometimes answers with a normal part instead of a function call.

def debug_response(data):
    for p in data["candidates"][0]["content"]["parts"]:
        print(p.get("text") or p)

Testing Gemini 3 function calling on a schedule surfaces silent schema violations before your users hit them. The harness above is small enough to copy into any repo and strict enough to block a model update that quietly renames a field.

Tagsgemini-3function-callingjson-schematesting

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All testing ai agents & tool calling posts →