Cloud Functions の単体テスト

このページでは、継続的インテグレーション(CI)システムの一部となるテストなど、関数の単体テストを作成するためのベスト プラクティスとツールについて説明します。テストを容易にするために、Firebase には、Cloud Functions 用の Firebase Test SDK が用意されています。これは firebase-functions-test として npm で配布され、firebase-functions に対するコンパニオン テスト SDK です。Cloud Functions 用の Firebase Test SDK には次の特長があります。

  • firebase-functions で必要とされる環境変数の設定や設定解除など、テストの適切なセットアップと破棄を行います。
  • サンプルデータとイベント コンテキストを生成します。したがって、ユーザーはそのテストに関係のあるフィールドを指定するだけで済みます。

テストのセットアップ

関数フォルダで次のコマンドを実行して、firebase-functions-test とテスト フレームワーク Mocha の両方をインストールします。

npm install --save-dev firebase-functions-test
npm install --save-dev mocha

次に、関数フォルダ内に test フォルダを作成し、その内部にテストコード用の新しいファイルを作成して index.test.js のような名前を付けます。

最後に、functions/package.json を変更して次を追加します。

"scripts": {
  "test": "mocha --reporter spec"
}

テストを作成し終えたら、関数ディレクトリ内で npm test を実行することによりこれらのテストを実施できます。

Cloud Functions 用の Firebase Test SDK の初期化

firebase-functions-test を使用するには、次の 2 つの方法があります。

  1. オンライン モード(推奨): データベースへの書き込みやユーザーの作成などが実際に行われ、テストコードによってその結果を検査できるように、テスト専用の Firebase プロジェクトとやり取りするテストを作成します。この場合、関数で使用される他の Google SDK も機能します。
  2. オフライン モード: 副作用が発生しない、サイロ化したオフラインの単体テストを作成します。つまり、Firebase プロダクトとやり取りするメソッド呼び出し(たとえば、データベースへの書き込みやユーザーの作成)はすべてスタブされる必要があります。Cloud Firestore または Realtime Database の関数を使用している場合は、テストコードが非常に複雑になるため、オフライン モードの使用は通常おすすめしません。

オンライン モードで SDK を初期化する(推奨)

テスト プロジェクトとやり取りするテストを作成する場合は、firebase-admin を通じてアプリを初期化するのに必要なプロジェクト構成値と、サービス アカウント キー ファイルへのパスを指定する必要があります。

Firebase プロジェクトの構成値を取得するには:

  1. Firebase コンソールでプロジェクト設定を開きます。
  2. [マイアプリ] で、目的のアプリを選択します。
  3. 右側のペインで、Apple アプリと Android アプリの構成ファイルをダウンロードするオプションを選択します。

    ウェブアプリの場合は、[構成] を選択して構成値を表示します。

キーファイルを作成するには:

  1. Google Cloud コンソールの [サービス アカウント] ペインを開きます。
  2. App Engine のデフォルトのサービス アカウントを選択し、右側のオプション メニューを使用して [キーを作成] を選択します。
  3. プロンプトが表示されたら、キーのタイプとして JSON を選択し、[作成] をクリックします。

キーファイルを保存したら、SDK を初期化します。

// At the top of test/index.test.js
const test = require('firebase-functions-test')({
  databaseURL: 'https://my-project.firebaseio.com',
  storageBucket: 'my-project.appspot.com',
  projectId: 'my-project',
}, 'path/to/serviceAccountKey.json');

オフライン モードで SDK を初期化する

完全にオフラインのテストを作成する場合は、パラメータを指定せずに SDK を初期化できます。

// At the top of test/index.test.js
const test = require('firebase-functions-test')();

構成値のモック

関数コードで functions.config() を使用すると、構成値をモックできます。たとえば、functions/index.js に次のコードが含まれているとします。

const functions = require('firebase-functions');
const key = functions.config().stripe.key;

