בדיקות יחידה (unit testing) של Cloud Functions

בדף הזה מתוארות שיטות מומלצות וכלים לכתיבה של בדיקות יחידה לפונקציות, כמו בדיקות שיהיו חלק ממערכת של שילוב רצוף (CI). כדי שיהיה קל יותר לבצע בדיקות, מערכת Firebase מספקת את Firebase Test SDK עבור Cloud Functions. הוא מופץ ב-npm בתור firebase-functions-test, והוא ערכת SDK לבדיקה שתואם ל-firebase-functions. Firebase Test SDK עבור Cloud Functions:

  • מנהלת את ההגדרה וההסרה של הבדיקות, למשל הגדרה וביטול של משתני הסביבה שנדרשים ל-firebase-functions.
  • יוצרת נתונים לדוגמה והקשר של אירוע, כך שצריך לציין רק את השדות שרלוונטיים לבדיקה.

הגדרת הבדיקה

מתקינים את firebase-functions-test ואת Mocha, מסגרת בדיקה, על ידי הפעלת הפקודות הבאות בתיקיית הפונקציות:

npm install --save-dev firebase-functions-test
npm install --save-dev mocha

לאחר מכן יוצרים תיקיית test בתוך התיקייה 'פונקציות', יוצרים בתוכה קובץ חדש בשביל קוד הבדיקה, ונותנים לו שם כמו index.test.js.

לבסוף, משנים את functions/package.json כדי להוסיף את הפרטים הבאים:

"scripts": {
  "test": "mocha --reporter spec"
}

אחרי שכותבים את הבדיקות, אפשר להריץ אותן על ידי הפעלת npm test בתוך הספרייה של הפונקציות.

מתבצע אתחול של Firebase Test SDK עבור Cloud Functions

יש שתי דרכים להשתמש ב-firebase-functions-test:

  1. מצב אונליין (מומלץ): כותבים בדיקות שמקיימות אינטראקציה עם פרויקט Firebase שמיועד לבדיקה, כדי שהפעולות של כתיבת מסדי נתונים, יצירת משתמשים וכו' יתרחשו בפועל, וקוד הבדיקה יוכל לבדוק את התוצאות. המשמעות היא שגם ערכות SDK אחרות של Google שנעשה בהן שימוש בפונקציות יפעלו.
  2. מצב אופליין: כתיבת בדיקות יחידה מבודדות ואופליין ללא תופעות לוואי. כלומר, צריך ליצור סטאב לכל קריאות שיטות שמקיימות אינטראקציה עם מוצר של Firebase (למשל, כתיבה במסד הנתונים או יצירת משתמש). בדרך כלל לא מומלץ להשתמש במצב אופליין אם יש לכם פונקציות Cloud Firestore או Realtime Database, כי הוא מגביר משמעותית את המורכבות של קוד הבדיקה.

הפעלת SDK במצב אונליין (מומלץ)

אם רוצים לכתוב בדיקות שמקיימות אינטראקציה עם פרויקט בדיקה, צריך לספק את ערכי התצורה של הפרויקט שנדרשים כדי לאתחל את האפליקציה דרך firebase-admin, ואת הנתיב לקובץ מפתח של חשבון שירות.

כדי לקבל את ערכי התצורה של פרויקט Firebase:

  1. פותחים את הגדרות הפרויקט במסוף Firebase.
  2. בקטע האפליקציות שלך, בוחרים את האפליקציה הרצויה.
  3. בחלונית השמאלית, בוחרים באפשרות להורדת קובץ תצורה לאפליקציות ל-Apple ול-Android.

    באפליקציות אינטרנט, בוחרים באפשרות Config כדי להציג את ערכי התצורה.

כדי ליצור קובץ מַפְתח:

  1. פותחים את החלונית Service Accounts במסוף Google Cloud.
  2. בוחרים את חשבון השירות שמוגדר כברירת מחדל, App Engine, ובתפריט האפשרויות שבצד שמאל בוחרים באפשרות Create key.
  3. כשמופיעה בקשה, בוחרים באפשרות JSON כסוג המפתח ולוחצים על יצירה.

אחרי ששומרים את קובץ המפתח, מפעילים את ה-SDK:

// At the top of test/index.test.js
const test = require('firebase-functions-test')({
  databaseURL: 'https://my-project.firebaseio.com',
  storageBucket: 'my-project.appspot.com',
  projectId: 'my-project',
}, 'path/to/serviceAccountKey.json');

אתחול ה-SDK במצב אופליין

אם רוצים לכתוב בדיקות אופליין לחלוטין, אפשר לאתחל את ה-SDK ללא פרמטרים:

