Développement local avec la suite d'émulateurs Firebase

1. Avant de commencer

Les outils de backend sans serveur tels que Cloud Firestore et Cloud Functions sont très faciles à utiliser, mais peuvent être difficiles à tester. La suite d'émulateurs locaux Firebase vous permet d'exécuter des versions locales de ces services sur votre machine de développement afin de développer votre application rapidement et en toute sécurité.

Conditions préalables

  • Un éditeur simple tel que Visual Studio Code, Atom ou Sublime Text
  • Node.js 10.0.0 ou version ultérieure (pour installer Node.js, utilisez nvm, et pour vérifier votre version, exécutez node --version)
  • Java 7 ou version ultérieure (pour installer Java, suivez ces instructions, et vérifiez votre version en exécutant java -version)

Objectifs de l'atelier

Dans cet atelier de programmation, vous allez exécuter et déboguer une application d'achat en ligne simple, qui repose sur plusieurs services Firebase :

  • Cloud Firestore:base de données NoSQL sans serveur, évolutive à l'échelle mondiale et dotée de fonctionnalités en temps réel.
  • Cloud Functions: code backend sans serveur qui s'exécute en réponse à des événements ou à des requêtes HTTP.
  • Firebase Authentication : service d'authentification géré qui s'intègre à d'autres produits Firebase.
  • Firebase Hosting : hébergement rapide et sécurisé pour les applications Web.

Vous connecterez l'application à la suite d'émulateurs pour activer le développement local.

2589e2f95b74fa88.png

Vous apprendrez également à :

  • Connecter votre application à la suite d'émulateurs et comprendre comment les différents émulateurs sont connectés
  • Fonctionnement des règles de sécurité Firebase et tester les règles de sécurité Firestore par rapport à un émulateur local
  • Découvrez comment écrire une fonction Firebase qui est déclenchée par des événements Firestore et comment écrire des tests d'intégration qui s'exécutent sur la suite d'émulateurs.

2. Configurer

Obtenir le code source

Dans cet atelier de programmation, vous commencez avec une version presque complète de l'exemple de Fire Store. La première chose que vous devez faire est donc de cloner le code source :

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

Accédez ensuite au répertoire de l'atelier de programmation, où vous travaillerez tout au long de cet atelier de programmation:

$ cd emulators-codelab/codelab-initial-state

À présent, installez les dépendances pour pouvoir exécuter le code. Si votre connexion Internet est lente, cette opération peut prendre une à deux minutes :

# Move into the functions directory
$ cd functions

# Install dependencies
$ npm install

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

Obtenir la CLI Firebase

La suite d'émulateurs fait partie de la CLI (interface de ligne de commande) Firebase, que vous pouvez installer sur votre ordinateur à l'aide de la commande suivante :

$ npm install -g firebase-tools

Vérifiez ensuite que vous disposez de la dernière version de la CLI. Cet atelier de programmation devrait fonctionner avec la version 9.0.0 ou une version ultérieure, mais les versions ultérieures incluent davantage de corrections de bugs.

$ firebase --version
9.6.0

Associer à votre projet Firebase

Si vous n'avez pas de projet Firebase, créez-en un dans la console Firebase. Notez l'ID de projet que vous choisissez, car vous en aurez besoin plus tard.

Nous devons maintenant associer ce code à votre projet Firebase. Commencez par exécuter la commande suivante pour vous connecter à la CLI Firebase :

$ firebase login

Exécutez ensuite la commande suivante pour créer un alias de projet. Remplacez $YOUR_PROJECT_ID par l'ID de votre projet Firebase.

$ firebase use $YOUR_PROJECT_ID

Vous êtes maintenant prêt à exécuter l'application.

3. Exécuter les émulateurs

Dans cette section, vous allez exécuter l'application en local. Il est donc temps de démarrer la suite d'émulateurs.

Démarrer les émulateurs

Dans le répertoire source de l'atelier de programmation, exécutez la commande suivante pour démarrer les émulateurs:

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

Le résultat devrait ressembler à ceci :

$ 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.

Lorsque le message Tous les émulateurs ont démarré s'affiche, l'application est prête à l'emploi.

Connecter l'application Web aux émulateurs

D'après le tableau des journaux, l'émulateur Cloud Firestore est à l'écoute sur le port 8080 et l'émulateur Authentication sur le port 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                             │
└────────────────┴────────────────┴─────────────────────────────────┘

