Testing guide for Cloud Firestore functions and security rules

Dana Hartweg
Oct 19 · 12 min read

Writing Cloud Firestore functions and security rules is relatively well documented and understood. Testing them, however, is not.

Presented here is a quick overview of how I’m writing and organizing functions and security rules, and more importantly how to confidently test your code. Everything below can be found in this repository.

As of this writing, the packages I’m using are:

{
"@firebase/testing": "^0.13.0",
"firebase-admin": "^8.5.0",
"firebase-functions": "^3.2.0",
"firebase-tools": "7.3.0" (installed globally),
"jest": "^24.8.0",
"ts-jest": "^24.0.2",
"tslint": "^5.12.1",
"typescript": "^3.5.0",
"uuid": "^3.3.3",
}

General setup

Overview: rules

{
"firestore": {
"rules": "rules/firestore.rules"
},
}

Other than that, I organize my firestore.rules file with generic helper functions at the top, followed by authentication and access role helper functions. All of the route-specific rules come last. Here’s an example of what that file looks like:

Overview: functions

This consists of an admin helper:

and a main server/functions/src/index.ts that will initialize the app and include all of our functions from each directory (making use of barrel files):

import { init } from './admin';init();export * from './homestead';

This does require unique names for every function, which should realistically be a best practice anyway in order to distinguish them in the admin console.

Functions package

That package at server/functions/package.json is really minimal, and looks like this:

{
"name": "testing-cloud-firestore-cloud-functions",
"engines": {
"node": "10"
},
"main": "build/index.js",
"dependencies": {
"firebase-admin": "^8.5.0",
"firebase-functions": "^3.2.0"
},
"private": true
}

Function to be tested

The associated barrel file at server/functions/src/homestead/index.ts simply looks like this:

export * from './update-membership-on-creation';

Overview: remaining setup

Script management

"scripts": {
"build:functions": "tsc -p ./functions/tsconfig.build.json",
"deploy:functions": "firebase deploy --only functions",
"deploy:rules": "firebase deploy --only firestore",
"lint:functions": "tslint -p ./functions",
"lint:rules": "tslint -p ./rules",
"lint": "yarn lint:functions && yarn lint:rules",
"postinstall": "yarn --cwd './functions' install",
"validate:functions": "tsc -p ./functions --noEmit",
"validate:rules": "tsc -p ./rules --noEmit",
"validate": "yarn validate:functions && yarn validate:rules"
}

TypeScript setup

{
"compilerOptions": {
"module": "commonjs",
"noImplicitReturns": true,
"noUnusedLocals": true,
"sourceMap": true,
"strict": true,
"target": "es2017"
}
}

server/rules/tsconfig.json will look like

{
"extends": "../tsconfig.json",
"include": ["**/*"]
}

and server/functions/tsconfig.json will look like

{
"extends": "../tsconfig.json",
"include": ["src/**/*", "test/**/*"]
}

At this point everything passes linting and validation, functions can be built, and you could deploy them to your project if you so desire. Next stop… testing!

Test helpers

Path and id generation

  • COLLECTIONS is just a convenience wrapper to hold all of the correct strings for the collections that will be under test
  • Generators for creating mock documents and security records. These just make it a little easier to maintain consistency and ensure objects aren’t unintentionally mutated.
  • documentPath is just a simple alias to make it clear what’s happening when an array of strings is joined by a slash in order to locate a Firestore document
  • membershipPath will use the format we’ve established to quickly locate a resource’s membership record
  • generateId and generateUserId are mainly helpful in testing functions. While testing functions there is currently no way to clear the database efficiently between test runs. If you attempt to do so, you’ll end up also triggering any functions that respond to document deletes and end up with a bunch of race conditions. The best solution I’ve found thus far is to simply ensure you’re working with unique ids and new records in every module under test.

Test harness setup and teardown

  • Exporting the types is just a convenience for the remainder of the test suite
  • All of the test increment and project id generation will ensure that rules tests use a fresh database for every module. The most important part here that’s easy to miss, and threw me for a loop for the longest time… you must use a real project id (i.e. one that you can set for you project on the command line using firebase use) in order to test Cloud Firestore triggers. Without matching (and valid) project ids, cloud functions will not trigger in response to anything happening in the test database. I’ve confirmed this is expected behavior with Firestore support, but there’s no documentation anywhere on it being required to properly test cloud function triggers with the local emulator.
  • getAdminApp will give you a Firestore instance that can access the database freely, bypassing any security rules
  • getAuthedApp will give you a Firestore instance that respects security rules
  • setup will make sure you’re using a new test increment, set up the database with any data you want to preload, and return a Firestore instance you can use to test with (note: any data supplied here will trigger the execution of cloud functions)
  • teardown will destroy any established apps

Testing scripts

