🐣

Firebase emulatorでfunctionsのローカル環境テストを書くぞ💪 firestoreトリガー編

2021/08/14に公開約14,500字

Firebase functions(以下、functionsと記載)の単体テストを書く機会があったので、いろいろやってみたことのメモです。
ご指摘ありましたらぜひお願いいたします!

今回はFirebase firestore(以下、firestoreと記載)でトリガーされるfunctionsのテストを書いてみます。(気が向いたら、firestoreのルールやhttpトリガーとauthトリガーも書きたい)

テストライブラリはjestを使います。

やりたいこと

  • firestoreのトリガーで実行されるfunctionsのテストを書きたい
  • functionsはデプロイせずにローカルで実行したい
  • トリガーとなるfirestoreもローカルで済ませたい

Firebase Test SDKについて

今回はFirebase test SDKを試してみることにします。
このSDKを使うことで、firestoreの自動トリガーではなく、コード上で実行したいfunctionsのwrapperを作成してfirestoreのスナップショットを噛ませて実行することができます。

使ってみて、個人的に、以下のようなメリットがありました。

  • テストコードで実行するのでfirestoreで発火したトリガーの完了を監視して待つ必要がない
  • functionsにテストコードからスタブやモックを仕込むことができる

注意点としては、emulatorでfunctionsを起動してしまうと自動トリガーも実行されてしまいます。

なので、以下のようにfunctions以外のemulatorを実行するようにします。
※今回はfirestoreのみなので、onlyでfirestoreを指定

firebase emulators:start --only firestore

ただ、デメリット・・・とまでは言わないですが、元のfunctionsのトリガーの発火が連鎖するような部分が多くあると、それぞれのfunctionsのwrapperを全部テストコード呼び出しする必要があったり、事前データを準備するのに別のAPI呼び出しなどと複雑に絡んでいると、なんだか書くのが辛くなってきます。