Connectons votre code de l'interface à l'émulateur plutôt qu'à l'environnement de production. Ouvrez le fichier public/js/homepage.js et recherchez la fonction onDocumentReady. Nous constatons que le code accède aux instances Firestore et Auth standards:

public/js/homepage.js.

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

Mettons à jour les objets db et auth pour qu'ils pointent vers les émulateurs locaux :

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);
  }

Désormais, lorsque l'application s'exécute sur votre machine locale (desservie par l'émulateur Hosting), le client Firestore pointe également vers l'émulateur local plutôt que vers une base de données de production.

Ouvrir EmulatorUI

Dans votre navigateur Web, accédez à http://127.0.0.1:4000/. L'interface utilisateur de la suite d'émulateurs doit s'afficher.

Écran d'accueil de l'UI des émulateurs

Cliquez pour afficher l'UI de l'émulateur Firestore. La collection items contient déjà des données en raison des données importées avec l'indicateur --import.

4ef88d0148405d36.png

4. Exécuter l'application

Ouvrez l'application.

Dans votre navigateur Web, accédez à http://127.0.0.1:5000. Vous devriez voir The Fire Store s'exécuter localement sur votre machine.

939f87946bac2ee4.png

Utiliser l'application

Sélectionnez un article sur la page d'accueil, puis cliquez sur Ajouter au panier. Malheureusement, vous rencontrez l'erreur suivante:

a11bd59933a8e885.png

Résolvons ce bug. Comme tout s'exécute dans les émulateurs, nous pouvons effectuer des tests sans nous soucier de l'impact sur les données réelles.

5. Déboguer l'application

Identifier le bug

OK, je vais regarder dans la console de développement Chrome. Appuyez sur Control+Shift+J (Windows, Linux, Chrome OS) ou Command+Option+J (Mac) pour afficher l'erreur sur la console:

74c45df55291dab1.png

Il semble qu'il y ait une erreur dans la méthode addToCart. Voyons cela. Où essayons-nous d'accéder à un élément appelé uid dans cette méthode, et pourquoi null ? Pour le moment, la méthode ressemble à ceci dans 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);
  }

Eurêka Nous ne sommes pas connectés à l'application. Selon la documentation Firebase Authentication, lorsque nous ne sommes pas connectés, auth.currentUser est null. Ajoutons une vérification:

public/js/homepage.js.

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

    // ...
  }

Tester l'application

Maintenant, actualisez la page, puis cliquez sur Add to Cart (Ajouter au panier). Vous devriez obtenir un message d'erreur plus clair cette fois :

C65f6c05588133f7.png

Toutefois, si vous cliquez sur Sign In (Se connecter) dans la barre d'outils supérieure, puis de nouveau sur Add to Cart (Ajouter au panier), vous constaterez que le panier est mis à jour.

Toutefois, les chiffres ne semblent pas du tout corrects :

239f26f02f959eef.png

Ne vous inquiétez pas, nous allons corriger ce bug rapidement. Tout d'abord, voyons ce qui s'est réellement passé lorsque vous avez ajouté un article à votre panier.

6. Déclencheurs de fonctions locales

Lorsque vous cliquez sur Ajouter au panier, une chaîne d'événements impliquant plusieurs émulateurs est lancée. Dans les journaux de la CLI Firebase, vous devriez voir des messages semblables aux suivants une fois que vous avez ajouté un article à votre panier:

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

Quatre événements clés se sont produits pour générer ces journaux et la mise à jour de l'interface utilisateur que vous avez observée :

68c9323f2ad10f7a.png

1) Écriture Firestore - Client

Un nouveau document est ajouté à la collection Firestore /carts/{cartId}/items/{itemId}/. Vous pouvez voir ce code dans la fonction addToCart dans 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);
  }

2) Fonction Cloud déclenchée

La fonction Cloud calculateCart écoute tous les événements d'écriture (création, mise à jour ou suppression) qui se produisent sur les articles du panier à l'aide du déclencheur onWrite, que vous pouvez voir dans 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) Écriture Firestore - Administrateur

La fonction calculateCart lit tous les articles du panier, additionne la quantité totale et le prix, puis met à jour le document "cart" avec les nouveaux totaux (voir cartRef.update(...) ci-dessus).

4) Lecture Firestore - Client

