Firebase Emulator Suite を使用したローカル開発

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 を実行します)

演習内容

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

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

アプリを Emulator Suite に接続して、ローカル開発を有効にします。

2589e2f95b74fa88.png

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

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

2. セットアップ

ソースコードを取得する

この Codelab では、完成間近の Fire Store サンプルのバージョンから始めます。そのため、まずはソースコードのクローンを作成する必要があります。

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

次に、Codelab ディレクトリに移動します。このディレクトリで、この Codelab の残りの作業を行います。

$ 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 を入手する

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

$ npm install -g firebase-tools

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

$ firebase --version
9.6.0

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

Firebase プロジェクトがない場合は、Firebase コンソールで新しい Firebase プロジェクトを作成します。選択したプロジェクト ID をメモしておきます。この 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.

[すべてのエミュレータが起動しました] というメッセージが表示されたら、アプリを使用できる状態です。

ウェブアプリをエミュレータに接続する

ログの表から、Cloud Firestore エミュレータがポート 8080 をリッスンし、Authentication エミュレータがポート 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);
  }

これで、アプリがローカルマシンで実行されている(Hosting エミュレータが提供)場合、Firestore クライアントは本番環境データベースではなくローカル エミュレータも参照します。

EmulatorUI を開く

ウェブブラウザで http://127.0.0.1:4000/ に移動します。Emulator Suite UI が表示されます。

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

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

4ef88d0148405d36.png

4. アプリを実行する

アプリを開く

ウェブブラウザで http://127.0.0.1:5000 にアクセスすると、マシンでローカルに実行されている Fire Store が表示されます。

939f87946bac2ee4.png

アプリを使用する

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

a11bd59933a8e885.png

バグを修正しましょう。すべてがエミュレータで実行されるため、実際のデータへの影響を心配することなく実験できます。

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

バグを見つける

それでは、Chrome デベロッパー コンソールを見てみましょう。Control+Shift+J キー(Windows、Linux、ChromeOS)または 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 のドキュメントによると、ログインしていない場合、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. ローカル関数のトリガー

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

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

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

68c9323f2ad10f7a.png

1)Firestore Write - クライアント

新しいドキュメントが 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 Functions の関数によってトリガーされる

Cloud Functions 関数 calculateCart は、onWrite トリガーを使用して、カート内のアイテムに対して発生する書き込みイベント(作成、更新、削除)をリッスンします。このトリガーは functions/index.js で確認できます。

functions/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 関数は、カート内のすべての商品を読み取り、合計数量と価格を合計してから、「cart」を更新します。新しい合計を含むドキュメントを作成します(上記の cartRef.update(...) を参照)。

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

ウェブ フロントエンドは、カートの変更に関する最新情報を受け取るよう登録されています。public/js/homepage.js でわかるように、Cloud Functions の関数が新しい合計を書き込んで 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. アプリに合わせたセキュリティ ルールを作成

ウェブアプリはデータを読み書きしますが、これまでセキュリティについては特に心配していませんでした。Cloud Firestore は、「セキュリティ ルール」と呼ばれるシステムを使用して、データの読み取りと書き込みにアクセスできるユーザーを宣言します。Emulator Suite は、これらのルールのプロトタイプを作成するための優れた方法です。

エディタで 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;
    }
  }
}

現在、誰でも当社のデータベースに対してデータを読み書きできます。有効なオペレーションのみが実行され、機密情報が漏洩しないようにするためです。

この Codelab では、最小権限の原則に従い、すべてのドキュメントをロックダウンし、すべてのユーザーが必要なアクセス権をすべて(それ以上は)持つまで、アクセス権を段階的に追加します。最初の 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 ディレクトリに移動します(Codelab の残りの部分ではこのディレクトリを使用します)。

$ 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 つのエラーは「ショッピング カート」次のことを確認します。

  • ユーザーは自分の買い物かごのみを作成、更新できます
  • ユーザーは自分のカートのみを読むことができます

functions/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. カートへのアクセスをテストする

Emulator Suite は、firestore.rules が保存されると自動的にルールを更新します。エミュレータでルールが更新されたことを確認するには、エミュレータを実行しているタブでメッセージ 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, で実行されているウェブ UI に戻り、カートに何かを追加してみます。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
    }));
  });

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

items サブコレクション内のドキュメントのルールを更新します。条件内の 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. [カートに追加] チェックボックスをもう一度

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

69ad26cee520bf24.png

firestore.rules が保存されると、クライアントはルールを自動的に再読み込みします。カートに商品を追加してみてください。

内容のまとめ

お疲れさまでした。アプリのセキュリティが強化されました。これは、アプリを本番環境に移行するうえで不可欠なステップです。これが本番環境アプリであれば、これらのテストを継続的インテグレーション パイプラインに追加できます。そうすれば、たとえ他のユーザーがルールを変更したとしても、今後はショッピング カートのデータにこれらのアクセス制御が適用されることを確信できます。

ba5440b193e75967.gif

でも、これだけではありません。

このまま続けると、次のことが学べます。

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

15. Cloud Functions のテストを設定する

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

エミュレータ スイートを使用すると、Cloud Functions を簡単にテストできます。Cloud Firestore などのサービスを使用する関数もテストできます。

エディタで 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. Functions のテストについて確認する

このテストは Cloud Firestore と Cloud Functions 間のインタラクションを検証するため、前回の Codelab のテストよりも多くの設定が必要です。このテストの内容を確認しましょう。

カートを作成する

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. ストアフロント UI で試す

最後のテストとして、ウェブアプリ(http://127.0.0.1:5000/)に戻り、商品をカートに追加します。

69ad26cee520bf24.png

カートが更新され、正しい合計値になっていることを確認します。ではさっそく、

内容のまとめ

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

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

c6a7aeb91fe97a64.gif