Firebase Functions with Tests

Serverless is just similar to hosting an application and deploy it somewhere with endpoints, since serverless gives us freedom to deploy functions / endpoints one by one, the release circle for each endpoint is comparatively shorter.

A stable serverless could be exiting in production for more than a year and still been used actively. In that sense, a testable and maintainable codebase is also important to serverless! The test cases written in code will help knowledge transfer, hence testing / TDD became important to serverless. In this article, let's dive into how to write unit test for firebase functions (serverless provide by firebase)

This article is a continue part for a previous article Firebase Functions, we will work in ./functions directory for the test Please check previous article for existing codebase and we are going to implement unit test for the code we wrote before.

# Set up

to begin with, we need install testing dependencies:

$ yarn add @types/jest firebase-functions-test jest ts-jest tslint typescript -D
1

We also need initialize some files for jest:

In my jest.config.js I have:

module.exports = {
  setupFilesAfterEnv: ['./jest.setup.js'],
  transform: {
    '^.+\\.ts?$': 'ts-jest',
  },
  moduleDirectories: ['node_modules', 'src'],
  moduleNameMapper: {
    'tests/(.*)': '<rootDir>/__tests__/$1',
  },
  testPathIgnorePatterns: ['<rootDir>/lib'],
};
1
2
3
4
5
6
7
8
9
10
11

In my jest.setup.js I have:

jest.setTimeout(10000); // in milliseconds
1

In my tsconfig.json, need add, "esModuleInterop": true,:

{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "esModuleInterop": true,
    "outDir": "lib",
    "sourceMap": true,
    "strict": true,
    "target": "es2017"
  },
  "compileOnSave": true,
  "include": ["src"]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Don't forgot having a script command in your package.json with "test": "jest":

