Obecnie pracuję przy projekcie, w którym używamy Firebase. Do cloud functions używamy NodeJS, jako bazy danych Firestore, a do testów używany jest Jest. Pojawił się problem, jak zamockować Firestore w testach jednostkowych.

Firebase poleca używania do testów emulatora, ale to rozwiązanie bardziej do testów integracyjnych, niż do testów jednostkowych i nie byliśmy z niego zbyt zadowoleni.

Własne mocki

Na szczęście ostatecznie Firestore to moduł, który możemy zamockować. Chociaż nie jest to trywialne.

Załóżmy, że mamy taką funkcję:

const firebaseAdmin = require("firebase-admin");

async function createPost(postId, content) {
  const document = { ...content };

  // ...

  return firebaseAdmin
    .firestore()
    .collection("Posts")
    .doc(postId)
    .create(document);
}

przyjmujemy id dokumentu i jego zawartość, przetwarzamy to jakoś i wsadzamy do Firestore.

Spróbujmy to zamockować.

// test file
const { when } = require("jest-when");

const collectionMock = jest.fn();
const documentsMock = jest.fn();

const getMock = jest.fn();
const updateMock = jest.fn();
const createMock = jest.fn();

const documentMock = {
  get: getMock,
  update: updateMock,
  set: createMock,
};

jest.spyOn(admin, "firestore", "get").mockImplementation(() =>
  jest.fn(() => {
    collection: collectionMock;
  })
);

when(collectionMock).calledWith("Posts").mockReturnValue({
  doc: documentsMock,
});

describe("Mocking Firestore", () => {
  it("should create", () => {
    const postId = "123";
    const content = { header: "Hello world" };

    when(documentsMock).calledWith(postId).mockReturnValue(documentMock);

    // ... - reszta testu i asercje na createMock
  });
});

Kod jest niekompletny, bo dodatkowo po drodze trzeba zainicjalizować firebase-admin, wyczyścić mocki itp., ale chyba oddaje ogólną ideę. Tworzymy sobie mocki, podmieniamy wszystko co po drodze jest używane w kodzie produkcyjnym, uruchamiamy test i możemy robić asercje na naszych mockach.

Sporo roboty i nie wygląda to za pięknie, ale zadziałało. Można to wyciągnąć do osobnego modułu, wyeksportować funkcję do mockowania i konkretne mocki - wtedy będzie używalne w wielu miejscach i testy będą ładniej wyglądały. Ale może ktoś już coś takiego zrobił?

Paczka firestore-jest-mock

Chwila w Googlu i znalazłem paczkę firestore-jest-mock, która wygląda bardzo fajnie i robi w zasadzie to, co my wcześniej, tylko lepiej i w większym zakresie.

Początkowo odrzuciliśmy ją, bo niestety zakładali, że projekt, który jej używa ma zależności zarówno do firebase, jak i do firebase-admin, podczas gdy my w projekcie używamy tylko firebase-admin. Na szczęście udało mi się to poprawić. Na ten moment (2020-05-21) poprawka jest tylko w branchu master, więc paczkę trzeba ściągnąć z gita, a nie z npm.

To już wygląda dużo ładniej:

const { mockFirebase } = require("firestore-jest-mock");
const { mockSet } = require("firestore-jest-mock/mocks/firestore");

mockFirebase({
  database: {
    Posts: [],
  },
});

// uwaga: musi być po `mockFirebase`
require("firebase-admin").initializeApp();

// ... testy

Importujemy sobie funkcję mockFirebase i odpowiednie mocki, wołamy mockFirebase z zawartością bazy, jaką chcemy (pod kluczem database), inicjalizujemy aplikację i lecimy z testami. Tutaj uwaga na inicjalizację - musi być po mockFirebase - inaczej Firebase nie zaczyta mocków, tylko domyślną implementację.

Do wybranej kolekcji (tutaj Posts) można dodać sobie dokumenty i będą działały przy pobraniu ich przez get. Trzeba jednak pamiętać, że to nie jest emulator Firestore w pamięci, a jedynie mocki. Więc jeśli stworzymy dokument przy użyciu set, to nie będzie on dostępny przy get, a możemy jedynie sprawdzić na mockSet co się zadziało.

Paczka udostępnia sporo mocków, nie tylko te trzy, które implementowaliśmy na począku. Dodatkowo potrafi też emulować moduł auth w Firebase, ale tej funkcjonalności nie używaliśmy. Po więcej szczegółów odsyłam do dokumentacji.