"scripts": {
"execute-tests:functions": "jest './functions'",
"execute-tests:rules": "jest './rules'",
"test": "yarn test:rules && yarn test:functions",
"test:functions": "yarn build:functions && firebase emulators:exec --only firestore,functions 'yarn execute-tests:functions'",
"test:rules": "firebase emulators:exec --only firestore 'yarn execute-tests:rules'",
}

edit (11/5/2019): at a great suggestion from Evgenij Beloded, I’ve moved setting the emulator host into a global jest setup file. There are a few changes involved with that, so it’s easiest to take a look at the corresponding commit.

Testing: rules

Catch-all tests

Test module setup

const COLLECTION = COLLECTIONS.HOMESTEADS;
const DOC_ID = generateId();
const USER_ID = generateUserId();

You’ll see the same teardown code throughout these testing modules. I’m going to include that here so it’s not repeated for no reason.

afterAll(() => teardown());

Creating

beforeAll(async () => {
db = await setup(USER_ID, {
[documentPath(COLLECTIONS.USERS, USER_ID)]: generateMockDocument(),
});
});

We don’t want to allow creation of a homestead if the user already has one. For this test we need to update the user record to have an ownedHomestead associated with it. We then assert that attempting to add a new homestead for the current user will fail.

test('disallow if a homestead has already been created', async () => {
const adminDb = getAdminApp();
await adminDb
.collection(COLLECTIONS.USERS)
.doc(USER_ID)
.update({ ownedHomestead: generateId() });
const document = db.collection(COLLECTION).doc(DOC_ID); await firebase.assertFails(
document.set(generateMockUpdateDocument())
);
});

We do want to allow creation of a homestead if the user does not have one. For this test we need to update the user record to not have an ownedHomestead associated with it. We then assert that attempting to add a new homestead for the current user will succeed.

test('allow if a homestead has not already been created', async () => {
const adminDb = getAdminApp();
await adminDb
.collection(COLLECTIONS.USERS)
.doc(USER_ID)
.update({ ownedHomestead: '' });
const document = db.collection(COLLECTION).doc(DOC_ID); await firebase.assertSucceeds(
document.set(generateMockUpdateDocument())
);
});

Notice in the above two tests that we use the adminDb to adjust the user record so we don’t have to worry about security rules blocking us from setting up our testing conditions.

Deleting

beforeAll(async () => {
db = await setup(USER_ID, {
[membershipPath(
COLLECTION,
DOC_ID,
USER_ID
)]: generateSecurityRecordOwner(),
});
});

For now, we’re not going to allow deletion of a homestead as it’s core to our functionality. You’ll notice we generated an owner security record during setup. Ideally we should also include a test case to make sure that any user role can’t delete a homestead, not just owners.

test('disallow', async () => {
const document = db.collection(COLLECTION).doc(DOC_ID);
await firebase.assertFails(document.delete());
});

Reading

beforeAll(async () => {
db = await setup(USER_ID, {
[membershipPath(
COLLECTION,
DOC_ID,
USER_ID
)]: generateSecurityRecordAny(),
});
});

Without a membership record reading the collection and document should both fail.

test('disallow without a membership record', async () => {
const collection = db.collection(COLLECTION);
const document = collection.doc(generateId());
await firebase.assertFails(collection.get());
await firebase.assertFails(document.get());
});

With a non-existent document reading the collection and document should both fail.

test('disallow on records that don\'t exist', async () => {
const collection = db.collection(COLLECTION);
const document = collection.doc(generateId());
await firebase.assertFails(collection.get());
await firebase.assertFails(document.get());
});

With a membership record reading should fail on the collection (we don’t want users to be able list all of the created homesteads) and succeed on the document.

test('allow with a membership record', async () => {
const collection = db.collection(COLLECTION);
const document = collection.doc(DOC_ID);
await firebase.assertFails(collection.get());
await firebase.assertSucceeds(document.get());
});

I’ve elected to test both collection list and document reads in the same test block as they share similar setup logic. Splitting them into separate test blocks would also be acceptable.

Updating

beforeAll(async () => {
db = await setup(USER_ID, {
[documentPath(COLLECTION, DOC_ID_1)]: generateMockDocument(),
[documentPath(COLLECTION, DOC_ID_2)]: generateMockDocument(),
[membershipPath(
COLLECTION,
DOC_ID_1,
USER_ID
)]: generateSecurityRecordOwner(),
[membershipPath(
COLLECTION,
DOC_ID_2,
USER_ID
)]: generateSecurityRecordAny(),
});
});

Non-owners should not be able to update the homestead.

test('disallow without an owner membership role', async () => {
const document = db.collection(COLLECTION).doc(DOC_ID_2);
await firebase.assertFails(
document.update(generateMockUpdateDocument())
);
});

If no record exists, that should also be a failed update.

test('disallow without an existing record', async () => {
const document = db.collection(COLLECTION).doc(generateId());
await firebase.assertFails(
document.update(generateMockUpdateDocument())
);
});

If there is no access for the user at all, that should also fail.

