Test Firestore security rules like real code with the Firebase Emulator Suite, @firebase/rules unit testing, assertSucceeds/assertFails cases, and CI gating.
A Firestore security rule is the only thing standing between an anonymous visitor and your database. In a client-only app there is no middle-tier API to sanity-check anything, no server code to catch a mistake — the rule is the backend. And yet the standard workflow is to eyeball a .rules file, deploy it, poke the live app a couple of times, and call it done. I ran my portfolio and blog that way for exactly one release before I decided that was insane. Rules are code that authorizes access to everything I own. Code that important gets tests.
Here's how I test Firestore security rules like real code: with the Firebase Local Emulator Suite, a proper unit-test harness, and CI that fails the build the moment someone weakens a rule by accident. It took an afternoon to set up, and it converted every "I hope drafts are private" into "I know they are, and the build proves it on every commit."
The Firebase console ships a Rules Playground, and it's genuinely fine for a one-off sanity check. But it tests one request at a time, by hand. That doesn't scale to the number of cases a real ruleset has, and it evaporates the instant you close the tab — there's nothing to re-run, nothing to diff, nothing to gate a deploy on.
Look at a single collection from my actual firestore.rules. Public content like blog posts is world-readable only when published, and writable only by me:
match /posts/{docId} { allow read: if isPublished() || isAdmin(); allow create, update, delete: if isAdmin();}That's four verbs (read, create, update, delete), two roles (anonymous visitor, admin), and two document states (draft, published). Multiply those out and one collection already carries a dozen meaningful cases. A visitor must not read a draft. A visitor must not create a post. The admin must read both. Now repeat that reasoning for leads, subscribers, apps, tools, profile, and the default-deny catch-all. You are not going to click through 60-plus cases in the Playground every time you touch the file. You'll check two, assume the rest, and one day discover drafts have been public for a month.
The security model behind Firestore rules is also unusual in a way that punishes manual testing: rules fail open in exactly the places you never check by hand. An overly-broad allow read doesn't throw an error or crash the app — it silently serves data. There's no stack trace, no 500, no log line screaming at you. The only way to catch that class of bug is to assert on it explicitly, which is what tests are for.
The fix is the same one we already use for application logic: write the cases down as executable tests, and run them on every change.
The Firebase Local Emulator Suite runs a real Firestore rules engine on your machine — the same evaluator that runs in production, not a reimplementation or a mock. Point your tests at it and you get authoritative, production-accurate pass/fail answers offline, for free, in milliseconds. No project quota consumed, no network flakiness, no risk of a test writing to live data.
The library that glues tests to the emulator is @firebase/rules-unit-testing. It gives you a test environment where you can mint authenticated and unauthenticated contexts, plus the helper matchers assertSucceeds and assertFails that assert on whether a rule allowed or denied an operation.
The mental model that makes all of this click: you are not testing your app, you are testing the rule from the perspective of a specific caller. Each test says "as this user, attempting this operation, on this document — allow or deny?" Once you internalize that framing, writing exhaustive coverage becomes mechanical.
You need Node, the Firebase CLI, and a small test package. I keep mine in a rules-tests/ folder with firebase-admin, firebase, @firebase/rules-unit-testing, and a test runner (Jest or Vitest both work fine). The harness boots one shared test environment, then clears Firestore between tests so no case leaks state into the next:
import { initializeTestEnvironment, assertFails, assertSucceeds,} from '@firebase/rules-unit-testing';import { doc, getDoc, setDoc } from 'firebase/firestore';import fs from 'node:fs';let testEnv;beforeAll(async () => { testEnv = await initializeTestEnvironment({ projectId: 'devshakibio', firestore: { rules: fs.readFileSync('firestore.rules', 'utf8') }, });});afterAll(() => testEnv.cleanup());beforeEach(() => testEnv.clearFirestore());const admin = () => testEnv.authenticatedContext('admin', { email: 'devshakib015@gmail.com' });const anon = () => testEnv.unauthenticatedContext();Two implementation details matter here. initializeTestEnvironment reads the actual firestore.rules file off disk, so the tests always run against the exact rules you'll deploy — never a stale copy pasted into a test. And clearFirestore() in beforeEach guarantees each test starts from an empty database, which is what makes the negative cases trustworthy.
Now the cases themselves. Notice how each one seeds a document, then asserts on a read or write from a specific caller:
test('anonymous CANNOT read a draft post', async () => { // Seed a draft, bypassing rules, so the read has something to hit. await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), 'posts/p1'), { status: 'draft' }); }); await assertFails(getDoc(doc(anon().firestore(), 'posts/p1')));});test('anonymous CAN read a published post', async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), 'posts/p2'), { status: 'published' }); }); await assertSucceeds(getDoc(doc(anon().firestore(), 'posts/p2')));});test('only the admin email can create a post', async () => { await assertFails(setDoc(doc(anon().firestore(), 'posts/p3'), { status: 'draft' })); await assertSucceeds(setDoc(doc(admin().firestore(), 'posts/p4'), { status: 'draft' }));});Two things are worth calling out. First, use withSecurityRulesDisabled for seeding, not your test user. Otherwise you can't set up a "draft exists" state without the very rule you're testing getting in the way — you'd be depending on a write rule to test a read rule, and a failure in either would fail the test ambiguously. The seed step is arrange, not assert; it should never touch the rules.
Second, always test the negative case. A ruleset that only proves "the admin can write" is worthless; the entire point of an authorization layer is proving that nobody else can. I treat the negatives as the primary tests and the positives as the sanity check, not the other way around.
The nastiest Firestore rules bugs hide in validation, not in read/write toggles. My leads collection (contact-form and hire-me submissions) lets any anonymous visitor create a document — that's by design, it's a public contact form. But the rule tightly constrains what they can write, because an unconstrained public write is a denial-of-wallet vector: someone can script thousands of oversized documents and run up my Firestore bill.
Here's the shape of that rule. It whitelists the allowed keys with hasOnly(), caps every field's size, forces read == false, and pins createdAt == request.time so nobody can forge a timestamp:
match /leads/{docId} { allow create: if request.resource.data.keys().hasOnly( ['name', 'email', 'subject', 'message', 'type', 'budget', 'company', 'read', 'createdAt'] ) && request.resource.data.name is string && request.resource.data.name.size() > 0 && request.resource.data.name.size() < 120 && isValidEmail(request.resource.data.email) && request.resource.data.message is string && request.resource.data.message.size() > 0 && request.resource.data.message.size() < 5000 && request.resource.data.read == false && request.resource.data.createdAt == request.time; allow read, update, delete: if isAdmin();}Every one of those clauses is a failure mode that deserves its own test. The oversized-payload guard, in particular, is the kind of thing a refactor silently deletes:
test('a lead with an oversized message is rejected', async () => { const bad = { name: 'Spammer', email: 'x@y.co', read: false, message: 'A'.repeat(5001), createdAt: new Date(), }; await assertFails(setDoc(doc(anon().firestore(), 'leads/l1'), bad));});test('a lead with an extra unexpected field is rejected', async () => { const bad = { name: 'X', email: 'x@y.co', read: false, message: 'hi', createdAt: new Date(), isAdmin: true, // not in hasOnly() whitelist }; await assertFails(setDoc(doc(anon().firestore(), 'leads/l2'), bad));});That second test is the payoff of writing rules with hasOnly(): a client can't smuggle in an unexpected field like isAdmin: true and hope some downstream code trusts it. The whitelist gives you a crisp, testable contract for the document shape.
Local tests you have to remember to run are tests you will eventually forget to run. The whole value shows up when the suite gates every change to the rules file automatically. The emulator ships a firebase emulators:exec command that boots the emulator, runs a command, and tears everything down with the command's exit code — which is exactly the contract a CI step needs.
- name: Test Firestore security rules run: | firebase emulators:exec --only firestore \ "npm test --prefix rules-tests"
Now a pull request that weakens isAdmin(), opens draft reads, or drops the oversized-payload check turns the pipeline red before it ever reaches firebase deploy. That's the whole game: the rules can't regress silently anymore. A reviewer doesn't have to catch a subtle rule change by reading a diff at 11 PM — the build catches it deterministically.
A couple of practical notes for the CI job: the Firebase CLI needs Java installed for the emulator (the Firestore emulator runs on the JVM), and you don't need real credentials — emulators:exec runs entirely against the local emulator, so no service account or project auth is required for the rules tests themselves.
Testing gets dramatically easier when the rules are written to be tested. A few habits that pay off every single time:
isAdmin(), isPublished(), isValidEmail(value) each become a single concept you can target with focused tests, instead of a wall of inlined && clauses you have to reason about as a whole. My isValidEmail regex, for instance, gets its own tiny suite of valid and malformed addresses.match /{document=**} { allow read, write: if false; } — and write a test that hits a made-up collection to prove nothing leaks through the gaps. New collections should be denied by default until you deliberately open them.hasOnly(). Whitelisting the allowed keys means a new field can't sneak in unvalidated, and it gives you a crisp thing to assert against (see the extra-field test above).< versus <= is precisely the kind of typo that never shows up when you only test the happy middle.create from update. They're different verbs for a reason. A rule that's safe on create can be dangerous on update (e.g. letting a visitor flip read to true or overwrite createdAt). Test them independently.@firebase/rules-unit-testing gives you assertSucceeds/assertFails and per-caller contexts. Seed data with withSecurityRulesDisabled so setup never depends on the rule you're testing.firebase emulators:exec so a weakened rule turns the build red before it can reach firebase deploy.hasOnly() whitelists, boundary tests, and separate create/update coverage.Firestore rules are production code that authorizes access to everything you own. Treat them like it: run the emulator as a real test backend, write assertSucceeds/assertFails cases for every role-times-operation-times-state combination that matters — especially the negatives — and gate them in CI so a bad rule can't reach deploy. It took me an afternoon, and it turned "I hope drafts are private" into "I know they are, and the build proves it on every commit."