用Mocha延迟测试Cloud Functions的内容

Delay content of test of Cloud Functions with Mocha

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

测试我的功能的文档

由于我要测试的功能是删除集合中的文档,因此我首先在测试中创建了一个推送到数据库的假文档。 然后我用包装函数调用来测试。它是异步的,所以需要一点时间。

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

我试图添加一个settimeout,但是测试returns 'passed'没有等待超时里面的代码到运行并检查断言。

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

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;
        });
}

我不太确定 .wrap() 是什么。那 return 是一个承诺吗? 如果你想使用.setTimeout(),你可以这样尝试:

const wrapped = test.wrap(myFunctions.deleteNotificationAfter7Days);
await wrapped();

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

或者您可以使用 setInterval(),它每 X 秒运行一次,如果任务仍未完成,它会自行重复。

您看到并错误地尝试解决的奇怪行为是由您错误实施的 Cloud Function 造成的。

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

  • 查找所有早于 7 天的通知并对每个通知开始删除操作。
  • 结束 Cloud Function,无需等待上述操作完成。

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

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


在您的代码中,您使用 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's List 通过使用字段仅获取文档 ID 列表(无内部文档数据)来提高效率["__name__"] 的掩码和适当的查询参数。


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

// 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);