Локальная разработка с помощью 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.

Что вам предстоит сделать

В этой практической работе вы запустите и отладите простое приложение для онлайн-покупок, которое работает на базе нескольких сервисов Firebase:

  • Cloud Firestore – глобально масштабируемая бессерверная база данных NoSQL с возможностями работы в реальном времени.
  • Cloud Functions – бессерверный код, который выполняется в ответ на события или HTTP-запросы.
  • Firebase Authentication – управляемый сервис аутентификации, который интегрируется с другими продуктами Firebase.
  • Firebase Hosting – быстрый и безопасный хостинг для веб-приложений.

Вы подключите приложение к Emulator Suite, чтобы включить локальную разработку.

2589e2f95b74fa88.png

Вы также узнаете, как:

  • Как подключить приложение к Emulator Suite и как подключены различные эмуляторы.
  • Как работают правила безопасности Firebase и как протестировать правила безопасности Firestore с помощью локального эмулятора.
  • Как написать функцию Firebase, которая запускается событиями Firestore, и как написать интеграционные тесты, которые выполняются в Emulator Suite.

2. Настроить

Как получить исходный код

В этой практической работе вы начнете с почти готовой версии примера The Fire Store, поэтому сначала вам нужно клонировать исходный код:

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

Затем перейдите в каталог практической работы, в котором вы будете работать в дальнейшем:

$ cd emulators-codelab/codelab-initial-state

Теперь установите зависимости, чтобы запустить код. Если у вас медленное подключение к интернету, это может занять несколько минут.

# Move into the functions directory
$ cd functions

# Install dependencies
$ npm install

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

Как получить интерфейс командной строки Firebase

Emulator Suite – это часть интерфейса командной строки Firebase, который можно установить на компьютер с помощью следующей команды:

$ npm install -g firebase-tools

Затем убедитесь, что у вас установлена последняя версия CLI. Для этой практической работы подойдет версия 9.0.0 или более поздняя, но в более поздних версиях исправлено больше ошибок.

$ firebase --version
9.6.0

Как подключиться к проекту Firebase

Создать проект Firebase

  1. Войдите в консоль Firebase, используя аккаунт Google.
  2. Нажмите кнопку, чтобы создать проект, и введите его название, например Emulators Codelab.
  3. Нажмите Продолжить.
  4. Если появится запрос, прочитайте и примите Условия использования Firebase, а затем нажмите Продолжить.
  5. (Необязательно.) Включите помощь от ИИ в консоли Firebase (Gemini в Firebase).
  6. Для этой практической работы Google Аналитика не нужна, поэтому отключите ее.
  7. Нажмите Создать проект, дождитесь его инициализации и нажмите Продолжить.

Как подключить код к проекту Firebase

Теперь нужно подключить этот код к проекту Firebase. Сначала выполните следующую команду, чтобы войти в интерфейс командной строки Firebase:

$ firebase login

Затем выполните следующую команду, чтобы создать псевдоним проекта. Замените $YOUR_PROJECT_ID идентификатором своего проекта Firebase.

$ firebase use $YOUR_PROJECT_ID

Теперь вы можете запустить приложение.

3. Запуск эмуляторов

В этом разделе вы запустите приложение локально. Это значит, что пришло время запустить Emulator Suite.

Запустите эмуляторы

Чтобы запустить эмуляторы, выполните следующую команду в каталоге с исходными файлами практической работы:

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

Когда появится сообщение All emulators started (Все эмуляторы запущены), приложение будет готово к использованию.

Как подключить веб-приложение к эмуляторам

Судя по таблице в журналах, эмулятор 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);
  }

Теперь, когда приложение работает на вашем локальном компьютере (обслуживается эмулятором хостинга), клиент Firestore также указывает на локальный эмулятор, а не на рабочую базу данных.

Как открыть EmulatorUI

В браузере перейдите по адресу http://127.0.0.1:4000/. Вы должны увидеть интерфейс Emulator Suite.

Главный экран интерфейса эмуляторов

Нажмите, чтобы посмотреть интерфейс эмулятора Firestore. Коллекция items уже содержит данные, импортированные с флагом --import.

4ef88d0148405d36.png

4. Запустите приложение.