L'interface Web est abonnée aux notifications concernant les modifications apportées au panier. Il reçoit une mise à jour en temps réel une fois que la fonction Cloud Functions a écrit les nouveaux totaux et mis à jour l'UI, comme vous pouvez le voir dans public/js/homepage.js :

public/js/homepage.js.

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

Résumé

Bravo ! Vous venez de configurer une application entièrement locale qui utilise trois émulateurs Firebase différents pour des tests entièrement locaux.

db82eef1706c9058.gif

Et ce n'est pas tout ! Dans la section suivante, vous allez découvrir :

  • Écrire des tests unitaires qui utilisent les émulateurs Firebase
  • Utiliser les émulateurs Firebase pour déboguer vos règles de sécurité

7. Créez des règles de sécurité adaptées à votre application

Notre application Web lit et écrit des données, mais jusqu'à présent, nous n'avons pas vraiment pensé à la sécurité. Cloud Firestore utilise un système appelé "Règles de sécurité" pour déclarer qui a accès à la lecture et à l'écriture des données. La suite d'émulateurs est un excellent moyen de prototyper ces règles.

Dans l'éditeur, ouvrez le fichier emulators-codelab/codelab-initial-state/firestore.rules. Vous constaterez que nos règles comportent trois sections principales :

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;
    }
  }
}

Pour le moment, tout le monde peut lire et écrire des données dans notre base de données ! Nous voulons nous assurer que seules les opérations valides sont transmises et que nous ne divulguons aucune information sensible.

Dans cet atelier de programmation, conformément au principe du moindre privilège, nous allons verrouiller tous les documents et ajouter progressivement des droits d'accès jusqu'à ce que tous les utilisateurs disposent de tous les droits dont ils ont besoin, mais pas plus. Modifions les deux premières règles pour refuser l'accès en définissant la condition sur 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. Exécuter les émulateurs et les tests

Lancer les émulateurs

Dans la ligne de commande, vérifiez que vous êtes bien dans emulators-codelab/codelab-initial-state/. Les émulateurs que vous avez lancés lors des étapes précédentes peuvent toujours être en cours d'exécution. Si ce n'est pas le cas, redémarrez les émulateurs:

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

Une fois les émulateurs en cours d'exécution, vous pouvez exécuter des tests localement sur eux.

Exécuter les tests

Sur la ligne de commande, dans un nouvel onglet de terminal à partir du répertoire emulators-codelab/codelab-initial-state/

Accédez d'abord au répertoire des fonctions (nous y resterons pendant le reste de l'atelier de programmation) :

$ cd functions

Exécutez maintenant les tests Mocha dans le répertoire des fonctions et faites défiler la page jusqu'en haut de la sortie:

# 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

Nous avons actuellement quatre échecs. À mesure que vous créez le fichier de règles, vous pouvez mesurer la progression en observant la réussite des autres tests.

9. Accès sécurisé au panier

Les deux premiers échecs sont les tests du panier, qui vérifient que:

  • Les utilisateurs ne peuvent créer et modifier que leur propre panier
  • Les utilisateurs ne peuvent lire que leurs propres paniers

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());
  });

Faisons en sorte que ces tests réussissent. Dans l'éditeur, ouvrez le fichier de règles de sécurité, firestore.rules, et mettez à jour les instructions dans 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;
    }

    // ...
  }
}

Ces règles n'autorisent désormais que le propriétaire du panier à lire et à écrire.

Pour vérifier les données entrantes et l'authentification de l'utilisateur, nous utilisons deux objets disponibles dans le contexte de chaque règle:

  • L'objet request contient des données et des métadonnées sur l'opération en cours de tentative.
  • Si un projet Firebase utilise Firebase Authentication, l'objet request.auth décrit l'utilisateur qui envoie la requête.

10. Tester l'accès au panier

La suite d'émulateurs met automatiquement à jour les règles chaque fois que firestore.rules est enregistré. Vous pouvez vérifier que l'émulateur a mis à jour les règles en recherchant le message Rules updated dans l'onglet exécutant l'émulateur :

5680da418b420226.png

Exécutez à nouveau les tests et vérifiez que les deux premiers tests réussissent désormais:

$ 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

Bravo ! Vous avez maintenant sécurisé l'accès aux paniers. Passons au test qui échoue.

11. Vérifier le parcours "Ajouter au panier" dans l'interface utilisateur

