firebase / firebase-functions-test

MIT License
231 stars 48 forks source link

❌ Firebase trigger function random data + storage result testing #211

Open PedroReyes opened 10 months ago

PedroReyes commented 10 months ago

Hello

Version info

{
  "name": "functions",
  "main": "lib/src/index.js",
  "scripts": {
    . . .
    "test": "mocha --timeout 20000 --reporter spec --require ts-node/register ./test/**/*.test.ts",
  },
  "engines": {
    "node": "16"
  },
  "dependencies": {
    "@firebase/rules-unit-testing": "^2.0.7",
    "firebase-admin": "^11.10.1",
    "firebase-functions": "^4.2.0"
  },
  "devDependencies": {
    "@types/mocha": "10.0.1",
    "@types/node": "20.4.8",
    "@typescript-eslint/eslint-plugin": "^5.12.0",
    "@typescript-eslint/parser": "^5.12.0",
    "eslint": "^8.9.0",
    "eslint-config-google": "^0.14.0",
    "eslint-plugin-import": "^2.25.4",
    "firebase": "^9.23.0",
    "firebase-functions-test": "3.1.0",
    "mocha": "10.2.0",
    "ts-node": "10.9.1",
    "typescript": "^4.9.0"
  },
  "private": true
}

Test case

I have a trigger function that updates the storage. I simplified the function to this:

export const profileUpdate = onDocumentWritten(FIRESTORE.USERS, async (event) => {
    logInfo(`✍ User updated - Updating profile users report. . .`);
    logInfo(event?.data?.after.data());
    logInfo(event?.data?.before.data());
    admin.storage().bucket(STORAGE_BUCKET_NAME)
        .file(STORAGE.USERS)
        .save(JSON.stringify({ testing: "this does not make sense!" }));

    return;
});

❌ Problem 1: In the console I get always the same random data that I don't know where is coming from instead of the data I provided. This is the result:

✅ before
[ '✍ User updated - Updating profile users report. . .' ]
[ { aString: 'foo', anObject: { a: 'qux', b: 'faz' }, aNumber: 7 } ] 👈 Random data
[ { anObject: { a: 'bar' }, aNumber: 7 } ]                           👈 Random data
👨‍🏫 Exists:  true
👨‍🏫 Downloading file . . .
{ testing: 'this does not make sense!' }
    ✔ tests a Cloud Firestore function (942ms)
✅ after

❌ Problem 2: I don't find the way to wait for the function to update the storage (either using firebase emulator preferably or a new "testing project" in Firebase)

Steps to reproduce

This is the code for the testing file:

import 'mocha';
import assert = require('assert');

import {
    COLLECTIONS, PROJECT_ID, STORAGE, STORAGE_BUCKET_NAME,
} from "../../../utils/setup";
import {
    normalUser
} from "../../../utils/users";

// that detects onDocumentWritten and then checks if the document exists or not
import admin = require("firebase-admin");
import { profileUpdate } from '../../../../src/ui/profile';

const firebaseFunctionEnv = require("firebase-functions-test")(
    {
        projectId: PROJECT_ID,
        storage: STORAGE_BUCKET_NAME,
        databaseURL: 'https://${PROJECT_ID}.firebaseio.com',
    },
    "./service-account.json"
);

