Developers building AI-powered features face a persistent set of friction points: unpredictable API costs, rate limits that interrupt flow, non-deterministic responses that break tests, and network dependencies that make CI/CD pipelines fragile. Rubberduck addresses these problems directly by providing a local OpenAI API emulator that returns deterministic, configurable responses, enabling teams to build and test AI integrations without external dependencies.
This article walks through setting up Rubberduck in a JavaScript project, configuring response fixtures, integrating it into development and testing workflows, and handling advanced scenarios like error simulation and streaming.
Table of Contents
- Why Local AI Emulation Matters
- Core Concepts of Rubberduck
- Setting Up Rubberduck in a JavaScript Project
- Integrating Rubberduck into Your Development Workflow
- Advanced Configuration and Patterns
- Implementation Checklist
- Limitations and When to Use the Real API
- Summary
Why Local AI Emulation Matters
The Problem with Developing Against Live APIs
Every call to the OpenAI API during development costs money. For teams iterating rapidly on prompts, refining UI components that consume completions, or running test suites that exercise AI-dependent code paths, a 50-developer team can burn through a $500 monthly budget in days. Rate limits compound the problem. OpenAI enforces per-minute and per-day request caps that can stall a developer mid-session, particularly on shared API keys across a team.
Beyond cost, the non-deterministic nature of language model responses makes automated testing unreliable. The same prompt can return different wording, different formatting, or different token counts across calls. Tests that assert on response content become flaky by design. Network dependency adds another layer of fragility: CI/CD pipelines that depend on an external service introduce a failure mode entirely outside the team's control. For organizations handling sensitive data, sending prompts to an external API during development risks exposing PII or proprietary content to OpenAI's servers. Note that Rubberduck mitigates this for the development phase only; staging and production environments that call the real API remain subject to OpenAI's data use policies.
The non-deterministic nature of language model responses makes automated testing unreliable. The same prompt can return different wording, different formatting, or different token counts across calls.
What Rubberduck Does Differently
Rubberduck is a lightweight, local HTTP server that emulates OpenAI's REST API surface. Unlike generic mocking libraries that require developers to manually stub HTTP responses, or broad-purpose stub servers that need extensive configuration to match a specific API's contract, Rubberduck is purpose-built for OpenAI API compatibility. It speaks the same request and response format, supports the same endpoint paths, and can be dropped into any project that already uses the OpenAI API with minimal wiring.
It does three things:
- API-compatible: no changes to application code required.
- Minimal-config for basic usage: a single configuration file is all you need; start it and point your client at localhost.
- Deterministic: you configure responses, not generate them, so they return identically every time.
Core Concepts of Rubberduck
How the Emulation Layer Works
Rubberduck runs as a local HTTP server, typically on a configurable port, that exposes endpoints mirroring OpenAI's REST API. Its primary endpoint is /v1/chat/completions, but Rubberduck also supports /v1/embeddings and other endpoints in the OpenAI surface area. When your application sends a request to Rubberduck, the emulator inspects the request body, matches it against configured response fixtures, and returns a response formatted identically to what the real OpenAI API would return, including the expected JSON structure with id, object, choices, usage, and other fields.
This format fidelity means that any client code using the OpenAI SDK or raw HTTP requests works without modification. Your application cannot distinguish between a Rubberduck response and a real one at the schema level.
Deterministic vs. Dynamic Response Modes
Rubberduck supports two primary response modes. In fixed response mode, every request to a matched endpoint returns the exact same configured response. This is ideal for unit tests where assertions depend on specific output. In template-based response mode, responses can include variable substitution, pulling values from the incoming request (such as echoing parts of the prompt or varying based on the specified model). This mode suits development scenarios where some variability helps exercise UI rendering or downstream processing without introducing true non-determinism.
You configure responses in a project-level config file, giving teams version-controlled, reviewable response definitions.
// Start Rubberduck and make a request
// Native fetch requires Node.js 18+. For earlier versions, install node-fetch:
// npm install node-fetch
// and import it: import fetch from 'node-fetch';
const { spawn } = require("child_process");
async function main() {
// Start the emulator on port 4010
const server = spawn("npx", ["rubberduck", "--port", "4010"]);
server.on("error", (err) => {
throw new Error(`Failed to start Rubberduck: ${err.message}`);
});
server.stderr.on("data", (data) => {
console.error("[rubberduck stderr]", data.toString());
});
// Give the server a moment to start
await new Promise((resolve) => setTimeout(resolve, 1000));
// Make a request identical to what you'd send to OpenAI
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15_000);
try {
const response = await fetch("http://localhost:4010/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_API_KEY || "dev-placeholder-key"}`,
},
body: JSON.stringify({
model: "gpt-4",
messages: [{ role: "user", content: "Summarize this document." }],
temperature: 0.7,
}),
signal: controller.signal,
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenAI API error ${response.status}: ${body}`);
}
const data = await response.json();
console.log(data.choices[0].message.content);
// Output: The configured deterministic response
console.log(data.usage);
// Output: { prompt_tokens: 12, completion_tokens: 20, total_tokens: 32 }
// (These values come from the response fixtures defined in rubberduck.config.js — see the next section.)
} finally {
clearTimeout(timeoutId);
server.kill();
}
}
main();
Notice that the response shape matches the real OpenAI API exactly: id, object, created, model, choices array with message objects, and usage statistics. Any code parsing real API responses works against this output without changes.
Setting Up Rubberduck in a JavaScript Project
Prerequisites
- Node.js 18 or later (
node --versionto check). Nativefetchand modern ES module features require Node 18+. - npm 8 or later.
- Verify the package name on npmjs.com/package/rubberduck-openai before installing. If the package is not available or has been renamed, check the project's official repository for current installation instructions.
Installation and Initial Configuration
Rubberduck installs as an npm package, typically as a dev dependency since it is only needed during development and testing. You will also need concurrently and start-server-and-test for the development and test scripts shown below.
# Install as a dev dependency
npm install --save-dev rubberduck-openai concurrently start-server-and-test
# Create the configuration file at the project root
# On Windows, create this file manually or use: New-Item rubberduck.config.js (PowerShell)
touch rubberduck.config.js
Your configuration file defines the port, default response behavior, and any response fixtures. A minimal setup looks like this:
// rubberduck.config.js
module.exports = {
port: 4010,
defaultModel: "gpt-4", // Sets the model identifier used in emulated responses
defaultResponse: {
content: "This is a default emulated response from Rubberduck.",
role: "assistant",
},
usage: {
prompt_tokens: 10,
completion_tokens: 15,
total_tokens: 25,
},
};
Note: Verify field names against the official Rubberduck configuration reference before use; the schema shown here reflects the version current at the time of writing and may change in future releases. The defaultModel field controls which model identifier appears in emulated responses. Use a model string matching whatever your application uses in production.
Add convenience scripts to package.json so that starting the emulator is a single command:
{
"scripts": {
"rubberduck": "rubberduck --config rubberduck.config.js",
"dev": "concurrently \"npm run rubberduck\" \"npm run start\"",
"test:integration": "start-server-and-test \"rubberduck --config rubberduck.config.js\" http://localhost:4010/v1/chat/completions \"vitest run\""
}
}
Running dev starts Rubberduck alongside your application using concurrently, while test:integration uses start-server-and-test to start the emulator, wait until it is listening, run the test suite, and then shut it down. This approach works cross-platform (Linux, macOS, and Windows). Note that start-server-and-test polls the given URL with GET; ensure the URL you provide returns a 2xx response to GET requests. If /v1/chat/completions does not respond to GET, use a known health endpoint (e.g., http://localhost:4010/healthz) or the server root, depending on what your Rubberduck version supports.
Configuring Custom Responses
For projects with multiple AI features, each calling OpenAI with different prompts and expecting different response shapes, Rubberduck supports named response fixtures. Rubberduck matches these against incoming requests based on prompt content, model, or other request properties. Requests matching no fixture return the defaultResponse defined at the top level of the configuration.
// rubberduck.config.js
module.exports = {
port: 4010,
latency: 200, // Simulate 200ms network delay globally
defaultResponse: {
content: "Default response when no fixture matches.",
role: "assistant",
},
fixtures: [
{
match: { messageContains: "summarize" },
response: {
content:
"The document discusses three main points: infrastructure scaling, cost optimization, and team velocity.",
role: "assistant",
},
usage: { prompt_tokens: 45, completion_tokens: 22, total_tokens: 67 },
latency: 350, // Override global latency for this fixture
},
{
match: { messageContains: "classify" },
response: {
content: JSON.stringify({
category: "technical",
confidence: 0.92,
subcategories: ["infrastructure", "devops"],
}),
role: "assistant",
},
usage: { prompt_tokens: 38, completion_tokens: 18, total_tokens: 56 },
},
],
};
Request code triggers specific fixtures based on prompt content:
// Triggers the summarization fixture
const summaryResponse = await fetch(
"http://localhost:4010/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_API_KEY || "dev-placeholder-key"}`,
},
body: JSON.stringify({
model: "gpt-4",
messages: [
{ role: "user", content: "Please summarize the following report..." },
],
}),
}
);
// Triggers the classification fixture
const classifyResponse = await fetch(
"http://localhost:4010/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_API_KEY || "dev-placeholder-key"}`,
},
body: JSON.stringify({
model: "gpt-4",
messages: [
{
role: "user",
content: "Classify this support ticket into a category...",
},
],
}),
}
);
Latency simulation adds realism. A global 200ms delay applies to all responses unless overridden at the fixture level, letting developers test loading states and timeout handling under conditions that approximate real network behavior without the unpredictability.
Integrating Rubberduck into Your Development Workflow
Swapping Between Local and Live APIs
Use an environment variable to control the API base URL. Application code reads this variable and defaults to the local Rubberduck URL in development, switching to the real OpenAI endpoint in production.
// lib/openai-client.js
// This file uses CommonJS syntax. If your project uses ES modules ("type": "module" in package.json),
// replace module.exports with export syntax and rename the file to .mjs if needed.
const OPENAI_API_BASE =
process.env.OPENAI_API_BASE || "http://localhost:4010/v1";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY || "dev-placeholder-key";
async function createChatCompletion(messages, model = "gpt-4", timeoutMs = 15_000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${OPENAI_API_BASE}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({ model, messages }),
signal: controller.signal,
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenAI API error ${response.status}: ${body}`);
}
return response.json();
} finally {
clearTimeout(timeoutId);
}
}
module.exports = { createChatCompletion };
Environment files make the toggle explicit:
# .env.development
OPENAI_API_BASE=http://localhost:4010/v1
OPENAI_API_KEY=dev-placeholder-key
# .env.production
OPENAI_API_BASE=https://api.openai.com/v1
OPENAI_API_KEY=sk-your-real-key-here
Security: Add .env.development and .env.production to .gitignore immediately. Never commit files containing real API keys to version control.
Important: Node.js does not read .env files automatically. Load environment variables using dotenv (npm install dotenv, then add require('dotenv').config() at your application entry point) or the --env-file flag available in Node 20+ (e.g., node --env-file .env.development app.js).
Zero code changes between environments. Your application code is identical regardless of whether it hits Rubberduck or OpenAI. This eliminates bugs from mock-specific code paths that diverge from production behavior.
Zero code changes between environments. Your application code is identical regardless of whether it hits Rubberduck or OpenAI. This eliminates bugs from mock-specific code paths that diverge from production behavior.
Writing Reliable Tests with Deterministic Responses
Because Rubberduck returns the same response for the same matched request every time, tests can make exact assertions on AI-dependent function output without mocking.
// tests/summarizer.test.js
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createChatCompletion } from "../lib/openai-client.js";
import { spawn } from "child_process";
let rubberduckProcess;
// Utility: poll the server until it responds or timeout is reached.
// Uses a GET-friendly path to avoid method mismatches on POST-only endpoints.
// Adjust healthUrl to match your Rubberduck version's health endpoint.
async function waitForServer(baseUrl, { timeout = 10000, interval = 250 } = {}) {
const healthUrl = baseUrl.replace(/\/v1\/.*$/, "") + "/healthz";
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const res = await fetch(healthUrl, { method: "GET" });
await res.text(); // always drain the body to release the socket
if (res.ok) return;
} catch {
// Server not ready yet — swallowed intentionally during startup polling
}
await new Promise((r) => setTimeout(r, interval));
}
throw new Error(`Server at ${healthUrl} did not become ready within ${timeout}ms`);
}
beforeAll(async () => {
rubberduckProcess = spawn("npx", [
"rubberduck",
"--config",
"rubberduck.config.js",
]);
rubberduckProcess.on("error", (err) => {
throw new Error(`Failed to start Rubberduck: ${err.message}`);
});
rubberduckProcess.stderr.on("data", (data) => {
console.error("[rubberduck stderr]", data.toString());
});
// Wait until the server is actually listening, rather than using a fixed delay
await waitForServer("http://localhost:4010/v1/chat/completions", {
timeout: 10000,
interval: 250,
});
});
afterAll(() => {
// Note: on Windows, process termination behavior may differ.
// The start-server-and-test approach in the test:integration script
// handles cross-platform teardown automatically.
// .kill() with no argument works cross-platform for child_process spawned processes.
if (rubberduckProcess) {
rubberduckProcess.kill();
}
});
describe("summarizer", () => {
it("returns a structured summary from the AI", async () => {
const result = await createChatCompletion([
{ role: "user", content: "Please summarize the following report..." },
]);
expect(result.choices[0].message.content).toContain(
"infrastructure scaling"
);
expect(result.choices[0].message.content).toContain("cost optimization");
expect(result.choices[0].message.role).toBe("assistant");
expect(result.usage.total_tokens).toBe(67);
});
it("returns classification results as structured JSON", async () => {
const result = await createChatCompletion([
{ role: "user", content: "Classify this support ticket into a category" },
]);
const parsed = JSON.parse(result.choices[0].message.content);
expect(parsed.category).toBe("technical");
expect(parsed.confidence).toBeGreaterThan(0.9);
expect(parsed.subcategories).toContain("infrastructure");
});
});
These tests are fully repeatable, run offline, and execute in milliseconds. Snapshot testing also becomes viable since the output never drifts between runs.
Using Rubberduck in CI/CD Pipelines
Running Rubberduck in CI requires starting the server before the test suite and tearing it down afterward. Most CI systems support background processes or service containers. The start-server-and-test-based test:integration script shown above works cross-platform and handles startup waiting and teardown automatically. For Docker-based pipelines, Rubberduck can run as a sidecar container exposing the configured port. The same configuration file committed to the repository ensures consistent behavior across every developer's machine, CI runner, and staging environment. No external network access is required during the test phase.
Advanced Configuration and Patterns
Simulating Error Responses and Edge Cases
AI integrations need to handle failures gracefully. Rubberduck supports error simulation through fixture configuration, allowing developers to test retry logic, fallback behavior, and error UI states.
// rubberduck.config.js — error fixtures
module.exports = {
port: 4010,
fixtures: [
{
match: { messageContains: "trigger-rate-limit" },
error: {
status: 429,
body: {
error: {
message: "Rate limit exceeded. Please retry after 20s.",
type: "rate_limit_error",
code: "rate_limit_exceeded",
},
},
},
},
{
match: { messageContains: "trigger-server-error" },
error: {
status: 500,
body: {
error: {
message: "Internal server error",
type: "server_error",
code: null,
},
},
},
},
],
};
Application-side retry logic can then be tested deterministically:
// lib/resilient-client.js
const OPENAI_API_BASE =
process.env.OPENAI_API_BASE || "http://localhost:4010/v1";
const OPENAI_API_KEY =
process.env.OPENAI_API_KEY || "dev-placeholder-key";
async function createCompletionWithRetry(messages, retries = 3) {
let lastError;
for (let attempt = 1; attempt <= retries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetch(
`${OPENAI_API_BASE}/chat/completions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({ model: "gpt-4", messages }),
signal: controller.signal,
}
);
if (response.status === 429 && attempt < retries) {
await response.text(); // drain body to release connection
await new Promise((r) => setTimeout(r, 1000 * attempt));
lastError = new Error(`Rate limited on attempt ${attempt}`);
continue;
}
if (!response.ok) {
const body = await response.text();
throw new Error(`API error ${response.status}: ${body}`);
}
return await response.json();
} catch (err) {
lastError = err;
if (attempt === retries) break;
} finally {
clearTimeout(timeoutId);
}
}
throw lastError ?? new Error("All retry attempts exhausted");
}
module.exports = { createCompletionWithRetry };
Tests can verify that the retry handler makes the expected number of attempts, respects backoff timing, and eventually throws the correct error.
Streaming Response Emulation
For applications consuming OpenAI's streaming responses via server-sent events (SSE), Rubberduck supports chunked streaming emulation. The emulator sends response tokens incrementally, mimicking the data: {"choices": [{"delta": {"content": "..."}}]} format that the real API uses with stream: true. This enables local testing of streaming UI components, token-by-token rendering, and stream parsing logic without any external dependency.
Note: Verify streaming support in the Rubberduck changelog or README before relying on this feature. The SSE format described above follows the OpenAI streaming specification, but confirm that your installed version of Rubberduck implements it.
Multi-Model and Multi-Endpoint Scenarios
Fixtures can be scoped by model name, allowing different response profiles for gpt-4, gpt-3.5-turbo (or its successor), or custom model identifiers. This is useful when an application uses different models for different tasks, such as a smaller, cheaper model for classification and a more capable model for generation. You can similarly configure the embeddings endpoint (/v1/embeddings) with fixed vector responses, supporting local testing of similarity search, RAG pipelines, and other embedding-dependent features. Organizing fixtures by feature or module keeps the configuration file manageable as the project grows.
Implementation Checklist
- Confirm Node.js 18+ and npm 8+ are installed
- Verify
rubberduck-openaipackage availability on npm - Install Rubberduck,
concurrently, andstart-server-and-testas dev dependencies - Create configuration file with base response fixtures
- Set up environment variable switching (
OPENAI_API_BASE) - Install and configure
dotenv(or use Node 20+--env-file) for.envloading - Add
.env.developmentand.env.productionto.gitignore - Abstract API base URL in your client module
- Define response fixtures for each AI feature/prompt
- Configure error simulation for retry/error handling tests
- If your app uses streaming, add streaming fixtures and verify feature support in the Rubberduck docs
- Write deterministic unit tests against emulated responses
- Add Rubberduck startup to CI/CD pipeline scripts (using
start-server-and-testfor cross-platform support) - Configure latency simulation for performance testing
- Document team setup instructions in project README
- Periodically diff your fixtures against actual OpenAI response schemas to catch drift
Limitations and When to Use the Real API
What Rubberduck Doesn't Cover
Rubberduck does not perform language model inference. You configure responses; Rubberduck does not generate them. That means it cannot help with prompt engineering, evaluating output quality, or testing how different prompts affect model behavior. That work still requires the real OpenAI API. There is also a schema drift risk: if OpenAI updates their API response format, you must update fixtures manually to stay in sync. Teams should periodically validate that their fixtures match the current production API schema.
A Balanced Testing Strategy
Layer Rubberduck into a broader strategy. Use it for local development, unit tests, and CI pipelines where speed, cost, and determinism matter most. Use the real OpenAI API for integration validation, prompt tuning, and staging environments where you need to observe actual model behavior. This layered approach cuts feedback loops from seconds (live API round-trips) to milliseconds (local fixture returns) during daily development, while preserving confidence in real-world behavior through periodic live validation.
This layered approach cuts feedback loops from seconds (live API round-trips) to milliseconds (local fixture returns) during daily development, while preserving confidence in real-world behavior through periodic live validation.
Summary
Rubberduck lets teams develop and test against the OpenAI API locally, with deterministic responses, zero cost, and no network dependency. For teams adopting it, the implementation checklist above provides a concrete starting point for integrating Rubberduck into both development environments and CI/CD pipelines.