// At the top of test/index.test.js
const test = require('firebase-functions-test')();

זיוף של ערכי תצורה

אם משתמשים ב-functions.config() בקוד הפונקציות, אפשר ליצור ערכים מדומים של ההגדרות. לדוגמה, אם functions/index.js מכיל את הקוד הבא:

const functions = require('firebase-functions/v1');
const key = functions.config().stripe.key;

לאחר מכן תוכלו לדמות את הערך שבקובץ הבדיקה, באופן הבא:

// Mock functions config values
test.mockConfig({ stripe: { key: '23wr42ewr34' }});

ייבוא הפונקציות

כדי לייבא את הפונקציות, משתמשים בפקודה require כדי לייבא את קובץ הפונקציות הראשי כמודול. חשוב לעשות זאת רק אחרי שמפעילים את firebase-functions-test ומזינים ערכים מדומים של הגדרות.

// after firebase-functions-test has been initialized
const myFunctions = require('../index.js'); // relative path to functions code

אם אתחלתם את firebase-functions-test במצב אופליין ויש לכם את admin.initializeApp() בקוד הפונקציות, תצטרכו להעתיק אותו לפני הייבוא של הפונקציות:

// If index.js calls admin.initializeApp at the top of the file,
// we need to stub it out before requiring index.js. This is because the
// functions will be executed as a part of the require process.
// Here we stub admin.initializeApp to be a dummy function that doesn't do anything.
adminInitStub = sinon.stub(admin, 'initializeApp');
// Now we can require index.js and save the exports inside a namespace called myFunctions.
myFunctions = require('../index');

בדיקת פונקציות ברקע (לא HTTP)

תהליך הבדיקה של פונקציות שאינן HTTP כולל את השלבים הבאים:

  1. כוללים את הפונקציה שרוצים לבדוק באמצעות השיטה test.wrap.
  2. יצירה של נתוני בדיקה
  3. מפעילים את הפונקציה הארוזת עם נתוני הבדיקה שיצרתם וכל שדות ההקשר של האירוע שרוצים לציין.
  4. להצהיר על התנהגות.

תחילה כוללים את הפונקציה שרוצים לבדוק. נניח שיש לכם פונקציה ב-functions/index.js בשם makeUppercase ואתם רוצים לבדוק אותה. צריך לכתוב את הטקסט הבא ב-functions/test/index.test.js

// "Wrap" the makeUpperCase function from index.js
const myFunctions = require('../index.js');
const wrapped = test.wrap(myFunctions.makeUppercase);

wrapped היא פונקציה שמפעילה את makeUppercase כשהיא מופעלת. wrapped כולל 2 פרמטרים:

  1. data (חובה): הנתונים שצריך לשלוח אל makeUppercase. הוא תואם ישירות לפרמטר הראשון שנשלח למטפל הפונקציה שכתבתם. firebase-functions-test מספקת שיטות ליצירת נתונים מותאמים אישית או נתונים לדוגמה.
  2. eventContextOptions (אופציונלי): שדות של הקשר האירוע שרוצים לציין. הקשר של האירוע הוא הפרמטר השני שנשלח למטפל הפונקציה שכתבתם. אם לא כוללים את הפרמטר eventContextOptions כשמפעילים את wrapped, עדיין נוצר הקשר של אירוע עם שדות הגיוניים. אפשר לשנות את הערכים של חלק מהשדות שנוצרו על ידי ציון הערכים הרצויים כאן. חשוב לזכור שצריך לכלול רק את השדות שרוצים לשנות. המערכת תיצור את כל השדות שלא שיניתם.
const data =  // See next section for constructing test data

// Invoke the wrapped function without specifying the event context.
wrapped(data);

// Invoke the function, and specify params
wrapped(data, {
  params: {
    pushId: '234234'
  }
});

// Invoke the function, and specify auth and auth Type (for real time database functions only)
wrapped(data, {
  auth: {
    uid: 'jckS2Q0'
  },
  authType: 'USER'
});

// Invoke the function, and specify all the fields that can be specified
wrapped(data, {
  eventId: 'abc',
  timestamp: '2018-03-23T17:27:17.099Z',
  params: {
    pushId: '234234'
  },
  auth: {
    uid: 'jckS2Q0' // only for real time database functions
  },
  authType: 'USER' // only for real time database functions
});

בניית נתוני הבדיקה

הפרמטר הראשון של פונקציה ארוזה הוא נתוני הבדיקה שבאמצעותם צריך להפעיל את הפונקציה הבסיסית. יש כמה דרכים לבנות נתוני בדיקה.

שימוש בנתונים בהתאמה אישית

