Firebase エミュレータ スイートを使用したローカル開発

1. 始める前に

Cloud Firestore や Cloud Functions などのサーバーレス バックエンド ツールは非常に使いやすいですが、テストが難しい場合があります。 Firebase Local Emulator Suite を使用すると、これらのサービスのローカル バージョンを開発マシン上で実行できるため、アプリを迅速かつ安全に開発できます。

前提条件

  • Visual Studio Code、Atom、Sublime Text などの単純なエディター
  • Node.js 10.0.0 以降 (Node.js をインストールするにはnvm を使用し、バージョンを確認するにはnode --versionを実行します)
  • Java 7 以降 (Java をインストールするには、次の手順を使用し、バージョンを確認するには、 java -versionを実行します)

何をしますか

このコードラボでは、複数の Firebase サービスを利用したシンプルなオンライン ショッピング アプリを実行してデバッグします。

  • Cloud Firestore:リアルタイム機能を備えた、グローバルにスケーラブルなサーバーレスの NoSQL データベース。
  • Cloud Functions : イベントまたは HTTP リクエストに応答して実行されるサーバーレス バックエンド コード。
  • Firebase Authentication : 他の Firebase 製品と統合されるマネージド認証サービス。
  • Firebase Hosting : ウェブアプリの高速かつ安全なホスティング。

アプリをエミュレータ スイートに接続して、ローカル開発を有効にします。

2589e2f95b74fa88.png

また、次の方法も学習します。

  • アプリをエミュレータ スイートに接続する方法と、さまざまなエミュレータを接続する方法。
  • Firebase セキュリティ ルールの仕組みと、ローカル エミュレータに対して Firestore セキュリティ ルールをテストする方法。
  • Firestore イベントによってトリガーされる Firebase 関数を作成する方法と、エミュレータ スイートに対して実行される統合テストを作成する方法。

2. セットアップ

ソースコードを取得する

このコードラボでは、ほぼ完成したバージョンの Fire Store サンプルから始めるため、最初に行う必要があるのは、ソース コードのクローンを作成することです。

$ git clone https://github.com/firebase/emulators-codelab.git

次に、コードラボ ディレクトリに移動し、このコードラボの残りの部分を作業します。

$ cd emulators-codelab/codelab-initial-state

次に、コードを実行できるように依存関係をインストールします。インターネット接続が遅い場合、これには 1 ~ 2 分かかる場合があります。

# Move into the functions directory
$ cd functions

# Install dependencies
$ npm install

# Move back into the previous directory
$ cd ../

Firebase CLI を入手する

エミュレータ スイートは Firebase CLI (コマンドライン インターフェイス) の一部であり、次のコマンドを使用してマシンにインストールできます。

$ npm install -g firebase-tools

次に、CLI が最新バージョンであることを確認します。このコードラボはバージョン 9.0.0 以降で動作するはずですが、それ以降のバージョンにはさらに多くのバグ修正が含まれています。

$ firebase --version
9.6.0

Firebase プロジェクトに接続する

Firebase プロジェクトがない場合は、 Firebase コンソールで新しい Firebase プロジェクトを作成します。選択したプロジェクト ID をメモしておきます。後で必要になります。

次に、このコードを Firebase プロジェクトに接続する必要があります。まず次のコマンドを実行して Firebase CLI にログインします。

$ firebase login

次に、次のコマンドを実行してプロジェクトのエイリアスを作成します。 $YOUR_PROJECT_ID Firebase プロジェクトの ID に置き換えます。

$ firebase use $YOUR_PROJECT_ID

これでアプリを実行する準備が整いました。

3. エミュレータを実行する

このセクションでは、アプリをローカルで実行します。これは、Emulator Suite を起動する時期が来たことを意味します。

エミュレータを起動する

Codelab ソース ディレクトリ内から次のコマンドを実行してエミュレータを起動します。

$ firebase emulators:start --import=./seed

次のような出力が表示されるはずです。

