Utilities and cli to help testing Firebase projects with Cypress
- Test environment config generation (including custom auth token) with
createTestEnvFile
- Custom cypress commands for auth and database interactions:
If you are interested in what drove the need for this checkout the why section
- If you do not already have it installed, install Cypress and add it to your package file:
npm i --save-dev cypress
oryarn add -D cypress
- Make sure you have a
cypress
folder containing Cypress tests (or create one by callingcypress open
)
Note: These instructions assume your tests are in the cypress
folder (cypress' default). See the folders section below for more info about other supported folders.
-
Install
cypress-firebase
andfirebase-admin
both:npm i --save-dev cypress-firebase firebase-admin
oryarn add -D cypress-firebase firebase-admin --save-dev
-
Go to project setting on firebase console and generate new private key. See how to do so in the Google Docs.
-
Add
serviceAccount.json
to your.gitignore
(THIS IS VERY IMPORTANT TO KEEPING YOUR INFORMATION SECURE!) -
Save the downloaded file as
serviceAccount.json
in the root of your project (make sure that it is .gitignored) - needed forfirebase-admin
to have read/write access to your DB from within your tests -
Add the following your custom commands file (
cypress/support/commands.js
):import firebase from "firebase/app"; import "firebase/auth"; import "firebase/database"; import "firebase/firestore"; import { attachCustomCommands } from "cypress-firebase"; const fbConfig = { // Your config from Firebase Console }; firebase.initializeApp(fbConfig); attachCustomCommands({ Cypress, cy, firebase });
-
Make sure that you load the custom commands file in an
cypress/support/index.js
like so:import "./commands";
NOTE: This is a pattern which is setup by default by Cypress, so this file may already exist
-
Setup plugin adding following your plugins file (
cypress/plugins/index.js
):const admin = require("firebase-admin"); const cypressFirebasePlugin = require("cypress-firebase").plugin; module.exports = (on, config) => { // Pass on function, config, and admin instance. Returns extended config return cypressFirebasePlugin(on, config, admin); };
-
To confirm things are working, add a cypress-firebase custom command (such as
cy.callFirestore
) to one of your tests:describe("Some Test", () => { it("Adds document to test_hello_world collection of Firestore", () => { cy.callFirestore("add", "test_hello_world", { some: "value" }); }); });
-
Look in the
test_hello_world
collection of your Firestore instance (or whichever collection you write in your test) to confirm that a document was added -
Pat yourself on the back, you are all setup to access Firebase/Firestore from within your tests!
-
Go to Authentication page of the Firebase Console and select an existing user to use as the testing account or create a new user. This will be the account which you use to login while running tests.
-
Get the UID of the account you have selected, we will call this UID
TEST_UID
-
Set the UID of the user you created earlier to the Cypress environment. You can do this using a number of methods:
-
Adding
CYPRESS_TEST_UID
to a.env
file which is gitignored -
Adding
TEST_UID
tocypress.env.json
(make sure you place this within your.gitignore
) -
Adding as part of your npm script to run tests with a tool such as
cross-env
here:"test": "cross-env CYPRESS_TEST_UID=your-uid cypress open"
-
-
Call
cy.login()
with thebefore
orbeforeEach
sections of your tests
- Start your local dev server (usually
npm start
) - for faster alternative checkout the test built version section - Open cypress test running by running
npm run test:open
in another terminal window
-
Add the following environment variables in your CI:
CYPRESS_TEST_UID
- UID of your test userSERVICE_ACCOUNT
- service account object
Login to Firebase using custom auth token
Loading TEST_UID
automatically from Cypress env:
cy.login();
Passing a UID
const uid = "123SomeUid";
cy.login(uid);
Log out of Firebase instance
cy.logout();
Call Real Time Database path with some specified action. Authentication is through FIREBASE_TOKEN
since firebase-tools is used (instead of firebaseExtra).
action
String The action type to call with (set, push, update, remove)actionPath
String Path within RTDB that action should be appliedoptions
object Optionsoptions.limitToFirst
number|boolean Limit to the first<num>
results. If true is passed than query is limited to last 1 item.options.limitToLast
number|boolean Limit to the last<num>
results. If true is passed than query is limited to last 1 item.options.orderByKey
boolean Order by key nameoptions.orderByValue
boolean Order by primitive valueoptions.orderByChild
string Select a child key by which to order resultsoptions.equalTo
string Restrict results to<val>
(based on specified ordering)options.startAt
string Start results at<val>
(based on specified ordering)options.endAt
string End results at<val>
(based on specified ordering)
Set data
const fakeProject = { some: "data" };
cy.callRtdb("set", "projects/ABC123", fakeProject);
Set Data With Meta
const fakeProject = { some: "data" };
// Adds createdAt and createdBy (current user's uid) on data
cy.callRtdb("set", "projects/ABC123", fakeProject, { withMeta: true });
Get/Verify Data
cy.callRtdb("get", "projects/ABC123").then((project) => {
// Confirm new data has users uid
cy.wrap(project).its("createdBy").should("equal", Cypress.env("TEST_UID"));
});
Other Args
const opts = { args: ["-d"] };
const fakeProject = { some: "data" };
cy.callRtdb("update", "project/test-project", fakeProject, opts);
Call Firestore instance with some specified action. Authentication is through serviceAccount.json since it is at the base level. If using delete, auth is through FIREBASE_TOKEN since firebase-tools is used (instead of firebaseExtra).
action
String The action type to call with (set, push, update, remove)actionPath
String Path within RTDB that action should be applieddataOrOptions
String Data for write actions or options for get actionoptions
Object Optionsoptions.args
Array Command line args to be passed
Basic
cy.callFirestore("set", "project/test-project", "fakeProject.json");
Recursive Delete
const opts = { recursive: true };
cy.callFirestore("delete", "project/test-project", opts);
Other Args
const opts = { args: ["-r"] };
cy.callFirestore("delete", "project/test-project", opts);
Full
describe("Test firestore", () => {
const TEST_UID = Cypress.env("TEST_UID");
const mockAge = 8;
beforeEach(() => {
cy.visit("/");
});
it("read/write test", () => {
cy.log("Starting test");
cy.callFirestore("set", `testCollection/${TEST_UID}`, {
name: "axa",
age: 8,
});
cy.callFirestore("get", `testCollection/${TEST_UID}`).then((r) => {
cy.wrap(r[0]).its("id").should("equal", TEST_UID);
cy.wrap(r[0]).its("data.age").should("equal", mockAge);
});
cy.log("Ended test");
});
});
-
Install cross-env for cross system environment variable support:
npm i --save-dev cross-env
-
Add the following to the
scripts
section of yourpackage.json
:"emulators": "firebase emulators:start --only database,firestore", "test": "cypress run", "test:open": "cypress open", "test:emulate": "cross-env FIREBASE_DATABASE_EMULATOR_HOST=\"localhost:$(cat firebase.json | jq .emulators.database.port)\" FIRESTORE_EMULATOR_HOST=\"localhost:$(cat firebase.json | jq .emulators.firestore.port)\" yarn test:open"
-
If not already set by
firebase init
, add emulator ports tofirebase.json
:"emulators": { "database": { "port": 9000 }, "firestore": { "port": 8080 } }
-
Modify your application code to connect to the emulators (where your code calls
firebase.initializeApp(...)
), updating the localhost ports as appropriate from theemulators
values in the previous step:const shouldUseEmulator = window.location.hostname === "localhost"; // or other logic to determine when to use // Emulate RTDB if (shouldUseEmulator) { fbConfig.databaseURL = `http://localhost:9000?ns=${fbConfig.projectId}`; console.debug(`Using RTDB emulator: ${fbConfig.databaseURL}`); } // Initialize Firebase instance firebase.initializeApp(fbConfig); const firestoreSettings = {}; // Pass long polling setting to Firestore when running in Cypress if (window.Cypress) { // Needed for Firestore support in Cypress (see https://github.com/cypress-io/cypress/issues/6350) firestoreSettings.experimentalForceLongPolling = true; } // Emulate Firestore if (shouldUseEmulator) { firestoreSettings.host = "localhost:8080"; firestoreSettings.ssl = false; console.debug(`Using Firestore emulator: ${firestoreSettings.host}`); firebase.firestore().settings(firestoreSettings); }
-
Make sure you also have init logic in
cypress/support/commands.js
orcypress/support/index.js
:import firebase from "firebase/app"; import "firebase/auth"; import "firebase/database"; import "firebase/firestore"; import { attachCustomCommands } from "cypress-firebase"; const fbConfig = { // Your Firebase Config }; // Emulate RTDB if Env variable is passed const rtdbEmulatorHost = Cypress.env("FIREBASE_DATABASE_EMULATOR_HOST"); if (rtdbEmulatorHost) { fbConfig.databaseURL = `http://${rtdbEmulatorHost}?ns=${fbConfig.projectId}`; } firebase.initializeApp(fbConfig); // Emulate Firestore if Env variable is passed const firestoreEmulatorHost = Cypress.env("FIRESTORE_EMULATOR_HOST"); if (firestoreEmulatorHost) { firebase.firestore().settings({ host: firestoreEmulatorHost, ssl: false, }); } attachCustomCommands({ Cypress, cy, firebase });
-
Start emulators:
npm run emulators
-
In another terminal window, start the application:
npm start
-
In another terminal window, open test runner with emulator settings:
npm run test:emulate
NOTE: If you are using react-scripts
(from create-react-app) or other environment management, you can use environment variables to pass settings into your app:
const {
REACT_APP_FIREBASE_DATABASE_EMULATOR_HOST,
REACT_APP_FIRESTORE_EMULATOR_HOST,
} = process.env;
// Emulate RTDB if REACT_APP_FIREBASE_DATABASE_EMULATOR_HOST exists in environment
if (REACT_APP_FIREBASE_DATABASE_EMULATOR_HOST) {
console.debug(`Using RTDB emulator: ${fbConfig.databaseURL}`);
fbConfig.databaseURL = `http://${REACT_APP_FIREBASE_DATABASE_EMULATOR_HOST}?ns=${fbConfig.projectId}`;
}
// Initialize Firebase instance
firebase.initializeApp(fbConfig);
const firestoreSettings = {};
if (window.Cypress) {
// Needed for Firestore support in Cypress (see https://github.com/cypress-io/cypress/issues/6350)
firestoreSettings.experimentalForceLongPolling = true;
}
// Emulate RTDB if REACT_APP_FIRESTORE_EMULATOR_HOST exists in environment
if (REACT_APP_FIRESTORE_EMULATOR_HOST) {
firestoreSettings.host = REACT_APP_FIRESTORE_EMULATOR_HOST;
firestoreSettings.ssl = false;
console.debug(`Using Firestore emulator: ${firestoreSettings.host}`);
firebase.firestore().settings(firestoreSettings);
}
It is often required to run tests against the built version of your app instead of your dev version (with hot module reloading and other dev tools). You can do that by running a build script before spinning up the:
- Adding the following npm script:
"start:dist": "npm run build && firebase emulators:start --only hosting",
- Add the emulator port to
firebase.json
:
"emulators": {
"hosting": {
"port": 3000
}
}
- Run
npm run start:dist
to build your app and serve it with firebase - In another terminal window, run a test command such as
npm run test:open
NOTE: You can also use firebase serve
:
"start:dist": "npm run build && firebase serve --only hosting -p 3000",
- Run
firebase login:ci
to generate a CI token forfirebase-tools
(this will give yourcy.callRtdb
andcy.callFirestore
commands admin access to the DB) - Set
FIREBASE_TOKEN
within CI environment variables
Pass commandNames
in the options
object to attachCustomCommands
:
const options = {
// Key is current command name, value is new command name
commandNames: {
login: 'newNameForLogin',
logout: 'newNameForLogout',
callRtdb: 'newNameForCallRtdb',
callFirestore: 'newNameForCallFirestore',
getAuthUser: 'newNameForGetAuthUser',
}
}
attachCustomCommands({ Cypress, cy, firebase }, options);
For more information about this feature, please see the original feature request.
If you are using a file preprocessor which is building for the browser environment, such as Webpack, you will need to make sure usage of fs
is handled since it is used within the cypress-firebase plugin. To do this with webpack, add the following to your config:
node: {
fs: "empty";
}
See #120 for more info
Separate Install
name: Test Build
on: [pull_request]
jobs:
ui-tests:
name: UI Tests
runs-on: ubuntu-16.04
steps:
- name: Checkout Repo
uses: actions/checkout@v2
# Cypress action manages installing/caching npm dependencies and Cypress binary.
- name: Cypress Run
uses: cypress-io/github-action@v1
with:
group: "E2E Tests"
env:
# pass the Dashboard record key as an environment variable
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_KEY }}
# UID of User to login as during tests
CYPRESS_TEST_UID: ${{ secrets.TEST_UID }}
# Service Account (used for creating custom auth tokens)
SERVICE_ACCOUNT: ${{ secrets.SERVICE_ACCOUNT }}
# Branch settings
GITHUB_HEAD_REF: ${{ github.head_ref }}
GITHUB_REF: ${{ github.ref }}
Using Start For Local
name: Test
on: [pull_request]
jobs:
ui-tests:
name: UI Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v2
# Cypress action manages installing/caching npm dependencies and Cypress binary
- name: Cypress Run
uses: cypress-io/github-action@v1
runs-on: ubuntu-16.04
with:
group: "E2E Tests"
start: npm start
wait-on: http://localhost:3000
env:
# pass the Dashboard record key as an environment variable
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_KEY }}
# UID of User to login as during tests
CYPRESS_TEST_UID: ${{ secrets.TEST_UID }}
# Service Account (used for creating custom auth tokens)
SERVICE_ACCOUNT: ${{ secrets.SERVICE_ACCOUNT }}
# Branch settings
GITHUB_HEAD_REF: ${{ github.head_ref }}
GITHUB_REF: ${{ github.ref }}
When testing, tests should have admin read/write access to the database for seeding/verifying data. It isn't currently possible to use Firebase's firebase-admin
SDK directly within Cypress tests due to dependencies not being able to be loaded into the Browser environment. Since the admin SDK is necessary to generate custom tokens and interact with Real Time Database and Firestore with admin privileges, this library provides convenience methods (cy.callRtdb
, cy.callFirestore
, cy.login
, etc...) which call custom tasks which have access to the node environment.
- fireadmin.io - A Firebase project management tool (here is the source)
- cv19assist.com - App for connecting volunteers with at-health-risk population during the coronavirus pandemic. (here is the source)