Открыть приложение

В веб-браузере перейдите по адресу 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, если мы не вошли в приложение, auth.currentUser – это null. Добавим проверку:

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 должны появиться сообщения, похожие на следующие:

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

Эти записи в журнале и обновление интерфейса были вызваны четырьмя ключевыми событиями:

68c9323f2ad10f7a.png

1) Firestore Write – Client

В коллекцию Firestore /carts/{cartId}/items/{itemId}/ добавляется новый документ. Этот код можно увидеть в функции addToCart в файле 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. Активация функции 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 Write – Admin

Функция calculateCart считывает все товары в корзине, суммирует их количество и цену, а затем обновляет документ "корзина" с новыми итоговыми значениями (см. cartRef.update(...) выше).

4. Чтение Firestore – клиент

Веб-интерфейс подписан на получение обновлений об изменениях в корзине. Она обновляется в реальном времени после того, как функция Cloud Functions записывает новые итоги и обновляет пользовательский интерфейс, как показано в public/js/homepage.js:

public/js/homepage.js

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

Recap

Отлично! Вы только что настроили полностью локальное приложение, которое использует три разных эмулятора Firebase для полностью локального тестирования.

db82eef1706c9058.gif

И это ещё не всё В следующем разделе вы узнаете:

  • Как писать модульные тесты, использующие эмуляторы Firebase.
  • Как использовать эмуляторы Firebase для отладки правил безопасности.

7. Создайте правила безопасности, адаптированные для вашего приложения

Наше веб-приложение считывает и записывает данные, но до сих пор мы вообще не беспокоились о безопасности. В Cloud Firestore используется система правил безопасности, которая определяет, у кого есть доступ к чтению и записи данных. Emulator Suite – отличный инструмент для создания прототипов таких правил.

В редакторе откройте файл emulators-codelab/codelab-initial-state/firestore.rules. Наши правила состоят из трех основных разделов:

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

Сейчас любой пользователь может читать и записывать данные в нашу базу данных! Мы хотим убедиться, что выполняются только допустимые операции и что мы не раскрываем конфиденциальную информацию.

В этой практической работе мы будем следовать принципу минимальных привилегий. Сначала мы заблокируем все документы, а затем будем постепенно добавлять доступ, пока у всех пользователей не будет всех необходимых прав, но не более того. Обновим первые два правила, чтобы запретить доступ, установив условие 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

Теперь запустите тесты mocha в каталоге functions и прокрутите вывод до самого верха:

# 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

Сейчас у нас четыре ошибки. По мере создания файла правил вы можете отслеживать прогресс, наблюдая за тем, как все больше тестов проходят успешно.

9. Безопасный доступ к корзине

Первые две ошибки связаны с тестированием корзины. Они означают, что:

  • Пользователи могут создавать и обновлять только свои корзины.
  • Пользователи могут просматривать только свои корзины.

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

    // ...
  }
}

Теперь эти правила разрешают доступ на чтение и запись только владельцу корзины.

Чтобы проверить входящие данные и аутентификацию пользователя, мы используем два объекта, которые доступны в контексте каждого правила:

  • Объект request содержит данные и метаданные о выполняемой операции.
  • Если в проекте Firebase используется Firebase Authentication, объект request.auth описывает пользователя, который отправляет запрос.

10. Проверить доступ к корзине

Emulator Suite автоматически обновляет правила при каждом сохранении файла firestore.rules. Вы можете убедиться, что эмулятор обновил правила, посмотрев на вкладку, на которой запущен эмулятор, и найдя сообщение Rules updated:

5680da418b420226.png

Повторно запустите тесты и убедитесь, что первые два теста теперь пройдены:

$ 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. Проверьте процесс добавления в корзину в интерфейсе

Сейчас владельцы корзин могут читать и записывать данные в корзину, но не могут читать или записывать данные отдельных товаров в корзине. Это связано с тем, что, хотя у владельцев есть доступ к документу корзины, у них нет доступа к подколлекции товаров корзины.

Это неисправное состояние для пользователей.

Вернитесь в веб-интерфейс, который работает на http://127.0.0.1:5000,, и попробуйте добавить что-нибудь в корзину. В консоли отладки появится ошибка Permission Denied, потому что мы ещё не предоставили пользователям доступ к созданным документам в подколлекции items.

