@firebase-bridge/firestore-functions
v0.0.9
Published
Bind firebase-functions v1 & v2 Firestore triggers to an in-memory Firestore (from @firebase-bridge/firestore-admin) for fast, deterministic backend tests—no emulator required.
Downloads
141
Maintainers
Readme
@firebase-bridge/firestore-functions
Bind
firebase-functionsv1 & v2 Firestore triggers to an in-memory Firestore database from @firebase-bridge/firestore-admin. Enables fast, deterministic end‑to‑end trigger testing with no emulator boot or deploy loop.
What it is
This package wires Cloud Functions for Firestore (both v1 and v2) to the in‑memory Firestore provided by the @firebase-bridge/firestore-admin companion package. Your tests can simulate a full backend — registering all Firestore triggers your production app exports — and drive them by performing writes against the mock database. No emulator, network, or deploy step required.
- Adapts Firestore trigger handlers declared using
firebase-functionsv1 (background functions) and v2 (CloudEvents) so they are invoked by changes in the in‑memory database - Generates realistic onCreate, onUpdate, onDelete, and onWrite event payloads (params/subject IDs,
ChangevsCloudEvent<Change<...>>,before/aftersnapshots or v2datashape) and metadata (event time/ID) suitable for backend tests - Preserves commit semantics: for multiple writes to the same document in a single atomic commit, only the final state for that path is delivered to triggers (no intermediary bleed‑through)
- Respects transaction/batch boundaries; triggers fire after the commit is applied
- Uses the mock’s SystemTime for event timestamps so your tests can be deterministic
Note: You can register any compatible Cloud Function. This package simulates Firestore change events and snapshots only; if your handler uses other Google Cloud services (Pub/Sub, Scheduler, Auth, Storage, etc.), provide your own test doubles/mocks or bind to those services' emulators for testing.
When to use it
- Unit or integration testing of Cloud Functions that depend on Firestore triggers
- Fast local testing in CI without the Firestore Emulator
- Deterministic tests with controllable time and atomic commit semantics
Why not the emulator (for this use case)
- Zero boot time. Zero deploy loop. Zero external processes — just edit, save, and test
- Deterministic in-memory Firestore with controllable time
- Suited to tight test loops and CI where startup cost matters
Companion Packages
- For a high‑fidelity in‑memory mock for the Firestore Admin SDK purpose‑built for fast, deterministic backend unit tests (no emulator boot, no deploy loop) use the companion package @firebase-bridge/firestore-admin.
- For a high-fidelity mock invocation layer for Firebase HTTPS Cloud Functions (v1 & v2) — run real
onCall/onRequesthandlers locally with realistic auth, App Check, instance ID, and headers (no emulator) — use the companion package @firebase-bridge/auth-context.
Support
This project is made freely available under the Apache 2.0 License. If you find it useful and would like to support ongoing development, you can buy me a coffee. ☕
Install
# npm
npm i -D @firebase-bridge/firestore-functions @firebase-bridge/firestore-admin firebase-admin firebase-functions
# pnpm
pnpm add -D @firebase-bridge/firestore-functions @firebase-bridge/firestore-admin firebase-admin firebase-functions
# yarn
yarn add -D @firebase-bridge/firestore-functions @firebase-bridge/firestore-admin firebase-admin firebase-functionsNode: 18+ recommended • TS: strict mode recommended.
Quick start
The preferred way to attach Firestore triggers in tests is the high‑level TriggerOrchestrator. It coordinates registration, invocation, waiting, observation, and teardown for both v1 and v2 handlers — all bound to a single in‑memory database.
// test.triggers.spec.ts
import { FirestoreMock } from '@firebase-bridge/firestore-admin';
import { TriggerOrchestrator } from '@firebase-bridge/firestore-functions';
import * as v1 from 'firebase-functions/v1';
import * as v2 from 'firebase-functions/v2';
// 1) Define a stable key for each trigger (enum recommended)
enum AppTrigger {
OnUserCreate = 'OnUserCreate',
OnUserWritten = 'OnUserWritten',
}
// 2) Create an in-memory Firestore database
const env = new FirestoreMock();
const ctrl = env.createDatabase();
const db = ctrl.firestore();
// 3) Construct the orchestrator and register handlers via the registrar callback
const triggers = new TriggerOrchestrator<AppTrigger>(ctrl, (reg) => {
// v1: background function
reg.v1(
AppTrigger.OnUserCreate,
v1.firestore.document('users/{uid}').onCreate(async (snap, ctx) => {
// Always return a Promise (use async/await)
await db
.collection('audit')
.add({ uid: ctx.params.uid, name: snap.data()?.name });
})
);
// v2: CloudEvent function
reg.v2(
AppTrigger.OnUserWritten,
v2.firestore.onDocumentWritten('users/{uid}', async (event) => {
const before = event.data.before.data();
const after = event.data.after.data();
await db
.collection('changeLog')
.add({ uid: event.params.uid, before, after });
})
);
});
// 4) Drive changes and assert effects
it('fires v1/v2 triggers', async () => {
await db.collection('users').doc('u1').set({ name: 'Ada' });
await db.collection('users').doc('u1').update({ name: 'Ada Lovelace' });
// Optionally await a specific invocation
await triggers.waitOne(AppTrigger.OnUserWritten, { timeout: 2000 });
});
// 5) Teardown — release orchestrator and database resources
afterAll(() => {
triggers.detach();
ctrl.delete(); // or env.deleteAll()
});Use your real production handlers: You don’t need to write test-only handlers—import the Cloud Function exports from your app and register them here. The orchestrator expects the wrapped CloudFunction objects created by
firebase-functionsv1/v2(i.e. the things you export for production), not raw(change, ctx) => {}functions.
// Example: registering production exports
import { onUserCreate } from '@my-app/functions/users'; // v1 export
import { onUserWritten } from '@my-app/functions/audit'; // v2 export
const triggers = new TriggerOrchestrator<AppTrigger>(ctrl, (reg) => {
reg.v1(AppTrigger.OnUserCreate, onUserCreate); // v1 CloudFunction
reg.v2(AppTrigger.OnUserWritten, onUserWritten); // v2 CloudFunction
});Enabled by default: After construction, the orchestrator enables all registered triggers. You can pause all invocations by setting
triggers.suspended = trueduring setup, then set it back tofalseto resume.
Why async/await matters
Handlers must be declared async (or return a Promise) so the orchestrator can await completion and capture errors thrown inside your handler. This mirrors production Cloud Functions behavior and prevents silent failures in tests.
Proper teardown
At the end of each suite, always release resources to prevent leaked timers/listeners:
afterAll(() => {
triggers.detach(); // unregisters all orchestrated triggers & cancels waiters
ctrl.delete(); // or env.deleteAll() to clear the environment
});Alternate: Direct registerTrigger usage
You can register triggers directly with
registerTrigger()from thev1andv2submodules. This provides a lightweight option for simple suites. Use this approach when you need a minimal harness without orchestration or statistics.
import { FirestoreMock } from '@firebase-bridge/firestore-admin';
import * as v1 from 'firebase-functions/v1';
import * as v2 from 'firebase-functions/v2';
import * as bridgeV1 from '@firebase-bridge/firestore-functions/v1';
import * as bridgeV2 from '@firebase-bridge/firestore-functions/v2';
const env = new FirestoreMock();
const ctl = env.createDatabase();
const db = ctl.firestore();
// v1 trigger
bridgeV1.registerTrigger(
ctl,
v1.firestore.document('users/{uid}').onCreate(async (snap, ctx) => {
await db.collection('audit').add({ uid: ctx.params.uid });
})
);
// v2 trigger
bridgeV2.registerTrigger(
ctl,
v2.firestore.onDocumentWritten('users/{uid}', async (event) => {
await db.collection('changeLog').add({ uid: event.params.uid });
})
);You can also register production exports directly:
import { onUserCreate } from '@my-app/functions/users';
bridgeV1.registerTrigger(ctl, onUserCreate);Optional per-event predicate (advanced)
Scope: Predicates apply to the direct
registerTriggerhelpers (v1/v2). TheTriggerOrchestratordoes not accept per‑event predicates; usesuspended,observe(), or thewait*utilities for orchestration‑level control.
You can attach a synchronous predicate to any registered trigger to gate invocation after the route matches and the change kind (create/update/delete/write) is determined. If the predicate returns false, the handler is not invoked for that event.
- Signature:
(arg: TriggerEventArg) => boolean - Receives low-level event data (e.g., params, doc path/snap info) for precise control
- Great for feature flags, test-scoped filters, or param-based gating
// Continuing from the Quick start example...
let enabled = true;
// v1: only run when `enabled` is true
const disposeV1 = bridgeV1.registerTrigger(
ctl,
v1.firestore.document('users/{uid}').onCreate(async (snap, ctx) => {
// ... your v1 handler ...
}),
() => enabled
);
// v2: only run for a specific route param (e.g., uid starts with "test-")
const disposeV2 = bridgeV2.registerTrigger(
ctl,
v2.firestore.onDocumentWritten('users/{uid}', async (event) => {
// ... your v2 handler ...
}),
(arg) => arg.params.uid?.startsWith('test-') === true
);
// Drive changes
await db.collection('users').doc('user-1').set({ name: 'Alice' }); // v1 gated off, v2 gated off
enabled = true;
await db.collection('users').doc('test-2').set({ name: 'Bob' }); // v1 + v2 both allowed
// Clean up
disposeV1();
disposeV2();Predicates run in-process and must be synchronous. If omitted, the trigger runs for all matching events.
Predicates run per delivered change after commit coalescing. In a batch that mutates multiple docs, a counter-based predicate (like “allow from the second event”) applies to the dispatch order of those changes, not to a specific doc. For doc-specific gating, use a param/data predicate (e.g.,
arg.params.uid?.startsWith('test-')).
Core concepts & API
type TriggerKey
string | number — A logical identifier you choose for each trigger (enums recommended). Keys are unique within an orchestrator and are required for per‑trigger operations.
class TriggerOrchestrator<TKey extends TriggerKey>
Coordinates v1 and v2 Firestore triggers bound to a single in‑memory database.
constructor(
ctrl: FirestoreController,
register: (registrar: TriggerRegistrar<TKey>) => void
)Lifecycle & control
epoch: number— The database epoch the orchestrator is currently bound to. Only trigger events whose stamped epoch matches this value are processed; events from prior/reset epochs are ignored to ensure test isolation and prevent leakage of late async work from earlier runs. Theepochautomatically rebinds whenever the bound database is reset.suspended: boolean— Whentrue, blocks new invocations at the registration gate (handlers are not entered; stats/observers do not change).attach(): void— Enables all registered triggers (does not clear observers/waiters).detach(): void— Disables all triggers, clears observers, and cancels active waiters. Stats are not cleared.reset(): void— Detaches, zeroes all counters, and re‑attaches every registered trigger.dipose(): void— Releases all resources and dipsoses the instance.all(enable: boolean): void— Enable/disable all triggers at once.enable(...keys: TKey[]): void/disable(...keys: TKey[]): void— Per‑key enable/disable (throws if a key wasn’t registered).isEnabled(key: TKey): boolean— Current enable state.
Stats & observation
getStats(key: TKey): TriggerStats<TKey>— Immutable snapshot of per-key counters.observe(key: TKey, observer: TriggerObserver<TKey>): () => void— Attachbefore/after/errorhooks for a key.on(key: TKey, callback: (arg: OrchestratorEventArg<TKey>) => void): () => void— Attach anafterhook for a key.observeAll(observer: TriggerObserver<TKey>): () => void— Attach the same observer to all currently registered triggers. Itsbefore,after, anderrorcallbacks fire for every trigger key using the same semantics asobserve. Returns an unsubscribe function that removes the observer from all keys.onAll(callback: (arg: OrchestratorEventArg<TKey>) => void): () => void— Register the same post-invocation (after) callback for all registered triggers. Equivalent toobserveAll({ after: callback }). Returns an unsubscribe function that removes this callback from all keys.watchErrors(cb: TriggerErrorWatcher<TKey>): () => void— Global watcher for any error raised by a trigger or observer.
Deterministic waiting
waitOne(key: TKey, options?: WaitOptions): Promise<OrchestratorEventArg<TKey>>— Wait for the next success for a key.wait(key: TKey, predicate: (e: OrchestratorEventArg<TKey>) => boolean, options?: WaitOptions)— Wait until a predicate over the extended event arg matches.waitOneError(key: TKey, options?: WaitErrorOptions): Promise<OrchestratorErrorEventArg<TKey>>— Wait for the next failure for a key.waitError(key: TKey, predicate: (arg: OrchestratorErrorEventArg<TKey>) => boolean, options?: WaitErrorOptions): Promise<OrchestratorErrorEventArg<TKey>>— Wait until a predicate over the error event arg matches.
WaitOptions
timeout?: number(default 3000ms) — Reject if not satisfied in time.cancelOnError?: boolean(default false) — Iftrue, a matching error will cancel the waiter before its predicate can succeed.
WaitErrorOptions
timeout?: number(default 3000ms) — Reject if not satisfied in time.
interface TriggerRegistrar<TKey extends TriggerKey>
Registrar passed to the orchestrator’s constructor. Use it to associate handlers with keys.
v1<T extends TriggerPayloadV1>(key: TKey, handler: CloudFunctionV1<T>): void
v2<T>(key: TKey, handler: CloudFunctionV2<CloudEvent<T>>): voidinterface TriggerStats<TKey>
{ key: TKey; initiatedCount: number; completedCount: number; errorCount: number }interface OrchestratorEventArg<TKey>
Extends TriggerEventArg (the low‑level Firestore change info) and TriggerStats<TKey> for the key.
interface OrchestratorErrorEventArg<TKey>
Extends OrchestratorEventArg<TKey> with:
origin: "trigger" | "onBefore" | "onAfter" | ...— Where the error came from.cause: unknown— The underlying error thrown/rejected.
interface TriggerObserver<TKey>
Optional hooks for a key:
before(arg)— Runs just before the handler executes (aftersuspendedgate).after(arg)— Runs only when the handler fulfills.error(arg, cause)— Runs only when the handler throws/rejects.
Semantics recap: Triggers fire after commit; multiple writes to the same doc within a commit are coalesced to a single event; timestamps derive from the mock’s SystemTime; triggers are enabled by default upon construction.
Supported trigger shapes
v1 (firebase-functions/v1)
functions.firestore.document('path').onCreate(handler)onUpdate(handler)onDelete(handler)onWrite(handler)(called on any of the above)
v2 (firebase-functions/v2)
firestore.onDocumentCreated('path', handler)firestore.onDocumentUpdated('path', handler)firestore.onDocumentDeleted('path', handler)firestore.onDocumentWritten('path', handler)
All handlers receive Admin SDK snapshots (v1) or CloudEvent payloads (v2) with appropriate route params populated from the path pattern (e.g., {uid} → ctx.params.uid in v1 or event.params.uid in v2 where applicable).
Event semantics & fidelity
- Commit boundary: Triggers run after a transaction/batch commit is applied.
- Coalescing: If the same document is written multiple times within a single commit, only the final change for that path is delivered to triggers.
- Ordering: Changes dispatch in the order they are committed, not necessarily the order inside your application code.
- Timestamps: Event times derive from the mock’s SystemTime; align your test clock as needed.
before/after: Provided per trigger kind; v1 usesChange<QueryDocumentSnapshot|DocumentSnapshot>, v2 wraps theChangein aCloudEvent.- Subjects/params: Route parameters (e.g.,
{uid}) are extracted from the changed path. v2 CloudEvent fields (id,source,subject,type,time) are populated consistently for testing.
The goal is to match Cloud Functions behavior closely enough for robust tests. If you observe divergence from the emulator or production, please file a minimal repro.
Testing patterns
Fast test loops
- Prefer one environment + database per suite, with
env.resetAll()inafterEach(). - For hard isolation between tests, create a fresh database via
env.createDatabase()insidebeforeEach()and dispose withenv.deleteAll()afterward.
Asserting effects
- Your triggers often write to Firestore; assert via reads on the same in-memory DB.
- For non‑Firestore side effects (e.g., Pub/Sub publish), inject test doubles into your handler code so you can assert invocations.
Time control
- Coordinate clock control with
env.systemTimefrom @firebase-bridge/firestore-admin. - If your handler uses
Timestamp.now(), consider Jest/Vitest fake timers to align global time, or patchTimestamp.nowin a scoped way (see the admin README’s SystemTime notes).
Non‑Firestore dependencies
This package focuses on Firestore trigger invocation. You can register any Cloud Function, but for behaviors that involve other GCP services you must supply the dependency yourself:
- Pub/Sub: wrap
publishbehind an interface and inject a mock in tests (or point to the Pub/Sub emulator). - Scheduler: invoke the handler directly with crafted context/time values.
- Auth/Storage/Other: inject test doubles or emulator clients and structure your handler for DI so tests and production share code paths.
In short: registration is supported for all function types, but this package only emits Firestore events; it does not emulate other products. Keep non‑Firestore calls behind thin abstractions for easy testing.
Compatibility & peer deps
- Peer dependencies:
firebase-admin,firebase-functions - Node: >= 18
- Works with Jest or Vitest in Node test environments (ESM or CJS).
Caveats & limitations
- Emulator parity is a goal, but some highly niche edge cases may differ.
- Network behaviors (retries/backoff, streaming resets) are not simulated; triggers run in‑process.
- Partitioned queries in the mock Admin layer are currently stubbed (empty stream). If your triggers depend on real partitioning, use the emulator/Firestore.
Contributing
Thanks for your interest! This project is in minimal-maintainer mode.
- Issues first. Please open an issue with a clear repro or failing test. Unsolicited feature PRs may be closed.
- PRs limited to: bug fixes with tests, small docs improvements, or build/release hygiene. New features require an accepted proposal in an issue first.
- Tests are required. Changes must include high-fidelity tests that show alignment (or documented divergence) with the Firebase Emulator.
- Review cadence. I review in batches and may be slow. There’s no support SLA.
- Scope guardrails. The goal is fidelity to Firestore/Admin SDK semantics; out-of-scope features will be declined.
If that works for you, awesome—bugfixes and docs tweaks are especially welcome.
License
Apache-2.0 © 2025 Bryce Marshall
Trademarks & attribution
This project is not affiliated with, associated with, or endorsed by Google LLC. “Firebase” and “Firestore” are trademarks of Google LLC. Names are used solely to identify compatibility and do not imply endorsement.