この場合、次のようにテストファイル内の値をモックできます。

// Mock functions config values
test.mockConfig({ stripe: { key: '23wr42ewr34' }});

関数のインポート

関数をインポートするには、require を使用して、メイン関数ファイルをモジュールとしてインポートします。これを行う前に、必ず firebase-functions-test を初期化し、構成値をモックしてください。

// after firebase-functions-test has been initialized
const myFunctions = require('../index.js'); // relative path to functions code

firebase-functions-testオフライン モードで初期化しており、関数コードに admin.initializeApp() が含まれる場合、それをスタブした後で関数をインポートする必要があります。

// If index.js calls admin.initializeApp at the top of the file,
// we need to stub it out before requiring index.js. This is because the
// functions will be executed as a part of the require process.
// Here we stub admin.initializeApp to be a dummy function that doesn't do anything.
adminInitStub = sinon.stub(admin, 'initializeApp');
// Now we can require index.js and save the exports inside a namespace called myFunctions.
myFunctions = require('../index');

バックグラウンド(非 HTTP)関数のテスト

非 HTTP 関数をテストするプロセスには、次の手順が含まれます。

  1. test.wrap メソッドを使用して、テストする関数をラップします。
  2. テストデータを作成します。
  3. 作成したテストデータと指定するイベント コンテキスト フィールドを渡し、ラップした関数を呼び出します。
  4. 動作に関するアサーションを作成します。

まず、テストする関数をラップします。たとえば、functions/index.jsmakeUppercase という関数があり、これをテストするとします。functions/test/index.test.js に次のように書き込みます。

// "Wrap" the makeUpperCase function from index.js
const myFunctions = require('../index.js');
const wrapped = test.wrap(myFunctions.makeUppercase);

wrapped は、呼び出されたときに makeUppercase を呼び出す関数です。wrapped は次の 2 つのパラメータをとります。

  1. data(必須): makeUppercase に送信するデータ。これは、作成した関数ハンドラに送信される最初のパラメータに直接対応します。firebase-functions-test は、カスタムデータまたはサンプルデータを作成するためのメソッドを提供します。
  2. eventContextOptions(省略可): 指定するイベント コンテキストのフィールド。イベント コンテキストは、作成した関数ハンドラに送信される 2 番目のパラメータです。wrapped を呼び出すときに eventContextOptions パラメータを含めていない場合であっても、有効なフィールドを持つイベント コンテキストが生成されます。フィールドをここで指定することにより、生成されるフィールドの一部をオーバーライドできます。ここで含める必要があるのはオーバーライドしたいフィールドのみです。オーバーライドしなかったフィールドはすべて生成されます。
const data = … // See next section for constructing test data

// Invoke the wrapped function without specifying the event context.
wrapped(data);

// Invoke the function, and specify params
wrapped(data, {
  params: {
    pushId: '234234'
  }
});

// Invoke the function, and specify auth and auth Type (for real time database functions only)
wrapped(data, {
  auth: {
    uid: 'jckS2Q0'
  },
  authType: 'USER'
});

// Invoke the function, and specify all the fields that can be specified
wrapped(data, {
  eventId: 'abc',
  timestamp: '2018-03-23T17:27:17.099Z',
  params: {
    pushId: '234234'
  },
  auth: {
    uid: 'jckS2Q0' // only for real time database functions
  },
  authType: 'USER' // only for real time database functions
});

テストデータの作成

ラップされた関数の最初のパラメータは、基になる関数を呼び出すときに使用するテストデータです。テストデータを構築するには、いくつかの方法があります。

カスタムデータの使用

firebase-functions-test には、関数をテストするのに必要なデータを構築するためのいくつかの関数があります。たとえば、test.firestore.makeDocumentSnapshot を使用して Firestore DocumentSnapshot を作成します。最初の引数はデータで、2 番目の引数は完全な参照パスです。オプションの 3 番目の引数は、スナップショットのその他の指定可能なプロパティです。

