Advanced Tips for Firebase Cloud Functions: Bolt, Testing & CI
In the previous post I talked about our extensive experience with Firebase Cloud Functions. Today I would like to build on that, adding a few more advanced topics.
Simplify Firebase Database Rules with Bolt
If you have a complex business logic chances are your Database Rules will be complex as well. After getting to the point where a single rule wouldn’t fit on a screen we decided to rewrite them using Bolt. Bolt is an official compiler (in beta) which compiles Bolt rules (written in a custom language) into your typical Firebase rules.
See a simplified sample for our Member type
As you can see, the Bolt language uses types to describe your data and how they should be validated. On top of that, you can use custom functions to provide additional business validation and read/write access logic. It works just like in any other object-oriented programming language, even with generics to describe more complex types.
The simplification might not be that obvious while you only have read/write rules, but once you start writing data validation logic you will immediately begin wondering why Bolt isn’t the default.
See what monstrosity the above code compiles into:
Notice how our custom function hasMaxLength compiled into string length checks in 4 different places. If we decide to make any changes, we won’t have to be searching all over the json rules, we will only need to make a single change in the bolt file.
To use the Bolt compiler, simply install it via npm:
$ npm install --global firebase-bolt
And compile your database.rules.bolt into database.rules.json.
$ firebase-bolt < database.rules.bolt
You don’t actually need to compile the bolt file manually, you just need to specify it in your firebase.json file and it will get compiled automatically during deployment.
You might be thinking that since Cloud Functions run in the cloud, there is no easy way to test them. Wrong! It is actually quite easy, there are even official docs dedicated to unit testing.
- mocha — a testing framework which allows you to write async tests
- sinon — mocking library which you can use to mock / stub / spy on objects
- chai — assertion library which lets you write more readable assertions
To a .NET developer, the above libraries would loosely translate to NUnit, Moq and FluentAssertion (for example).
In an npm project, your tests go into the “test” folder. Running them is a single command:
$ npm test
Once you have the initial setup it’s “just” a matter of writing your tests. Here’s a sample for validating that Change is generated correctly when a new Transaction is inserted:
The test method itself starts on line #51. It
- creates a sample transaction,
- creates a Firebase Event with previous data = null & new data = tx (this means it’s an insert operation),
- calls changes.writeTransactionsChange and grabs the result,
- creates an expected Change object (saying it should be an insert etc.),
- asserts the expected change and actual change are equal.
To make this whole thing isolated from the actual Firebase servers notice how the ‘ref’ and ‘adminRef’ of the Event are stubbed — the implementing code thinks it’s getting and setting data to Firebase Database, but it is instead only accessing a stub.
Another thing to notice is that I’m not
admin.initializeApp() anywhere. The reason is that this line is only in the index.ts file, which isn’t imported in the tests, so it doesn’t need to be mocked.
Deploying Functions from a CI/CD server
Continuous integration & deployment (CI/CD) is a must in any non-trivial project. It saves you the hassle of having to run your tests and deploy manually. Not only is this error-prone, since you can just forget to run your tests, but it is also annoying and time consuming. You want to have this automated.
Our code is hosted in Github and we use CircleCI for running tests and deployments. To tell CircleCI when and how to do those, you need to have a circle.yml file checked into your repository. See a sample below.
I’ll give you a quick overview of what happens when a git push is made to our Github repository:
- Branch that was pushed into is cloned in CircleCI
- Dependencies are installed (Typescript and everything in package.json)
- Mocha tests are run
- Functions are (maybe) deployed
As far as deployment is concerned, we use multiple environments and the build server needs to tell which one it should deploy into (if any at all). This is highly individual and should be tailored to how you need it and what branching strategy you use. We currently:
- Deploy to sandbox on every push to master
- Only compile and run tests on other (feature) branches
- Deploy to live if there is a version specified in a tag (e.g. ‘v1.1’)
I believe most of the commands in the yml file are clear, however, I would like to point out a few perhaps not-so-obvious things.
- The package.json is in a ./functions subfolder, which means you can’t just “npm install”, but you need to hint npm the correct location. Moreover, you cannot just “cd functions” because each command is run in a separate subshell and doesn’t have any effect on the subsequent commands.
- Our tsconfig.json has watch option set to true to automatically recompile upon making changes. This means the compiler will never finish (until you terminate it) because it’s constantly watching for file changes. This would freeze your deployment, which is why you need to override this option in you compile command.
- Normally when deploying you need to be authenticated for authorization purposes. To deploy from a CI server you need to run
$ firebase login:cion your machine, grab the generated token and save it as a variable on your CI server. Then simply deploy using the token switch, as you can see in the sample yml file.
- firebase use <envrionment> actually looks into your .firebaserc file where you have your project project aliases set up.
In this post I showed additional techniques we use to simplify working with Firebase Cloud Functions. All of the above are incredibly useful and make our lives a lot easier. With Bolt we understand our security rules without having to read complex and long string expressions inside a json file. Tests cover some of the core business functionality so we don’t need to be afraid to make changes in the future — if we break anything important, we’ll know about it.
Having a CI/CD setup completes the circle. It guarantees that tests are always run before deployment. It saves us the hassle of deploying manually. And because it is tied with github commits / tags, we can always see what exactly is deployed to which environment.