Cloud Functions for Firebase入門 (簡単なテストまで）

# モチベーション
最近`Cloud Functions for Firebase`の開発を少しやってみて色々右往左往してたので、初心者なりにフローをまとめてみました。
フロント側を最近Vueを使った開発を行っており、極力現在のフロント環境（`es6, webpackによるバンドル化など`）と合わせる構成にしています。

# ゴール
firebase functinonsの開発/運用するために以下の点をできるようにする
* デバッグ(ローカル上での実行およびホットリロード)
* テスト
* デプロイ

# Cloud Functions for Firebaseとは
GCPサービスとして提供されているCloud Functionsを簡単にfirebaseの各種サービスと連携して使える、いわゆるFaasサービスです。詳しく書くとにわかがばれそうですが、[公式リファレンス](https://firebase.google.com/docs/functions?hl=ja)をみることをおすすめします。

# 作成したもの
こちらに[サンプル](https://github.com/glassmonkey/firebase-functions-sample)をおいています。これを通して説明をしていきます。
また、firebaseの設定に関しては深くはふれません。今回は`.firebaserc`に予め開発用プロジェクトIDを設定し、`firebase login`も済んでいる状態とします。

また以下のツール、フレームワークを利用しました。
* [express](https://expressjs.com/ja/) (ルーティングロジック簡略化のため)
* [webpack](https://webpack.js.org/) (フロント環境と記法をあわせるため）
* [jest](https://jestjs.io/ja/) (テストの利用）

## ディレクトリ構成

今回は下記のような構成にしました。
 * プロジェクトルートディレクトリにpackge.jsonなどの設定ファイル
 * `src/*`配下に開発用のソースコード
 * `test/*`配下にテストコード

```
︙
├── firebase.json
├── node_modules
├── package.json
├── src
│   └── main.js
├── tests
│   └── main.test.js
├── babel.config.js
└── webpack.config.js
```

しかし、デフォルトは`functions`ディレクトリ以下に`firebase.json`と`package.json`とエントリーポイントなjs(例は`index.js`)が存在することが求められており、今回はwebpackで無理やり解決しました。

```
├── functions
│   ├── firebase.json
│   ├── package.json
│   ├── node_modules
│   └── index.js
├── package.json
├── babel.config.js
└── webpack.config.js
```



## ビルド設定

[webpack.config.js](https://github.com/glassmonkey/firebase-functions-sample/blob/master/webpack.config.js)


```js

const nodeExternals = require('webpack-node-externals');
const GenerateJsonPlugin = require('generate-json-webpack-plugin')

const env = process.env.NODE_ENV;
const dist = "functions";
const distDir =`${__dirname}/${dist}`;
const mode = env && env == "prod" ? "production" : "development";

module.exports = {
    mode: mode,
    target: 'node',
    entry: ["./src/main.js"],
    output: {
        filename: "index.js",
        path: distDir
    },
    plugins: [
        //firebaseは各種jsonをfunctions内に入れないといけないのでコピー
        new GenerateJsonPlugin('package.json', require('./package')),
        new GenerateJsonPlugin('firebase.json', require('./firebase'))
    ],
    watchOptions: {
        poll: 1000
    },
    externals: [nodeExternals({
        whitelist: []
    })]
};
```
基本的には[How to build firebase function by webpack](https://dev.to/mizchi/how-to-build-firebase-function-by-webpack-e)を参考にしました。多分以下の点が肝かなと思います。

* [generate-json-webpack-plugin](https://github.com/elliottsj/generate-json-webpack-plugin) を使って、functions内にpackage.jsonとfirebase.jsonを無理やり一緒にバンドルさせる。

*  `watchOptions.poll`の指定 
    *  macとLinuxのファイルシステムの違いでデフォルトでウォッチが機能しない。(参考: [Docker内で Webpack の watch でファイルの変更を検知する](http://hironemu.hatenablog.com/entry/20170909/1504966556) )

## アプリケーションコード

[アプリケーションコード](https://github.com/glassmonkey/firebase-functions-sample/blob/master/src/main.js)。
`hello world`を返すだけのシンプルなものです。
expressを使った書き方に関しては[Cloud Functions for Firebaseでexpressを使う](https://qiita.com/Miracle-T-Shirt09/items/e768c259eb1fe64437e1)の記事を参考にさせていただきました。


```js

import express from "express"
import cors from "cors";
import * as functions from "firebase-functions"
import * as bodyParser from "body-parser";

const app = express();
app.use(cors({origin: true}));

app.get('/', bodyParser.urlencoded({extended: false}), (req, res) => {
    return res.send("hello world");
});

exports.sample = functions.https.onRequest(app);
```


クロスオリジン設定は今後の開発で私が必要なので設定を追加しているだけなので、不要なら削除してください。

```js

app.use(cors({origin: true}));
```

なお、今回はfunctions名を`sample`としてします。`export.{functions名}`で分けられるようですね。

```js

exports.sample = functions.https.onRequest(app);
```


## テストコード
[テストコード](https://github.com/glassmonkey/firebase-functions-sample/blob/master/tests/main.test.js)

```js

import supertest from 'supertest'
import functions from "../src/main"

const request = supertest(functions.sample);

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

describe('hello world sample', () => {
    it('successfully invokes function', async () => {
        let actual = await request.get('/');
        let {ok, status, body, text} = actual;
        expect(ok).toBe(true);
        expect(status).toBeGreaterThanOrEqual(200);
        expect(body).toBeDefined();
        expect(text).toEqual("hello world");
    });
});
```
今回はユニットテストの体なのでいわゆるオフラインテストになります。firebaseにアクセスはしませんので、認証系は握り潰す必要があるので握り潰しました。

```js

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

また、本来は公式テストフレームワークの[firebase-functions-test](https://github.com/firebase/firebase-functions-test)を使いたかったのですが、expressを使ったエンドポイントがうまく機能してくれなかったので[こちら](https://stackoverflow.com/questions/49352060/how-do-you-unit-test-a-firebase-function-wrapped-with-express)を参考にHttpテストライブラリの[supertest](https://github.com/visionmedia/supertest)を使いました。

```js

describe('hello world sample', () => {
    it('successfully invokes function', async () => {
        let actual = await request.get('/');
        let {ok, status, body, text} = actual;
        expect(ok).toBe(true);
        expect(status).toBeGreaterThanOrEqual(200);
        expect(body).toBeDefined();
        expect(text).toEqual("hello world");
    });
});
```
簡単に書けるのいいですね。

es6でテストが書けるように`jest.config.js`に設定を追加させています。
[こちら](https://stackoverflow.com/questions/35756479/does-jest-support-es6-import-export/52224329)をほぼコピペしています。

```js

module.exports = {
    moduleFileExtensions: ["js", "json", "jsx", "ts", "tsx", "json"],
    transform: {
        '^.+\\.(js|jsx)?$': 'babel-jest'
    },
    testEnvironment: 'node',
    moduleNameMapper: {
        '^@/(.*)$': '<rootDir>/$1'
    },
    testMatch: [
        '<rootDir>/**/*.test.(js|jsx|ts|tsx)', '<rootDir>/(tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx))'
    ],
    transformIgnorePatterns: ['<rootDir>/node_modules/']
};
```

`async/await`と使っていると`ReferenceError: regeneratorRuntime is not defined`のエラーが発生するので、
`babel.config.js`に下記のように現在のnodeのバージョンの指定もしておく事も忘れずに必要です。(参考: [async/awaitを使用して、"regeneratorRuntime is not defined "エラーが出た時の対処](https://qiita.com/nbstsh/items/44a63b724214a9f920e2))

```js

module.exports = {
    "presets": [
        [
            "@babel/preset-env", {
            "targets": {
                "node": "current"
            }
        }
        ]
    ]
}
```


# 実行と結果

実行コマンドはpackage.jsonの`script`に集約させるようにしました。
ゴール設定していた、`デバッグ`, `テスト`, `デプロイ`に関して解説します。
前述しましたが、今回は`.firebaserc`に予め開発用プロジェクトIDを設定し、`firebase login`も済んでいる状態とします。


```json

"scripts": {
    "start": "npm run watch & npm run serve",
    "watch": "NODE_ENV=dev webpack -w",
    "build": "NODE_ENV=dev webpack",
    "release": "NODE_ENV=prod webpack",
    "serve": "npm run build && firebase serve  -o 0.0.0.0 -p 8080 --only functions",
    "deploy": "npm run release && firebase deploy --only functions:sample",
    "lint": "eslint src/* tests/*",
    "test": "jest"
  },
```

## デバッグ
```
$ npm run start
```
serve自体をwatchせずに動かす場合もあると思ってるので、`watch`と`serve`は一応別コマンドで作成しています。

<img width="761" alt="デバッグ結果" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152938/910ea54c-e582-bf87-1f9f-ea15956e3eeb.png">
塗り部した箇所は開発用プロジェクトなので各自読み替えてください。
`http function initialized`以下の`(http://0.0.0.0:8080/<your-project-id>/<your-regison>/sample)`にアクセスするとローカルで実行中のfunctionsにアクセスできるはずです。
`src/main.js`のファイルを編集するとそれに応じて内容もかわるはずです。



## テスト
```
$ npm run test
```
<img width="911" alt="テスト結果" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152938/e2f29062-3879-9593-6533-b3670384a0a4.png">
無事にテストが通っていますね。
環境変数が設定されておらずで怒られますが、ローカルの実行のみなので今回は割愛させていただきます。


## deploy
```
$ npm run deploy
```
<img width="607" alt="スクリーンショット 2019-12-30 23.29.49.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152938/43859dd9-640a-d1dd-8eb3-6d5a48eda3ac.png">


なお今回デプロイするにあたって、`firebase deploy --only functions:sample"`とデプロイする関数名を明記しています。理由はデプロイする対象のものは明示的に指定するほうが誤った削除を防ぐ意味で事故防止になって良いかなと考えています。


# 感想

一通り運用のための最低限のことは最低限できました。模索しながら色々やったのでほぼ1日費やしてしまいましたが、サクッと試す分は5分もかからずできるのでfirebase大好きになりました。

webpack周りで結構ハマったので、es6にこだわず素直にcommonjsで素直に書けば良かったかもですね。そこまでやるならおとなしくtype scriptをやるべきだったなと反省しました。

なお、ほとんどJSでテストコード書いたことがなく、ほぼ今回始めてだったのですがjestは学習コスト低そうで活用していきたいですね。jsのテストフレームワークを調べると色々組み合わせがあるようで辟易してたのですが、jestが色々できそうなのでまずは寄せて書くことから始めてみようとおもいました。

余談ですが、testには本来はfirebase-testを使いたかったのですが、下記のところで怒られており、誰か教えてください。作成した関数に`__trigger.labels.deployment-callable`はなかったです。
https://github.com/firebase/firebase-functions-test/blob/master/src/main.ts#L75

```js

if (has(cloudFunction, '__trigger.httpsTrigger') &&
      (get(cloudFunction, '__trigger.labels.deployment-callable') !== 'true')) {
    throw new Error('Wrap function is only available for `onCall` HTTP functions, not `onRequest`.');
  }
```