Testing Firestore: Firebase Emulator, Security Rules, and Real-Time Listeners
Testing Firestore well means combining the Firebase Emulator for integration tests, @firebase/rules-unit-testing for security rule validation, and carefully structured async tests for real-time listeners — all without touching production Firestore.
Key Takeaways
Security rules are code — test them like code. Every path in your firestore.rules file has a happy path and a rejection path. Untested rules are a security hole waiting to happen.
Real-time listeners require careful async handling. Tests for onSnapshot must account for the initial snapshot, subsequent updates, and proper cleanup — or you'll get test pollution and flaky results.
Use a fresh Firestore project ID per test suite. The emulator supports multiple project IDs simultaneously, giving you clean isolation without teardown complexity.
Why the Firebase Emulator Matters
Firestore is not just a database — it is a real-time sync engine with access control baked in at the data layer. Testing it against production means paying for reads, risking data corruption, and introducing network flakiness into your CI pipeline. The Firebase Emulator Suite eliminates all three problems.
The emulator runs locally and supports Firestore, Authentication, Storage, Functions, and more. For testing purposes, the key insight is that the emulator enforces your actual firestore.rules file, which means your security rule tests are testing the same logic that runs in production.
Setting Up the Firebase Emulator
Install the Firebase CLI and initialize the emulator:
npm install -g firebase-tools
firebase init emulators
# Select: Firestore, AuthenticationYour firebase.json should include:
{
"emulators": {
"firestore": {
"port": 8080
},
"auth": {
"port": 9099
},
"ui": {
"enabled": true,
"port": 4000
}
}
}Start the emulator in your test setup:
firebase emulators:start --only firestore,authOr use firebase emulators:exec to run tests and shut down automatically:
firebase emulators:exec --only firestore,auth <span class="hljs-string">"jest"Install the testing package:
npm install --save-dev @firebase/rules-unit-testingTesting Security Rules
This is the most important and most overlooked part of Firestore testing. Security rules define who can read and write what — and they have bugs just like any other code.
Here is a real-world rules file for a multi-tenant application:
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /organizations/{orgId} {
allow read: if request.auth != null && request.auth.token.orgId == orgId;
allow write: if request.auth != null
&& request.auth.token.orgId == orgId
&& request.auth.token.role == 'admin';
}
match /organizations/{orgId}/projects/{projectId} {
allow read: if request.auth != null && request.auth.token.orgId == orgId;
allow create: if request.auth != null
&& request.auth.token.orgId == orgId
&& request.resource.data.createdBy == request.auth.uid;
allow update, delete: if request.auth != null
&& request.auth.token.orgId == orgId
&& request.auth.token.role in ['admin', 'editor'];
}
}
}Now test every branch:
// test/firestore-rules.test.ts
import {
initializeTestEnvironment,
RulesTestEnvironment,
assertSucceeds,
assertFails,
} from "@firebase/rules-unit-testing";
import { doc, getDoc, setDoc, deleteDoc } from "firebase/firestore";
import { readFileSync } from "fs";
let testEnv: RulesTestEnvironment;
beforeAll(async () => {
testEnv = await initializeTestEnvironment({
projectId: "rules-test-project",
firestore: {
rules: readFileSync("firestore.rules", "utf8"),
host: "localhost",
port: 8080,
},
});
});
afterAll(async () => {
await testEnv.cleanup();
});
afterEach(async () => {
await testEnv.clearFirestore();
});
describe("organization document rules", () => {
test("allows member to read their own org", async () => {
const alice = testEnv.authenticatedContext("alice-uid", {
orgId: "org-abc",
role: "member",
});
const orgDoc = doc(alice.firestore(), "organizations/org-abc");
await assertSucceeds(getDoc(orgDoc));
});
test("denies member from reading a different org", async () => {
const alice = testEnv.authenticatedContext("alice-uid", {
orgId: "org-abc",
role: "member",
});
const orgDoc = doc(alice.firestore(), "organizations/org-xyz");
await assertFails(getDoc(orgDoc));
});
test("denies unauthenticated read", async () => {
const anon = testEnv.unauthenticatedContext();
const orgDoc = doc(anon.firestore(), "organizations/org-abc");
await assertFails(getDoc(orgDoc));
});
test("allows admin to write to their org", async () => {
const admin = testEnv.authenticatedContext("admin-uid", {
orgId: "org-abc",
role: "admin",
});
const orgDoc = doc(admin.firestore(), "organizations/org-abc");
await assertSucceeds(setDoc(orgDoc, { name: "Acme Corp" }));
});
test("denies non-admin member from writing", async () => {
const member = testEnv.authenticatedContext("member-uid", {
orgId: "org-abc",
role: "member",
});
const orgDoc = doc(member.firestore(), "organizations/org-abc");
await assertFails(setDoc(orgDoc, { name: "Acme Corp" }));
});
});
describe("project subcollection rules", () => {
test("enforces createdBy on project create", async () => {
const alice = testEnv.authenticatedContext("alice-uid", {
orgId: "org-abc",
role: "member",
});
const projectDoc = doc(alice.firestore(), "organizations/org-abc/projects/proj-1");
// Correct: createdBy matches auth uid
await assertSucceeds(setDoc(projectDoc, { name: "Project A", createdBy: "alice-uid" }));
// Wrong: createdBy is someone else
const projectDoc2 = doc(alice.firestore(), "organizations/org-abc/projects/proj-2");
await assertFails(setDoc(projectDoc2, { name: "Project B", createdBy: "bob-uid" }));
});
test("editor can update but not delete", async () => {
// Seed data as admin first
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), "organizations/org-abc/projects/proj-1"), {
name: "Project A",
createdBy: "alice-uid",
});
});
const editor = testEnv.authenticatedContext("editor-uid", {
orgId: "org-abc",
role: "editor",
});
const projectDoc = doc(editor.firestore(), "organizations/org-abc/projects/proj-1");
await assertSucceeds(setDoc(projectDoc, { name: "Updated" }, { merge: true }));
await assertFails(deleteDoc(projectDoc));
});
});Note testEnv.withSecurityRulesDisabled() — this bypasses rules for seeding test data, which is the correct pattern. Never try to set up test state through authenticated clients when the goal is to test something other than setup.
Testing Real-Time Listeners
onSnapshot tests are tricky because they involve asynchronous event streams. The key pattern is wrapping the listener in a Promise that resolves on the first (or Nth) snapshot:
// test/realtime-listeners.test.ts
import { initializeTestEnvironment, RulesTestEnvironment } from "@firebase/rules-unit-testing";
import { collection, doc, onSnapshot, setDoc, updateDoc } from "firebase/firestore";
let testEnv: RulesTestEnvironment;
beforeAll(async () => {
testEnv = await initializeTestEnvironment({
projectId: `listener-test-${Date.now()}`,
firestore: { host: "localhost", port: 8080 },
});
});
afterAll(() => testEnv.cleanup());
afterEach(() => testEnv.clearFirestore());
test("onSnapshot fires with initial data", async () => {
const ctx = testEnv.authenticatedContext("user-1");
const db = ctx.firestore();
// Seed data before attaching listener
await testEnv.withSecurityRulesDisabled(async (adminCtx) => {
await setDoc(doc(adminCtx.firestore(), "tasks/task-1"), { title: "Buy milk", done: false });
});
const snapshot = await new Promise<any>((resolve, reject) => {
const unsub = onSnapshot(
doc(db, "tasks/task-1"),
(snap) => { unsub(); resolve(snap); },
reject
);
});
expect(snapshot.exists()).toBe(true);
expect(snapshot.data().title).toBe("Buy milk");
});
test("onSnapshot fires again on document update", async () => {
const ctx = testEnv.authenticatedContext("user-1");
const db = ctx.firestore();
await testEnv.withSecurityRulesDisabled(async (adminCtx) => {
await setDoc(doc(adminCtx.firestore(), "tasks/task-2"), { title: "Walk dog", done: false });
});
const updates: any[] = [];
await new Promise<void>((resolve, reject) => {
let callCount = 0;
const unsub = onSnapshot(
doc(db, "tasks/task-2"),
async (snap) => {
updates.push(snap.data());
callCount++;
if (callCount === 1) {
// Trigger an update after initial snapshot
await testEnv.withSecurityRulesDisabled(async (adminCtx) => {
await updateDoc(doc(adminCtx.firestore(), "tasks/task-2"), { done: true });
});
}
if (callCount === 2) {
unsub();
resolve();
}
},
reject
);
});
expect(updates[0].done).toBe(false);
expect(updates[1].done).toBe(true);
});Always unsubscribe (unsub()) before resolving the Promise. Leaving active listeners after a test ends causes Jest to hang.
Collection Group Query Testing
Collection group queries let you query across all subcollections with the same name. Test them carefully — they require a specific index and the emulator respects that:
test("collectionGroup returns documents across parent paths", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
const db = ctx.firestore();
await setDoc(doc(db, "users/alice/comments/c1"), { text: "Hello", userId: "alice" });
await setDoc(doc(db, "users/bob/comments/c2"), { text: "World", userId: "bob" });
await setDoc(doc(db, "users/alice/comments/c3"), { text: "Again", userId: "alice" });
});
const ctx = testEnv.authenticatedContext("admin-uid", { role: "admin" });
const { getDocs, collectionGroup, query, where } = await import("firebase/firestore");
const q = query(collectionGroup(ctx.firestore(), "comments"), where("userId", "==", "alice"));
const snap = await getDocs(q);
expect(snap.size).toBe(2);
expect(snap.docs.map((d) => d.data().text)).toEqual(expect.arrayContaining(["Hello", "Again"]));
});Offline Persistence Testing
Testing offline behavior requires the client SDK rather than the admin SDK. Use enableIndexedDbPersistence in a browser-based test environment (Playwright or Cypress), not in Jest:
// cypress/e2e/offline-persistence.cy.ts
it("shows cached data when network is offline", () => {
cy.visit("/app");
cy.contains("My Project").should("be.visible");
// Go offline
cy.window().then((win) => {
cy.stub(win.navigator, "onLine").value(false);
win.dispatchEvent(new Event("offline"));
});
// Reload — data should still be visible from IndexedDB cache
cy.reload();
cy.contains("My Project").should("be.visible");
cy.contains("You are offline").should("be.visible");
});Structuring Your Test Suite
For a production Firestore project, organize tests into three layers:
- Rules tests (
test/rules/) — pure@firebase/rules-unit-testing, no app code, run fast - Repository tests (
test/repositories/) — test your data access layer against the emulator with rules disabled - Integration tests (
test/integration/) — full stack with rules enabled, testing real user flows
Run all three in CI. Rules tests are the fastest (under 1 second each) and catch the most critical bugs.
HelpMeTest can run your Firestore integration tests automatically on every pull request — sign up free.