Firebase is Google’s app development platform, Firestore is its document database, and Cypress is a browser-based end-to-end testing tool. This guide is for teams already using Firestore who want local development and E2E tests to run against a realistic, disposable database instead of a shared or production instance.
If your app uses Firestore in production, you want two things during development: a local Firestore you can poke at without touching a shared instance, and end-to-end tests that exercise the real client SDK against realistic data. The Firebase Local Emulator Suite gives you both, and it pairs well with Cypress.
This guide walks through a battle-tested setup for using the Firestore emulator both for day-to-day development and for Cypress E2E tests. The patterns apply to any framework that uses the Firebase Admin SDK on the server.
Why the emulator?
A few reasons to reach for the emulator instead of pointing your dev environment at a shared Firestore database:
- Isolation. You can wipe and reseed data between tests without affecting anyone else
- Safety. Tests can never accidentally write to production (we'll wire in an explicit guard for this below)
- Determinism. Same seed data → same test outcomes
- Clarity when exploring. When debugging or manually testing, you can inspect the database and trust that any changes came from your own actions
Prerequisites
- Firebase CLI installed (
npm install -g firebase-tools or pnpm add -g firebase-tools) - Java runtime (The Firestore emulator runs on the JVM. Install OpenJDK if you don't already have it.)
- Your app's existing Firebase Admin SDK setup. No application code changes; we'll point it at the emulator via environment variables
Step 1: Configure the emulator
Add a firebase.json at the root of the app you're emulating. For Firestore-only, this is all you need:
{
"emulators": {
"firestore": {
"port": 8181
}
}
}
A couple of notes on the port:
- The default Firestore emulator port is
8080. Pick something else if you already have services on8080—we used8181, which also makes it obvious in environment variables which host you're talking to. - If you add Auth, Storage, or Functions emulators later, they each get their own entry under
emulators.
You can now start the emulator with:
firebase emulators:start --project demo-emulator-app --only firestore
The demo- prefix on the project ID is important; see the next section.
Step 2: Environment variables
The Firebase Admin SDK doesn't need any code changes to talk to the emulator. It checks a handful of environment variables at startup and routes traffic accordingly. The crucial ones:
FIRESTORE_EMULATOR_HOST=127.0.0.1:8181to ensure the app connects to the emulator instead of a hosted Firestore instance (see Connect your app to the Cloud Firestore Emulator).GCLOUD_PROJECT=demo-emulator-app and GOOGLE_CLOUD_PROJECT=demo-emulator-appto use a demo project as an additional safeguard against accidentally connecting to any live Firestore instance (we set both for compatibility with different Google Cloud libraries).TZ=UTCfor consistent datetime values between runs regardless of environment.
Why the demo- prefix on the project ID? It's a documented Firebase convention that signals "this project does not exist in real GCP." Even if a client library fails to reach the emulator and tries to fall back to a hosted instance, there are no real Firebase resources behind a demo- project for it to talk to.
A shared script that sets all of them
Rather than scatter these exports through multiple package.json scripts, put them in one place. We use a small shell script, scripts/with-emulator-env.sh:
#!/usr/bin/env bash
set -e # Exit on any error
# Script to run commands with Firebase emulator environment variables.
# Usage:
# ./scripts/with-emulator-env.sh <command> # Run command with env vars
# source ./scripts/with-emulator-env.sh # Just export env vars (when sourced)
export FIRESTORE_EMULATOR_HOST=127.0.0.1:8181
export GCLOUD_PROJECT=demo-emulator-app
export GOOGLE_CLOUD_PROJECT=demo-emulator-app
export TZ=UTC
# Execute the command passed as arguments (if any)
if [[ $# -gt 0 ]]; then
"$@"
fi
The dual-mode design lets you wrap a single command (./scripts/with-emulator-env.sh pnpm dev) or source the script to set the variables for the rest of your shell session.
Side note: if your app has an auth bypass for non-prod environments (e.g. a mocked user email so you don't have to integrate with your real auth provider locally), export it from this script too—e.g. export MOCK_AUTH_EMAIL=test.user@example.com. Keeping it alongside the other emulator env vars means tests and local dev pick it up automatically.
Wire this into your package.json:
{
"scripts": {
"emulator:start": "firebase emulators:start --project demo-emulator-app --only firestore",
"dev": "next dev",
"dev:withEmulatorEnv": "./scripts/with-emulator-env.sh pnpm run dev",
"cypress:open": "./scripts/with-emulator-env.sh cypress open --e2e",
"cypress:run": "./scripts/with-emulator-env.sh cypress run --e2e"
}
}
Step 3: Local development workflow
Open two terminals:
# Terminal 1: emulator
pnpm run emulator:start
# Terminal 2: app, with emulator env vars set
pnpm run dev:withEmulatorEnv
Your app's Firestore reads and writes now hit the local emulator.
Persisting data between restarts
By default, the emulator starts with an empty database every time. For tests, that's what you want, but for local exploration it can be annoying to lose your data after every Ctrl-C. The emulator supports import/export:
{
"scripts": {
"emulator:start:persist": "firebase emulators:start --project demo-emulator-app --import=./.firebase-emulator-data --export-on-exit"
}
}
--import loads from a directory on startup; --export-on-exit writes back to the same directory when the emulator shuts down cleanly. Add .firebase-emulator-data/ to .gitignore.
Step 4: Cypress + the emulator
Cypress tests run in the browser, but the most reliable way to seed Firestore data is from Node, using the Admin SDK in Cypress's setupNodeEvents callback. Cypress exposes this Node context to tests via the cy.task() API.
Registering Firestore tasks
In cypress.config.ts:
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
async setupNodeEvents(on, config) {
// Dynamically import tasks to allow Cypress to resolve TypeScript path aliases
// This avoids Node.js module resolution issues with path mappings
const { cypressFirestoreEmulatorTasks } = await import(
"./cypress/support/cypressFirestoreEmulatorTasks"
);
// Register Firestore tasks for test data management
Object.entries(cypressFirestoreEmulatorTasks).forEach(([name, task]) => {
on("task", { [name]: task });
});
return config;
},
},
});
Then in cypress/support/cypressFirestoreEmulatorTasks.ts, define the tasks. The emulator guard is a higher-order function that refuses to run any task unless we can prove we're pointed at the emulator:
import firebase from "<your firebase admin wrapper>";
import type { DocumentData } from "@google-cloud/firestore";
function verifyEmulatorEnv(): void {
if (
!process.env.FIRESTORE_EMULATOR_HOST ||
!process.env.GOOGLE_CLOUD_PROJECT?.startsWith("demo-") ||
!process.env.GCLOUD_PROJECT?.startsWith("demo-")
) {
throw new Error(
"Missing or misconfigured emulator environment variables. " +
"These Firestore helpers can only be used with the emulator.",
);
}
}
function withEmulatorGuard<T extends (...args: any[]) => Promise<any>>(
taskFn: T,
): T {
return (async (...args: Parameters<T>) => {
verifyEmulatorEnv();
return taskFn(...args);
}) as T;
}
This is a second line of defense on top of the demo- project prefix: if Cypress is ever run with the wrong env vars, the test fails loudly instead of silently mutating real data.
Now wrap each Firestore operation with the guard:
const seedCollection = withEmulatorGuard(
async (options: {
collection: string;
documents: Array<{ id?: string; data: DocumentData }>;
}) => {
const batch = firebase.db.batch();
options.documents.forEach(({ id, data }) => {
const ref = id
? firebase.db.collection(options.collection).doc(id)
: firebase.db.collection(options.collection).doc();
batch.set(ref, data);
});
await batch.commit();
return null;
},
);
const clearAllData = withEmulatorGuard(async () => {
const projectId = "demo-emulator-app";
const url = `http://127.0.0.1:8181/emulator/v1/projects/${projectId}/databases/(default)/documents`;
const response = await fetch(url, { method: "DELETE" });
if (!response.ok) {
throw new Error(`Failed to clear emulator: ${response.status}`);
}
return null;
});
export const cypressFirestoreEmulatorTasks = {
"firestoreEmulator:seedCollection": seedCollection,
"firestoreEmulator:clearAllData": clearAllData,
// ...and any other tasks you need: setDocument, updateDocument, etc.
};
clearAllData uses the emulator's REST API rather than iterating over documents. It's a built-in endpoint for exactly this purpose, and it's instantaneous. See the Firebase docs on clearing the emulator between tests.
Writing tests against the tasks
A typical test looks like this:
describe("Some feature", () => {
beforeEach(() => {
cy.task("firestoreEmulator:seedCollection", {
collection: "users",
documents: [
{ id: "test-user", data: { name: "Test User", role: "admin" } },
],
});
});
afterEach(() => {
cy.task("firestoreEmulator:clearAllData");
});
it("does the thing", () => {
cy.visit("/some-page");
// ...assertions
});
});
Each test starts from a known-clean Firestore state and seeds exactly the data it needs.
Step 5: Running everything in CI
For local development, the two-terminal flow is fine. CI needs a single command that builds, boots the emulator and app, runs Cypress, and tears it all down cleanly.
The obvious tools, start-server-and-test and firebase emulators:exec, both have rough edges we hit in practice:
start-server-and-testcan break stdin handling in nested package-manager contexts, surfacing asCannot read properties of undefined (reading 'stdin')firebase emulators:execinterferes with TypeScript module resolution in some setups, preventing Cypress from loadingcypress.config.ts
The most reliable approach is a bash script that manages the lifecycle directly:
#!/usr/bin/env bash
set -e
wait_for_url() {
local url=$1 pid=$2 name=$3 timeout=60 elapsed=0
echo "Waiting for $name at $url..."
until curl --silent --fail "$url" > /dev/null 2>&1; do
if ! kill -0 "$pid" 2>/dev/null; then
echo "$name process crashed!"
exit 1
fi
if [ $elapsed -ge $timeout ]; then
echo "$name failed to start within ${timeout}s"
exit 1
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "$name is ready!"
}
source "$(dirname "$0")/with-emulator-env.sh"
export PORT=3000
export HOSTNAME=0.0.0.0
# ...any other build/runtime env vars your app needs
echo "Building standalone app..."
pnpm run build
firebase emulators:start --project demo-emulator-app --only firestore &
EMULATOR_PID=$!
node build/standalone/server.js &
SERVER_PID=$!
cleanup() {
kill $SERVER_PID 2>/dev/null || true
pkill -P $EMULATOR_PID 2>/dev/null || true
kill $EMULATOR_PID 2>/dev/null || true
wait $SERVER_PID 2>/dev/null || true
wait $EMULATOR_PID 2>/dev/null || true
}
trap cleanup EXIT INT TERM HUP
wait_for_url "http://${FIRESTORE_EMULATOR_HOST}/" $EMULATOR_PID "Emulator"
wait_for_url "http://localhost:3000" $SERVER_PID "Server"
pnpm exec cypress run --e2e --config baseUrl=http://localhost:3000
Some details:
set -eat the top: any unhandled error aborts the script.wait_for_urlpolls the service AND checks that its process is still alive (kill -0 $pid). If the emulator crashes during startup, you find out immediately instead of waiting the full 60-second timeout.pkill -P $EMULATOR_PIDkills the emulator's child processes. The Firebase CLI spawns a Java process for the JVM; killing only the CLI wrapper leaves the JVM running and ports occupied. Cleaning up the children of the emulator PID handles this.trap cleanup EXIT INT TERM HUPensures cleanup runs on success, failure,Ctrl-C, or hangup, not just on normal exit.
Wire it into package.json:
{
"scripts": {
"cypress:run:ci": "./scripts/run-cypress-ci.sh"
}
}
Interacting with the emulator from Claude Code
When you're using the emulator for exploratory testing, you may eventually want Claude Code to read and write Firestore data on your behalf, the same way you might ask it to run a SQL query against a local Postgres. In theory, there are three obvious ways to do this: the Firebase MCP server, the Firebase CLI, or the Firestore REST API over curl. In practice, only the REST API gives you full CRUD against the emulator.
What works and what doesn't:
- Firebase MCP server (
mcp__plugin_firebase_firebase__firestore_*)—the official MCP docs don't mention emulator support; the server picks up auth and project from the firebase CLI environment. Mostfirestore_*tools route requests to real Firestore and fail with an OAuth error against an emulator project. One tool (firestore_query_collection) exposes ause_emulatorflag in its schema that isn't mentioned in the docs page, but it targets the Emulator Hub of the firebase CLI's active project, which has to be set viafirebase use. Sincefirebase userequires real Firebase projects, you can't point it at ademo-prefixed emulator project. Net result: no reliable path to the emulator from the MCP today - Firebase CLI—has no general-purpose
firestore:get,firestore:add, orfirestore:updatecommands at all. The only document-mutating command isfirestore:delete(andfirestore:bulkdelete), and these do respectFIRESTORE_EMULATOR_HOST. Useful, but not enough for full CRUD - REST API via
curl—works for everything: list, read, create, update, delete. No auth required against the emulator
Using the REST API
The base URL pattern is:
http://127.0.0.1:8181/v1/projects/<your-demo-project>/databases/(default)/documents/<collection>[/<doc-id>]
A few patterns you'll reach for repeatedly:
# List documents in a collection
curl "http://127.0.0.1:8181/v1/projects/demo-emulator-app/databases/(default)/documents/users?pageSize=10"
# Read a single document
curl "http://127.0.0.1:8181/v1/projects/demo-emulator-app/databases/(default)/documents/users/alice"
# Create with a specific document ID
curl -X POST "http://127.0.0.1:8181/v1/projects/demo-emulator-app/databases/(default)/documents/users?documentId=alice" \ -H "Content-Type: application/json" \ -d '{"fields":{"name":{"stringValue":"Alice"},"active":{"booleanValue":true}}}'
# Partial update (PATCH with updateMask)
curl -X PATCH "http://127.0.0.1:8181/v1/projects/demo-emulator-app/databases/(default)/documents/users/alice?updateMask.fieldPaths=name" \ -H "Content-Type: application/json" \ -d '{"fields":{"name":{"stringValue":"Alice Updated"}}}'
# Delete a document
curl -X DELETE "http://127.0.0.1:8181/v1/projects/demo-emulator-app/databases/(default)/documents/users/alice"
A couple of things to know:
- Field values are wrapped in typed objects (
stringValue,integerValue,booleanValue,arrayValue,mapValue, etc.). It's verbose but unambiguous; see the Firestore Value type for the full list - Without
updateMask.fieldPaths, aPATCHreplaces the document with only the fields you sent. Provide the mask for partial updates. See the projects.databases.documents.patch reference for the exact semantics
For Claude Code, that means: when you ask it to "check what's in the users collection on the emulator," you'll get the best results by either pointing it at curl directly, or providing it a small helper script that wraps these endpoints with your project ID baked in.
A few gotchas
The emulator isn't a perfect mirror of production. Per Google Cloud's Firestore emulator docs: the emulator doesn't track composite indexes (so queries that would fail in production for missing indexes will succeed against the emulator), doesn't enforce all production limits, and doesn't faithfully simulate every transaction edge case. A passing CI build isn't a guarantee that production will work the same way. Treat the emulator as a high-fidelity sandbox, not as the source of truth for query validity.
Port mismatches between dev server and Cypress. If your dev server runs on a non-default port (e.g. 3002), but your CI script starts a production build on 3000, your cypress.config.ts baseUrl will be wrong for one of them. The cleanest fix is to set the right baseUrl per environment, either with --config baseUrl=... on the CLI, or CYPRESS_BASE_URL=... as an environment variable.
Stale emulator data when persisting. If you use --import / --export-on-exit and the emulator crashes (rather than exits cleanly), it won't write to disk on the way out. Treat the persisted data as a best-effort fixture, not as a source of truth.
Be sure to use the demo- prefix everywhere. The prefix only takes effect if the project ID string starts with demo-. A project named my-app-demo won't trigger the safeguard. Put demo- at the front.
Multiple env var names for the same thing. GCLOUD_PROJECT and GOOGLE_CLOUD_PROJECT are both checked by different Google Cloud client libraries. Setting both is the safe default.
Don't reuse production seed data. It's tempting to dump a slice of production Firestore and load it into the emulator. Resist. Even "anonymized" data tends to leak details, and it'll grow stale anyway. Write fixtures inline in your tests; the Cypress task interface (seedCollection) keeps that lightweight.
Recap
The whole setup is four pieces:
firebase.jsonwith an emulator port- A
with-emulator-env.shscript that exports the right env vars in one place - Cypress tasks that seed and clear Firestore data, with a hard guard against running outside the emulator
- A CI script that manages the emulator + app + Cypress lifecycle directly (skipping the wrappers that don't play nicely)
And one workflow note: if you want Claude Code (or another AI assistant) reading and writing emulator data on your behalf, reach for the Firestore REST API via curl. The Firebase MCP server and CLI don't have reliable emulator support for full CRUD.
Robert Komaromi is a Senior Software Consultant at Test Double and has experience in TypeScript, React, Vue, Cypress, and Rails.