Pour le moment, bien que les propriétaires de panier lisent et écrivent des articles dans leur panier, ils ne peuvent pas lire ni écrire d'articles individuels dans leur panier. En effet, bien que les propriétaires aient accès au document du panier, ils n'ont pas accès à la sous-collection d'articles de celui-ci.

Il s'agit d'un état défectueux pour les utilisateurs.

Revenez à l'interface utilisateur Web qui s'exécute sur http://127.0.0.1:5000, et essayez d'ajouter un article à votre panier. Vous obtenez une erreur Permission Denied, visible dans la console de débogage, car nous n'avons pas encore accordé aux utilisateurs l'accès aux documents créés dans la sous-collection items.

12. Autoriser l'accès aux articles du panier

Ces deux tests confirment que les utilisateurs ne peuvent lire ou ajouter des articles que dans leur propre panier:

  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
    }));
  });

Nous pouvons donc écrire une règle qui autorise l'accès si l'utilisateur actuel possède le même UID que le propriétaire du document du panier. Comme il n'est pas nécessaire de spécifier des règles différentes pour create, update, delete, vous pouvez utiliser une règle write, qui s'applique à toutes les requêtes qui modifient des données.

Modifiez la règle pour les documents de la sous-collection "articles". Le get dans la condition lit une valeur de Firestore (dans ce cas, ownerUID sur le document panier).

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. Tester l'accès aux articles du panier

Nous pouvons maintenant réexécuter le test. Faites défiler la page jusqu'en haut du résultat et vérifiez que d'autres tests réussissent:

$ 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

Nice! Tous nos tests réussissent. Un test est en attente, mais nous y reviendrons dans quelques étapes.

14. Vérifier à nouveau le parcours "Ajouter au panier"

Revenez à l'interface Web (http://127.0.0.1:5000) et ajoutez un article au panier. Il s'agit d'une étape importante pour confirmer que nos tests et nos règles correspondent à la fonctionnalité requise par le client. (Rappelez-vous que la dernière fois que nous avons essayé l'interface utilisateur, les utilisateurs n'ont pas pu ajouter d'articles à leur panier.)

69ad26cee520bf24.png

Le client recharge automatiquement les règles lorsque le firestore.rules est enregistré. Essayez d'ajouter un article au panier.

Résumé

Bravo ! Vous venez d'améliorer la sécurité de votre application, une étape essentielle pour la préparer à la production. S'il s'agissait d'une application de production, nous pourrions ajouter ces tests à notre pipeline d'intégration continue. Cela nous permettrait de nous assurer que nos données de panier seront dotées de ces contrôles d'accès à l'avenir, même si d'autres personnes modifient les règles.

ba5440b193e75967.gif

Mais ce n'est pas tout !

En continuant, vous découvrirez:

  • Écrire une fonction déclenchée par un événement Firestore
  • Créer des tests qui fonctionnent sur plusieurs émulateurs

15. Configurer des tests Cloud Functions

Jusqu'à présent, nous nous sommes concentrés sur l'interface de notre application Web et sur les règles de sécurité Firestore. Mais cette application utilise également Cloud Functions pour maintenir à jour le panier de l'utilisateur. Nous voulons donc également tester ce code.

La suite d'émulateurs vous permet de tester facilement Cloud Functions, même les fonctions qui utilisent Cloud Firestore et d'autres services.

Dans l'éditeur, ouvrez le fichier emulators-codelab/codelab-initial-state/functions/test.js et faites défiler la page jusqu'au dernier test du fichier. Elle est actuellement marquée comme en attente :

//  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 () => {
    ...
  });
});

Pour activer le test, supprimez .skip, qui se présente comme suit:

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

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

Recherchez ensuite la variable REAL_FIREBASE_PROJECT_ID en haut du fichier et remplacez-la par l'ID de votre projet Firebase :

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

Si vous avez oublié votre ID de projet Firebase, vous le trouverez dans les paramètres du projet de la console Firebase:

D6d0429b700d2b21.png

16. Parcourir les tests Functions

Étant donné que ce test valide l'interaction entre Cloud Firestore et Cloud Functions, il nécessite plus de configuration que les tests des ateliers de programmation précédents. Examinons ce test pour avoir une idée de ce qu'il attend.

Créer un panier