$ firebase emulators:start --import=./seed
i  emulators: Starting emulators: auth, functions, firestore, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: database, pubsub
i  firestore: Importing data from /Users/samstern/Projects/emulators-codelab/codelab-initial-state/seed/firestore_export/firestore_export.overall_export_metadata
i  firestore: Firestore Emulator logging to firestore-debug.log
i  hosting: Serving hosting files from: public
✔  hosting: Local server: http://127.0.0.1:5000
i  ui: Emulator UI logging to ui-debug.log
i  functions: Watching "/Users/samstern/Projects/emulators-codelab/codelab-initial-state/functions" for Cloud Functions...
✔  functions[calculateCart]: firestore function initialized.

┌─────────────────────────────────────────────────────────────┐
│ ✔  All emulators ready! It is now safe to connect your app. │
│ i  View Emulator UI at http://127.0.0.1:4000                │
└─────────────────────────────────────────────────────────────┘

┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator       │ Host:Port      │ View in Emulator UI             │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ 127.0.0.1:9099 │ http://127.0.0.1:4000/auth      │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Functions      │ 127.0.0.1:5001 │ http://127.0.0.1:4000/functions │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore      │ 127.0.0.1:8080 │ http://127.0.0.1:4000/firestore │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Hosting        │ 127.0.0.1:5000 │ n/a                             │
└────────────────┴────────────────┴─────────────────────────────────┘
  Emulator Hub running at 127.0.0.1:4400
  Other reserved ports: 4500

Issues? Report them at https://github.com/firebase/firebase-tools/issues and attach the *-debug.log files.

「すべてのエミュレータが開始されました」というメッセージが表示されたら、アプリを使用できるようになります。

Web アプリをエミュレータに接続する

ログのテーブルに基づいて、Cloud Firestore エミュレータがポート8080でリッスンし、認証エミュレータがポート9099でリッスンしていることがわかります。

┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator       │ Host:Port      │ View in Emulator UI             │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ 127.0.0.1:9099 │ http://127.0.0.1:4000/auth      │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Functions      │ 127.0.0.1:5001 │ http://127.0.0.1:4000/functions │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore      │ 127.0.0.1:8080 │ http://127.0.0.1:4000/firestore │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Hosting        │ 127.0.0.1:5000 │ n/a                             │
└────────────────┴────────────────┴─────────────────────────────────┘

フロントエンド コードを本番環境ではなくエミュレータに接続しましょう。 public/js/homepage.jsファイルを開き、 onDocumentReady関数を見つけます。コードが標準の Firestore インスタンスと Auth インスタンスにアクセスしていることがわかります。

public/js/homepage.js

  const auth = firebaseApp.auth();
  const db = firebaseApp.firestore();

ローカル エミュレータを指すようにdbオブジェクトとauthオブジェクトを更新しましょう。

public/js/homepage.js

  const auth = firebaseApp.auth();
  const db = firebaseApp.firestore();

  // ADD THESE LINES
  if (location.hostname === "127.0.0.1") {
    console.log("127.0.0.1 detected!");
    auth.useEmulator("http://127.0.0.1:9099");
    db.useEmulator("127.0.0.1", 8080);
  }

アプリがローカル マシン (ホスティング エミュレータによって提供される) で実行されている場合、Firestore クライアントも運用データベースではなくローカル エミュレータをポイントします。

エミュレータUIを開きます

Web ブラウザでhttp://127.0.0.1:4000/に移動します。エミュレータ スイート UI が表示されるはずです。

エミュレータ UI ホーム画面

クリックすると、Firestore エミュレータの UI が表示されます。 --importフラグを使用してデータがインポートされたため、 itemsコレクションにはすでにデータが含まれています。

4ef88d0148405d36.png

4. アプリを実行する

アプリを開く

Web ブラウザでhttp://127.0.0.1:5000に移動すると、Fire Store がマシン上でローカルに実行されていることがわかります。

939f87946bac2ee4.png

アプリを使用する

ホームページでアイテムを選択し、 [カートに追加]をクリックします。残念ながら、次のエラーが発生します。

a11bd59933a8e885.png

そのバグを修正しましょう!すべてがエミュレータ内で実行されるため、実際のデータへの影響を心配せずに実験できます。

