Cloud Functions for Firebase のオフライン単体テストで secrets をモックする

## 概要

エミュレータやテスト用プロジェクトに繋がず、オフラインで Cloud Functions for Firebase (以下 functions) の単体テストをするとき、Secret Manager で管理している secrets をモックしないとエラーになるので、そのワークアラウンドです。

## 例題

なにかしら説明しやすい関数があるといいので、適当にシンプルな関数を例に取ってみます。
この関数 `logSecret` は `MY_SECRET` なる secret を定義し、それをログに出力し、HTTP 200 OK を返します。

```typescript:functions/logSecret.ts
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 を利用していますが、要旨にはあまり関係ないはずです。

```typescript:functions/__tests__/logSecret.test.ts
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 のソースコードを追ってみると、以下の箇所でエラーを出力しているようです。

https://github.com/firebase/firebase-functions/blob/v4.4.1/src/params/types.ts#L379-L388

```typescript:src/params/types.ts
  /** @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 || "";
  }
```

このメソッドの先頭付近で環境変数が定義されているかチェックして、その値が存在しなければエラーにしているようです。

## 対策

したがって、ユニットテストの開始に先立って適切な環境変数を設定してやればよさそうです。

### シェルに設定する

いちばん簡単な方法は、シェルからテストを実行する前に環境変数を渡すことです。

```sh
MY_SECRET='***' npx jest
```

あるいは、以下のように先に export したり、

```sh
export MY_SECRET='***'
npx jest
```

NPM スクリプトに設定してもいいかもしれません。

```json:package.json
{
  "scripts": {
    "test": "env MY_SECRET='***' jest"
  }
}
```

### .env に設定する

とはいえ、設定すべき secrets の数が多くなってきたり、チーム内で共有する必要が生じてくると、直接環境変数を設定する方法は面倒になってきます。

こういうときは dotenv を使って管理すると見通し良くなります。

```sh
npm install dotenv --save-dev
```

で `dotenv` をインストールしつつ、

```sh:functions/.env
MY_SECRET="***"
```

のように `.env` を置いてやりましょう。

Jest を利用している場合は、設定ファイルに以下のように書くことでテスト開始時に自動で .env から環境変数を読み込めます。

```json:functions/jest.config.json
{
  "setupFiles": ["dotenv/config"]
}
```

あとは最初に通らなかった `npx jest` を叩くと、テストが通ります。

デプロイから除外したり VCS にチェックインできるように以下の設定もすると良いでしょう。

```json:firebase.json
{
  "functions": [
    {
      "source": "functions",
      "codebase": "default",
      "ignore": [
        ".env", # <= 追加
        ".git",
        "firebase-debug.*.log",
        "firebase-debug.log",
        "node_modules",
        "__tests__"
      ]
    }
  ]
}
```

```functions/.gitignore
.env.*
.env # <= あれば削除
```

### 環境変数をテスト中に差し替える

ところで、複数の secret のパターンを検証したい際など、テスト外の環境変数に頼らずに、テスト内だけで secret をモックしたいこともあるかもしれません。

その場合最も素直な方法は、`process.env` をテスト中に書き換えることでしょう。
が、環境変数の差し替え・後片付け以外にもテスト対象の関数を遅延読み込みする必要があるなど、やや考慮すべきことが多いです。

```typescript:functions/__tests__/logSecret.test.ts
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 の内部実装に強く依存するので、他の方法より壊れやすいと思います。

```typescript:functions/__tests__/logSecret.test.ts
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);
  });
});
```