Les fonctions Cloud s'exécutent dans un environnement de serveur de confiance et peuvent utiliser l'authentification de compte de service utilisée par le SDK Admin . Tout d'abord, vous devez initialiser une application à l'aide de initializeAdminApp au lieu de initializeApp. Ensuite, créez une référence DocumentReference pour le panier auquel nous allons ajouter des articles, puis initialisez celui-ci:

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 });

    ...
  });

Déclencher la fonction

Ajoutez ensuite des documents à la sous-collection items de notre document "panier" afin de déclencher la fonction. Ajoutez deux éléments pour vous assurer de tester l'addition qui se produit dans la fonction.

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 });

    ...
    });
  });

Définir les attentes concernant les tests

Utilisez onSnapshot() pour enregistrer un écouteur pour toutes les modifications apportées au document du panier. onSnapshot() renvoie une fonction que vous pouvez appeler pour annuler l'enregistrement de l'écouteur.

Pour ce test, ajoutez deux articles dont le prix total est de 9,98 $. Vérifiez ensuite si le panier contient les itemCount et totalPrice attendus. Si c’est le cas, alors la fonction a fait son travail.

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. Exécuter les tests

Il est possible que les émulateurs des tests précédents continuent de s'exécuter. Si ce n'est pas le cas, démarrez les émulateurs. Depuis la ligne de commande, exécutez

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

Ouvrez un nouvel onglet de terminal (laissez les émulateurs en cours d'exécution) et accédez au répertoire des fonctions. Il est possible que cette page soit encore ouverte lors des tests des règles de sécurité.

$ cd functions

Exécutez maintenant les tests unitaires. Vous devriez voir cinq tests au total :

$ 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

Si vous examinez l'échec spécifique, il semble s'agir d'une erreur de délai avant expiration. En effet, le test attend que la fonction soit correctement mise à jour, mais cela ne se produit jamais. Nous sommes maintenant prêts à écrire la fonction pour satisfaire le test.

18. Écrire une fonction

Pour corriger ce test, vous devez mettre à jour la fonction dans functions/index.js. Bien qu'une partie de cette fonction soit écrite, elle n'est pas complète. Voici à quoi ressemble actuellement la fonction :

// 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) {
      }
    });

La fonction définit correctement la référence au panier, mais au lieu de calculer les valeurs de totalPrice et itemCount, elle les met à jour avec celles codées en dur.

Récupérez et itérez

items sous-collection

Initialisez une nouvelle constante, itemsSnap, qui deviendra la sous-collection items. Ensuite, itérez tous les documents de la collection.

// 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) {
      }
    });

Calculer le prix total et le nombre d'articles

Commençons par initialiser les valeurs de totalPrice et itemCount sur zéro.

Ajoutez ensuite la logique à notre bloc d'itération. Tout d'abord, vérifiez que l'article a un prix. Si vous n'avez pas spécifié de quantité, définissez la valeur par défaut sur 1. Ajoutez ensuite la quantité au total cumulé de itemCount. Enfin, ajoutez le prix de l'article multiplié par la quantité au total cumulé de 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) {
      }
    });

Vous pouvez également ajouter une journalisation pour faciliter le débogage des états de réussite et d'erreur :

// 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. Réexécuter les tests

Dans la ligne de commande, vérifiez que les émulateurs sont toujours en cours d'exécution et exécutez à nouveau les tests. Vous n'avez pas besoin de redémarrer les émulateurs, car ils détectent automatiquement les modifications apportées aux fonctions. Tous les tests devraient réussir :

$ 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)

Bravo !

20. Essayez-la dans l'UI de la vitrine

Pour le test final, revenez à l'application Web (http://127.0.0.1:5000/) et ajoutez un article au panier.

69ad26cee520bf24.png

Vérifiez que le panier est mis à jour avec le total correct. Parfait !

Résumé

Vous avez suivi un cas de test complexe entre Cloud Functions pour Firebase et Cloud Firestore. Vous avez écrit une fonction Cloud pour que le test réussisse. Vous avez également confirmé que la nouvelle fonctionnalité fonctionnait dans l'interface utilisateur. Vous avez effectué toutes ces opérations en local, en exécutant les émulateurs sur votre propre ordinateur.

Vous avez également créé un client Web qui s'exécute sur les émulateurs locaux, personnalisé des règles de sécurité pour protéger les données et testé ces règles à l'aide des émulateurs locaux.

c6a7aeb91fe97a64.gif