UI testing is done with Cypress. The tests confirm that the front end correctly writes to database. This is done using callFirestore and callRtdb from cypress-firebase.

File/Folder Structure

├── cypress
│   ├── fixtures           # Fake data to be used within tests
│   ├── plugins            # Cypress Plugins (reacting to emitted events)
│   │   └── index.js
│   ├── support            # Global Code Applied to Cypress
│   │   └── index.js
│   ├── utils              # Utilities shared between custom commands, plugins, and tests
│   │   └── index.js
│   ├── integration        # Tests folder
│   │   └── Some.spec.js   # Test files ending in .spec.js
│   └── Dockerfile         # Docker image for running UI tests
├── cypress.json               # Cypress configuration (including defaults)
└── serviceAccount.json        # Service Account From Firebase/Google Project

Run Locally

Setup

  1. Get the UID of the user that you want to use while testing from the Authentication tab of the Firebase console to
  2. Create a cypress.env.json with the following format:
  {
    "TEST_UID": "<- user account's UID ->"
  }
  1. Run yarn start:emulate
  2. In a separate terminal run: yarn test:emulate

That will open Cypress’s local test runner UI where you can run single tests or all tests. In the background a JWT was generated for the user which you provided the UID for.

Run In CI

Tests are setup to run automatically in the Github Actions CI environment based on settings in .github/workflows/app-verify.

Writing Tests

Selectors

Use the data-test attribute to make it so your tests can easily select the element from the DOM:

<div data-test="some-feature">Words Inside</div>

Then in tests confirm:

Element Has Content

cy.get(createSelector("some-feature")).should("have.value", "Words Inside");

Input Has Partial Value

cy.get("input").invoke("val").should("contain", "partial");
// or
cy.get("input").then(($input) => {
  expect($input.val()).to.contain("partial");
});

Multiple Element Assertions

cy.get(createSelector("some-feature")).should(($divs) => {
  expect($divs).to.have.length(1); // only one element matching selector
  expect($divs.first()).to.contain("Words Inside"); // matching div contains expected text
});

Tagging/Grouping

Single test files can be run by adding them to a group. To add a group, visit /testGroups and click “Create Test Group”.

Recipes

Basic Test

import { createSelector } from "../utils";

describe("Home Page", () => {
  // Setup before tests including creating a server to listen for external requests
  before(() => {
    // Go to home page
    cy.visit("/");
  });

  it('shows a "testGroups" button goes to projects page', () => {
    cy.get(createSelector("feature-testGroups")).click();
  });
});

Test Requiring Auth

Use cy.login in before to auth as qa user before all tests and place cy.logout in after to logout after.

import { createSelector } from "../utils";

describe("Projects Page", () => {
  before(() => {
    // Login using custom token
    cy.login();
  });

  after(() => {
    // Logout to cleaup auth state
    cy.logout();
  });

  beforeEach(() => {
    // Start at listings page
    cy.visit("/transactions/listings");
  });
});

Seed Database With Data

Placing data within the database before running a test, or “seeding”, is a key piece of having tests stand free from each other. callRtdb and callFirestore use admin credentials, so it is not nessesary to call cy.login before using them.

import { createSelector } from "../utils";
import { PROPERTY } from "../fixtures/transaction";
import { DOC } from "../fixtures/transactionDocs";

const TEST_TRANSACTION_ID = "123abc";
const TEST_TRANSACTION_PATH = `transactions/listings/${TEST_TRANSACTION_ID}`;
const TEST_TRANSACTION_DOCS_PATH = `transactions_docs/listings/${TEST_TRANSACTION_ID}`;
const fakeTransaction = { property: PROPERTY };
const fakeTransactionDocs = [DOC];

describe("Listings Page", () => {
  beforeEach(() => {
    // Set transaction documents to Real Time Database
    cy.callRtdb("set", TEST_TRANSACTION_DOCS_PATH, fakeTransactionDocs);
    // Set listing to Real Time Database with meta (createdAt and createdBy)
    cy.callRtdb("set", TEST_TRANSACTION_PATH, fakeTransaction);
    // Login using custom token (Not needed for callRtdb)
    cy.login();
    // Go to listings page
    cy.visit("/transactions/listings");
  });

  afterEach(() => {
    // Remove project to cleanup
    cy.callRtdb("remove", TEST_TRANSACTION_PATH);
  });

  it("Edit button opens listing", () => {
    // Click on the new project edit button
    cy.get(createSelector("edit-button")).first().click();
    cy.url().should("include", TEST_TRANSACTION_PATH);
  });
});

Verify App Writes Data To Database

Real Time Database

import { createSelector } from "../utils";
const TEST_TRANSACTION_PATH = "transactions/listings/123abc";

describe("Listings Page", () => {
  beforeEach(() => {
    // Login using custom token
    cy.login();
    // Go to listings page
    cy.visit("/transactions/listings");
  });

  afterEach(() => {
    // Remove project to cleanup
    cy.callRtdb("remove", TEST_TRANSACTION_PATH);
  });

  it("Creates a new Listing created by the current user", () => {
    // Click to create new transaction
    cy.get(createSelector("new-transaction-button")).click();
    // Confirm that the new project contains createdBy
    cy.callRtdb("get", TEST_TRANSACTION_PATH).then((project) => {
      // Confirm new  has name
      cy.wrap(project)
        .its("createdBy")
        .should("equal", Cypress.env("TEST_UID"));
    });
  });
});

Firestore

import { createSelector } from "../utils";
const TEST_TRANSACTION_ID = "123abc";

describe("Listings Page", () => {
  beforeEach(() => {
    // Login using custom token
    cy.login();
    // Go to listings page
    cy.visit("/transactions/listings");
  });

  afterEach(() => {
    // Remove project to cleanup
    cy.callFirestore("delete", "projects", {
      where: [
        ["createdBy", "==", Cypress.env("TEST_UID")],
        ["name", "==", "Test project"],
      ],
      limit: 1,
    });
  });

  it("Creates a new Listing created by the current user", () => {
    // Click to create new transaction
    cy.get(createSelector("new-transaction-button")).click();
    // Confirm that the new project contains createdBy
    // Query database for the just created project
    cy.callFirestore("get", "projects", {
      where: [
        ["createdBy", "==", Cypress.env("TEST_UID")],
        ["name", "==", "Test project"],
      ],
      limit: 1,
    }).then(([project]) => {
      // Confirm project has name
      cy.wrap(project).its("data.name").should("equal", "Test project");
    });
  });
});

Custom Commands

Cypress supports creating your custom API through adding commands on the cypress object. We have already used this ability to add some commands which are outlined in the existing commands section.

For more details visit the Cypress Docs on custom commands.

Existing Commands

Some custom commands have been added to simplify writing tests with Firebase applications, they are documented below:

Auth

  • cy.login() - Login using UID provided in settings (qa@residebrokerage.com account is default)
  • cy.logout() - Logout of currently authed Firebase instance

File Uploading

  • cy.uploadFile() - Upload a file to an uploader

Database Communication

  • cy.callRtdb - Make a call to Firebase Real Time Database with admin credentials
  • cy.callFirestore() - Make a call to Firestore with admin credentials

Adding A Custom Command

To add a custom command, use Cypress.Commands.add

Cypress.Commands.add("myFunc", (action, actionPath, fixturePath, opts = {}) => {
  cy.log("Some custom command!");
});

Then use it in tests:

cy.myFunc();