Firebase emulatorでfunctionsのローカル環境テストを書く💪 firestoreトリガー編
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