12. Разрешить доступ к товарам в корзине

Эти два теста подтверждают, что пользователи могут добавлять товары только в свою корзину и просматривать только ее содержимое:

  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, что и ownerUID в документе корзины. Поскольку для 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

Отлично! Теперь все наши тесты пройдены. У нас есть один ожидающий тест, но мы вернемся к нему через несколько шагов.

14. Проверьте процесс добавления в корзину ещё раз.

Вернитесь в веб-интерфейс (http://127.0.0.1:5000) и добавьте товар в корзину. Это важный шаг, который позволяет убедиться, что наши тесты и правила соответствуют функциональным требованиям клиента. (Помните, что в прошлый раз, когда мы тестировали интерфейс, пользователи не могли добавлять товары в корзину!)

69ad26cee520bf24.png

Клиент автоматически перезагружает правила при сохранении firestore.rules. Попробуйте добавить что-нибудь в корзину.

Recap

Отлично! Вы повысили безопасность приложения. Это важный шаг на пути к публикации рабочей версии. Если бы это было рабочее приложение, мы могли бы добавить эти тесты в наш конвейер непрерывной интеграции. Это позволит нам быть уверенными, что у данных корзины будут эти средства контроля доступа, даже если другие пользователи изменят правила.

ba5440b193e75967.gif

Но это ещё не всё!

Если вы продолжите, то узнаете:

  • Как написать функцию, активируемую событием Firestore
  • Как создавать тесты, которые работают на нескольких эмуляторах

15. Как настроить тесты Cloud Functions

До сих пор мы фокусировались на фронтенде нашего веб-приложения и правилах безопасности Firestore. Но это приложение также использует Cloud Functions, чтобы поддерживать актуальность корзины пользователя, поэтому мы хотим протестировать и этот код.

Emulator Suite позволяет легко тестировать функции 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:

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

Если вы забыли идентификатор проекта Firebase, его можно найти в настройках проекта в консоли Firebase:

d6d0429b700d2b21.png

16. Тестирование функций

Поскольку этот тест проверяет взаимодействие между Cloud Firestore и Cloud Functions, он требует больше настроек, чем тесты в предыдущих практических работах. Давайте разберём этот тест и поймём, что от него ожидается.

Как создать корзину

Функции Cloud Functions выполняются в доверенной серверной среде и могут использовать аутентификацию сервисного аккаунта, применяемую Admin SDK . Сначала инициализируйте приложение, используя initializeAdminApp вместо initializeApp. Затем создайте объект 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 документа корзины, чтобы активировать функцию. Добавьте два элемента, чтобы убедиться, что вы тестируете добавление, которое происходит в функции.

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 доллара США. Затем проверьте, есть ли в корзине ожидаемые itemCount и totalPrice. Если да, то функция выполнила свою задачу.

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

Функция правильно задает ссылку на корзину, но вместо того, чтобы рассчитать значения totalPrice и itemCount, она заменяет их на заданные в коде.

Получение и итерация по

items subcollection

Инициализируйте новую константу 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

Сначала присвоим переменным totalPrice и itemCount нулевые значения.

Затем добавьте логику в блок итерации. Сначала проверьте, указана ли цена товара. Если количество не указано, по умолчанию используется значение 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. Попробуйте использовать интерфейс витрины

Для финального теста вернитесь в веб-приложение (http://127.0.0.1:5000/) и добавьте товар в корзину.

69ad26cee520bf24.png

Убедитесь, что в корзине указана правильная итоговая сумма. Прекрасно!

Recap

Вы рассмотрели сложный тестовый пример между Cloud Functions для Firebase и Cloud Firestore. Вы написали функцию Cloud Functions, чтобы пройти тест. Вы также подтвердили, что новые функции работают в пользовательском интерфейсе. Вы сделали все это локально, запустив эмуляторы на своем компьютере.

Вы также создали веб-клиент, который работает с локальными эмуляторами, настроили правила безопасности для защиты данных и протестировали правила безопасности с помощью локальных эмуляторов.

c6a7aeb91fe97a64.gif