(でもこれはfunctionsの設計がよろしくないのかなと・・・

functionもエミュレータで動かしてしまえば手動で動かさなくてすみますが、それはそれで実行待ちに時間がかかり、functionsの完了を検知しないといけないのでどんどんカオスになりました。。。

コードの概要

Userがあるチケットの支払いをすると(paymentコレクションにdocumentが作成)、チケットの価格がpaymentにセットされるようにするfunctionがあります。

できるだけシンプルに、firestoreはこんな感じです。

/users // ユーザ情報を保持
/users/payments // ユーザの支払いごとに作成されるドキュメント
/tickets // 名称や価格などのチケット情報とチケットの購入された枚数を保持
/histories // 支払いドキュメントの変更履歴を保持

作成したfunctionはこんな感じです。

  • onCreatePayment
    /users/paymentsのonCreateトリガー 支払いが行われると、該当チケットの購入された枚数を増やす
  • onUpdatePaymentStatus
    /users/paymentsのonCreateトリガー 支払いのステータスがキャンセルされると、該当チケットの購入された枚数を減らす
  • onDeleteTicket
    /ticketsのonDeleteトリガー チケットが削除されると、該当チケットの支払いがあればステータスをrefundにする
  • onWritePayment 
    /users/paymentsのonWriteトリガー create, update, delete全てで発火する。変更履歴をタイムスタンプと共に記録する

onDeleteとonWriteはあまり実務では使いませんでしたが、とりあえず全種類試すと言うことで実際こう言うことするのかは微妙ですが作ってみました。
実際はトランザクションを考慮すべきものもありますがお試しということで。

準備

ローカル(Emulator)で動かせればOKとしているので、実際にfirebaseの環境で実行する場合にはcredentialsやkeyが必要になります。

firebaseのセットアップ

Project IDは必要なので控えておきましょう。

firebase login
firebase init firestore
firebase init functions

Emulatorのセットアップ

firebase init emulators

jestとfirebase test SDKのインストール

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

package.jsonにtestスクリプトを追加しておきます。

  "scripts": {
    ...
    "test": "jest"
  },

実際のコード

setup

index.test.js

// Emulatorの単体テストでは、projectIdがあればとりあえず動きますが、実際は他のキーも必要です
const firebaseTest = require('firebase-functions-test')({
    projectId: [firebaseのセットアップで指定したproject id],
});

const admin = require('firebase-admin');
let myFunctions;
let db;

// この環境変数でemulatorのfirestoreを見にいってくれます。もちろん普通にexportしても問題ないです
process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080";

beforeAll(() => {
    myFunctions = require('../index'); // firebase functionsのコードをimport
    db = admin.firestore();
});

afterAll(() => {
    firebaseTest.cleanup();
});

firestore追加のトリガー(onCreate)

functionsコード

exports.onCreatePayment = functions.firestore.document('/users/{userId}/payments/{paymentId}').onCreate(async (snap, cxt) => {
    const userId = cxt.params.userId;
    const paymentId = cxt.params.paymentId;
    const ticketId = snap.data().ticket_id;
    const TicketRef = db.collection("tickets").doc(ticketId);

    // Get payment ticket price and how many time it was purchased
    const { num, price } = (await TicketRef.get()).data();

    await TicketRef.set({
        num: num ? num + 1 : 1 // チケット枚数を増やす
    }, { merge: true });

    await snap.ref.set({
        created_at: admin.firestore.Timestamp.now().toDate(),
        updated_at: admin.firestore.Timestamp.now().toDate(),
        price,
        status: "succeeded",
        ticket_id: ticketId
    }, { merge: true });


    functions.logger.log('User create payment: ', userId, paymentId);
});

testコード

ポイントとしては、snapshotとは別にcontextをパラメータとして渡す必要があるということです。(最初、snapshopにpathがあるから必要だと思わなくて気づかなかった・・・が、functions見れば明らかだった)

あとは、firestoreの日時型はfirestore.Timestampなのですが、toDateでDate型に変換できます。firestore.Timestamp型のassertionはtoBeInstanceOfを使いました。

test('Payment information should be added to payment doc when user purchase a ticket, ', async () => {
    const PRICE = 30000;
    const wrapped = firebaseTest.wrap(myFunctions.onCreatePayment);

    // Make a user
    const userId = (await db.collection("users").add({ name: "test user" })).id;

    // Make a ticket with price
    const ticketId = (await db.collection("tickets").add({ price: PRICE })).id;
    const paymentId = "PAYMENT_ID";

    // Make a payment snapshot
    const snap = firebaseTest.firestore.makeDocumentSnapshot({ ticket_id: ticketId }, `user/${userId}/payment/${paymentId}`);
    // Call wrapped function with the snapshot
    await wrapped(snap, { params: { userId, paymentId } });

    const { price, status, created_at, updated_at } = (await snap.ref.get()).data();
    expect(price).toBe(PRICE);
    expect(status).toBe("succeeded");
    expect(created_at).toBeInstanceOf(admin.firestore.Timestamp);
    expect(updated_at).toBeInstanceOf(admin.firestore.Timestamp);

    const { num } = (await db.collection("tickets").doc(ticketId).get()).data();
    expect(num).toBe(1);
});

firestore更新のトリガー(onUpdate)

更新の場合、beforeとafterのスナップショットを作っておいて、test SDKのmakeChangeでwrapperに噛ませるデータを作成します。あとはほぼonCreateと同じです。

functionsコード

exports.onUpdatePaymentStatus = functions.firestore.document('/users/{userId}/payments/{paymentId}').onUpdate(async (change, cxt) => {
    const userId = cxt.params.userId;
    const paymentId = cxt.params.paymentId;
    const ticketId = change.after.data().ticket_id;
    const TicketRef = db.collection("tickets").doc(ticketId);
    const { num } = (await TicketRef.get()).data();

    // Check change of status
    const beforeStatus = change.before.data().status;
    const afterStatus = change.after.data().status;
    if (beforeStatus === "succeeded" && afterStatus === "canceled") {
        await TicketRef.set({
            num: num ? num - 1 : 0
        }, { merge: true });

        await change.after.ref.set({
            ...change.after.data(),
            cancelled_at: admin.firestore.Timestamp.now().toDate(),
            updated_at: admin.firestore.Timestamp.now().toDate(),
        }, { merge: true });

        functions.logger.log('User cancel payment: ', userId, paymentId);
    }
});

testコード


test('Number of times a ticket has purchased should be decremented to payment doc when payment is canceled, ', async () => {
    const PRICE = 30000;

    const wrappedOnCreatePayment = firebaseTest.wrap(myFunctions.onCreatePayment);
    const wrappedOnUpdatePaymentStatus = firebaseTest.wrap(myFunctions.onUpdatePaymentStatus);

    // Make a user
    const userId = (await db.collection("users").add({ name: "test user" })).id;

    // Make a ticket with price
    const ticketId = (await db.collection("tickets").add({ price: PRICE })).id;
    const paymentId = "PAYMENT_ID";

    const beforePayment = {
        ticket_id: ticketId,
        created_at: admin.firestore.Timestamp.now(),
        updated_at: admin.firestore.Timestamp.now(),
        price: PRICE,
        status: "succeeded",
    };

    // Make a payment snapshot
    const beforeSnap = firebaseTest.firestore.makeDocumentSnapshot(beforePayment, `user/${userId}/payment/${paymentId}`);
    // Call wrapped function with the snapshot
    await wrappedOnCreatePayment(beforeSnap, { params: { userId, paymentId } });

    const beforeTicket = (await db.collection("tickets").doc(ticketId).get()).data();
    expect(beforeTicket.num).toBe(1);

    // Update snap
    const afterSnap = firebaseTest.firestore.makeDocumentSnapshot({ ...beforePayment, status: "canceled" }, `user/${userId}/payment/${paymentId}`);
    const changeSnap = firebaseTest.makeChange(beforeSnap, afterSnap);

    // Call wrapped function with the snapshot
    await wrappedOnUpdatePaymentStatus(changeSnap, { params: { userId, paymentId } });

    const afterPayment = (await afterSnap.ref.get()).data();
    expect(afterPayment.status).toBe("canceled");
    expect(afterPayment.cancelled_at).toBeInstanceOf(admin.firestore.Timestamp);
    expect(afterPayment.updated_at).toBeInstanceOf(admin.firestore.Timestamp);
    expect(afterPayment.created_at.isEqual(beforePayment.created_at)).toBe(true);

    const afterTicket = (await db.collection("tickets").doc(ticketId).get()).data();
    expect(afterTicket.num).toBe(0);

});

firestore削除のトリガー(OnDelete)

実務ではRDBも含めてあまりdeleteすることはないので、このトリガーも実務では使ったことがあまりないです。

deleteトリガー自体は特に変わったところはないのですが、今回はfirestoreの複数ドキュメントをupdateする際にfunctions内でbatchを使ってみました。500件以上は分けて実行しないといけない仕様ですが、サンプルかつテストと言うことで割愛してます。
Promise.allしないと先にテストが終了してしまうのがポイントです。

functionsコード

exports.onDeleteTicket = functions.firestore.document('/tickets/{ticketId}').onDelete(async (snap, cxt) => {
    const ticketId = cxt.params.ticketId;

    // Get all payments for the ticket
    // Change status to wait_refund if payment status is succeeded 
    const users = (await db.collection("users").get()).docs.map(userDoc => userDoc.id);

    const batch = admin.firestore().batch();

    await Promise.all(users.map(async userId =>
        (await db.collection("users").doc(userId).collection("payments")
            .where("ticket_id", "==", ticketId)
            .where("status", "==", "succeeded")
            .get())
            .forEach(paymentDoc => {
                batch.set(paymentDoc.ref, { status: "refund" }, { merge: true });
            })
    ));

    await batch.commit();
    functions.logger.log('Ticket is deleted: ', ticketId);
});

testコード

test('All payment status should be set to refund when an ticket is deleted, ', async () => {
    const PRICE = 30000;

    const wrappedOnDeleteTicket = firebaseTest.wrap(myFunctions.onDeleteTicket);

    // Make a user
    const userId = (await db.collection("users").add({ name: "test user" })).id;

    // Make a ticket with price
    const ticketId = (await db.collection("tickets").add({ price: PRICE })).id;
    const deletedTicketId = (await db.collection("tickets").add({ price: PRICE })).id;

    const succeededPayment = {
        ticket_id: ticketId,
        created_at: admin.firestore.Timestamp.now(),
        updated_at: admin.firestore.Timestamp.now(),
        price: PRICE,
        status: "succeeded",
    };

    const succeededButRefundPayment = {
        ticket_id: deletedTicketId,
        created_at: admin.firestore.Timestamp.now(),
        updated_at: admin.firestore.Timestamp.now(),
        price: PRICE,
        status: "succeeded",
    };

    await db.collection("users").doc(userId).collection("payments").add(succeededPayment);
    await db.collection("users").doc(userId).collection("payments").add(succeededPayment);
    await db.collection("users").doc(userId).collection("payments").add(succeededButRefundPayment);
    await db.collection("users").doc(userId).collection("payments").add(succeededButRefundPayment);

    const snap = (await db.collection("tickets").doc(ticketId).get());
    const users = (await db.collection("users").get()).docs.map(userDoc => userDoc.id);

    // Call wrapped function with the snapshot
    await wrappedOnDeleteTicket(snap, { params: { ticketId: deletedTicketId } });

    await Promise.all(users.map(async userId => {
        (await db.collection("users").doc(userId).collection("payments").get()).forEach(paymentDoc => {
            const payment = paymentDoc.data();
            if (payment.ticket_id === deletedTicketId) {
                return expect(payment.status).toBe("refund");
            } else {
                return expect(payment.status).toBeDefined();
            }
        });
    }));
});

firestoreの書込のトリガー(OnWrite)

create, update, delete全てに対して発火します。
これもdelete同様に実務であまり使いませんでした。あまり良い例ではないですが、データ変更のログをとるようなfunctionsにしてみました。

ポイントとしては、OnWriteに渡すのはスナップショットのchangeオブジェクトと言うところです。なので、トリガーがCreateの場合はchange.before.exists=falseとなっています。

firebase test SDKで存在しないスナップショットを作成するには、{}を渡せば問題ないです👍

functionsコード

exports.onWritePayment = functions.firestore.document('/users/{userId}/payments/{paymentId}').onWrite(async (change, cxt) => {
    const userId = cxt.params.userId;
    const paymentId = cxt.params.paymentId;

    const logRecord = {
        user_id: userId,
        payment_id: paymentId,
        event_type: "payment",
        created_at: admin.firestore.Timestamp.now().toDate(),
        afterData: change.after.exists ? { ...change.after.data() } : null,
        beforeData: change.before.exists ? { ...change.before.data() } : null,
    };

    await db.collection("histories").add(logRecord);

});

testコード

test('Log event of users/payments ', async () => {
    const PRICE = 30000;

    const wrapped = firebaseTest.wrap(myFunctions.onWritePayment);

    // Make a user
    const userId = (await db.collection("users").add({ name: "test user" })).id;

    // Make a ticket with price
    const ticketId = (await db.collection("tickets").add({ price: PRICE })).id;
    const paymentId = "PAYMENT_ID";

    const payment = { ticket_id: ticketId, something: "some info" };

    // Make a payment snapshot
    const beforeSnap = firebaseTest.firestore.makeDocumentSnapshot({}, `user/${userId}/payment/${paymentId}`);
    const afterSnap = firebaseTest.firestore.makeDocumentSnapshot(payment, `user/${userId}/payment/${paymentId}`);
    const change = firebaseTest.makeChange(beforeSnap, afterSnap);

    // Call wrapped function with the snapshot
    await wrapped(change, { params: { userId, paymentId } });

    const logRecords = [];
    (await db.collection("histories").where("user_id", "==", userId).where("payment_id", "==", paymentId).get()).forEach(doc => {
        logRecords.push(doc.data());
    });

    expect(logRecords).toHaveLength(1);
    const logRecord = logRecords[0];
    expect(logRecord.user_id).toBe(userId);
    expect(logRecord.event_type).toBe("payment");
    expect(logRecord.created_at).toBeInstanceOf(admin.firestore.Timestamp);
    expect(logRecord.beforeData).toBe(null);
    expect(logRecord.afterData).toEqual(payment);

});

実行

npm run test
 PASS  __tests__/index.test.js
  ✓ Payment information should be added to payment doc when user purchase a ticket,  (496 ms)
  ✓ Number of times a ticket has purchased should be decremented to payment doc when payment is canceled,  (33 ms)
  ✓ All payment status should be set to refund when an ticket is deleted,  (62 ms)
  ✓ Log event of users/payments  (12 ms)

Tada!!!🎉

Discussion

ログインするとコメントできます