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-proin the examples) - Network access to
generativelanguage.googleapis.com - The
requests,jsonschema, andpytestpackages
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.