5. アプリをデバッグする

バグを見つけてください

それでは、Chrome 開発者コンソールを見てみましょう。 Control+Shift+J (Windows、Linux、Chrome OS) またはCommand+Option+J (Mac) を押して、コンソールにエラーを表示します。

74c45df55291dab1.png

addToCartメソッドで何らかのエラーがあったようです。それを見てみましょう。そのメソッドのどこでuidと呼ばれるものにアクセスしようとしますか?なぜそれがnullになるのでしょうか?現在、メソッドはpublic/js/homepage.js内で次のようになります。

public/js/homepage.js

  addToCart(id, itemData) {
    console.log("addToCart", id, JSON.stringify(itemData));
    return this.db
      .collection("carts")
      .doc(this.auth.currentUser.uid)
      .collection("items")
      .doc(id)
      .set(itemData);
  }

ああ!アプリにサインインしていません。 Firebase Authentication docsによると、サインインしていない場合、 auth.currentUsernullになります。そのためのチェックを追加しましょう。

public/js/homepage.js

  addToCart(id, itemData) {
    // ADD THESE LINES
    if (this.auth.currentUser === null) {
      this.showError("You must be signed in!");
      return;
    }

    // ...
  }

アプリをテストする

次に、ページを更新して「カートに追加」をクリックします。今度は、より適切なエラーが表示されるはずです。

c65f6c05588133f7.png

ただし、上部のツールバーで[サインイン] をクリックし、もう一度[カートに追加]をクリックすると、カートが更新されていることがわかります。

ただし、数値はまったく正確ではないようです。

239f26f02f959eef.png

心配しないでください。そのバグはすぐに修正されます。まず、カートに商品を追加したときに実際に何が起こったのかを詳しく見てみましょう。

6. ローカル関数トリガー

「カートに追加」をクリックすると、複数のエミュレータが関与する一連のイベントが開始されます。カートに商品を追加すると、Firebase CLI ログに次のようなメッセージが表示されるはずです。

i  functions: Beginning execution of "calculateCart"
i  functions: Finished "calculateCart" in ~1s

これらのログと UI の更新を生成するために発生した 4 つの主要なイベントがありました。

68c9323f2ad10f7a.png

1) Firestore書き込み - クライアント

新しいドキュメントが Firestore コレクション/carts/{cartId}/items/{itemId}/に追加されます。このコードは、 public/js/homepage.js内のaddToCart関数で確認できます。

public/js/homepage.js

  addToCart(id, itemData) {
    // ...
    console.log("addToCart", id, JSON.stringify(itemData));
    return this.db
      .collection("carts")
      .doc(this.auth.currentUser.uid)
      .collection("items")
      .doc(id)
      .set(itemData);
  }

2) クラウド機能のトリガー

Cloud Function calculateCart functions/index.jsで確認できるonWriteトリガーを使用して、カート項目に発生する書き込みイベント (作成、更新、または削除) をリッスンします。

関数/index.js

exports.calculateCart = functions.firestore
    .document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      try {
        let totalPrice = 125.98;
        let itemCount = 8;

        const cartRef = db.collection("carts").doc(context.params.cartId);

        await cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    }
);

3) Firestore 書き込み - 管理者

calculateCart関数は、カート内のすべてのアイテムを読み取り、合計数量と価格を合計し、新しい合計で「カート」ドキュメントを更新します (上記のcartRef.update(...)を参照)。

4) Firestore 読み取り - クライアント

Web フロントエンドは、カートへの変更に関する更新を受信するためにサブスクライブされます。 public/js/homepage.jsでわかるように、Cloud Function が新しい合計を書き込んで UI を更新した後、リアルタイム更新を取得します。

public/js/homepage.js

this.cartUnsub = cartRef.onSnapshot(cart => {
   // The cart document was changed, update the UI
   // ...
});

要約

よくやった!完全なローカル テスト用に 3 つの異なる Firebase エミュレータを使用する完全なローカル アプリをセットアップするだけです。

db82eef1706c9058.gif

