Documentation
Test Fireclass model validation, CRUD, queries, deletes, and subscriptions without Firebase.
FakeAdapter provides deterministic, in-memory model tests. Use it for domain
behavior and keep a smaller emulator suite for Firebase indexes, rules, and SDK
integration.
Install core directly so the testing subpath is available in strict package managers such as pnpm.
npm install --save-dev @dharayush7/fireclass-core vitestThe application runtime already supplies the class-validator and
class-transformer peers.
A fresh adapter, bound base class, and model per test prevent data and generated ids from leaking between cases.
import { beforeEach, describe, expect, it } from "vitest";
import {
Collection,
ValidationFailedError,
defineBaseModel,
} from "@dharayush7/fireclass-core";
import { FakeAdapter } from "@dharayush7/fireclass-core/testing";
import { IsEmail, IsInt, IsString, Min } from "class-validator";
function createHarness() {
const adapter = new FakeAdapter({
seed: {
users: {
ada: {
name: "Ada",
email: "ada@example.com",
age: 36,
active: true,
},
},
},
idFactory: () => "generated_user",
});
const BaseModel = defineBaseModel(adapter);
@Collection("users")
class User extends BaseModel<User> {
@IsString()
name!: string;
@IsEmail()
email!: string;
@IsInt()
@Min(0)
age!: number;
active!: boolean;
constructor(data?: Partial<User>) {
super(data);
Object.assign(this, data);
}
}
return { adapter, User };
}describe("User model", () => {
it("rejects invalid data without writing", async () => {
const { adapter, User } = createHarness();
const user = new User({
name: "Invalid",
email: "not-an-email",
age: -1,
active: true,
});
await expect(user.save()).rejects.toBeInstanceOf(
ValidationFailedError,
);
expect(await adapter.get("users", "generated_user")).toBeNull();
});
it("creates a valid document with a deterministic id", async () => {
const { adapter, User } = createHarness();
const user = new User({
name: "Grace",
email: "grace@example.com",
age: 37,
active: true,
});
await expect(user.save()).resolves.toBe("generated_user");
expect(user.id).toBe("generated_user");
expect(await adapter.get("users", "generated_user")).toEqual({
name: "Grace",
email: "grace@example.com",
age: 37,
active: true,
});
});
});Because adapter inputs and outputs are cloned, mutating a returned object cannot silently change stored test data.
it("hydrates and merge-updates an existing model", async () => {
const { adapter, User } = createHarness();
const user = await User.findById("ada");
expect(user).toBeInstanceOf(User);
expect(user?.id).toBe("ada");
if (!user) throw new Error("Expected seeded user");
user.age = 37;
await user.save();
expect(await adapter.get("users", "ada")).toMatchObject({
name: "Ada",
age: 37,
});
});it("filters, orders, limits, and counts", async () => {
const { User } = createHarness();
await new User({
name: "Grace",
email: "grace@example.com",
age: 37,
active: true,
}).save();
const adults = await User.findMany({
where: {
active: { equals: true },
age: { gte: 18 },
},
orderBy: { age: "desc" },
limit: 10,
});
expect(adults.map((user) => user.name)).toEqual(["Grace", "Ada"]);
await expect(
User.count({ where: { active: { equals: true } } }),
).resolves.toBe(2);
});The evaluator supports every Fireclass filter operator, AND conditions, single-field ordering, and limits.
it("returns the documents removed by deleteMany", async () => {
const { adapter, User } = createHarness();
const removed = await User.deleteMany({
where: { active: { equals: true } },
});
expect(removed.map((user) => user.id)).toEqual(["ada"]);
expect(await adapter.get("users", "ada")).toBeNull();
});FakeAdapter implements batchDelete, so this exercises the model's batched
capability path.
import { buildQuerySpec } from "@dharayush7/fireclass-core";
it("emits current and updated query results", async () => {
const { adapter } = createHarness();
const seen: string[][] = [];
const unsubscribe = adapter.subscribe(
"users",
buildQuerySpec({ orderBy: { age: "asc" } }),
(docs) => seen.push(docs.map((doc) => doc.id)),
);
await adapter.add("users", {
name: "Grace",
email: "grace@example.com",
age: 37,
active: true,
});
unsubscribe();
expect(seen).toEqual([
["ada"],
["ada", "generated_user"],
]);
});The callback fires immediately, after writes to that collection, and stops after
unsubscribe. FakeAdapter does not provide React's additional subscribeDoc
contract.
When exact reuse matters, define the model through a small factory and bind it to the production base class at the module boundary.
import {
Collection,
type BaseModelClass,
} from "@dharayush7/fireclass-js";
import { IsEmail } from "class-validator";
import { BaseModel } from "../lib/fireclass.js";
export function defineUserModel(Base: BaseModelClass) {
@Collection("users")
class UserModel extends Base<UserModel> {
@IsEmail()
email!: string;
}
return UserModel;
}
export const User = defineUserModel(BaseModel);
export type User = InstanceType<typeof User>;The test can call defineUserModel(defineBaseModel(fakeAdapter)); production
continues importing User normally. Use this pattern only when adapter
injection is worth the extra model factory.
FakeAdapter is not Firestore
It does not reproduce Security Rules, authentication, indexes, backend operator validation, transactions, network behavior, or cursor pagination.
Keep emulator tests for:
startAfter pagination;See the Testing API for exact evaluator and subscription semantics.