// Make snapshot
const snap = test.firestore.makeDocumentSnapshot({foo: 'bar'}, 'document/path');
// Call wrapped function with the snapshot
const wrapped = test.wrap(myFunctions.myFirestoreDeleteFunction);
wrapped(snap);

onUpdate または onWrite 関数をテストする場合は、前の状態と後の状態の 2 つのスナップショットを作成する必要があります。次に、makeChange メソッドを使用して、これらのスナップショットで Change オブジェクトを作成できます。

// Make snapshot for state of database beforehand
const beforeSnap = test.firestore.makeDocumentSnapshot({foo: 'bar'}, 'document/path');
// Make snapshot for state of database after the change
const afterSnap = test.firestore.makeDocumentSnapshot({foo: 'faz'}, 'document/path');
const change = test.makeChange(beforeSnap, afterSnap);
// Call wrapped function with the Change object
const wrapped = test.wrap(myFunctions.myFirestoreUpdateFunction);
wrapped(change);

他のデータ型向けの類似する関数については、API リファレンスを参照してください。

サンプルデータの使用

テストで使用するデータをカスタマイズする必要がない場合、firebase-functions-test には、各関数タイプ用のサンプルデータを生成するメソッドがあります。

// For Firestore onCreate or onDelete functions
const snap = test.firestore.exampleDocumentSnapshot();
// For Firestore onUpdate or onWrite functions
const change = test.firestore.exampleDocumentSnapshotChange();

各関数タイプのサンプルデータを取得するメソッドについては、API リファレンスを参照してください。

スタブデータの使用(オフライン モード用)

オフライン モードで SDK を初期化し、Cloud Firestore または Realtime Database の関数をテストしている場合、実際の DocumentSnapshot または DataSnapshot を作成する代わりに、スタブとともにプレーン オブジェクトを使用してください。

次の関数の単体テストを作成しているとします。

// Listens for new messages added to /messages/:pushId/original and creates an
// uppercase version of the message to /messages/:pushId/uppercase
exports.makeUppercase = functions.database.ref('/messages/{pushId}/original')
    .onCreate((snapshot, context) => {
      // Grab the current value of what was written to the Realtime Database.
      const original = snapshot.val();
      functions.logger.log('Uppercasing', context.params.pushId, original);
      const uppercase = original.toUpperCase();
      // You must return a Promise when performing asynchronous tasks inside a Functions such as
      // writing to the Firebase Realtime Database.
      // Setting an "uppercase" sibling in the Realtime Database returns a Promise.
      return snapshot.ref.parent.child('uppercase').set(uppercase);
    });

関数の内部で snap は 2 回使用されます。

  • snap.val()
  • snap.ref.parent.child('uppercase').set(uppercase)

テストコードでは、これらの両方のコードパスが動作するプレーン オブジェクトを作成し、Sinon を使用してメソッドをスタブします。

// The following lines creates a fake snapshot, 'snap', which returns 'input' when snap.val() is called,
// and returns true when snap.ref.parent.child('uppercase').set('INPUT') is called.
const snap = {
  val: () => 'input',
  ref: {
    parent: {
      child: childStub,
    }
  }
};
childStub.withArgs(childParam).returns({ set: setStub });
setStub.withArgs(setParam).returns(true);

アサーションの作成

SDK を初期化し、関数をラップし、データを構築した後、構築されたデータを指定してラップされた関数を呼び出して、動作に関するアサーションを作成できます。これらのアサーションの作成には、Chai などのライブラリを使用できます。

オンライン モードでのアサーションの作成

Cloud Functions 用の Firebase Test SDK をオンライン モードで初期化した場合、firebase-admin SDK を使用して、目的の操作(データベース書き込みなど)が実行されたことをアサートできます。

以下の例では、テスト プロジェクトのデータベースに「INPUT」が書き込まれたことについてアサートしています。