しかし、待ってください、まだあります!次のセクションでは、次のことを学習します。

  • Firebase エミュレータを使用する単体テストを作成する方法。
  • Firebase エミュレータを使用してセキュリティ ルールをデバッグする方法。

7. アプリに合わせたセキュリティ ルールを作成する

私たちの Web アプリはデータの読み取りと書き込みを行いますが、これまでのところセキュリティについてはまったく心配していません。 Cloud Firestore は、「セキュリティ ルール」と呼ばれるシステムを使用して、データの読み取りおよび書き込みアクセス権を持つユーザーを宣言します。エミュレータ スイートは、これらのルールのプロトタイプを作成する優れた方法です。

エディターで、ファイルemulators-codelab/codelab-initial-state/firestore.rulesを開きます。ルールには 3 つの主要なセクションがあることがわかります。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // User's cart metadata
    match /carts/{cartID} {
      // TODO: Change these! Anyone can read or write.
      allow read, write: if true;
    }

    // Items inside the user's cart
    match /carts/{cartID}/items/{itemID} {
      // TODO: Change these! Anyone can read or write.
      allow read, write: if true;
    }

    // All items available in the store. Users can read
    // items but never write them.
    match /items/{itemID} {
      allow read: if true;
    }
  }
}

現在、誰でもデータベースのデータを読み書きできるようになりました。私たちは、有効な操作のみが通過し、機密情報が漏洩しないようにしたいと考えています。

このコードラボでは、最小特権の原則に従って、すべてのドキュメントをロックダウンし、すべてのユーザーが必要なアクセス権をすべて取得できるまで、徐々にアクセス権を追加していきます。最初の 2 つのルールを更新して、条件をfalseに設定してアクセスを拒否しましょう。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // User's cart metadata
    match /carts/{cartID} {
      // UPDATE THIS LINE
      allow read, write: if false;
    }

    // Items inside the user's cart
    match /carts/{cartID}/items/{itemID} {
      // UPDATE THIS LINE
      allow read, write: if false;
    }

    // All items available in the store. Users can read
    // items but never write them.
    match /items/{itemID} {
      allow read: if true;
    }
  }
}

8. エミュレータとテストを実行する

エミュレータを起動する

コマンドラインで、 emulators-codelab/codelab-initial-state/にいることを確認します。前の手順で作成したエミュレータがまだ実行されている可能性があります。そうでない場合は、エミュレータを再度起動します。

$ firebase emulators:start --import=./seed

エミュレータが実行されたら、エミュレータに対してローカルでテストを実行できます。

テストを実行する

コマンドラインでemulators-codelab/codelab-initial-state/ディレクトリから新しいターミナル タブを作成します。

まず、functions ディレクトリに移動します (コードラボの残りの部分はここに留まります)。

$ cd functions

次に、functions ディレクトリで mocha テストを実行し、出力の一番上までスクロールします。

# Run the tests
$ npm test

> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping carts
    1) can be created and updated by the cart owner
    2) can be read only by the cart owner

  shopping cart items
    3) can be read only by the cart owner
    4) can be added only by the cart owner

  adding an item to the cart recalculates the cart total. 
    - should sum the cost of their items


  0 passing (364ms)
  1 pending
  4 failing

現在、失敗が4件あります。ルール ファイルを構築するときに、より多くのテストが通過するのを確認することで進捗状況を測定できます。

9. 安全なカートアクセス

最初の 2 つの失敗は、次のことをテストする「ショッピング カート」テストです。

  • ユーザーは自分のカートのみを作成および更新できます
  • ユーザーは自分のカートのみを読み取ることができます

関数/test.js

  it('can be created and updated by the cart owner', async () => {
    // Alice can create her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart").set({
      ownerUID: "alice",
      total: 0
    }));

    // Bob can't create Alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart").set({
      ownerUID: "alice",
      total: 0
    }));

    // Alice can update her own cart with a new total
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart").update({
      total: 1
    }));

    // Bob can't update Alice's cart with a new total
    await firebase.assertFails(bobDb.doc("carts/alicesCart").update({
      total: 1
    }));
  });

  it("can be read only by the cart owner", async () => {
    // Setup: Create Alice's cart as admin
    await admin.doc("carts/alicesCart").set({
      ownerUID: "alice",
      total: 0
    });

    // Alice can read her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart").get());

    // Bob can't read Alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart").get());
  });

