Skip to main content
Test Double company logo
Services
Pragmatic Services Overview
Holistic software investment consulting
Acccelerate Software Delivery
Balance efficiency and quality
Improve Product Impact
Drive results that matter
Upgrade Rails Seamlessly
Update Ruby and Rails versions
Scale DevOps
Dev experience and infrastructure
Technical Recruitment
Build tech & product teams
Case Studies
Solutions
Legacy Modernization
Renovate legacy software systems
Pragmatic AI
Solve business problems without hype
Technical & Product Assessments
Uncover root causes & improvements
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say Hello
Test Double logo
Menu
Services
BackGrid of dots icon
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Impact
Drive results that matter
Cycle icon
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Case Studies
Solutions
Solutions
Legacy Modernization
Renovate legacy software systems
Pragmatic AI
Solve business problems without hype
Technical & Product Assessments
Uncover root causes & improvements
About
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say hello
Developers
Developers
Developers
Software tooling & tips

A practical guide to Firebase Emulator for local dev and Cypress testing

Setting up Firebase Emulator to work with local dev, Cypress, CI, and Claude Code—what works, what doesn’t, and edge cases to watch out for.
Robert Komaromi
|
August 11, 2026
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

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 on 8080—we used 8181, 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:8181 to 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-app to 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=UTC for 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-test can break stdin handling in nested package-manager contexts, surfacing as Cannot read properties of undefined (reading 'stdin')
  • firebase emulators:exec interferes with TypeScript module resolution in some setups, preventing Cypress from loading cypress.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 -e at the top: any unhandled error aborts the script.
  • wait_for_url polls 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_PID kills 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 HUP ensures 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. Most firestore_* tools route requests to real Firestore and fail with an OAuth error against an emulator project. One tool (firestore_query_collection) exposes a use_emulator flag 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 via firebase use. Since firebase use requires real Firebase projects, you can't point it at a demo-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, or firestore:update commands at all. The only document-mutating command is firestore:delete (and firestore:bulkdelete), and these do respect FIRESTORE_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, a PATCH replaces 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:

  1. firebase.json with an emulator port
  2. A with-emulator-env.sh script that exports the right env vars in one place
  3. Cypress tasks that seed and clear Firestore data, with a hard guard against running outside the emulator
  4. 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.

Related Insights

No items found.

Explore our insights

See all insights
Developers
Developers
Developers
AI Should Give Us Our Lives Back, Not Fill Them With More Work

What do you do with the time saved using AI? River Bailey explores multiple threads while exploring the possibilities.

by
River Lynn Bailey
Leadership
Leadership
Leadership
Distributed process ownership eliminates team bottlenecks

Process ownership concentrated in a few hands creates invisible organizational drag. When distributed across the team, it eliminates bottlenecks, improves delivery, and frees leaders to do higher-value work.

by
Sukhraj Singh
Developers
Developers
Developers
Software engineering and AI: Finding the plot(again)

Is "clean code" still the point when an agent writes it? River Bailey unpacks quality, tech debt, and what it means to be a product engineer.

by
River Lynn Bailey
Letter art spelling out NEAT

Join the conversation

Technology is a means to an end: answers to very human questions. That’s why we created a community for developers and product managers.

Explore the community
Test Double Executive Leadership Team

Learn about our team

Like what we have to say about building great software and great teams?

Get to know us
Test Double company logo
Improving the way the world builds software.
What we do
Services OverviewSoftware DeliveryProduct StrategyLegacy ModernizationPragmatic AIDevOpsUpgrade RailsTechnical RecruitmentAssessments
Who WE ARE
About UsCulture & CareersGreat CausesEDIOur TeamContact UsNews & AwardsN.E.A.T.
Resources
Case StudiesAll InsightsLeadership InsightsDeveloper InsightsProduct InsightsPairing & Office Hours
NEWSLETTER
Sign up hear about our latest innovations.
Your email has been added!
Oops! Something went wrong while submitting the form.
Standard Ruby badge
614.349.4279hello@testdouble.com
Privacy PolicyTerms & Conditions
© 2020 Test Double. All Rights Reserved.