首页 > 解决方案 > 用 Mocha 延迟测试 Cloud Functions 的内容

问题描述

我正在尝试测试与 Firestore 数据库交互的云功能。我正在关注在在线模式下使用 firebase-functions-test 和 mocha 测试我的功能的文档(https://firebase.google.com/docs/functions/unit-testing

由于我要测试的函数删除集合中的文档,因此我首先在测试中创建了一个假文档,并将其推送到数据库。然后我用 wrap 我的函数调用来测试。它是异步的,因此需要一些时间。

我要验证的是该文档已被正确删除。但是获取文档然后断言的调用有时比我执行删除的实际函数更快。我想在进行验证之前添加一点延迟。

我尝试添加一个 settimeout,但测试返回“通过”,而无需等待超时内的代码运行并检查断言。

这是我的测试。任何帮助将不胜感激!

const myFunctions = require('../src/delete_notification.ts');

    it('delete notification of db more than 7 days old', async() => {

        // create test notification in db
        const notificationToDeleteId = 'TEST_1234567890';
        const notificationToDelete = {
            uid: notificationToDeleteId,
            createdOn: '2021-01-01T00:00:00.00000'
        };

        await admin.firestore().collection('notifications')
                 .doc(notificationToDeleteId)
                 .set(notificationToDelete);

        // call the cloud function
        const wrapped = test.wrap(myFunctions.deleteNotificationAfter7Days);
        await wrapped();

        //this code needs to be delayed by a few seconds

        return admin.firestore()
            .collection('notifications')
            .doc(notificationToDeleteId).get().then((deleteDoc) => {
                //console.log(deleteDoc.data());
                assert.equal(deleteDoc.data(), null);
        });
    }); 

更新 :

我试过这段代码:

return wrapped().then(() => {
            return setTimeout(() => {
              return admin.firestore().collection('notifications').doc(notificationToDeleteId)
                  .get().then((deleteDoc) => {
                      console.log(deleteDoc.data());
                      assert.equal(deleteDoc.data(), null);
                  });
            }, 5000)
        });

同时确保我调用的函数不会删除文档。就像那样,我希望我的测试失败。但是使用下面的这段代码(使用 settimeout),测试总是通过。与 setInterval 相同。

在此处输入图像描述

这是我要测试的功能:

 import * as functions from 'firebase-functions';
    import * as admin from 'firebase-admin';
    
    if (admin.apps.length === 0) admin.initializeApp();
    const db = admin.firestore();
    
    // run every  7 days
    export const deleteNotificationAfter7Days = functions
    .region('europe-west6')
    .pubsub
    .schedule('every 24 hours')
    .timeZone('Africa/Accra')
    .onRun(async context => {
    
        const currentDate = new Date();
        const currentDateMinus7Days = new Date(currentDate.getTime() - 604800000);
        const currentDateMinus7DaysString = currentDateMinus7Days.toISOString();
    
        //console.log("date minus 7 days " + currentDateMinus7DaysString);
    
       try {
            const querySnapshot = await db.collection('notifications').where("createdOn", "<", currentDateMinus7DaysString).get();
            if(querySnapshot.empty) return;
    
            querySnapshot.forEach( async function(doc){
           

     const notificationId = doc.id;

            //console.log('notificationId id ' + notificationId);
            await deleteNotification(notificationId);
            return;
        });
    } catch (error) {
        console.log('Error deleting notifications ' + error);
    }
    return;
});

async function deleteNotification(notificationId : string) {

    //console.log('DELETE FROM NOTIFICATION');

    return db.collection('notifications')
        .doc(notificationId)
        .delete()
        .then(function() {
            console.log('Notification deleted');
        })
        .catch(function(error) {
            throw error;
        });
}

标签: typescriptunit-testinggoogle-cloud-firestoregoogle-cloud-functionsmocha.js

解决方案


您看到并错误地尝试解决的奇怪行为是由您错误实现的云函数引起的。

当前,您的函数执行以下操作:

  • 查找所有超过 7 天的通知,并为每个通知启动删除操作。
  • 无需等待上述完成即可结束 Cloud Function。

第二点是为什么您必须等待几秒钟才能删除数据库中的数据。

在已部署的函数中,一旦函数返回,所有进一步的操作都应被视为永远不会执行 ,如此处所述。“非活动”功能可能随时终止,受到严重限制,您进行的任何网络调用(如删除文档)可能永远不会执行。


在您的代码中,您用于const notificationId = doc.id; deleteNotificationId(notificationId)删除通知。这可以替换doc.ref.delete()为用于相同的目的。

要修复您的函数,我们需要等待删除操作完成,然后再从函数返回并结束其生命周期。

import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
    
if (admin.apps.length === 0) admin.initializeApp();
const db = admin.firestore();
    
export const deleteNotificationAfter7Days = functions
  .region('europe-west6')
  .pubsub
  .schedule('every 24 hours')
  .timeZone('Africa/Accra')
  .onRun(async context => {
    
    const currentDateMinus7Days = new Date(Date.now() - 604800000);
    const currentDateMinus7DaysString = currentDateMinus7Days.toISOString();
    
    try {
      const querySnapshot = await db.collection('notifications').where("createdOn", "<", currentDateMinus7DaysString).get();
      if (querySnapshot.empty) {
        console.log("No notification documents to clean up. Aborted.");
        return;
      }
    
      const deleteDocPromises = [];

      querySnapshot.forEach(function (doc) {
        deleteDocPromises.push(doc.ref.delete());
      });

      // wait for all operations to complete
      await Promise.all(deleteDocPromises);

      console.log("All old notifications cleaned up successfully.");
    } catch (error) {
      console.log('Unexpected error deleting old notifications: ' + error);
    }
});

使用您当前的函数(包括上面的代码),如果任何单个文档的删除操作失败,整个函数就会崩溃。虽然您可以捕获错误以免发生这种情况,但如果您有 200 个文档要删除并且所有文档都失败了,那么您将有 200 个错误和 200 个失败的网络请求。相反,您应该设置一个阈值,以便在出现 X 错误后使函数崩溃。这允许一些失败,同时仍然删除其他的,失败的将在下次函数运行时重新尝试。

const deleteDocPromises = [];

let errorCount = 0;
const handleError = (error: any) => {
  if (++errorCount > 10) {
    throw new Error("Error threshold exceeded");
  return error;
};

querySnapshot.forEach(function (doc) {
  deleteDocPromises.push(
    doc.ref
      .delete()
      .catch(handleError)
  );
});

另一个改进是使用批处理来执行删除,以减少函数的网络开销:

import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
    
if (admin.apps.length === 0) admin.initializeApp();
const db = admin.firestore();
    
export const deleteNotificationAfter7Days = functions
  .region('europe-west6')
  .pubsub
  .schedule('every 24 hours')
  .timeZone('Africa/Accra')
  .onRun(async context => {
    
    const currentDateMinus7Days = new Date(Date.now() - 604800000);
    const currentDateMinus7DaysString = currentDateMinus7Days.toISOString();
    
    try {
      const querySnapshot = await db.collection('notifications').where("createdOn", "<", currentDateMinus7DaysString).get();
      if (querySnapshot.empty) {
        console.log("No notification documents to clean up. Aborted.");
        return;
      }
    
      let currentBatch = db.batch(), currentBatchCount = 0;
      const batches = [currentBatch];
   
      // for each document, queue its deletion
      querySnapshot.forEach(function (doc) {
        if (++currentBatchCount > 500) {
          // more than 500 operations in the current batch, start a new one
          currentBatch = db.batch();
          currentBatchCount = 1;
          batches.push(currentBatch);
        }

        currentBatch.delete(doc.ref);
      });

      // wait for all operations to complete
      const batchErrors = await Promise.all(batches.map(b => {
        return b.commit()
          .then(
            () => null,
            (error) => error // trap errors so other batches can still complete
          );
      }));

      const errorCodeSummary: Record<string, number> = {};
      let errorCount = 0;

      batchErrors
        .forEach((error) => {
          if (error === null)
            return;

          errorCount++;
          const errorCode = error.code || "unknown";
          errorCodeSummary[errorCode] = (errorCodeSummary[errorCode] || 0) + 1;
        });

      if (errorCount > 0) {
        console.error(
          `${errorCount}/${batches.length} batches failed while cleaning up old notifications. ` +
          `They had these error codes: ${JSON.stringify(errorCodeSummary)}`
        );
      } else {
        console.log("All old notifications cleaned up successfully.");
      }
    } catch (error) {
      console.log('Unexpected error deleting old notifications: ' + error);
    }
});

注意:您可以使用REST API 的 List["__name__"]通过使用字段掩码和适当的查询参数来获取文档 ID 列表(没有内部文档数据),从而提高效率。


通过上述任一修复,您的测试将变为:

// run the function
await wrapped();

// check function result
const deletedDocSnapshot = await admin.firestore()
  .collection('notifications')
  .doc(notificationToDeleteId)
  .get();

assert.equal(deletedDocSnapshot.data(), null);

但是,为了也回答原始问题,此函数将创建一个可等待的 setTimeout:

function setTimeoutPromise(callback: (...args: any[]) => any | Promise<any>, timeoutMS: number, ...args: any[]) {
  return new Promise((resolve, reject) => {
    setTimeout((...args) => {
      try {
        Promise.resolve(callback(...args))
          .then(resolve, reject); // <-- handles Promise-based errors
      } catch (err) {
        reject(err); // <-- handles errors if `callback()` isn't returning a Promise
      }
    }, timeoutMS, ...args);
  });
}

然后使用它:

// run the function
await wrapped();

await setTimeoutPromise(() => {
  return admin.firestore()
    .collection('notifications')
    .doc(notificationToDeleteId)
    .get()
    .then((deleteDoc) => {
      //console.log(deleteDoc.data());
      assert.equal(deleteDoc.data(), null);
    });
}, 5000);

推荐阅读