これらのテストをパスさせましょう。エディターでセキュリティ ルール ファイルfirestore.rulesを開き、 match /carts/{cartID}内のステートメントを更新します。

firestore.rules

rules_version = '2';
service cloud.firestore {
    // UPDATE THESE LINES
    match /carts/{cartID} {
      allow create: if request.auth.uid == request.resource.data.ownerUID;
      allow read, update, delete: if request.auth.uid == resource.data.ownerUID;
    }

    // ...
  }
}

これらのルールでは、カート所有者による読み取りおよび書き込みアクセスのみが許可されるようになりました。

受信データとユーザー認証を検証するために、すべてのルールのコンテキストで使用できる 2 つのオブジェクトを使用します。

  • requestオブジェクトには、試行されている操作に関するデータとメタデータが含まれています。
  • Firebase プロジェクトがFirebase Authenticationを使用している場合、 request.authオブジェクトはリクエストを行っているユーザーを記述します。

10. カートへのアクセスをテストする

firestore.rulesが保存されるたびに、Emulator Suite はルールを自動的に更新します。エミュレータでルールが更新されたことを確認するには、エミュレータを実行しているタブでRules updated 」というメッセージを確認します。

5680da418b420226.png

テストを再実行し、最初の 2 つのテストが成功したことを確認します。

$ npm test

> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping carts
    ✓ can be created and updated by the cart owner (195ms)
    ✓ can be read only by the cart owner (136ms)

  shopping cart items
    1) can be read only by the cart owner
    2) can be added only by the cart owner

  adding an item to the cart recalculates the cart total. 
    - should sum the cost of their items

  2 passing (482ms)
  1 pending
  2 failing

よくやった!これでショッピング カートへのアクセスが確保されました。次の失敗したテストに進みましょう。

11. UIの「カートに追加」フローを確認します

現時点では、カート所有者はカートの読み取りと書き込みはできますが、カート内の個々のアイテムの読み取りや書き込みはできません。これは、所有者はカート ドキュメントにはアクセスできますが、カートのアイテム サブコレクションにはアクセスできないためです。

これはユーザーにとって壊れた状態です。

http://127.0.0.1:5000,カートに何かを追加してみます。 itemsサブコレクション内の作成されたドキュメントへのアクセスをユーザーにまだ許可していないため、デバッグ コンソールに表示されるPermission Denied 」エラーが表示されます。

12. カート項目へのアクセスを許可する

これら 2 つのテストでは、ユーザーが自分のカートに項目を追加したり、自分のカートから項目を読み取ったりすることしかできないことを確認します。

  it("can be read only by the cart owner", async () => {
    // Alice can read items in her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart/items/milk").get());

    // Bob can't read items in alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart/items/milk").get())
  });

  it("can be added only by the cart owner",  async () => {
    // Alice can add an item to her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart/items/lemon").set({
      name: "lemon",
      price: 0.99
    }));

    // Bob can't add an item to alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart/items/lemon").set({
      name: "lemon",
      price: 0.99
    }));
  });

したがって、現在のユーザーがカート ドキュメントの ownerUID と同じ UID を持っている場合にアクセスを許可するルールを作成できます。 create, update, deleteに異なるルールを指定する必要がないため、データを変更するすべてのリクエストに適用されるwriteルールを使用できます。

項目サブコレクション内のドキュメントのルールを更新します。条件のget Firestore から値を読み取ります。この場合は、カート ドキュメントのownerUID

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // ...

    // UPDATE THESE LINES
    match /carts/{cartID}/items/{itemID} {
      allow read, write: if get(/databases/$(database)/documents/carts/$(cartID)).data.ownerUID == request.auth.uid;
    }

    // ...
  }
}

13. カート項目へのアクセスをテストする

これでテストを再実行できるようになりました。出力の一番上までスクロールし、さらに多くのテストが合格したことを確認します。

$ npm test

> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping carts
    ✓ can be created and updated by the cart owner (195ms)
    ✓ can be read only by the cart owner (136ms)

  shopping cart items
    ✓ can be read only by the cart owner (111ms)
    ✓ can be added only by the cart owner


  adding an item to the cart recalculates the cart total. 
    - should sum the cost of their items


  4 passing (401ms)
  1 pending

ニース!これで、すべてのテストが成功しました。保留中のテストが 1 つありますが、それについては数ステップで完了します。

14.「カートに入れる」フローを再度確認する

Web フロントエンド ( http://127.0.0.1:5000 ) に戻り、アイテムをカートに追加します。これは、テストとルールがクライアントが必要とする機能と一致していることを確認するための重要なステップです。 (前回 UI を試したとき、ユーザーはカートに商品を追加できなかったことを思い出してください。)

69ad26cee520bf24.png

firestore.rulesが保存されると、クライアントはルールを自動的に再ロードします。そこで、カートに何かを追加してみてください。

要約

よくやった!アプリのセキュリティが向上しました。これは、実稼働の準備を整えるための重要なステップです。これが実稼働アプリの場合、これらのテストを継続的統合パイプラインに追加できます。これにより、たとえ他の人がルールを変更したとしても、ショッピング カート データにはこれらのアクセス制御が適用されるという確信が今後得られます。

ba5440b193e75967.gif

しかし、待ってください、まだあります!

続ければ、次のことを学ぶことができます。

  • Firestore イベントによってトリガーされる関数を作成する方法
  • 複数のエミュレータで動作するテストを作成する方法

15. Cloud Functions テストのセットアップ

これまでは、Web アプリのフロントエンドと Firestore セキュリティ ルールに焦点を当ててきました。ただし、このアプリはユーザーのカートを最新の状態に保つために Cloud Functions も使用しているため、そのコードもテストしたいと考えています。

エミュレータ スイートを使用すると、Cloud Firestore やその他のサービスを使用する機能も含め、Cloud Functions を非常に簡単にテストできます。

エディターで、 emulators-codelab/codelab-initial-state/functions/test.jsファイルを開き、ファイル内の最後のテストまでスクロールします。現在、保留中としてマークされています。

//  REMOVE .skip FROM THIS LINE
describe.skip("adding an item to the cart recalculates the cart total. ", () => {
  // ...

  it("should sum the cost of their items", async () => {
    ...
  });
});

テストを有効にするには、 .skipを削除して、次のようにします。

describe("adding an item to the cart recalculates the cart total. ", () => {
  // ...

  it("should sum the cost of their items", async () => {
    ...
  });
});

次に、ファイルの先頭にあるREAL_FIREBASE_PROJECT_ID変数を見つけて、実際の Firebase プロジェクト ID に変更します。

// CHANGE THIS LINE
const REAL_FIREBASE_PROJECT_ID = "changeme";

プロジェクト ID を忘れた場合は、Firebase コンソールのプロジェクト設定で Firebase プロジェクト ID を確認できます。

d6d0429b700d2b21.png

16. 関数テストのウォークスルー

このテストは Cloud Firestore と Cloud Functions の間の対話を検証するため、前のコードラボのテストよりも多くのセットアップが必要になります。このテストを実際に見て、何が期待されるのかを理解してみましょう。

カートを作成する

Cloud Functions は信頼されたサーバー環境で実行され、Admin SDK で使用されるサービス アカウント認証を使用できます。まず、 initializeAppではなく、 initializeAdminAppを使用してアプリを初期化します。次に、アイテムを追加するカートのDocumentReferenceを作成し、カートを初期化します。

it("should sum the cost of their items", async () => {
    const db = firebase
        .initializeAdminApp({ projectId: REAL_FIREBASE_PROJECT_ID })
        .firestore();

    // Setup: Initialize cart
    const aliceCartRef = db.doc("carts/alice")
    await aliceCartRef.set({ ownerUID: "alice", totalPrice: 0 });

    ...
  });

機能をトリガーする

次に、関数をトリガーするために、カート ドキュメントのitemsサブコレクションにドキュメントを追加します。 2 つの項目を追加して、関数内で発生する追加をテストしていることを確認します。

it("should sum the cost of their items", async () => {
    const db = firebase
        .initializeAdminApp({ projectId: REAL_FIREBASE_PROJECT_ID })
        .firestore();

    // Setup: Initialize cart
    const aliceCartRef = db.doc("carts/alice")
    await aliceCartRef.set({ ownerUID: "alice", totalPrice: 0 });

    //  Trigger calculateCart by adding items to the cart
    const aliceItemsRef = aliceCartRef.collection("items");
    await aliceItemsRef.doc("doc1").set({name: "nectarine", price: 2.99});
    await aliceItemsRef.doc("doc2").set({ name: "grapefruit", price: 6.99 });

    ...
    });
  });