test('disallow without a membership record', async () => {
const document = db.collection(COLLECTION).doc(generateId());
await firebase.assertFails(
document.update(generateMockUpdateDocument())
);
});

Our only success condition for updating a homestead is via the owner of the homestead record.

test('allow with an owner membership role', async () => {
const document = db.collection(COLLECTION).doc(DOC_ID_1);
await firebase.assertSucceeds(
document.update(generateMockUpdateDocument())
);
});

Testing: functions

Neither of those jive with me and actually getting cloud function triggers into a testable state against the local emulator was the impetuous of writing this guide in the first place.

Setup

We then actually set up the test database with any additional data the cloud function needs to access as it runs (note: any data supplied here will trigger the execution of cloud functions).

await setup(USER_ID, {
[documentPath(COLLECTIONS.USERS, USER_ID)]: {
displayName: userName,
},
});
db = getAdminApp();

The next bit is what’s going to kick off the remainder of our test cases. We want to actually trigger the cloud function we want to test. In this case, adding a homestead. Most important is the last line… we can’t run our test cases until setup is complete, which means ensuring the cloud function has had time to trigger in the emulator and finish running.

At the moment, I know of no way to do this more intelligently than simply waiting. I suppose you could set up an interval and check the database routinely for something you know is going to change based on the output of your function, but that has the potential to be quite brittle.

homesteadRef = await db
.collection(COLLECTIONS.HOMESTEADS)
.add({ name: homesteadName, owner: USER_ID });
return waitForCloudFunctionExecution();

Our first test case is making sure we update the user record to include the new homestead. We do this so we can have easy access in the user’s profile to the homesteads they belong to.

test('updates the user with the homestead name', async () => {
const userDocument = await db
.collection(COLLECTIONS.USERS)
.doc(USER_ID)
.get();
const homesteads = userDocument.get('homesteads');
expect(homesteads[homesteadRef.id]).toEqual(homesteadName);
});

Our second test case makes sure we set the current user as the owner of the homestead that was just created.

test('sets the current user as the homestead owner', async () => {
const membershipRecord = await db
.collection(COLLECTIONS.HOMESTEADS)
.doc(homesteadRef.id)
.collection('members')
.doc(USER_ID)
.get();
expect(membershipRecord.data()).toEqual({
displayName: userName,
role: 'owner',
});
});

The third test case makes sure we clean up the homestead document that was just created and remove the temporary owner id we passed along.

test('removes the temporary owner field', async () => {
const homesteadDoc = await homesteadRef.get();
expect(homesteadDoc.get('owner')).toBeUndefined();
});

You’ll notice that these tests run in order, and all on the output from just one trigger of the cloud function. To test other scenarios you’d need to write a brand new homestead document and test those results.

Running on GitLab CI

I’ve broken everything down into three stages. One for validation and linting, one for testing, and one to actually deploy everything to Firestore on merging code to a dev branch.

stages:
- validate
- test
- firestore

Validation

validate:server:
image: node:10.16.1-alpine
stage: validate
script:
- cd server
- yarn install
- yarn lint
- yarn validate

Testing

edit (11/5/2019): the Dockerfile is super-simple, and looks like the below:

FROM openjdk:8-jre-alpine
LABEL maintainer "Dana Hartweg <dana.hartweg@gmail.com>"
RUN apk update && \
apk add --no-cache nodejs=10.14.2-r0 \
yarn
RUN yarn global add firebase-tools@7.2.1 && \
firebase setup:emulators:firestore

As we discussed above, in order to run the function tests you must supply an actual project id as well as a valid token to authenticate against that project. No worries, everything solely happens in the emulator and doesn’t touch your supplied project.

test:server:
image: dhartweg/node-java-yarn-firebase:10.14.2-7.2.1
stage: test
script:
- cd server
- yarn install
- firebase use your-project-name --token $FIREBASE_TEST_RUNNER_TOKEN
- FIREBASE_TOKEN=$FIREBASE_TEST_RUNNER_TOKEN yarn test

Deploying

firestore:dev:
image: andreysenov/firebase-tools
stage: firestore
only:
- dev
script:
- cd server
- yarn install
- firebase use --token $FIREBASE_DEV_DEPLOY_TOKEN your-project-name
- firebase deploy -m "Pipeline $CI_PIPELINE_ID, build $CI_BUILD_ID" --non-interactive --token $FIREBASE_DEV_DEPLOY_TOKEN

Note: this will deploy all rules and functions on every merge to the dev branch, even if nothing has changed. There are definitely ways to make this smarter, I just haven’t reached a point where I’m actually ready to invest time into working through that.

Wrapping up

Happy testing!

The Startup

Medium's largest active publication, followed by +527K people. Follow to join our community.

Dana Hartweg

Written by

Senior Front End Software Engineer, InVision Studio

The Startup

Medium's largest active publication, followed by +527K people. Follow to join our community.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade