概要
エミュレータやテスト用プロジェクトに繋がず、オフラインで Cloud Functions for Firebase (以下 functions) の単体テストをするとき、Secret Manager で管理している secrets をモックしないとエラーになるので、そのワークアラウンドです。
例題
なにかしら説明しやすい関数があるといいので、適当にシンプルな関数を例に取ってみます。
この関数 logSecret
は MY_SECRET
なる secret を定義し、それをログに出力し、HTTP 200 OK を返します。
import * as functions from "firebase-functions";
import * as logger from "firebase-functions/logger";
import {defineSecret} from "firebase-functions/params";
const mySecret = defineSecret("MY_SECRET");
const logSecret = functions
.runWith({
secrets: [mySecret],
})
.https
.onRequest((request, response) => {
logger.debug(mySecret);
response.status(200);
});
これを素朴に単体テストに落とし込むと以下のようになります。
テストフレームワークは Jest と supertest を利用していますが、要旨にはあまり関係ないはずです。
import * as express from "express";
import * as supertest from "supertest";
import {logSecret} from "../logSecret";
describe(logSecret, () => {
const app = express().use(logSecret);
it("returns 200 OK", async () => {
await supertest(app)
.get("/logSecret")
.expect(200); // Error! received: 500
});
});
このテストを npx jest
のようにして実行すると、テスト対象の関数から HTTP 500 Internal Server Error が返ってきて落ちます。
原因
ここでテスト対象の関数が出力したログを見ると、以下のように表示されているはずです。
{
"severity": "WARNING",
"message": "No value found for secret parameter \"MY_SECRET\". A function can only access a secret if you include the secret in the function's dependency array."
}
要するに
-
MY_SECRET
が見つからない - secret にアクセスする関数は secrets への依存を宣言してね
ということみたいです。
後半の行がややミスリーディングですが、このエラーは本番環境で .runWith({secrets: ...})
の宣言を忘れた場合にも同じものが出るため、そちらを想定したエラーメッセージになっているのでしょう。
さて、このエラーを出さないようにするにはどうしたらいいでしょうか。
firebase-functions のソースコードを追ってみると、以下の箇所でエラーを出力しているようです。
/** @internal */
runtimeValue(): string {
const val = process.env[this.name];
if (val === undefined) {
logger.warn(
`No value found for secret parameter "${this.name}". A function can only access a secret if you include the secret in the function's dependency array.`
);
}
return val || "";
}
このメソッドの先頭付近で環境変数が定義されているかチェックして、その値が存在しなければエラーにしているようです。
対策
したがって、ユニットテストの開始に先立って適切な環境変数を設定してやればよさそうです。
シェルに設定する
いちばん簡単な方法は、シェルからテストを実行する前に環境変数を渡すことです。
MY_SECRET='***' npx jest
あるいは、以下のように先に export したり、
export MY_SECRET='***'
npx jest
NPM スクリプトに設定してもいいかもしれません。
{
"scripts": {
"test": "env MY_SECRET='***' jest"
}
}
.env に設定する
とはいえ、設定すべき secrets の数が多くなってきたり、チーム内で共有する必要が生じてくると、直接環境変数を設定する方法は面倒になってきます。
こういうときは dotenv を使って管理すると見通し良くなります。
npm install dotenv --save-dev
で dotenv
をインストールしつつ、
MY_SECRET="***"
のように .env
を置いてやりましょう。
Jest を利用している場合は、設定ファイルに以下のように書くことでテスト開始時に自動で .env から環境変数を読み込めます。
{
"setupFiles": ["dotenv/config"]
}
あとは最初に通らなかった npx jest
を叩くと、テストが通ります。
デプロイから除外したり VCS にチェックインできるように以下の設定もすると良いでしょう。
{
"functions": [
{
"source": "functions",
"codebase": "default",
"ignore": [
".env", # <= 追加
".git",
"firebase-debug.*.log",
"firebase-debug.log",
"node_modules",
"__tests__"
]
}
]
}
.env.*
.env # <= あれば削除
環境変数をテスト中に差し替える
ところで、複数の secret のパターンを検証したい際など、テスト外の環境変数に頼らずに、テスト内だけで secret をモックしたいこともあるかもしれません。
その場合最も素直な方法は、process.env
をテスト中に書き換えることでしょう。
が、環境変数の差し替え・後片付け以外にもテスト対象の関数を遅延読み込みする必要があるなど、やや考慮すべきことが多いです。
import * as express from "express";
import * as supertest from "supertest";
describe("logSecret", () => {
let app: Express;
let oldEnv;
beforeAll(() => {
oldEnv = {...process.env}; // コピーが必要
});
beforeEach(async () => {
process.env.MY_SECRET = "***";
// import 時に環境変数をチェックするので、dynamic import する必要がある
const logSecret = await import("../logSecret");
app = express().use(logSecret);
});
afterAll(() => {
process.env = oldEnv;
});
it("returns 200 OK", async () => {
await supertest(app)
.get("/logSecret")
.expect(200);
});
});
特に必要がなければ、先に紹介した環境変数による差し込みを使うのが良いでしょう。
番外編 (無理やりモックする)
Jest のような強力な module mock が可能な環境では、無理やり secrets のバリデーションを通しつつ、好きな値に差し替えることも一応可能です。firebase-functions の内部実装に強く依存するので、他の方法より壊れやすいと思います。
import * as express from "express";
import * as supertest from "supertest";
const actualParams = jest.requireActual("firebase-functions/params");
jest.mock("firebase-functions/params", () => ({
...actualParams,
defineSecret(name: string) {
// 非公開クラス `SecretParam` のインスタンスを返す必要があるので、元の実装を利用する
const secret = actualParams.defineSecret(name);
// テスト対象の関数では `SecretParam.prototype.value()` を呼ぶので、任意の値を返すようにインスタンスメソッドを上書きする
secret.value = () => {
switch (name) {
case "MY_SECRET":
return "***";
default:
throw new Error(`Unknown secret '${name}'.`);
}
};
return secret;
},
}));
describe("logSecret", () => {
const app = express();
beforeAll(async () => {
// import 時に環境変数をチェックするので、dynamic import する必要がある
const logSecret = await import("../logSecret");
app.use(logSecret);
});
it("returns 200 OK", async () => {
await supertest(app)
.get("/logSecret")
.expect(200);
});
});