テストの期待値を設定する

onSnapshot()を使用して、カート ドキュメントの変更に対するリスナーを登録します。 onSnapshot()リスナーの登録を解除するために呼び出すことができる関数を返します。

このテストでは、合わせて $9.98 の 2 つのアイテムを追加します。次に、カートに予想されるitemCounttotalPriceがあるかどうかを確認します。そうであれば、関数はその役割を果たしています。

it("should sum the cost of their items", (done) => {
    const db = firebase
        .initializeAdminApp({ projectId: REAL_FIREBASE_PROJECT_ID })
        .firestore();

    // Setup: Initialize cart
    const aliceCartRef = db.doc("carts/alice")
    aliceCartRef.set({ ownerUID: "alice", totalPrice: 0 });

    //  Trigger calculateCart by adding items to the cart
    const aliceItemsRef = aliceCartRef.collection("items");
    aliceItemsRef.doc("doc1").set({name: "nectarine", price: 2.99});
    aliceItemsRef.doc("doc2").set({ name: "grapefruit", price: 6.99 });
    
    // Listen for every update to the cart. Every time an item is added to
    // the cart's subcollection of items, the function updates `totalPrice`
    // and `itemCount` attributes on the cart.
    // Returns a function that can be called to unsubscribe the listener.
    await new Promise((resolve) => {
      const unsubscribe = aliceCartRef.onSnapshot(snap => {
        // If the function worked, these will be cart's final attributes.
        const expectedCount = 2;
        const expectedTotal = 9.98;
  
        // When the `itemCount`and `totalPrice` match the expectations for the
        // two items added, the promise resolves, and the test passes.
        if (snap.data().itemCount === expectedCount && snap.data().totalPrice == expectedTotal) {
          // Call the function returned by `onSnapshot` to unsubscribe from updates
          unsubscribe();
          resolve();
        };
      });
    });
   });
 });

17. テストを実行する

以前のテストで使用したエミュレータがまだ実行されている可能性があります。そうでない場合は、エミュレータを起動します。コマンドラインから実行します。

$ firebase emulators:start --import=./seed

新しいターミナル タブを開き (エミュレータは実行したままにし)、functions ディレクトリに移動します。セキュリティ ルールのテストでこれがまだ開いている可能性があります。

$ cd functions

ここで単体テストを実行すると、合計 5 つのテストが表示されるはずです。

$ npm test

> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping cart creation
    ✓ can be created by the cart owner (82ms)

  shopping cart reads, updates, and deletes
    ✓ cart can be read by the cart owner (42ms)

  shopping cart items
    ✓ items can be read by the cart owner (40ms)
    ✓ items can be added by the cart owner

  adding an item to the cart recalculates the cart total. 
    1) should sum the cost of their items

  4 passing (2s)
  1 failing

特定の障害を見ると、タイムアウト エラーであるように見えます。これは、テストが関数が正しく更新されるのを待っているにもかかわらず、更新されないためです。これで、テストを満たす関数を作成する準備が整いました。

18. 関数を書く

このテストを修正するには、 functions/index.js内の関数を更新する必要があります。この関数の一部は記述されていますが、完全ではありません。現在の関数は次のようになります。

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }

      let totalPrice = 125.98;
      let itemCount = 8;
      try {
        
        const cartRef = db.collection("carts").doc(context.params.cartId);

        await cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    });

