MockBuilder: Fluent Mock Registration
The MockBuilder provides a chainable API for registering mocks, making common mocking patterns more concise and readable.
Quick Start
from stuntdouble import MockToolsRegistry
registry = MockToolsRegistry()
# Simple static mock
registry.mock("get_customer").returns({"id": "123", "name": "Test Corp"})
# Conditional mock with input matching
registry.mock("list_bills").when(status="active").returns({"bills": [{"id": "B001"}]})
# Echo input fields back in response
registry.mock("create_user").echoes_input("user_id", "email").returns({"status": "created"})
Using the Default Registry
For quick testing and prototyping, you can use default_registry.mock() from stuntdouble.langgraph without an explicit registry:
from stuntdouble import default_registry
# No registry parameter needed—uses default_registry
default_registry.mock("get_customer").returns({"id": "123", "name": "Mocked"})
# Conditional mock
default_registry.mock("list_bills").when(status="active").returns({"bills": []})
# Echo input
default_registry.mock("create_invoice").echoes_input("customer_id").returns({"status": "created"})
# Verify registration
assert default_registry.is_registered("get_customer")
assert default_registry.is_registered("list_bills")
assert default_registry.is_registered("create_invoice")
print(default_registry.list_registered())
# ['get_customer', 'list_bills', 'create_invoice']
This is ideal when using the pre-configured mockable_tool_wrapper:
from langgraph.prebuilt import ToolNode
from stuntdouble import default_registry, mockable_tool_wrapper
# Register mocks (automatically on default_registry)
default_registry.mock("get_customer").returns({"id": "123", "name": "Test Corp"})
default_registry.mock("list_bills").returns({"bills": []})
# Use the pre-configured wrapper (reads from default_registry)
tool_node = ToolNode(tools, awrap_tool_call=mockable_tool_wrapper)
API Reference
from stuntdouble import MockToolsRegistry
registry = MockToolsRegistry()
# Fluent API via registry
registry.mock(tool_name: str) -> MockBuilder
# Default registry (no explicit registry needed)
from stuntdouble import default_registry
default_registry.mock(tool_name: str) -> MockBuilder
Methods
Method |
Description |
Example |
|---|---|---|
|
Register mock that returns a static value |
|
|
Register mock that uses a callable |
|
|
Add a scenario predicate, input conditions, or both |
|
|
Echo input fields in response |
|
Examples
Static Return Value
from stuntdouble import MockToolsRegistry
registry = MockToolsRegistry()
registry.mock("get_weather").returns({"temp": 72, "conditions": "sunny"})
Or with default registry:
from stuntdouble import default_registry
default_registry.mock("get_weather").returns({"temp": 72, "conditions": "sunny"})
Conditional Mocking
# Gate on scenario metadata
registry.mock("send_email").when(
lambda md: md.get("mode") == "test"
).returns({"sent": True})
# Match specific input values
registry.mock("get_customer").when(customer_id="VIP-001").returns({"tier": "platinum"})
# Match with operators
registry.mock("list_bills").when(status={"$in": ["paid", "pending"]}).returns({"priority": "low"})
# Multiple conditions (AND logic)
registry.mock("get_bills").when(status="overdue", amount={"$gt": 5000}).returns({"priority": "URGENT"})
# Combine with operators
registry.mock("get_invoice").when(
status={"$in": ["paid", "pending"]},
amount={"$gt": 100}
).returns({"priority": "high"})
Input Echoing
# Echo specific input fields in response
registry.mock("create_invoice").echoes_input("customer_id", "amount").returns({"status": "created"})
# This creates a mock that returns:
# {
# "customer_id": <input value>,
# "amount": <input value>,
# "status": "created"
# }
Custom Callable
import time
# Use a function for dynamic responses
def dynamic_mock(**kwargs):
return {"id": kwargs.get("id"), "timestamp": time.time()}
registry.mock("create_record").returns_fn(dynamic_mock)
# With calculation logic
registry.mock("calculate_total").returns_fn(
lambda items, tax_rate: {"total": sum(i["price"] for i in items) * (1 + tax_rate)}
)
Chaining Methods
# Chain multiple configuration methods
(registry.mock("get_bills")
.when(status="overdue")
.echoes_input("customer_id")
.returns({"priority": "high", "action": "notify"}))
Scenario Predicates and Input Conditions
registry.mock("create_invoice").when(
lambda md: md.get("tenant") == "sandbox",
amount={"$gt": 1000},
).returns({"status": "queued"})
The scenario predicate controls whether the mock is resolved at all. Input conditions are then checked against the actual tool-call kwargs when the mock executes.
Notes and Limits
.returns()and.returns_fn()are terminal methods. They register the mock immediately.MockBuilderregisters one mock per tool name. Callingregistry.mock("tool")again overwrites the previous registration.Placeholder resolution such as
{{now}}and{{input.customer_id}}belongs to the data-driven mock flow, notMockBuilder.
Integration Examples
With LangGraph Per-Invocation Mocking
from stuntdouble import MockToolsRegistry, create_mockable_tool_wrapper
from langgraph.prebuilt import ToolNode
registry = MockToolsRegistry()
# Register mocks using builder
registry.mock("get_customer").returns({"id": "123", "name": "Test Corp"})
registry.mock("list_bills").when(status="active").returns({"bills": [{"id": "B001"}]})
# Create wrapper and use with ToolNode
wrapper = create_mockable_tool_wrapper(registry)
tool_node = ToolNode(tools, awrap_tool_call=wrapper)
With Default Registry (Simplest)
from stuntdouble import default_registry, mockable_tool_wrapper
from langgraph.prebuilt import ToolNode
# Register mocks on default_registry
default_registry.mock("get_customer").returns({"id": "123", "name": "Test Corp"})
default_registry.mock("list_bills").when(status="active").returns({"bills": [{"id": "B001"}]})
# Use pre-configured wrapper
tool_node = ToolNode(tools, awrap_tool_call=mockable_tool_wrapper)
Input Condition Behavior
When using .when(**conditions), the mock raises InputNotMatchedError if the tool call arguments don’t match. This prevents non-matching calls from silently returning None as a successful tool result.
from stuntdouble import MockToolsRegistry
from stuntdouble.exceptions import InputNotMatchedError
registry = MockToolsRegistry()
registry.mock("get_bills").when(status="active").returns({"bills": []})
fn = registry.resolve("get_bills", {})
fn(status="active") # -> {"bills": []}
fn(status="inactive") # -> raises InputNotMatchedError
The wrapper catches InputNotMatchedError and routes it through the standard no-mock handling:
Strict mode (
require_mock_when_scenario=True): raisesMissingMockErrorwith a descriptive message about which conditions failedLenient mode (
require_mock_when_scenario=False): falls back to the real tool
Builder vs. Data-Driven
The builder registers one mock per tool. Calling registry.mock("tool") twice overwrites the first registration. For multi-case matching (multiple input/output pairs for the same tool), use register_data_driven with scenario_metadata instead:
# Builder: one mock, one response per tool (simple cases)
registry.mock("get_weather").returns({"temp": 72})
# Data-driven: multiple cases per tool (complex scenarios)
registry.register_data_driven("query_bills")
# Cases come from scenario_metadata at runtime:
# {"mocks": {"query_bills": [
# {"input": {"status": "overdue"}, "output": {"bills": [...]}},
# {"input": {"status": "paid"}, "output": {"bills": [...]}},
# {"output": {"bills": []}}
# ]}}
Advanced Mock Function Patterns
Beyond the basics, MockBuilder supports patterns for complex business logic, error simulation, and context-aware mocking.
Complex Logic with returns_fn()
When your mock needs branching logic, pass a callable to returns_fn(). Here a billing calculator computes totals from structured input:
registry.mock("calculate_total").returns_fn(
lambda items, tax_rate=0.0: {
"subtotal": sum(item["price"] * item["qty"] for item in items),
"tax": sum(item["price"] * item["qty"] for item in items) * tax_rate,
"total": sum(item["price"] * item["qty"] for item in items) * (1 + tax_rate),
"item_count": len(items),
}
)
The callable receives the same keyword arguments the real tool would. Use this whenever a static .returns() value isn’t expressive enough.
Error Simulation
Mock tools that return errors to verify your agent handles failures gracefully:
# Simulate a service outage
registry.mock("send_email").returns({"error": "Service unavailable", "status": 503})
# Simulate rate limiting
registry.mock("call_api").returns_fn(
lambda **kwargs: {"error": "Rate limit exceeded", "retry_after": 30}
)
# Simulate partial failure
registry.mock("batch_update").returns_fn(
lambda records: {
"succeeded": [r["id"] for r in records[:3]],
"failed": [{"id": r["id"], "error": "Conflict"} for r in records[3:]],
}
)
This is especially useful for testing retry logic, fallback paths, and user-facing error messages in your agent.
Scenario Predicates with when()
Pass a lambda predicate to .when() to gate mocks on scenario metadata. This enables feature-flag style mocking:
# Mock only when scenario has a specific flag
registry.mock("get_pricing").when(
lambda metadata: metadata.get("feature_flags", {}).get("new_pricing") is True
).returns({"plan": "v2", "price": 29.99})
# Default pricing when flag is off
registry.mock("get_pricing").returns({"plan": "v1", "price": 19.99})
Scenario predicates are checked first. If the predicate doesn’t match, the next registered mock for that tool is tried. This lets you layer conditional mocks with a fallback default.
Combining when() + echoes_input() + returns()
All three methods can be chained together. Input conditions, echoed fields, and static return values are merged into the final response:
registry.mock("create_user").when(
role={"$in": ["admin", "superadmin"]}
).echoes_input("email", "role").returns({
"id": "USR-001",
"status": "active",
"permissions": ["read", "write", "delete"],
"created_at": "2025-01-01T00:00:00Z",
})
# Input: {"email": "admin@acme.com", "role": "admin"}
# Output: {"id": "USR-001", "status": "active", "permissions": [...],
# "created_at": "...", "email": "admin@acme.com", "role": "admin"}
The echoed fields (email, role) are merged into the static return value. If an input doesn’t match the .when() conditions, InputNotMatchedError is raised as usual.
Mock Factory with registry.register() for Full Control
When you need access to scenario_metadata at mock-creation time, use the lower-level register() method directly:
def tenant_aware_mock(scenario_metadata: dict, config: dict = None):
"""Mock that varies behavior based on scenario metadata."""
tenant = scenario_metadata.get("tenant", "default")
if tenant == "enterprise":
return lambda account_id: {
"account_id": account_id,
"features": ["sso", "audit_log", "custom_roles"],
"max_users": 10000,
}
else:
return lambda account_id: {
"account_id": account_id,
"features": ["basic_auth"],
"max_users": 5,
}
registry.register("get_account_features", mock_fn=tenant_aware_mock)
register() gives you a two-phase mock: the outer function receives scenario_metadata and returns the actual mock callable. This is the most powerful pattern when you need runtime context to determine mock behavior.
See Also
Quickstart Guide - Getting started with StuntDouble
LangGraph Approach - LangGraph-specific mocking patterns
Matchers and Resolvers - Input matching operators and placeholders
Call Recording - Verify tool calls in tests