Skip to content

Latest commit

 

History

History
546 lines (366 loc) · 22.7 KB

Writing tests.md

File metadata and controls

546 lines (366 loc) · 22.7 KB

Writing Tests

Contents:


Testing Security Rules

For testing security rules, the library provides an immutable way of testing Firestore operations. This means your test data is not modified - you merely get the information whether it would be modified, by such operation and authentication.

Because of this, the tests can now be written in a simpler way.

import { 
  collection, 
  serverTimestamp,
  deleteField,
  arrayRemove,
  arrayUnion
} from 'firebase-jest-testing/firestoreRules'

Your normal test might not need all the above imports - they are listed here for completeness.

collection

collection is a CollectionRef -like interface (not the full thing!):

{
  collection: collectionPath => {
    as: { uid: string }|null => CollectionReference -like
  }
}

Setting the access role happens at the collection level. An example:

describe("'/invites' rules", () => {
  let unauth_invitesC, abc_invitesC, def_invitesC;

  beforeAll( () => {
    const coll = collection('invites');

    unauth_invitesC = coll.as(null);
    abc_invitesC = coll.as({ uid: 'abc' });
    def_invitesC = coll.as({ uid: 'def' });
  });

  ...

This code prepares variants of the same collection: one unauthenticated, and two with varying users supposed to have signed in. These can then be used throughout the tests, to check who has access.

Note: the now taken order of collection first is simply an API decision. This style seems to match practical use cases better than the reverse, since there's normally one collection but multiple users, per a test suite. More choices can be offered in the future, if use cases warrant the need for them.

You can use the provided CollectionReference-like handles in the normal Firebase fashion:

expect( unauth_invitesC.get() ).toDeny()
method description
.get() Check whether reading any document within the collection is allowed (i.e. can the user subscribe to it).
.get(docName) Check whether reading a specific document is allowed. Same as .doc(docName).get().
.doc(docName) Get a DocumentReference-like handle.

DocumentReference-like

method description
.get() Check whether reading the document is allowed.
.set(any) Check whether writing the document, with the provided data, is allowed.
.update(any) Check whether merging with the existing document is allowed.
.delete() Check whether deleting the document is allowed.

These methods try to be careful reproductions of the Firebase JS client SDK's similar methods. However, underneath there is no client - just REST API calls that interact with the Firebase Emulators.

FieldValue look-alikes

The Firebase JS SDK provides server-side modifiers in the form of FieldValue.

Here are the look-alikes:

use purpose
serverTimestamp serverTimestamp() Time of the request
deleteField deleteField() Removes the field
arrayRemove arrayRemove("a","b") Removes certain values from an array
arrayUnion arrayUnion("a","b") Adds certain values to an array, unless they already exist

The use of these should be exactly as with a Firebase client.

Not all FieldValues have a corresponding look-alike. Only the ones deemed essential for testing Security Rules are implemented.

Note: serverTimestamp() and deleteField() always return the same sentinel value. The API has been kept as a function nonetheless, to match the Firebase JS SDK API.

Note: Our aim is not to provide comprehensive reproduction of a whole client, but all the necessary elements for testing Security Rules, in a fashion that is as 1-to-1 with the latest client API as it makes sense.

Example:

import { test, expect, describe, beforeAll } from '@jest/globals'

import { collection, serverTimestamp } from 'firebase-jest-testing/firestoreRules'

describe("'/invites' rules", () => {
  let abc_invitesC;

  beforeAll( () => {
    const coll = collection('invites');

    abc_invitesC = coll.as({uid:'abc'});
  });

  test('only a member of a project can invite', () => {
    const d = { email: "aa@b.com", project: "1", by: "abc", at: serverTimestamp() };

    return expect( abc_invitesC.doc("aa@b.com:1").set(d).toAllow() )   // author can invite
  })	
});

This is a simplified take on the test-rules/invitesC.test.js. You can find the whole file (and more) in the project repo's sample folder.

While we are at the sample, notice the lack of Firebase specific setup.

The library picks up the configuration from firebase.json automatically (if you have renamed it, set the FIREBASE_JSON env.var). Just import the library and Jest.

.toAllow() and .toDeny()

The example has a .toAllow, at the end of the test. Its counterpart, .toDeny can be used to check a user does not have access.

These are Jest extensions, and they are automatically enabled by importing firestoreRules.

Their use is self-explanatory. You have a Promise that should pass the security rules? expect it .toAllow(). It should not? .toDeny().

Note: The extensions were introduced by Jeff Delaney in Oct 2018: Testing Firestore Security Rules With the Emulator. The implementation here is different, but the API style is retained.

Promise.all or sequencial awaits?

You can use either fashion. Promise.all may increase the level of parallelism in your tests slightly, but there's no guarantee.

If you have lots of tests, and do performance comparisons between Promise.all vs. sequential awaits, let the author know how it went.

Testing Cloud Function Events

firebase-jest-testing does not provide function-level testing tools for Cloud Functions. Instead, the aim is at end-to-end testing, where the tests set some aspect of the Firestore database, and remain listening whether changes are propagated elsewhere, as expected.

This part of the library uses firebase-admin, which means Security Rules are not involved. The approach of the library is to test Security Rules separately, and forget about them even existing in other kinds of tests.

import { 
  collection, 
  doc
} from 'firebase-jest-testing/firestoreAdmin'

Sample:

describe("userInfo shadowing", () => {

  beforeAll( () => {
    preheat_EXP("projects/1/userInfo");
  })

  test('Central user information is distributed to a project where the user is a member', async () => {
    const william = {
      displayName: "William D.",
      photoURL: "https://upload.wikimedia.org/wikipedia/commons/a/ab/Dalton_Bill-edit.png"
    };

    // Write in 'userInfo' -> causes a Cloud Function to update 'projectC/{project-id}/userInfo/{uid}'
    //
    await collection("userInfo").doc("abc").set(william);

    await expect( docListener("projects/1/userInfo/abc") ).resolves.toContainObject(william);
  });

...

The full example can be seen in sample/test-fns/userInfo.test.js.

collection, doc

These are the CollectionReference and DocumentReference -like handles, and have only some of their methods exposed:

methods
CollectionReference-like .doc
DocumentReference-like .get, .set, .onSnapshot

The library has configured them for emulator access, and will do cleanup for you.

If you need some further methods, contact the author with the use case.

Testing Cloud Function Callables

Cloud Functions provide callable functions that are "similar but not identical to HTTP functions".

To exercise these callables, one normally needs a client-side SDK. firebase-admin does not provide access to callables - it's not its thing.

firebase-jest-testing uses the REST API and ducks the need for pulling in a client dependency. Its firebaseClientLike interface tries to be close to that of the JS SDK, but it's not 100% the same.

import { 
  httpsCallable, 
  setRegion 
} from 'firebase-jest-testing/firebaseClientLike'

Here's the whole sample test:

/*
* sample/test-fns/greet.test.js
*/
import { test, expect, describe, beforeAll } from '@jest/globals'

import { httpsCallable, setRegion } from 'firebase-jest-testing/firebaseClientLike'

const region = "mars-central2";

describe ('Cloud Function callables', () => {
  beforeAll( () => {
    setRegion(region)
  });

  test ('returns a greeting', async () => {
    const msg = 'Jack';

    const fnGreet = httpsCallable("greet");
    const { data } = await fnGreet(msg);

    expect(data).toBe("Greetings, Jack.");
  });
});

The regions story?

  • Cloud Functions can be run in regions.
  • The Cloud Functions emulator is regions aware (since 9.12.0) and tests need to target a region that exists.
  • Your functions can use no regions (defaults to us-central1), one or multiple.
  • We don't want your Cloud Functions implementation to have any special arrangements for testing.
  • We cannot (without heuristics analysis of the Cloud Functions emulator logs) know in the tests, which regions your functions were set up to run on.

These statements lead us to the following advice, with regard to testing callables:

  1. If you run Cloud Functions in the default region (even if you also run them in other regions), you don't need to do anything special. Skip setRegion in the example.
  2. If you only run in non-default region(s), specify one such region in the setRegion call.

This should cover most of the use cases.

If you have different implementations in different regions, and wish to test them separately, you can still use setRegion by calling it multiple times, but must take care of the execution order of such tests; setRegion influences all httpsCallable tests following it as a global setting.

httpsCallable

Like the Firebase JS SDK call of the same name, but without the first (fns) parameter.

httpsCallable(name)   // (string) => ((data) => Promise of { data: any|undefined, error: object|undefined })

Give the name of the callable, and then call the returned function with input data.

The returned promise should work as with the JS SDK client. If there are communication level problems (e.g. the named callable is not reached), the promise rejects.

Note: Roles are not currently implemented, but can be. It would be like: httpsCallable(name).as({ uid: "you" }). Let the author know (with a testable use case) if you need it.

setRegion

Call this to set the region where your callables are running, under emulation.

setRegion(region)    // (string) => ()

Only needed if one of the regions is not the Firebase default, or if your implementations vary between regions, and you wish to separately test them.

Priming with JSON data

To run tests you need some seed data in Firestore. Such data is often hand crafted alongside the tests, and firebase-jest-testing provides the means to read it from a JSON / .js file.

Add this to your Jest configuration:

  globalSetup: "./setup.jest.js"

In the setup.jest.js:

/*
* Sets the (immutable) data for the Rules tests.
*/
import { docs } from './docs.js'
import { prime } from 'firebase-jest-testing/firestoreAdmin/setup'

const projectId = "rules-test"
async function setup() {
  await prime(projectId, docs);
}

export default setup;

The project id (rules-test) keeps unrelated tests apart in Firestore. The specific id does not really matter (but must be lower case, without spaces).

prime

prime(projectId, docs)   // (string, { <docPath>: any }) => Promise of ()

Calling prime clears the earlier data contents, and primes the database with the docs.

The docs are simply a flat object with the doc path as the key.

Warnings

Priming is done using firebase-admin and it therefore bypasses any security rules.

If you have schema checks as part of the Security Rules, the seed data may be in breach of these rules. Be extra careful to manually craft the data so that it is valid.

Then again, maybe you need to test for earlier non-conforming data cases so this really is the way it should be.

As a particular case to watch for, create timestamps with new Date() (or serverTimestamp()). firebase-admin converts them to Cloud Firestore timestamps, but this is not the case for Date.now() and Date.parse which return Unix epoch numbers.

expression value
new Date() Mon Aug 24 2020 17:16:58 GMT+0300
new Date('27 Mar 2020 14:17:00 GMT+0300') Fri Mar 27 2020 13:17:00 GMT+0200

Cloud Functions are not currently deactivated during the priming (we'll change that if it's needed).