ב-firebase-functions-test יש מספר פונקציות לבניית נתונים שדרושים לבדיקת הפונקציות. לדוגמה, אפשר להשתמש ב-test.firestore.makeDocumentSnapshot כדי ליצור DocumentSnapshot ב-Firestore. הארגומנט הראשון הוא הנתונים, הארגומנט השני הוא נתיב ההפניה המלא, ויש ארגומנט שלישי אופציונלי לנכסים אחרים של קובץ snapshot שאפשר לציין.

// Make snapshot
const snap = test.firestore.makeDocumentSnapshot({foo: 'bar'}, 'document/path');
// Call wrapped function with the snapshot
const wrapped = test.wrap(myFunctions.myFirestoreDeleteFunction);
wrapped(snap);

אם אתם בודקים פונקציית onUpdate או onWrite, תצטרכו ליצור שני קובצי snapshot: אחד למצב הקודם ואחד למצב הבא. לאחר מכן תוכלו להשתמש ב-method‏ makeChange כדי ליצור אובייקט Change באמצעות קובצי snapshot האלה.

// Make snapshot for state of database beforehand
const beforeSnap = test.firestore.makeDocumentSnapshot({foo: 'bar'}, 'document/path');
// Make snapshot for state of database after the change
const afterSnap = test.firestore.makeDocumentSnapshot({foo: 'faz'}, 'document/path');
const change = test.makeChange(beforeSnap, afterSnap);
// Call wrapped function with the Change object
const wrapped = test.wrap(myFunctions.myFirestoreUpdateFunction);
wrapped(change);

במאמר בנושא הפניית API מפורטות פונקציות דומות לכל סוגי הנתונים האחרים.

שימוש בנתונים לדוגמה

אם אתם לא צריכים להתאים אישית את הנתונים שבהם אתם משתמשים בבדיקות, אז firebase-functions-test מציע שיטות ליצירת נתונים לדוגמה לכל סוג פונקציה.

// For Firestore onCreate or onDelete functions
const snap = test.firestore.exampleDocumentSnapshot();
// For Firestore onUpdate or onWrite functions
const change = test.firestore.exampleDocumentSnapshotChange();

בחומר העזר בנושא API מפורטות שיטות לקבלת נתוני לדוגמה לכל סוג פונקציה.

שימוש בנתונים זמניים (למצב אופליין)

אם אתחלתם את ה-SDK במצב אופליין ואתם בודקים פונקציית Cloud Firestore או Realtime Database, צריך להשתמש באובייקט פשוט עם stubs במקום ליצור DocumentSnapshot או DataSnapshot אמיתיים.

נניח שאתם כותבים בדיקת יחידה לפונקציה הבאה:

// Listens for new messages added to /messages/:pushId/original and creates an
// uppercase version of the message to /messages/:pushId/uppercase
exports.makeUppercase = functions.database.ref('/messages/{pushId}/original')
    .onCreate((snapshot, context) => {
      // Grab the current value of what was written to the Realtime Database.
      const original = snapshot.val();
      functions.logger.log('Uppercasing', context.params.pushId, original);
      const uppercase = original.toUpperCase();
      // You must return a Promise when performing asynchronous tasks inside a Functions such as
      // writing to the Firebase Realtime Database.
      // Setting an "uppercase" sibling in the Realtime Database returns a Promise.
      return snapshot.ref.parent.child('uppercase').set(uppercase);
    });

בתוך הפונקציה, נעשה שימוש ב-snap פעמיים:

  • snap.val()
  • snap.ref.parent.child('uppercase').set(uppercase)

בקוד הבדיקה, יוצרים אובייקט רגיל שבו שני נתיבי הקוד האלה יפעלו, ומשתמשים ב-Sinon כדי ליצור סטאבים של השיטות.

// The following lines creates a fake snapshot, 'snap', which returns 'input' when snap.val() is called,
// and returns true when snap.ref.parent.child('uppercase').set('INPUT') is called.
const snap = {
  val: () => 'input',
  ref: {
    parent: {
      child: childStub,
    }
  }
};
childStub.withArgs(childParam).returns({ set: setStub });
setStub.withArgs(setParam).returns(true);

טענות נכוֹנוּת (assertion)

אחרי שמפעילים את ה-SDK, עוטפים את הפונקציות ובונים נתונים, אפשר להפעיל את הפונקציות העטופות עם הנתונים שנוצרו ולבצע טענות נכוֹנוּת לגבי ההתנהגות. אפשר להשתמש בספרייה כמו Chai כדי לבצע את ההצהרות האלה.

טענות נכוֹנוּת (assertion) במצב אונליין

