Firebase Functions Testing: Unit Tests and Firebase Emulator Suite

Firebase Functions Testing: Unit Tests and Firebase Emulator Suite

Firebase Functions testing uses two approaches: unit tests with mocked Firebase SDKs (fast, no infrastructure), and integration tests with the Firebase Emulator Suite (accurate, uses real Firestore, Auth, and Storage emulators locally). The Firebase Test SDK (@firebase/rules-unit-testing) simplifies emulator interaction in tests.

Key Takeaways

The Firebase Emulator Suite runs locally without billing. It emulates Firestore, Auth, Storage, Realtime Database, and more. Point your tests at the emulators instead of production Firebase.

firebase-functions-test wraps your functions for testing. It provides helpers for wrapping Callable Functions, HTTP Functions, and Firestore triggers so you can invoke them in tests without deploying.

Set FIREBASE_AUTH_EMULATOR_HOST and FIRESTORE_EMULATOR_HOST environment variables. The Firebase Admin SDK automatically connects to emulators when these are set.

Clear emulator data between tests. Use the REST API (DELETE http://localhost:8080/emulator/v1/projects/test-project/databases/(default)/documents) to reset Firestore data between tests.

Wrap Firestore trigger tests with makeDocumentSnapshot. The firebase-functions-test library's makeDocumentSnapshot creates the before/after snapshots needed to test onCreate, onUpdate, and onDelete triggers.

Firebase Functions Testing Architecture

Firebase Functions have several trigger types, each with different testing approaches:

  • HTTP Functions — invoke via HTTP request
  • Callable Functions — invoke via Firebase SDK (client-side)
  • Firestore Triggers — respond to document create/update/delete
  • Auth Triggers — respond to user creation/deletion
  • Pub/Sub Triggers — respond to Pub/Sub messages

Unit-test the business logic inside each function. Integration-test triggers against the Firebase Emulator Suite.

Setup

Install required packages:

npm install --save-dev firebase-functions-test @firebase/rules-unit-testing jest
npm install firebase-admin firebase-functions

Install and start the Firebase Emulator Suite:

npm install -g firebase-tools
firebase init emulators
firebase emulators:start

firebase.json emulator configuration:

{
  "emulators": {
    "functions": {
      "port": 5001
    },
    "firestore": {
      "port": 8080
    },
    "auth": {
      "port": 9099
    },
    "storage": {
      "port": 9199
    },
    "ui": {
      "enabled": true,
      "port": 4000
    }
  }
}

Unit Testing Firebase Functions

Extracting Business Logic

// functions/src/orders/service.js
/**
 * Pure business logic—no Firebase dependencies.
 */
function validateOrder(orderData) {
  if (!orderData.customerId) {
    throw new Error('customerId is required');
  }
  if (!orderData.items || orderData.items.length === 0) {
    throw new Error('items cannot be empty');
  }
  if (orderData.items.some(item => item.quantity <= 0)) {
    throw new Error('item quantity must be greater than 0');
  }
}

function calculateOrderTotal(items) {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

function buildOrder(orderData) {
  validateOrder(orderData);
  return {
    orderId: null, // Set by caller
    customerId: orderData.customerId,
    items: orderData.items,
    total: calculateOrderTotal(orderData.items),
    status: 'pending',
    createdAt: new Date().toISOString(),
  };
}

module.exports = { validateOrder, calculateOrderTotal, buildOrder };
// functions/src/orders/service.test.js
const { validateOrder, calculateOrderTotal, buildOrder } = require('./service');

describe('validateOrder', () => {
  it('passes for valid order', () => {
    expect(() => validateOrder({
      customerId: 'cust-1',
      items: [{ productId: 'p1', quantity: 2, price: 50 }],
    })).not.toThrow();
  });
  
  it('throws for missing customerId', () => {
    expect(() => validateOrder({ items: [{ productId: 'p1', quantity: 1, price: 10 }] }))
      .toThrow('customerId is required');
  });
  
  it('throws for empty items', () => {
    expect(() => validateOrder({ customerId: 'c1', items: [] }))
      .toThrow('items cannot be empty');
  });
  
  it('throws for zero quantity', () => {
    expect(() => validateOrder({
      customerId: 'c1',
      items: [{ productId: 'p1', quantity: 0, price: 10 }],
    })).toThrow('quantity must be greater than 0');
  });
});

describe('calculateOrderTotal', () => {
  it('calculates total correctly', () => {
    expect(calculateOrderTotal([
      { quantity: 2, price: 50 },
      { quantity: 1, price: 30 },
    ])).toBe(130);
  });
  
  it('returns 0 for empty items', () => {
    expect(calculateOrderTotal([])).toBe(0);
  });
});

Testing HTTP Functions

// functions/src/orders/httpHandler.js
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const { buildOrder } = require('./service');

exports.createOrder = functions.https.onRequest(async (req, res) => {
  if (req.method !== 'POST') {
    res.status(405).json({ error: 'Method not allowed' });
    return;
  }
  
  try {
    const order = buildOrder(req.body);
    const docRef = await admin.firestore().collection('orders').add(order);
    res.status(201).json({ ...order, orderId: docRef.id });
  } catch (error) {
    if (error.message.includes('required') || error.message.includes('cannot')) {
      res.status(400).json({ error: error.message });
    } else {
      console.error(error);
      res.status(500).json({ error: 'Internal server error' });
    }
  }
});
// functions/src/orders/httpHandler.test.js
const test = require('firebase-functions-test')();
const { createOrder } = require('./httpHandler');

// Mock firebase-admin
jest.mock('firebase-admin', () => ({
  firestore: jest.fn(() => ({
    collection: jest.fn(() => ({
      add: jest.fn().mockResolvedValue({ id: 'generated-id-123' }),
    })),
  })),
  initializeApp: jest.fn(),
}));

function mockReqRes(body, method = 'POST') {
  const req = { method, body };
  const res = {
    status: jest.fn().mockReturnThis(),
    json: jest.fn().mockReturnThis(),
  };
  return { req, res };
}

describe('createOrder HTTP function', () => {
  it('creates an order and returns 201', async () => {
    const { req, res } = mockReqRes({
      customerId: 'cust-123',
      items: [{ productId: 'p1', quantity: 2, price: 50.0 }],
    });
    
    await createOrder(req, res);
    
    expect(res.status).toHaveBeenCalledWith(201);
    expect(res.json).toHaveBeenCalledWith(
      expect.objectContaining({
        orderId: 'generated-id-123',
        customerId: 'cust-123',
        total: 100.0,
      })
    );
  });
  
  it('returns 400 for invalid order', async () => {
    const { req, res } = mockReqRes({ items: [] });
    await createOrder(req, res);
    expect(res.status).toHaveBeenCalledWith(400);
  });
  
  it('returns 405 for non-POST methods', async () => {
    const { req, res } = mockReqRes({}, 'GET');
    await createOrder(req, res);
    expect(res.status).toHaveBeenCalledWith(405);
  });
});

Testing Callable Functions

// functions/src/orders/callable.js
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const { buildOrder } = require('./service');

exports.createOrderCallable = functions.https.onCall(async (data, context) => {
  if (!context.auth) {
    throw new functions.https.HttpsError('unauthenticated', 'Must be authenticated');
  }
  
  try {
    const order = buildOrder({ ...data, customerId: context.auth.uid });
    const docRef = await admin.firestore().collection('orders').add(order);
    return { ...order, orderId: docRef.id };
  } catch (error) {
    throw new functions.https.HttpsError('invalid-argument', error.message);
  }
});
// functions/src/orders/callable.test.js
const test = require('firebase-functions-test')();
const { createOrderCallable } = require('./callable');

jest.mock('firebase-admin', () => ({
  firestore: jest.fn(() => ({
    collection: jest.fn(() => ({
      add: jest.fn().mockResolvedValue({ id: 'doc-123' }),
    })),
  })),
  initializeApp: jest.fn(),
}));

const wrappedCreate = test.wrap(createOrderCallable);

describe('createOrderCallable', () => {
  it('creates order for authenticated user', async () => {
    const result = await wrappedCreate(
      { items: [{ productId: 'p1', quantity: 1, price: 100 }] },
      { auth: { uid: 'user-123', token: {} } }
    );
    
    expect(result.orderId).toBe('doc-123');
    expect(result.customerId).toBe('user-123');
  });
  
  it('throws unauthenticated for no auth context', async () => {
    await expect(
      wrappedCreate({ items: [{ productId: 'p1', quantity: 1, price: 10 }] }, {})
    ).rejects.toMatchObject({ code: 'unauthenticated' });
  });
});

Integration Tests with Firebase Emulator Suite

// tests/integration/orders.integration.test.js
const { initializeApp, applicationDefault } = require('firebase-admin/app');
const { getFirestore, connectFirestoreEmulator } = require('firebase-admin/firestore');
const axios = require('axios');

const app = initializeApp({ projectId: 'test-project' });
const db = getFirestore(app);
connectFirestoreEmulator(db, 'localhost', 8080);

// Clear Firestore data before each test
async function clearFirestore() {
  await axios.delete(
    'http://localhost:8080/emulator/v1/projects/test-project/databases/(default)/documents'
  );
}

beforeEach(async () => {
  await clearFirestore();
});

describe('Orders Integration', () => {
  it('creates order document in Firestore', async () => {
    const response = await axios.post('http://localhost:5001/test-project/us-central1/createOrder', {
      customerId: 'cust-123',
      items: [{ productId: 'p1', quantity: 2, price: 50.0 }],
    });
    
    expect(response.status).toBe(201);
    const { orderId } = response.data;
    
    // Verify it was saved to Firestore
    const doc = await db.collection('orders').doc(orderId).get();
    expect(doc.exists).toBe(true);
    expect(doc.data().customerId).toBe('cust-123');
    expect(doc.data().total).toBe(100.0);
  });
});

Testing Firestore Triggers

// functions/src/orders/triggers.js
const functions = require('firebase-functions');
const admin = require('firebase-admin');

exports.onOrderCreated = functions.firestore
  .document('orders/{orderId}')
  .onCreate(async (snapshot, context) => {
    const order = snapshot.data();
    const { orderId } = context.params;
    
    // Send notification when order is created
    await admin.firestore()
      .collection('notifications')
      .add({
        type: 'order_created',
        orderId,
        customerId: order.customerId,
        total: order.total,
        createdAt: admin.firestore.FieldValue.serverTimestamp(),
      });
  });
// functions/src/orders/triggers.test.js
const test = require('firebase-functions-test')();
const { onOrderCreated } = require('./triggers');

jest.mock('firebase-admin', () => {
  const firestoreMock = {
    collection: jest.fn(() => ({
      add: jest.fn().mockResolvedValue({ id: 'notif-123' }),
    })),
    FieldValue: { serverTimestamp: jest.fn().mockReturnValue('TIMESTAMP') },
  };
  return {
    firestore: jest.fn(() => firestoreMock),
    initializeApp: jest.fn(),
  };
});

const admin = require('firebase-admin');
const firestoreInstance = admin.firestore();

const wrappedOnOrderCreated = test.wrap(onOrderCreated);

describe('onOrderCreated trigger', () => {
  it('creates a notification when an order is created', async () => {
    const snapshot = test.firestore.makeDocumentSnapshot(
      {
        customerId: 'cust-123',
        total: 150.0,
        status: 'pending',
      },
      'orders/ord-123'
    );
    
    await wrappedOnOrderCreated(snapshot, { params: { orderId: 'ord-123' } });
    
    expect(firestoreInstance.collection).toHaveBeenCalledWith('notifications');
    expect(firestoreInstance.collection('notifications').add).toHaveBeenCalledWith(
      expect.objectContaining({
        type: 'order_created',
        orderId: 'ord-123',
        customerId: 'cust-123',
        total: 150.0,
      })
    );
  });
});

CI Configuration

# .github/workflows/firebase-tests.yml
name: Firebase Functions Tests
on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: functions
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test

  integration-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - name: Install Firebase CLI
        run: npm install -g firebase-tools
      
      - name: Install Java for emulators
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
      
      - name: Install dependencies
        run: |
          npm ci
          cd functions && npm ci
      
      - name: Start Firebase Emulators and run integration tests
        run: |
          firebase emulators:exec --project test-project "npm run test:integration"
        env:
          FIREBASE_PROJECT_ID: test-project
          FIRESTORE_EMULATOR_HOST: localhost:8080
          FIREBASE_AUTH_EMULATOR_HOST: localhost:9099

Cleanup

// After all tests
afterAll(() => {
  test.cleanup();
});

Summary

Firebase Functions testing in three layers:

  1. Unit tests — extract business logic from Firebase triggers, test it with Jest, mock firebase-admin
  2. Trigger unit tests — wrap functions with firebase-functions-test, mock the Firestore and Auth SDKs
  3. Integration tests — run the Firebase Emulator Suite, test against real Firestore/Auth emulators

The Firebase Emulator Suite eliminates the need to connect to a production Firebase project for tests. All integration tests run locally with no billing impact. Use firebase emulators:exec in CI to start emulators, run tests, and shut down automatically.

Read more