describe.only('Storage testing for ${STORAGE.USERS}', () => {
    after(() => {
        firebaseFunctionEnv.cleanup();
    });

    // let db: RulesTestEnvironment;

    before(async () => {
        console.log("✅ before");
    });

    after(async () => {
        console.log("✅ after");
    });

    it("tests a Cloud Firestore function", async () => {
        // Create a fake event object
        const mockedData = {
            uid: normalUser.uid,
            displayName: normalUser.displayName,
            email: normalUser.email,
        }

        // Make a fake document snapshot to pass to the function
        const before = firebaseFunctionEnv.firestore.makeDocumentSnapshot(
            {},
            "/" + COLLECTIONS.USERS + "/" + normalUser.uid
        );
        const after = firebaseFunctionEnv.firestore.makeDocumentSnapshot(
            mockedData,
            "/" + COLLECTIONS.USERS + "/" + normalUser.uid
        );

        // Make change
        const change = firebaseFunctionEnv.makeChange(before, after);
        const profileUpdateWrapped = firebaseFunctionEnv.wrap(profileUpdate);
        await profileUpdateWrapped(change, { params: { userId: normalUser.uid } });

        // Get the profile user list from the Storage emulator
        try {
            let fileRef = admin.storage().bucket(STORAGE_BUCKET_NAME).file(STORAGE.USERS);
            let fileRefExists: any = await fileRef.exists();
            fileRefExists = fileRefExists[0];
            console.log("👨‍🏫 Exists: ", fileRefExists);

            if (fileRefExists) {
                assert.ok(true);
            } else {
                assert.fail("The file does not exist");
            }

            console.log("👨‍🏫 Downloading file . . .");

            let fileContent = await fileRef.download();
            fileContent = JSON.parse(fileContent.toString());

            console.log(fileContent);
        } catch (e: any) {
            console.log(e);
            assert.fail("The file was not modified/created");
        }

        assert.ok(true);
    }).timeout(20000);

});

Expected behavior

I would expect to receive in my firebase function the data I have passed which is:

const mockedData = {
            uid: normalUser.uid,
            displayName: normalUser.displayName,
            email: normalUser.email,
}

Actual behavior

I am getting this random data coming from nowhere when calling makeDocumentSnapshot, makeChange, wrap methods from firebase-function-testing:

[ { aString: 'foo', anObject: { a: 'qux', b: 'faz' }, aNumber: 7 } ]
[ { anObject: { a: 'bar' }, aNumber: 7 } ]

Right now I am using mocha for the testing and directly a firebase project instead of the emulator so I am running the test either with:

mocha --timeout 20000 --reporter spec --require ts-node/register ./test/**/*.test.ts

or this:

firebase emulators:exec --project audit-6b96e 'npm run test'

Thanks in advance for your help, Pedro Reyes.

PedroReyes commented 9 months ago

😊

PedroReyes commented 8 months ago

😬

lpotapczuk commented 4 months ago

@PedroReyes - I am expecting the same behaviour. Did you managed to solve this issue?

PedroReyes commented 4 months ago

Hello @lpotapczuk ,

I gave up trying to use this firebase suite.

What I did as a solution:

  1. Run firebase emulator as usual
  2. Run the scripts for testing giving some time between writes to test that the final result you are expecting in the firestore or storage does ocurr indeed. I am using 5000 millseconds.

I will show here one of my "subtests" of a test suite for the user profile updates test so you can take it directly. Once you understand this one you can simply expand it to your needs.

// https://firebase.google.com/docs/firestore/security/test-rules-emulator
import "mocha";
import assert = require("assert");
import admin = require("firebase-admin");
import { initAdminApp } from "../../utils/setup";
import { createRandomFirestoreUser, wait } from "../../utils/utils";
import { FIRESTORE, STORAGE } from "../../../src/shared/utils/setup";
import { StorageMetadata } from "../../../src/shared/types/storage";
import { FirebaseUser } from "../../../src/shared/types/users";

initAdminApp();

/**
 * 📘 This test mainly checks that firestore document timestamp is updated whenever
 * an update happens in the storage document. This is very helpful to know when
 * the storage document was updated for the last time and for updating in real time
 * the frontend. We "monitor" the firestore document that matches the storage document
 * and we can react to changes in real time in the frontend.
 */
describe(`📖 Firestore user updates`, () => {
  let userForDeletion: FirebaseUser;
  let userForUpdates: FirebaseUser;
  const WAITING_TIME = 5000;

  before(async () => {
    // Create user in firestore to be able to trigger the function for deletion
    userForDeletion = await createRandomFirestoreUser(admin);

    // Create user in firestore to be able to trigger the function for updates
    userForUpdates = await createRandomFirestoreUser(admin);

    // Wait for trigger function to complete
    await wait(WAITING_TIME);
  });

  it(`should create ${STORAGE.USERS_DOC_PATH} 🟩 if it doesn't exist`, async () => {
    try {
      // Get current date in firestore
      (
        await admin.firestore().doc(FIRESTORE.STORAGE(STORAGE.USERS_DOC_PATH)).get()
      ).data() as StorageMetadata;
      assert.fail("Should not be any document");
    } catch (e) {
      assert.ok(true);
    }

    // Create random number
    await createRandomFirestoreUser(admin);

    // Wait for trigger function to complete
    await wait(WAITING_TIME);

    // Get current date in firestore
    const metadataFileAfter: StorageMetadata = (
      await admin.firestore().doc(FIRESTORE.STORAGE(STORAGE.USERS_DOC_PATH)).get()
    ).data() as StorageMetadata;
    assert.ok(metadataFileAfter?.generation !== undefined);
  });
});