אם אתם מאתחלים את Firebase Test SDK עבור Cloud Functions במצב אונליין, תוכלו להשתמש ב-SDK של firebase-admin כדי לוודא שהפעולות הרצויות (כמו כתיבת בבסיס נתונים) בוצעו.

הדוגמה הבאה קובעת שהערך 'INPUT' נכתב במסד הנתונים של פרויקט הבדיקה.

// Create a DataSnapshot with the value 'input' and the reference path 'messages/11111/original'.
const snap = test.database.makeDataSnapshot('input', 'messages/11111/original');

// Wrap the makeUppercase function
const wrapped = test.wrap(myFunctions.makeUppercase);
// Call the wrapped function with the snapshot you constructed.
return wrapped(snap).then(() => {
  // Read the value of the data at messages/11111/uppercase. Because `admin.initializeApp()` is
  // called in functions/index.js, there's already a Firebase app initialized. Otherwise, add
  // `admin.initializeApp()` before this line.
  return admin.database().ref('messages/11111/uppercase').once('value').then((createdSnap) => {
    // Assert that the value is the uppercased version of our input.
    assert.equal(createdSnap.val(), 'INPUT');
  });
});

הצהרות נכוֹנוּת במצב אופליין

אפשר להצהיר על ערך ההחזרה הצפוי של הפונקציה:

const childParam = 'uppercase';
const setParam = 'INPUT';
// Stubs are objects that fake and/or record function calls.
// These are excellent for verifying that functions have been called and to validate the
// parameters passed to those functions.
const childStub = sinon.stub();
const setStub = sinon.stub();
// The following lines creates a fake snapshot, 'snap', which returns 'input' when snap.val() is called,
// and returns true when snap.ref.parent.child('uppercase').set('INPUT') is called.
const snap = {
  val: () => 'input',
  ref: {
    parent: {
      child: childStub,
    }
  }
};
childStub.withArgs(childParam).returns({ set: setStub });
setStub.withArgs(setParam).returns(true);
// Wrap the makeUppercase function.
const wrapped = test.wrap(myFunctions.makeUppercase);
// Since we've stubbed snap.ref.parent.child(childParam).set(setParam) to return true if it was
// called with the parameters we expect, we assert that it indeed returned true.
return assert.equal(wrapped(snap), true);

אפשר גם להשתמש בצופן Sinon כדי לוודא שנקראו שיטות מסוימות, עם הפרמטרים הצפויים.

בדיקת פונקציות HTTP

כדי לבדוק פונקציות HTTP onCall, משתמשים באותה גישה כמו בדיקת פונקציות ברקע.

אם אתם בודקים פונקציות onRequest של HTTP, כדאי להשתמש ב-firebase-functions-test אם:

  • אתם משתמשים ב-functions.config()
  • הפונקציה שלכם פועלת בשילוב עם פרויקט Firebase או עם ממשקי Google API אחרים, ואתם רוצים להשתמש בפרויקט Firebase אמיתי ובפרטי הכניסה שלו לצורך הבדיקות.

פונקציית onRequest של HTTP מקבלת שני פרמטרים: אובייקט בקשה ואובייקט תגובה. כך אפשר לבדוק את הפונקציה addMessage() לדוגמה:

  • משנים את פונקציית ההפניה האוטומטית באובייקט התגובה, כי sendMessage() קורא לה.
  • בתוך פונקציית ההפניה האוטומטית, משתמשים ב-chai.assert כדי לטעון על הפרמטרים שבהם צריך להפעיל את פונקציית ההפניה האוטומטית:
// A fake request object, with req.query.text set to 'input'
const req = { query: {text: 'input'} };
// A fake response object, with a stubbed redirect function which asserts that it is called
// with parameters 303, 'new_ref'.
const res = {
  redirect: (code, url) => {
    assert.equal(code, 303);
    assert.equal(url, 'new_ref');
    done();
  }
};

// Invoke addMessage with our fake request and response objects. This will cause the
// assertions in the response object to be evaluated.
myFunctions.addMessage(req, res);

ניקוי הבדיקה

בסוף קוד הבדיקה, צריך להפעיל את פונקציית הניקוי. הפעולה הזו מבטלת את הגדרת משתני הסביבה שה-SDK הגדיר כשהוא הופעל, ומוחקת אפליקציות Firebase שייתכן שנוצרו אם השתמשתם ב-SDK כדי ליצור מסד נתונים בזמן אמת DataSnapshot או Firestore DocumentSnapshot.

test.cleanup();

הצגת הדוגמאות המלאות ומידע נוסף

אפשר לעיין בדוגמאות המלאות במאגר GitHub של Firebase.

מידע נוסף זמין בחומר העזרה של ה-API של firebase-functions-test.