Lesson 7

[Sprint One] Circumventing Firebase Authentication for E2E Tests

Setting up cypress-firebase

PRO

Lesson Outline

Circumventing Authentication

You might have been hoping to make some good progress on this E2E test, but we immediately hit another big hurdle. You will likely notice that when the login button is clicked we are greeted with our signInWithPopup window:

Sign in with popup

This is a problem.

Now we get into the tricky part, and the tricky part can be separated into two distinct non equally tricky parts:

  1. We need to be authenticated before most tests
  2. We need to authenticate using an external service

Since most of the tests we write are going to be for the admin user, it will require that we have admin access to whatever it is we are testing. We could go through the entire authentication flow at the beginning of every test to authenticate as the admin, but this is going to slow our tests down.

The other issue is that if we were using a more standard authentication process, like just entering in a username/password into a text field, then handling the authentication with Cypress would be easy. We could just log in a demo account by sending the username/password to the appropriate fields using Cypress (although this isn't necessarily the best solution).

However, we are going to be using Firebase/Google to authenticate. That means there are parts of the authentication process that need to happen outside of Cypress. The user clicks the login button, then a separate Google authentication window pops up and they use that to sign in. We have no control over that in our Cypress test.

To deal with this issue we are going to implement two solutions:

  1. We will make sure that our application can utilise the Firebase emulators whilst in development mode
  2. We will use the cypress-firebase package to create a custom authentication token for us, which will allow us to fake a login in our tests (as well as providing other helper methods for interacting with our Firestore database in tests)

First, we are going to set up the emulators for our application so that when we are running in development mode will be connecting to an emulated Firestore and Auth instance.

Setting up Emulators for Development

To get our application to use the emulators in development mode we are going to need to make a few changes. First, we will need to modify our app.module.ts file:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { initializeApp, provideFirebaseApp, getApp } from '@angular/fire/app';
import { environment } from '../environments/environment';
import { provideAuth, getAuth, connectAuthEmulator } from '@angular/fire/auth';
import {
  initializeFirestore,
  provideFirestore,
  connectFirestoreEmulator,
  getFirestore,
  Firestore,
} from '@angular/fire/firestore';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    provideFirebaseApp(() => initializeApp(environment.firebase)),
    provideFirestore(() => {
      let firestore: Firestore;

      if (environment.useEmulators) {
        // Long polling required for Cypress
        firestore = initializeFirestore(getApp(), {
          experimentalForceLongPolling: true,
        });

        connectFirestoreEmulator(firestore, 'localhost', 8080);
      } else {
        firestore = getFirestore();
      }

      return firestore;
    }),
    provideAuth(() => {
      const auth = getAuth();
      if (environment.useEmulators) {
        connectAuthEmulator(auth, 'http://localhost:9099', {
          disableWarnings: true,
        });
      }
      return auth;
    }),
  ],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent],
})
export class AppModule {}

Notice that in the provideAuth and provideFirestore methods we are now checking for a useEmulators option from our environment config files. If that value is set to true then we call a method to connect to the emulator. For Cypress to work properly with Firebase we also need to set the experimentalForceLongPolling option - so if we are using emulators we also manually configure Firestore to use this setting (otherwise we don't need to manually configure any settings)

We also need to make sure we set that value in our environment files.

Modify environments/environment.prod.ts to reflect the following:

export const environment = {
  firebase: {
    // your firebase config
  },
  production: true,
  useEmulators: false,
};

Modify environments/environment.ts to reflect the following:

export const environment = {
  firebase: {
    projectId: 'demo-project',
    // your firebase config
  },
  production: false,
  useEmulators: true,
};

NOTE: Make sure to change your projectId for environment.ts (i.e. your dev environment) to demo-project. It doesn't have to specifically be this name but it does have to be in the demo-* format. This is a special naming convention for the Firebase emulator suite that we are using, which will indicate that we are not connecting to a real project - just emulators. If you use projectId like this you should notice the following message when we start up the emulators:

emulators: Detected demo project ID "demo-project", emulated services will use a demo configuration and attempts to access non-emulated services for this project will fail.

Finally, so that we don't always have to remember to manually start the emulators, let's modify our start script in package.json to start the emulators before running ionic serve:

"start": "firebase emulators:exec --project=demo-project --ui 'ionic serve'",

NOTE: We've also added the --ui flag so that we can access the Emulator UI on http://localhost:4000, and --project to make sure we are connecting to a demo project.

Now we should be able to run:

npm start

and our application should now be using the emulators since we are in development mode. When we do a production build with --prod it will connect to the real Firebase project (because it will use environment.prod.ts which has useEmulators set to false).

That's convenient for development, but we also need to do the same thing for our Cypress tests:

"e2e": "firebase emulators:exec --project=demo-project --ui 'ng e2e'",

Now when we run npm run e2e the emulators will be started and then our tests will run.

Setting up cypress-firebase

Getting the emulators to run in development is only half the story, now we need to set up cypress-firebase. To do this, we are going to need to install a few packages:

npm i --save-dev cypress-firebase [email protected] cross-env

NOTE: Version 9 of firebase-admin is required specifically here as cypress-firebase has not yet been updated for v10

The cypress-firebase package can connect to your actual Firebase project by using a serviceAccount.json file (which is a way to provide admin access to your project), but we are only going to be using it with the emulator so we don't need to create that file.

Now we need to configure Cypress so that we can use the custom cypress-firebase commands like login() and callFirestore.

Add the following to cypress/support/commands.ts

import firebase from 'firebase/compat/app';
import 'firebase/compat/auth';
import 'firebase/compat/database';
import 'firebase/compat/firestore';
import { attachCustomCommands } from 'cypress-firebase';
import { environment } from '../../src/environments/environment';

firebase.initializeApp(environment.firebase);

const firestoreEmulatorHost = Cypress.env('FIRESTORE_EMULATOR_HOST');

if (firestoreEmulatorHost) {
  firebase.firestore().settings({
    host: firestoreEmulatorHost,
    experimentalForceLongPolling: true,
    ssl: false,
  });
}

const authEmulatorHost = Cypress.env('FIREBASE_AUTH_EMULATOR_HOST');
if (authEmulatorHost) {
  firebase.auth().useEmulator(`http://${authEmulatorHost}/`);
}

attachCustomCommands({ Cypress, cy, firebase });

We use the same Firebase config here that we have already set up in our environment file. Also note how we are enabling the usage of the emulators here - this relies on us defining the FIRESTORE_EMULATOR_HOST and FIREBASE_AUTH_EMULATOR_HOST environment variables. We will get to that in a moment.

Uncomment the following line in cypress/support/e2e.ts:

import './commands';

Modify cypress/plugins/index.ts to reflect the following:

PRO

Thanks for checking out the preview of this lesson!

The full version of this lesson is only available to pro members. If you would like full access to this module and all of the other pro modules on Elite Ionic you can become a pro member (or log in if you are already a member).