// Create a DataSnapshot with the value 'input' and the reference path 'messages/11111/original'.
const snap = test.database.makeDataSnapshot('input', 'messages/11111/original');

// Wrap the makeUppercase function
const wrapped = test.wrap(myFunctions.makeUppercase);
// Call the wrapped function with the snapshot you constructed.
return wrapped(snap).then(() => {
  // Read the value of the data at messages/11111/uppercase. Because `admin.initializeApp()` is
  // called in functions/index.js, there's already a Firebase app initialized. Otherwise, add
  // `admin.initializeApp()` before this line.
  return admin.database().ref('messages/11111/uppercase').once('value').then((createdSnap) => {
    // Assert that the value is the uppercased version of our input.
    assert.equal(createdSnap.val(), 'INPUT');
  });
});

オフライン モードでのアサーションの作成

関数の予期される戻り値に関するアサーションを作成できます。

const childParam = 'uppercase';
const setParam = 'INPUT';
// Stubs are objects that fake and/or record function calls.
// These are excellent for verifying that functions have been called and to validate the
// parameters passed to those functions.
const childStub = sinon.stub();
const setStub = sinon.stub();
// The following lines creates a fake snapshot, 'snap', which returns 'input' when snap.val() is called,
// and returns true when snap.ref.parent.child('uppercase').set('INPUT') is called.
const snap = {
  val: () => 'input',
  ref: {
    parent: {
      child: childStub,
    }
  }
};
childStub.withArgs(childParam).returns({ set: setStub });
setStub.withArgs(setParam).returns(true);
// Wrap the makeUppercase function.
const wrapped = test.wrap(myFunctions.makeUppercase);
// Since we've stubbed snap.ref.parent.child(childParam).set(setParam) to return true if it was
// called with the parameters we expect, we assert that it indeed returned true.
return assert.equal(wrapped(snap), true);

また、Sinon スパイを使用することにより、想定されたパラメータを使って特定のメソッドが呼び出されたことをアサートできます。

HTTP 関数のテスト

HTTP onCall 関数をテストするには、バックグラウンド関数のテストと同じ方法を使用します。

HTTP onRequest 関数をテストする際に、次に該当する場合は firebase-functions-test を使用する必要があります。

  • functions.config() を使用する場合。
  • 関数が Firebase プロジェクトや他の Google API とやり取りし、実際の Firebase プロジェクトとその認証情報をテストに使用する場合。

HTTP onRequest 関数は、2 つのパラメータ(リクエスト オブジェクトとレスポンス オブジェクト)を受け入れます。addMessage() サンプル関数をテストする方法は次のとおりです。

  • sendMessage() はレスポンス オブジェクトのリダイレクト関数を呼び出すので、この関数をオーバーライドします。
  • リダイレクト関数内で chai.assert を使用して、リダイレクト関数を呼び出す際のパラメータに関するアサーションを作成します。
// A fake request object, with req.query.text set to 'input'
const req = { query: {text: 'input'} };
// A fake response object, with a stubbed redirect function which asserts that it is called
// with parameters 303, 'new_ref'.
const res = {
  redirect: (code, url) => {
    assert.equal(code, 303);
    assert.equal(url, 'new_ref');
    done();
  }
};

// Invoke addMessage with our fake request and response objects. This will cause the
// assertions in the response object to be evaluated.
myFunctions.addMessage(req, res);

テストのクリーンアップ

テストコードの最後にクリーンアップ関数を呼び出します。これにより、SDK が初期化時に設定した環境変数が設定解除され、SDK を使用してリアルタイムのデータベース DataSnapshot または Firestore DocumentSnapshot を作成した場合に作成された可能性のある Firebase アプリが削除されます。

test.cleanup();

完全なサンプルをレビューし、詳細を確認する

Firebase GitHub リポジトリで完全なサンプルを確認できます。

詳しくは、firebase-functions-testAPI リファレンスをご覧ください。