{
  "name": "functions",
  "scripts": {
    "lint": "tslint --project tsconfig.json",
    "build": "tsc",
    "serve": "npm run build && firebase serve --only functions",
    "shell": "npm run build && firebase functions:shell",
    "start": "npm run shell",
    "test": "jest",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log"
  },
  "engines": {
    "node": "8"
  },
  "main": "lib/index.js",
  "dependencies": {
    "firebase-admin": "^8.6.0",
    "firebase-functions": "^3.3.0"
  },
  "devDependencies": {
    "@types/jest": "^25.1.4",
    "firebase-functions-test": "^0.2.0",
    "jest": "^25.2.1",
    "ts-jest": "^25.2.1",
    "tslint": "^6.1.0",
    "typescript": "^3.8.3"
  },
  "private": true
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

After this, once you have test files:

$ mkdir tests
$ touch tests/auth.offline.test.ts
1
2

for example in tests/auth.offline.test.ts will be:

test('sample test!', () => {
  expect(1).toBe(1);
});
1
2
3

the test can be run as yarn test:

$ yarn test
yarn run v1.22.1
$ jest
 PASS  tests/auth.offline.test.ts
  ✓ Sample test! (10ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        5.305s
Ran all test suites.
✨  Done in 6.72s.
1
2
3
4
5
6
7
8
9
10
11
12

# Tests!

# Create test for auth (on user create)

in tests/auth.offline.test.ts, we have:

import { addDefaultUserRole } from '../src/index';
import test from 'firebase-functions-test';
import admin from 'firebase-admin';

const testEnv = test();

/**
 * mock setup
 */
var setCustomUserClaimsMock = jest.fn();
var docSetMock = jest.fn();

jest.mock('firebase-admin', () => ({
  initializeApp: jest.fn(),
  firestore: () => ({
    doc: jest.fn((path) => ({
      set: docSetMock.mockReturnValue(true),
    })),
  }),
  auth: () => ({
    setCustomUserClaims: setCustomUserClaimsMock.mockReturnValue(
      Promise.resolve()
    ),
  }),
}));

describe('#addDefaultUserRole', () => {
  const wrapped = testEnv.wrap(addDefaultUserRole);

  it('should give role with admin and update firestore', async () => {
    const testUser = {
      uid: 'id-1',
      email: 'marvin5064@gmail.com',
      displayName: 'marvin',
      photoURL: 'handsome-face.jpg',
    };

    await wrapped(testUser);

    expect(setCustomUserClaimsMock).toBeCalledWith('id-1', {
      isAdmin: true,
      role: 'admin',
    });
    expect(admin.firestore().doc('user/id-1').set).toBeCalledWith({
      isAdmin: true,
    });
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

# Create test for firestore document(on update)

in tests/firestore.offline.test.ts, we have:

import { userUpdated } from '../src/index';
import test from 'firebase-functions-test';

const testEnv = test();

/**
 * mock setup
 */
var setCustomUserClaimsMock = jest.fn();

jest.mock('firebase-admin', () => ({
  initializeApp: jest.fn(),
  auth: () => ({
    setCustomUserClaims: setCustomUserClaimsMock.mockReturnValue(
      Promise.resolve()
    ),
  }),
}));

describe('#userUpdated', () => {
  const wrapped = testEnv.wrap(userUpdated);

  it('should update role to non-admin and update claims', async () => {
    const handler = {
      after: {
        data() {
          return {
            isAdmin: false,
          };
        },
      },
    };

    const context = {
      params: { uid: 'id-1' },
    };

    await wrapped(handler, context);

    expect(setCustomUserClaimsMock).toBeCalledWith('id-1', {
      isAdmin: false,
      role: 'non-admin',
    });
  });

  it('should update role to admin and update claims', async () => {
    const handler = {
      after: {
        data() {
          return {
            isAdmin: true,
          };
        },
      },
    };

    const context = {
      params: { uid: 'id-1' },
    };

    await wrapped(handler, context);

    expect(setCustomUserClaimsMock).toBeCalledWith('id-1', {
      isAdmin: true,
      role: 'admin',
    });
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

# Test endpoint (on request)

in the tests/request.offline.test.ts:

import { helloWorld } from '../src/index';

/**
 * mock setup
 */

jest.mock('firebase-admin', () => ({
  initializeApp: jest.fn(),
}));

var responseSendMock = jest.fn();
var responseMock = {
  send: responseSendMock.mockReturnValue(1),
};

describe('#helloWorld', () => {
  it('should update role to non-admin and update claims', async () => {
    const req = {} as any;

    await helloWorld(req, responseMock as any);
    expect(responseSendMock).toBeCalled;
    expect(responseSendMock).toBeCalledTimes(1);
    expect(responseSendMock).toBeCalledWith(
      'My first try on firebase for on request!'
    );
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

Over all the file structure will be (excluding node_modules and lib):

$ tree
.
├── jest.config.js
├── jest.setup.js
├── package-lock.json
├── package.json
├── src
│   ├── auth.ts
│   ├── firestore.ts
│   ├── index.ts
│   ├── request.ts
│   └── types.ts
├── tests
│   ├── auth.offline.test.ts
│   ├── firestore.offline.test.ts
│   └── request.offline.test.ts
├── tsconfig.json
├── tslint.json
└── yarn.lock
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

This chapter only covers firebase offline unit test, which only do mocking and test, it is not really testing against firebase ecosystem, e.g.

  • When a firestore data changed, is the claims structure really changed? This is not been covered!
  • Offline unit test is not covering the function itself, onUpdate of document or onUserCreate during authentication, they were kind of getting tested upon wrapped function

worth a time to take look at official document (opens new window) for the first part (we covered second part), which is teaching how to setup a firebase instance for testing purpose and having some unit/integration test against a real firebase

Firebase Cloud Messaging notification via service worker
Flutter Emulator Setup

Flutter Emulator Setup

Again back to Flutter for mobile development, I realise the emulator I setup in previous article has default size 320x480, which is too small and easy to lost motivation on mobile development. I need a better way to have larger resolution for development emulator!