As you can see I init the admin firebase with a function:

/**
 * Initializes the admin app and sets environment variables for
 * development environment.
 *
 * You will have to set this at the beginning of the test in case of having problems with
 * environment credentials to setup the admin app.
 */
export async function initAdminApp() {
  // Using admin SDK to connect to firestore emulator in local tests - https://github.com/firebase/firebase-admin-node/issues/776#issuecomment-1129048690
  // Set this environment variables if we are in a development environment
  process.env["FUNCTIONS_EMULATOR"] = "true";
  process.env["FIRESTORE_EMULATOR_HOST"] = "localhost:8080";
  process.env["FIREBASE_STORAGE_EMULATOR_HOST"] = "localhost:9199";

  if (admin.apps.length === 0) {
    logInfo("🤖 Initializing admin app 🔁");
    admin.initializeApp({
      projectId: `${PROJECT_ID}`,
      storageBucket: `${STORAGE_BUCKET_NAME}`,
    });
    logInfo("🤖 Admin app initialized ✅");
  } else {
    logInfo("🤖 Admin app already initialized 🆗");
  }
}

My "wait" time function is something simple:

/**
 * The `wait` function is an asynchronous function that waits for a specified delay before resolving.
 * @param [delay=1000] - The `delay` parameter is the amount of time in milliseconds that the function
 * should wait before resolving the promise. By default, it is set to 1000 milliseconds (1 second).
 */
export async function wait(delay = 1000) {
    await new Promise((resolve: any) => setTimeout(resolve, delay));
}

And here are the function for creating random users:

/**
 * The function creates a random Firebase user object with a unique ID, display name, email, and
 * default user role.
 * @returns a new FirebaseUser object with randomly generated values for uid, displayName, email, and
 * roles.
 */
export function createRandomFirebaseUser(): FirebaseUser {
    const randomNumber: number = Math.floor(Math.random() * 1000000000000000);

    // Create new FirebaseUser object
    const newUser: FirebaseUser = {
        uid: `${randomNumber}_Uid`,
        displayName: `${randomNumber}_DisplayName`,
        email: `${randomNumber}_Email`,
        photoURL: `${randomNumber}_PhotoURL`,
    };
    return newUser;
}

/**
 * The function creates a random user and saves it to a Firestore database.
 * @returns a `FirebaseUser` object.
 */
export async function createRandomFirestoreUser(firebaseAdmin: any) {
    const newUser: FirebaseUser = createRandomFirebaseUser();

    // Create new user
    if (newUser) {
        await firebaseAdmin.firestore().doc(FIRESTORE.USERS(newUser.uid)).set(newUser);
    }

    return newUser;
}

This is my command script from package json to run the triggers in my CI/CD for example:

- run: npm ci && npm run build && firebase emulators:exec 'npm run test:triggers'

The command npm run test:triggers is a package.json script command, the next one:

"test:triggers": "node --experimental-vm-modules --dns-result-order=ipv4first node_modules/mocha/bin/_mocha --timeout 40000 --reporter spec --require ts-node/register ./test/triggers/**/*.test.ts",

I'd say that's all you need to make my code yours.

Best regards, Pedro Reyes.

lpotapczuk commented 4 months ago

@PedroReyes thank you!

Moment after I've posted the message, I've found the solution to our problem in another thread:

https://github.com/firebase/firebase-functions-test/issues/205

Basically, the solution was to modify:

await wrapped(change);

to

await wrapped({ data: change, params: {...} });

PedroReyes commented 4 months ago

@lpotapczuk thank you!

I'll give it a try as soon as I can ✌️