@effect-firebase/mock
v1.0.1
Published
An in-memory, reactive `FirestoreService` implementation for testing and developing Effect Firebase applications. No Firebase connection required.
Readme
@effect-firebase/mock
An in-memory, reactive FirestoreService implementation for testing and developing Effect Firebase applications. No Firebase connection required.
Beyond a plain test double, the mock is a small simulated backend built for developer experience:
- Fixtures — seed hard-coded models through your real schemas, so reads exercise the exact decoding path production data takes.
- Reactive streams —
streamDoc/streamQueryare live: writes and runtime toggles push new emissions through already-subscribed streams, just likeonSnapshot. - Simulated states — flip any collection between
data,empty,loadinganderrorat runtime with theMockController, and watch your UI's spinner, empty and error paths render with no backend involved. - Latency simulation — add artificial delay to every operation.
- Write fidelity — server timestamps materialize on write,
delete/arrayUnion/arrayRemove/incrementsentinels are honored, and queries (where, orderBy, cursors, limits, collection groups) are evaluated in-process.
Installation
npm install --save-dev @effect-firebase/mockUsage
Provide layer in place of the real Admin or Client layer:
import { Effect, Option } from 'effect';
import { layer as mockFirestore } from '@effect-firebase/mock';
await Effect.runPromise(
Effect.gen(function* () {
const repo = yield* PostRepository;
const postId = yield* repo.add({
title: 'Test',
content: '...',
status: 'draft',
});
const post = yield* repo.getById(postId);
expect(Option.getOrThrow(post).title).toBe('Test');
}).pipe(Effect.provide(mockFirestore())),
);Repositories built with Firestore.makeRepository are effects that only need FirestoreService, so providing the mock layer once covers all of them. Each Effect.provide(layer()) call gets a fresh in-memory store, so tests are isolated by default.
Fixtures
Seed the backend with hard-coded models. Documents are encoded through the model's schema, so getById, query and streams decode them exactly like real data:
import { fixture, layer } from '@effect-firebase/mock';
import { DateTime } from 'effect';
const posts = fixture(PostModel, {
collectionPath: 'posts',
idField: 'id',
docs: [
new PostModel({
id: PostId.make('1'),
title: 'Hello world',
content: '...',
createdAt: DateTime.makeUnsafe('2024-01-01'),
// ...
}),
],
});
const mock = layer({ fixtures: [posts] });For documents without a model schema, use rawFixture with already-encoded data:
import { rawFixture } from '@effect-firebase/mock';
const settings = rawFixture('settings', {
general: { theme: 'dark' },
});To fill a page with volume (long lists, pagination, layout stress), map over an array — fixture takes any ReadonlyArray of models:
const manyPosts = fixture(PostModel, {
collectionPath: 'posts',
idField: 'id',
docs: Array.from({ length: 50 }, (_, i) => makePost(i)),
});Simulated states
The layer also provides a MockController service for driving the backend at runtime — from tests, a dev panel, or a devtools plugin:
import { layer, MockController, MockState } from '@effect-firebase/mock';
Effect.gen(function* () {
const controller = yield* MockController;
// Live streams re-emit immediately:
yield* controller.setState('posts', 'empty');
yield* controller.setState('posts', 'loading'); // reads hang, streams go silent
yield* controller.setState('posts', 'error'); // reads/writes fail: code 'unavailable'
yield* controller.setState('posts', MockState.error('permission-denied'));
yield* controller.setState('posts', 'data'); // back to normal
// Apply to every collection at once:
yield* controller.setState(MockState.All, 'loading');
// Other controls:
yield* controller.clearState('posts'); // drop a per-collection override
yield* controller.setLatency('300 millis');
yield* controller.seed(morePosts);
yield* controller.setDoc('posts/raw', { title: 'Raw' }); // bypasses states and latency
yield* controller.removeDoc('posts/raw');
yield* controller.reset;
// Inspect:
const states = yield* controller.states;
const docs = yield* controller.docs; // all docs keyed by full path
// controller.changes is a Stream<StoreSnapshot> of every store change
});MockState also exports data, empty and loading constants; MockState.error takes a Firestore error code or a full FirestoreError. Fixtures passed to layer/make and controller.seed must use models whose schemas need no extra encoding services.
States can also be set up front:
const mock = layer({
fixtures: [posts],
states: { comments: 'loading' },
latency: '200 millis',
});A collection group query (queryGroup / streamQueryGroup, or a
repository's group view) resolves its state by collection ID, so
states: { comments: 'loading' } covers both a top-level comments
collection and the comments group across every parent. As in Firestore,
a __name__ cursor (Query.orderByDocumentId) in a group query must be a
full document path (posts/p1/comments/c1); a bare ID fails with
invalid-argument, as it does on the real SDKs. A single-collection query
takes a bare ID.
Driving the backend from outside Effect
make() returns a handle instead of just a layer: the same options as layer(), plus direct access to the controller as a plain value. Every controller effect requires no services, so React components, Storybook decorators or test helpers can run them with Effect.runPromise directly. This is what the @effect-firebase/devtools panel builds on:
import { Effect } from 'effect';
import { Atom } from 'effect/reactivity';
import { make } from '@effect-firebase/mock';
const mock = make({ fixtures: [posts] });
// Provide mock.layer to your app runtime (all provides share one store)...
const runtime = Atom.runtime(mock.layer);
// ...and drive the same store from anywhere:
await Effect.runPromise(mock.controller.setState('posts', 'loading'));Notes on semantics:
emptyaffects reads only; writes still land in the store.loadingsuspends reads and writes, and live streams stop emitting. A stream subscribed while loading emits nothing until the state flips.errorfails effects per call. A live stream fails terminally (matchingonSnapshotsemantics) — consumers must re-subscribe after the state recovers, e.g. by refreshing the atom/query that owns the stream.
Multiple repositories
Provide one mock layer with fixtures for every collection; each repository reads from the same store:
const program = Effect.gen(function* () {
const posts = yield* PostRepository;
const users = yield* UserRepository;
// ...
}).pipe(Effect.provide(layer({ fixtures: [posts, users] })));Error handling
A missing document is Option.none(), not a failure. Use the error state to exercise failure paths:
await Effect.runPromise(
Effect.gen(function* () {
const controller = yield* MockController;
yield* controller.setState('posts', 'error');
const repo = yield* PostRepository;
return yield* repo.query(Query.limit(10));
}).pipe(
Effect.provide(layer()),
Effect.catchTag('FirestoreError', (e) => Effect.succeed(e.code)), // 'unavailable'
),
);Stubbing individual methods
For a plain test double without the in-memory store, MockFirestoreService(overrides) returns a FirestoreService layer built from the methods you pass; any method you leave out throws "not implemented" when called.
NaN field values
Firestore stores and normalizes NaN, and the mock follows its semantics:
- Ordering —
NaNis placed in the total order below-Infinity, soorderBylists it first ascending and last descending.TimestampandGeoPointsub-fields use the same rule for their numeric components. - Equality (
==,!=) —NaNis equal only toNaN. This also drivesarrayUnion/arrayRemovededup, so a storedNaNneither absorbs nor is absorbed by a finite value. - Range filters (
<,<=,>,>=) — aNaNfield value is excluded from range scans entirely, even though it sorts below-InfinityfororderBy. in— never matches aNaNfield value, even with aNaNcandidate (unlike== NaN, which matches).not-in— always includes aNaNfield value, regardless of the candidate list.- Live streams —
streamDoc/streamQuerydetect aNaN-to-number transition as a real change (a storedNaNand a finite number are not equal) and re-emit, instead of collapsing it via the oldNaN-equals-everything comparison.
Limitations
- In-memory only — no persistence between process restarts
- Queries are evaluated in-process — behaviour may differ from real Firestore for edge cases (composite index requirements are not enforced,
not-in/!=null semantics are simplified, and aNaNquery operand such aswhere('v', '<', NaN)is not validated — real Firestore rejects it withinvalid-argument, the mock returns an empty/ordered result; onlyNaNfield-value semantics are emulated) - Simulated states are keyed per collection path (or the
'*'wildcard), not per query; collection group queries resolve their state by collection ID - No security rules evaluation
withTransactionandwithBatchrun the effect directly — no retries, no rollback, and no staged writes- No multi-client synchronization
For tests that need full Firestore semantics, use the Firebase Emulator Suite.
License
MIT