この関数はカート参照を正しく設定していますが、 totalPriceitemCountの値を計算する代わりに、ハードコードされた値に更新します。

フェッチして反復処理する

itemsサブコレクション

新しい定数itemsSnap初期化してitemsサブコレクションにします。次に、コレクション内のすべてのドキュメントを繰り返し処理します。

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }


      try {
        let totalPrice = 125.98;
        let itemCount = 8;

        const cartRef = db.collection("carts").doc(context.params.cartId);
        // ADD LINES FROM HERE
        const itemsSnap = await cartRef.collection("items").get();

        itemsSnap.docs.forEach(item => {
          const itemData = item.data();
        })
        // TO HERE
       
        return cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    });

totalPrice と itemCount を計算する

まず、 totalPriceitemCountの値をゼロに初期化しましょう。

次に、ロジックを反復ブロックに追加します。まず、商品に価格が付いているかどうかを確認します。アイテムに数量が指定されていない場合は、デフォルトの1に設定します。次に、その数量をitemCountの累計に追加します。最後に、品目の価格に数量を乗算した値をtotalPriceの現在までの合計に追加します。

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }

      try {
        // CHANGE THESE LINES
        let totalPrice = 0;
        let itemCount = 0;

        const cartRef = db.collection("carts").doc(context.params.cartId);
        const itemsSnap = await cartRef.collection("items").get();

        itemsSnap.docs.forEach(item => {
          const itemData = item.data();
          // ADD LINES FROM HERE
          if (itemData.price) {
            // If not specified, the quantity is 1
            const quantity = itemData.quantity ? itemData.quantity : 1;
            itemCount += quantity;
            totalPrice += (itemData.price * quantity);
          }
          // TO HERE
        })

        await cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    });

成功状態とエラー状態のデバッグに役立つログを追加することもできます。

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }

      let totalPrice = 0;
      let itemCount = 0;
      try {
        const cartRef = db.collection("carts").doc(context.params.cartId);
        const itemsSnap = await cartRef.collection("items").get();

        itemsSnap.docs.forEach(item => {
          const itemData = item.data();
          if (itemData.price) {
            // If not specified, the quantity is 1
            const quantity = (itemData.quantity) ? itemData.quantity : 1;
            itemCount += quantity;
            totalPrice += (itemData.price * quantity);
          }
        });

        await cartRef.update({
          totalPrice,
          itemCount
        });

        // OPTIONAL LOGGING HERE
        console.log("Cart total successfully recalculated: ", totalPrice);
      } catch(err) {
        // OPTIONAL LOGGING HERE
        console.warn("update error", err);
      }
    });

19. テストを再実行する

コマンド ラインで、エミュレータがまだ実行中であることを確認し、テストを再実行します。エミュレータは機能への変更を自動的に検出するため、エミュレータを再起動する必要はありません。すべてのテストが成功するはずです。

$ npm test
> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping cart creation
    ✓ can be created by the cart owner (306ms)

  shopping cart reads, updates, and deletes
    ✓ cart can be read by the cart owner (59ms)

  shopping cart items
    ✓ items can be read by the cart owner
    ✓ items can be added by the cart owner

  adding an item to the cart recalculates the cart total. 
    ✓ should sum the cost of their items (800ms)


  5 passing (1s)

よくやった!

20. Storefront UI を使用して試してみる

最後のテストとして、Web アプリ ( http://127.0.0.1:5000/ ) に戻り、アイテムをカートに追加します。

69ad26cee520bf24.png

カートが正しい合計で更新されていることを確認します。素晴らしい!

要約

Cloud Functions for Firebase と Cloud Firestore の間の複雑なテスト ケースを説明しました。テストに合格するための Cloud Function を作成しました。また、新しい機能が UI で動作していることも確認できました。これらすべてをローカルで行い、自分のマシン上でエミュレータを実行しました。

また、ローカル エミュレータに対して実行する Web クライアントを作成し、データを保護するためにセキュリティ ルールを調整し、ローカル エミュレータを使用してセキュリティ ルールをテストしました。

c6a7aeb91fe97a64.gif