Utilities to help testing firebase projects with cypress.
Cypress plugin and custom commands for testing Firebase projects
If you are interested in what drove the need for this checkout the why section
npm i --save-dev cypressor
yarn add -D cypress
cypressfolder containing Cypress tests (or create one by calling
cypress open)
Note: These instructions assume your tests are in the
cypressfolder (cypress' default). See the folders section below for more info about other supported folders.
cypress-firebaseand
firebase-adminboth:
yarn add -D cypress-firebase firebase-adminor
npm i --save-dev cypress-firebase firebase-admin
serviceAccount.jsonto your
.gitignore(THIS IS VERY IMPORTANT TO KEEPING YOUR INFORMATION SECURE!)
serviceAccount.jsonin the root of your project (make sure that it is .gitignored) - needed for
firebase-adminto have read/write access to your DB from within your tests
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 });
cypress/support/index.jslike so:
import "./commands";
NOTE: This is a pattern which is setup by default by Cypress, so this file may already exist
cypress/plugins/index.js):
const admin = require("firebase-admin"); const cypressFirebasePlugin = require("cypress-firebase").plugin;module.exports = (on, config) => { const extendedConfig = cypressFirebasePlugin(on, config, admin);
// Add other plugins/tasks such as code coverage here return extendedConfig;
};
cypress/integration/examples/test_hello_world.js) adding a test that uses the cypress-firebase custom command (
cy.callFirestore):
describe("Some Test", () => { it("Adds document to test_hello_world collection of Firestore", () => { cy.callFirestore("add", "test_hello_world", { some: "value" }); }); });
$(npm bin)/cypress open. In the Cypress window, click your new test (
test_hello_world.js) to run it.
test_hello_worldcollection to confirm that a document was added.
TEST_UID
CYPRESS_TEST_UIDto a
.envfile which is gitignored
TEST_UIDto
cypress.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-envhere:
"test": "cross-env CYPRESS_TEST_UID=your-uid cypress open"
cy.login()with the
beforeor
beforeEachsections of your tests
npm start) - for faster alternative checkout the test built version section
npm run test:openin another terminal window
CYPRESS_TEST_UID- UID of your test user
SERVICE_ACCOUNT- service account object
Login to Firebase using custom auth token
Loading
TEST_UIDautomatically 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 such as
set,
updateand
remove
actionString The action type to call with (set, push, update, remove)
actionPathString Path within RTDB that action should be applied
optionsobject Options
options.limitToFirstnumber|boolean Limit to the first results. If true is passed than query is limited to last 1 item.
options.limitToLastnumber|boolean Limit to the last results. If true is passed than query is limited to last 1 item.
options.orderByKeyboolean Order by key name
options.orderByValueboolean Order by primitive value
options.orderByChildstring Select a child key by which to order results
options.equalTostring Restrict results to (based on specified ordering)
options.startAtstring Start results at (based on specified ordering)
options.endAtstring End results at (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 });
Set Data With Timestamps
import firebase from "firebase/app"; import "firebase/database";const fakeProject = { some: "data", createdAt: firebase.database.ServerValue.TIMESTAMP, }; cy.callRtdb("set", "projects/ABC123", fakeProject);
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.
actionString The action type to call with (set, push, update, delete)
actionPathString Path within Firestore that action should be applied
dataOrOptionsString|Object Data for write actions or options for get action
optionsObject Options
options.withMetaboolean Whether or not to include
createdAtand
createdBy
options.mergeboolean Merge data during set
options.batchSizenumber Size of batch to use while deleting
options.whereArray Filter documents by the specified field and the value should satisfy
options.orderBystring|Array Order documents
options.limitnumber Limit to n number of documents
options.limitToLastnumber Limit to last n number of documents
options.staticsadmin.firestore Firestore statics (i.e.
admin.firestore). This should only be needed during testing due to @firebase/testing not containing statics
Basic
cy.callFirestore("set", "project/test-project", "fakeProject.json");
Set Data With Server Timestamps
import firebase from "firebase/app"; import "firebase/firestore";const fakeProject = { some: "data", // Use new firebase.firestore.Timestamp.now in place of serverTimestamp() createdAt: firebase.firestore.Timestamp.now(), // Or use fromDate if you would like to specify a date // createdAt: firebase.firestore.Timestamp.fromDate(new Date()) }; cy.callFirestore("set", "projects/ABC123", fakeProject);
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.log("get returned: ", r); cy.wrap(r).its("data.age").should("equal", mockAge); }); cy.log("Ended test");
}); });
npm i --save-dev cross-env
scriptssection of your
package.json:
"emulators": "firebase emulators:start --only database,firestore", "test": "cypress run", "test:open": "cypress open", "test:emulate": "cross-env FIREBASE_AUTH_EMULATOR_HOST=\"localhost:$(cat firebase.json | jq .emulators.auth.port)\" 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"
firebase init, add emulator ports to
firebase.json:
"emulators": { "database": { "port": 9000 }, "firestore": { "port": 8080 }, "auth": { "port": 9099 } }
firebase.initializeApp(...)), updating the localhost ports as appropriate from the
emulatorsvalues 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);
}
// Emulate Auth if (shouldUseEmulator) { firebase.auth().useEmulator(
http://localhost:9099/
); console.debug(Using Auth emulator: http://localhost:9099/
); }
cypress/support/commands.jsor
cypress/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, }); }
const authEmulatorHost = Cypress.env("FIREBASE_AUTH_EMULATOR_HOST"); if (authEmulatorHost) { firebase.auth().useEmulator(
http://${authEmulatorHost}/
); console.debug(Using Auth emulator: http://${authEmulatorHost}/
); }attachCustomCommands({ Cypress, cy, firebase });
npm run emulators
npm start
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); }
Firebase instance config can be overriden by passing another argument to the cypress-firebase plugin. We can use this to override the
databaseURL:
cypress/plugins/index.js):
const admin = require("firebase-admin"); const cypressFirebasePlugin = require("cypress-firebase").plugin;module.exports = (on, config) => { const overrideFirebaseConfig = { databaseURL: "http://localhost:9000?ns=my-other-namespace", }; const extendedConfig = cypressFirebasePlugin(on, config, admin);
// Add other plugins/tasks such as code coverage here return extendedConfig;
};
databaseURLwhen initializing the firebase instance within cypress (
cypress/support/index.js)
databaseURLwhen initializing the firebase instance within your app code
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:
json "start:dist": "npm run build && firebase emulators:start --only hosting",
firebase.json:
json "emulators": { "hosting": { "port": 3000 } }
npm run start:distto build your app and serve it with firebase
npm run test:open
NOTE: You can also use
firebase serve:
"start:dist": "npm run build && firebase serve --only hosting -p 3000",
firebase login:cito generate a CI token for
firebase-tools(this will give your
cy.callRtdband
cy.callFirestorecommands admin access to the DB)
Pass
commandNamesin the
optionsobject 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
fsis 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 Buildon: [pull_request]
jobs: ui-tests: name: UI Tests runs-on: ubuntu-16.04 steps: - name: Checkout Repo uses: actions/[email protected]
# Cypress action manages installing/caching npm dependencies and Cypress binary. - name: Cypress Run uses: cypress-io/[email protected] 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: Teston: [pull_request]
jobs: ui-tests: name: UI Tests runs-on: ubuntu-latest steps: - name: Checkout Repo uses: actions/[email protected]
# Cypress action manages installing/caching npm dependencies and Cypress binary - name: Cypress Run uses: cypress-io/[email protected] 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-adminSDK 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.
The issue is most likely due to a circular object, such as a timestamp, being included in data you are attempting to write to Firestore. Instead of using
firebase.firestore.FieldValue.serverTimestamp()you should instead use
firebase.firestore.Timestamp.now()or you would like to specify a certain date
firebase.firestore.Timestamp.fromDate(new Date('01/01/18')).
This comes from the fact that cypress stringifies values as it is passing them from the browser environment to the node environment through
cy.task.