Creating a Modern Firebase Powered Application with TDD
Use reactive programming and tests to build a professional app
[Sprint One] Setting up Firebase
Setting up Firebase emulators, security rules, and tests
PROModule Outline
- Source Code & Resources PRO
- Lesson 1: Introduction PUBLIC
- Lesson 2: The Structure of this Module PUBLIC
- Lesson 3: [Sprint One] Setting up Firebase PUBLIC
- Lesson 4: [Sprint One] Creating Security Rules with TDD PRO
- Lesson 5: [Sprint One] Testing Authentication PRO
- Lesson 6: [Sprint One] Component Store PRO
- Lesson 7: [Sprint One] Circumventing Firebase Authentication for E2E Tests PRO
- Lesson 8: [Sprint Two] Displaying Client List from Firestore PRO
- Lesson 9: [Sprint Two] - Adding Clients PRO
- Lesson 10: [Sprint Two] - Editing Clients PRO
- Lesson 11: [Sprint Two] - Client Details PRO
- Lesson 12: Preparing for Delivery PRO
- Lesson 13: Configuring for Production PRO
- Lesson 14: [Sprint Three] - Refactoring PRO
- Lesson 15: [Sprint Three] Setting up PWA PRO
- Lesson 16: [Sprint Three] Logout PRO
- Lesson 17: [Sprint Three] Delete a Client PRO
- Lesson 18: [Sprint Three] - Feedback Mechanism PRO
- Lesson 19: [Sprint Three] View Feedback PRO
- Lesson 20: More Styling PRO
- Lesson 21: [Sprint Four] - Refactoring Feedback PRO
- Lesson 22: [Sprint Four] - Feedback Dates PRO
- Lesson 23: [Sprint Four] - Client Survey PRO
- Lesson 24: [Sprint Four] - View Survey PRO
- Lesson 25: Final Touches PRO
- Lesson 26: Conclusion PRO
Lesson Outline
The First Sprint
In case you are not progressing through the project management module, these are the user stories that we decided to implement for our first sprint:
- #21 feat: log in to the application using my Google account from any device
- #1 feat: record a client's details
- #2 feat: see a list of all clients
- #3 feat: edit a clients details
- #5 feat: access the full details of a specific client
I will give more complete details for each user story as we address each one. The lessons in this module are generally going to be pretty light on context, and instead just focus on the implementation details. As I mentioned before, we will be utilising TDD for this process. Although I will be generally explaining the tests that we create, I won't be giving much/any context on the basics, so if you aren't already at least somewhat familiar with Cypress/Jest and the general TDD approach I highly recommend completing the Test Driven Development with Cypress/Jest module first (unless you plan to just ignore the tests entirely, which is fine, but consider learning to write tests at some point!).
Create the application
If you have not completed the project management module, the only important parts you have missed in order to be able to build the application is the creation of a blank application and setting up tests. You should do that now:
ionic start refresh-app blank --type=angular
To set up tests, you can follow the guide in the lesson below:
As I mentioned before, we are also going to be using this folder structure for the features in our application:
- feature
- ui
- data-access
- utils
Since we already have a default home component generated for us, we need to make sure we restructure that appropriately.
Create a folder at
src/app/home/feature
Move all of the
home.*
files inside of thefeature
folder
Update
src/app/app-routing.module.ts
to importhome.module
from the correct place
With that out of the way, we can jump straight into building!
Setting up a Firebase Development Environment
The first thing we will generally do when we start working with the TDD methodlogy is to write an end-to-end test for the functionality we want to build. We could just start doing that right away now, but we are going to run into walls immediately because we don't have our development environment or any dependencies set up.
Another thing to note is that we will have one set of tests for our frontend application, and another for our Firestore security rules on the backend. The vast majority of tests we write will be for our frontend, and the process will look something like this:
- Write a failing E2E test with Cypress that captures the feature we want to build
- Write a failing unit/component/integration test that would get us closer to the E2E test passing
- Implement the functionality to make the unit test pass
- If the E2E test still doesn't pass, keep adding more unit tests until it does
However, we will be implementing special Firebase specific tests that just test that our security rules do what we expect them to do. We are going to start with just having our security rules blanket deny any requests, and then when our normal tests fail as we are trying to build out our user stories (because the database is denying all requests) we will implement the tests and functionality for our security rules then.
But, we need to set up tools to facilitate all of this first. We don't have a database to work against, we don't have a way to write tests for our security rules, we don't have anywhere to define the security rules! So, setting up all of this first is a must.
If you've been following along with the project management module then you would know that we already experimented with setting up a Firestore database for our prototype. But, as I mentioned, a prototype is meant to be thrown away. In a sense, we don't want our prototype to corrupt our "real" project by starting from a base with shaky foundations that didn't follow best practices.
To that end, we will create a new Firestore project. We can still reference the work we have done in creating a prototype, and utilise/copy the bits we want, but this should be done in a manner that conforms with the processes we are using. You should treat any work you have created in a prototype as if you just found some random code through Googling - it might be useful, but you probably shouldn't just dump it all into your project.
Create a project in the Firebase console
We are going to set up a Firebase project for what will be the production version of our application, but it is important to note that this is not what we will be testing and building with. This is just for our live application that will be sent to the client. We are going to get a local development environment set up in a moment, but first we will just get this project set up (and it is important that we do this right away, because we are intending to deliver a live version of this application to the client after each iteration - not just at the end of the development process).
Create a new project using the Firebase console
Create a Firestore database:
Firestore database
>Create database
>Start in production mode
(production mode starts with security rules that block everything)
Enable Authentication:
Authentication
>Get started
>Enable
>Save
Set up Firebase emulators for local development
If you wanted to, you could just set up an additional Firebase project and treat that as your "development" environment. There isn't really anything wrong with that, but Firebase have made these awesome local development tools available to us with the Firebase Emulator Suite, so it makes a lot of sense to utilise them.
The key benefits to setting up a local development environment with the Firebase emulator suite are (according to me):
- It's awkward having two separate Firebase projects - one "real" one and one "fake" one - it's just kind of annoying to manage, but there is the potential risk of mixing the two up
- A local development environment is completely isolated and you can feel free to play around however you want - import whatever data you want, delete anything, change anything - you are completely free to develop at ease without worrying about messing something up that might impact users
- Although we are getting our production app set up in Firebase right away in this case, the local emulators do give you the opportunity to just develop a Firebase app locally without even needing to worry about setting up a Firebase application for real until you are ready to deploy
- We can run our tests against the local development environment so it is going to be faster (no network requests), safer (again, we have a safe sandbox/playground to work with), and if you are running a lot of tests long term it is going to be cheaper since you aren't executing real requests to Firebase
To enable this, we are first going to install a global CLI tool that we will use on our machine:
npm install -g firebase-tools
This is a generic Firebase CLI tool that will allow us to do all sorts of things including using emulators and deploying our application.
Then, we are also going to set up Firebase in our project so if you are following along with the project management steps you will need to make sure to follow the process below - remember, any time we are making a change to our project (even if it is just configuration) we will need to create an issue for that work and a task branch.
NOTE: Remember, if you aren't following the project management module, you can ignore snippets like the below that describe what needs to be done for the project management process.
Project Management
- Create an issue for the work we are about to do, e.g:
chore: add firebase
- Move the card in the Kanban board to the
Code
column (we don't need to create tests for things like installing dependencies) - Make sure you have the latest code in your main branch:
git checkout main
git pull
- Create a new branch for this task and switch to it:
git checkout -b jm-31
(always use your own initials instead of mine, and replace 21 with whatever the number for the issue you just created is)
In the beginning, I will give you explicit instructions like this for the project management process. However, as we progress, I will just give simpler prompts so that you can try to implement the process yourself.
Run the command below to install the Firebase SDK in your project:
npm install firebase
and we will be using the AngularFire library to help integrate Firebase with our Angular project, so let's install that now too:
ng add @angular/fire
This will prompt you for what you want to add to your project, we will want to select the following options:
- Firestore
- Authentication
- Make sure to deselect
ng deploy -- hosting
Then just proceed through the prompts of selecting your account, choosing your project from Firebase, creating a web app, and so on.
Although this automatically does some of our work for us like setting up our environment files and the imports for our root module, there are still additional things we want to configure in our project for Firebase (like a local security rules file and emulators). So, we are also going to use the standard Firebase CLI to set some things up.
Make sure to run these commands inside of your project:
firebase login
firebase init
This will prompt you with a few options, select the following:
Choose Firestore and Emulators
Use an existing project
Select the Firebase project you created
Keep the default firestore rules name (hit enter)
Keep the default name for firestore indexes
Enable the Authentication and Firestore emulator
Use port
9099
for the auth emulatorUse port
8080
for the firestore emulatorEnable the Emulator UI
Leave the port for Emulator UI empty
Say yes to downloading the emulators now
We should now have the emulators downloaded and 4 new files in our project
- .firebaserc
- firebase.json
- firestore.indexes.json
- firestore.rules
This is mostly just configuration for Firebase, but the firestore.rules
file is where we will define our security rules (rather than implementing/publishing them directly through the GUI in the Firebase console). Although you do not need to do this now, to publish firestore.rules
so that they are active on your production database you can run:
firebase deploy
and if you specifically want to just deploy the rules and nothing else you can use:
firebase deploy --only firestore:rules
However, we will still be able to test our rules with the local Emulator without needing to push them live all the time.
Project Management
It's a good idea to commit your code and push frequently - don't wait until you are completely finished with the work on a branch before committing and pushing. Now would be a good time to commit and push to your remote branch:
git add .
git commit -m "wip: #31 setting up firebase"
(you can usewip:
to indicate that work is not yet finished)git push --set-upstream origin jm-31
(the first time you push to this branch you will need to include--set-upstream origin jm-31
because this branch does not yet exist in the remote repository)
Doing this creates nice "checkpoints", if we mess something up we can easily come back to this clean/working state.
Now we need a place to store our firestore security rules unit tests. Let's create a new folder and file at the root of our project:
firestore-test/firestore.rules.spec.ts
This project is going to have a reasonably simple set of rules so it is going to be manageable to keep all of them in the same file, but you could break these out into multiple files/folders if you wanted to.
In order to run this unit test file, we are going to add the following entry to "scripts"
in package.json
to just run this file:
"test:rules": "jest test --config=firebase.jest.config.js --detectOpenHandles --forceExit",
Notice that we are supplying a separate config for this test since we are running it independently of our other tests. We will need to create that firebase.jest.config.js
file at the root of our project and add the following:
module.exports = {
preset: "jest-preset-angular",
testPathIgnorePatterns: ["<rootDir>/cypress/", "<rootDir>/src/"],
testEnvironment: "node",
};
A key difference here is that we are using the node
test environment. Then when we want to run tests for the security rules we can just run:
npm run test:rules
NOTE: This will fail now because we haven't defined any tests yet.
We are also going to add a configuration so that our standard Jest config ignores the directory for the security rules.
Modify
jest.config.js
to reflect the following:
module.exports = {
moduleNameMapper: {
"@core/(.*)": "<rootDir>/src/app/core/$1",
},
preset: "jest-preset-angular",
setupFilesAfterEnv: ["<rootDir>/setup-jest.ts"],
testPathIgnorePatterns: ["<rootDir>/cypress/", "<rootDir>/firestore-test/"],
};
To help create our firestore security rules tests, we are going to install the following package:
npm install @firebase/rules-unit-testing
Now let's just add a basic test to check that everything is working in regard to testing our security rules. As I mentioned, we are going to start with just the default set of rules that will blanket deny everything. As it becomes necessary (e.g. as we are implementing our first user story) we will add more tests and modify the security rules to suit the feature we are developing.
Modify
firestore-test/firestore.rules.spec.ts
to reflect the following:
import {
assertFails,
assertSucceeds,
initializeTestEnvironment,
RulesTestEnvironment,
TokenOptions,
} from '@firebase/rules-unit-testing';
let testEnv: RulesTestEnvironment;
const getFirestore = (authUser?: { uid: string; token: TokenOptions }) =>
authUser
? testEnv.authenticatedContext(authUser.uid, authUser.token).firestore()
: testEnv.unauthenticatedContext().firestore();
describe('Firestore security rules', () => {
beforeAll(async () => {
testEnv = await initializeTestEnvironment({
projectId: 'refresh-module',
});
});
beforeEach(async () => {
await testEnv.clearFirestore();
});
it('can not read from the clients collection', async () => {
const db = getFirestore();
const testDoc = db.collection('clients').doc('testDoc');
await assertFails(testDoc.get());
});
afterAll(async () => {
await testEnv.cleanup();
});
});
For some background on the basics of using @firebase/rules-unit-testing
you should ready this guide. We will talk about these tests a little more as we build them, but let's just talk a little about this basic set up first.
A key part of this is the test environment we are setting up in our beforeAll
hook. We then use that test environment to grab the appropriate context:
const getFirestore = (authUser?: { uid: string; token: TokenOptions }) =>
authUser
? testEnv.authenticatedContext(authUser.uid, authUser.token).firestore()
: testEnv.unauthenticatedContext().firestore();
This is going to allow us to test three different types of situations in our tests. If we don't pass an authUser
to this function then we will be testing the rules against an unauthenticated user. However, we might also want to test our rules against an authenticated user. Later, we will pass in two types of authenticated users: one with the appropriate admin email address, and another for a case where someone manage to create an account but they don't have the appropriate admin email address.
An important aspect of the design of this application is that only a single admin user will have read/write access to the data determined by their email address. There is only one instance where an unauthenticated user will have write access (we will get to that later).
Then the basic idea with these security rules tests is that we perform some operation and asserts whether the operation should succeed or fail with assertSucceeds
and assertFails
.
Before we run this test, we will need to make sure we have the Firestore emulator running. Open up separate terminal window, go to your project, and run:
firebase emulators:start
NOTE: You will need Java installed to run the emulator. If you like you can install adoptopenjdk
with brew: brew install --cask adoptopenjdk
Once the emulators are running, you will be able to trigger the test in another terminal window with:
npm run test:rules
However, you should see that this fails. This is because we haven't specified the port that Firestore is running on locally in the test. It's a bit annoying to have to manually make sure the emulators are running before we run the test and make sure we set up the correct ports. What we are going to do instead is make a small change to our package.json
script:
"test:rules": "firebase emulators:exec 'jest test --config=firebase.jest.config.js --detectOpenHandles --forceExit'",
What this will do is first start the emulators, and then from within that context execute our test script. That means that our test will automatically be able to see what port the Firestore emulator is running on, so there is no need to manually start it first.
NOTE: If you have already run the emulators manually, you should stop that process in the terminal with Ctrl + C
before running npm run test:rules
again.
After running npm run test:rules
again we should see that it succeeds:
i emulators: Starting emulators: auth, firestore
i firestore: Firestore Emulator logging to firestore-debug.log
i Running script: jest test --config=firebase.jest.config.js --detectOpenHandles --forceExit
PASS firestore-test/firestore.rules.spec.ts (8.017 s)
Firestore security rules
✓ can not read from the clients collection (4367 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 9.907 s
Ran all test suites matching /test/i.
✔ Script exited successfully (code 0)
i emulators: Shutting down emulators.
i firestore: Stopping Firestore Emulator
i auth: Stopping Authentication Emulator
i hub: Stopping emulator hub
Also note how the emulators are being started and then stopped.
Project Management
We have now completed the work for the jm-31
branch which was to set up Firebase. Make sure that you pay attention to this as you develop, and don't get carried away by continuing to add more unrelated features into the one branch (this puts you in a situation where either your project management becomes messy, or you need to do some tricky Git commands to fix up the work that you put in the wrong branch).
Typically we will just have a short snippet in the lessons when it is time to finish up work on a branch, but we are going to go back and visit the project management module now to learn more about merging with pull requests.
Switch modules: Proceed to Lesson 15 of the Project Management for Professional Ionic Applications module
If you are not also working on the project management module, you can proceed straight to the next lesson where we are going to start working towards our first feature (logging in) by adding some more security rules (but not before writing some more tests!).