Cloud Firestore エミュレータ を concurrently と一緒に使う

概要

Cloud Firestore エミュレータ を コマンドの並列実行支援モジュール concurrently と一緒に使う場合、コマンドを少し工夫しないと期待通りに動作しない。以下を行う必要がある。

  1. エミュレータの起動の際、firebase serve --only firestore < /dev/null とし、標準入力を /dev/null から取るようにする。

  2. concurrently の--success (-s) オプションを適切に設定する。

期待通りに動作する npm script の例を以下に示す。

"scripts": {
    "test": "concurrently --success=first --kill-others -n 'emulator,test' 'npm run test:run-emulator' 'sleep 7 && npm run test:mocha'",
    "test:run-emulator": "firebase serve --only firestore < /dev/null",
    "test:mocha": "mocha --recursive"
}

 

背景

Cloud Firestore エミュレータと、@firebase/testing モジュールを使うと、Cloud Firestore のセキュリティルールを実際にデプロイせずともローカルでテストできるので便利だ 1

この方法でテストを行う場合、バックグラウンドでエミュレータを立ち上げた上でテストコードを実行する必要がある。テストを行うたびに毎回エミュレータの立ち上げ作業をするのは面倒なので、エミュレータの立ち上げとテストコードの実行を行うようなコマンドを作成し、npm script として登録したい。

コマンドの並列実行を支援する npm モジュールとして、concurrently がある。これは並列実行したコマンドの出力を見やすく表示したり、一つのプロセスが終了した際に他のプロセスをきれいに終了してくれるモジュールだ。

concurrently を使ってエミュレータとテストコードを一緒に実行すればいいだけだと思っていたのだが、(おそらく)エミュレータ固有の問題があり、動かすまで思った以上に時間がかかった。この記事では起こった問題と解決策を記載する。

正直なところ、ぼくはなぜこの方法で動くようになっているのか理解できない。試行錯誤の結果なぜか奇跡的に解決策にたどり着いたのだが全く理由が分からない。もし分かる方がいれば教えていただけるとありがたい。

問題と解決策

直観的には、

# このコマンドは期待通りに動作しない
concurrently --kill-others 'firebase serve --only firestore' 'sleep 10 && mocha --recursive'  

のようなコマンドを実行すれば良いように思える。エミュレータを起動しつつ(第2引数)、エミュレータの起動完了まで十分な時間(ここでは10秒)待ってテストコードの実行(ここでは mocha)をすれば(第3引数)良いように思える。第1引数--kill-others オプションにより、mocha の終了時にエミュレータも終了してくれるはずだ。

しかし、これを実行すると

Exception in thread "main"  
java.io.IOException: Failed to bind

...

Caused by: java.net.SocketException: Protocol family unavailable  

といったエラーでエミュレータが終了してしまう。これは、エミュレータの起動時に標準入力を /dev/null に向けることで抑制できる。先に述べたように、これで何故解決するのか全く分からないのだが、ともかくこれで解決する 2

上記を解決し、

# このコマンドは(まだ)期待通りに動作しない
concurrently --kill-others 'firebase serve --only firestore < /dev/null' 'sleep 10 && mocha --recursive'  

のようなコマンドを考える。このコマンドはエミュレータの起動とテストコードの実行まで終わるが、concurrently によって終了させられたエミュレータが異常な終了ステータスを返すために concurrently 自体の終了ステータスも異常なものになる。これは、テストの終了ステータスを見て後続の処理を行う場合に特に問題になるだろう。

この問題は、concurrently に --success=first (または -s first)オプションを渡せば解決する。

しかし、この方法で問題が解決する理由がまたも分からない。ドキュメントには以下のように書いている。

-s, --success     Return exit code of zero or one based on the success or
                failure of the "first" child to terminate, the "last child",
                or succeed only if "all" child processes succeed.
                          [choices: "first", "last", "all"] [default: "all"]

https://www.npmjs.com/package/concurrently

すなわち、このオプションはどのコマンドの終了ステータスを用いて concurrently 自身の終了ステータスを決定するかを指定するものだが。しかし、ここで first を指定することは、やはり firebase の終了コードを利用するはずだ。 本来は last を指定するべきのように思うが、last を指定しても期待したとおりには動かない。英語の読み間違いか、concurrently のバグだと思っているが、詳細には調査はしていない。

以上までをまとめ、

concurrently --success=first --kill-others 'firebase serve --only firestore < /dev/null' 'sleep 10 && mocha --recursive'  

とすれば期待通り動作する。npm script として設定する場合は、各コマンド部を別のスクリプトとして切り出し、

"scripts": {
    "test": "concurrently --success=first --kill-others -n 'emulator,test' 'npm run test:run-emulator' 'sleep 7 && npm run test:mocha'",
    "test:run-emulator": "firebase serve --only firestore < /dev/null",
    "test:mocha": "mocha --recursive"
}

とすると見やすいかもしれない。(上記では加えて -n オプションでプロセスに名前をつけ、出力行を見やすくした。)

  1. https://firebase.google.com/docs/firestore/security/test-rules-emulator?hl=ja#install_the_emulator

  2. また、別の解決策として concurrently に

    undefined オプションを渡すという方法がある。このオプションは concurrently が行う出力の整形(出力行に対して、どのプロセスからの出力であるかが表示される)を無効にし、出力をそのまま表示するものである。この方法だと(当然)出力が見にくくなるが、場合によっては適切だろう。