XcodeのUnitTestでFirestore Emulatorと接続してテストを書く

Saturday, September 28, 2019

XcodeのUnitTestでCloud FirestoreのRead,Writeのテストをしたい場合に、
Firestore Emulatorを使うと実際のプロジェクトを使わずに読み書きのテストができるので非常に便利です。
またこの方法を使うと、テスト用にFirebase Projectを作成する必要もなくなります。
記事の続きで導入方法を紹介します。
(2019/09/29: セキュリティルール周りに関して記事を修正をしました。)

事前条件

  • Firebase iOS SDKを導入している
  • Unit Testが実行出来る状態である

このあたりは基本事項としてスキップさせていただきます。 🙏

Firestore Emulatorの準備

PCのグローバルな領域でも、プロジェクト用に /firebase ディレクトリ作ってでも良いので、firebase-toolsをインストールします。

$ npm install -g firebase-tools

その後、Firestore Emulatorをインストールします。

$ firebase setup:emulators:firestore

インストールができたら、次のコマンドでエミュレータを立ち上げます。

$ firebase emulators:start --only firestore

(旧来の、firebase serve --only firestore では、後述のセキュリティルールの読み込みができないので注意です。)

エミュレータが立ち上がると、デフォルトだとlocalhost:8080にアクセスすると「Ok」が表示されるかと思います。
これでエミュレータ側の準備は完了です。テストを実行する前にエミュレータを起動するようにすればokです。

エミュレータを実行すると、firebase-debug.logfirestore-debug.logが吐き出されることがあるので、気になる場合は.gitignoreで指定しておくと良いでしょう。

この状態ではセキュリティルールはない状態で、すべての読み書きが許可されている状態になっています。指定したセキュリティルールを読み込ませたい場合は次を参照してください。

セキュリティルールルールを読み込ませる場合

エミュレータを起動する際に、firebase.jsonfirestore.rulesを準備すれば、任意のrulesを読み込ませてエミュレータを起動することが出来ます。
例えば、次のようにfirebaseディレクトリを作り、そこに2つのファイルを次のように作成して配置します。

$ pwd
# path/to/project
mkdir firebase
touch firebase/firebase.json
touch firebase/firestore.rules
  • firebase.json
{
  "firestore": {
    "rules": "firestore.rules"
  }
}
  • firestore.rules
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write;
    }
  }
}

rulesはいわゆるテストモードの例を出したので、ここは所望するルールを書いてみてください。
あとは、firebase ディレクトリ上でエミュレータを起動すれば、読み込んでくれます。

$ cd firebase
$ firebase emulators:start --only firestore
i  Starting emulators: ["firestore"]
i  firestore: Serving WebChannel traffic on at http://localhost:8081
i  firestore: Emulator logging to firestore-debug.log
✔  firestore: Emulator started at http://localhost:8080
i  firestore: For testing set FIRESTORE_EMULATOR_HOST=localhost:8080
✔  All emulators started, it is now safe to connect.

もし、うまく読み込めていない場合は、次のようなログが吐き出されます。

⚠  Could not find config (firebase.json) so using defaults.
i  Starting emulators: ["firestore"]
⚠  No Firestore rules file specified in firebase.json, using default rules.

この場合は、ルールを指定しなかった場合と同様に、すべての書き込みが許可されている状態で扱われます。

こちらはmonoさんが教えてくれました。ありがとうございます。

UnitTest用FirebaseAppの作成と接続先変更をするための処理を書く

FirebaseTestHelper.swiftを作成し、次のように記述します。

import Foundation
import Firebase

private let dateFormatter: DateFormatter = {
    let f = DateFormatter()
    f.locale = Locale(identifier: "en-US")
    f.dateFormat = "yyyyMMddHHmmss"
    return f
}()

enum FirebaseTestHelper {
    static func setupFirebaseApp() {
        if FirebaseApp.app() == nil {
            let options = FirebaseOptions(googleAppID: "1:123:ios:123abc", gcmSenderID: "sender_id")
            options.projectID = "test-" + dateFormatter.string(from: Date())
            FirebaseApp.configure(options: options)
            let settings = Firestore.firestore().settings
            settings.host = "localhost:8080"
            settings.isSSLEnabled = false
            Firestore.firestore().settings = settings
            print("FirebaseApp has been configured")
        }
    }

    static func deleteFirebaseApp() {
        guard let app = FirebaseApp.app() else {
            return
        }
        app.delete { _ in print("FirebaseApp has been deleted") }
    }
}

重要なポイントは、Firestoreのsettingsの書き換えで、
Firestoreの接続先となるhostlocalhost:8080に、isSSLEnabledfalseに書き換えてあげます。

また、FirebaseOptionsを自作することで、実際のGoogleService-Info.plistは不要になります。
googleAppIDは内部でvalidationが書けられる模様で、ひとまず上記のようなIDを使っておくといいと思います。
projectIDは、テスト毎に被らないようにするために、test-{日付}のIDを生成するようにしています。 後の項目は不要です。(BundleIDとか)

Testを書く

あとは、テストのsetup/tearDownの部分で先程記述した処理を呼び出した上で、テストを書いていきます。

class SomeFirestoreTests: XCTestCase {

    override func setUp() {
        super.setUp()
        FirebaseTestHelper.setupFirebaseApp()
    }

    override func tearDown() {
        super.tearDown()
        FirebaseTestHelper.deleteFirebaseApp()
    }

    func test() {
        let exp = expectation(description: #function)

        let userRef = Firestore.firestore().collection("users").document()
        userRef.setData(["name": "john"]) { error in
            if let error = error {
                XCTFail("\(error)")
            }
            userRef.getDocument { snapsot, error in
                if let error = error {
                    XCTFail("\(error)")
                }
                XCTAssertEqual(snapsot?.data()?["name"] as? String, "john")
                exp.fulfill()
            }
        }

        wait(for: [exp], timeout: 5.0)
    }
}

あとはテストを実行するだけです。
事前にエミュレータを起動することをお忘れなく。

使い所

通信部分をスタブできるようになるのでネットの接続、実際のプロジェクトのDBの状態に関係なくFirestoreの処理が絡む部分をテストすることが可能になります。
何かしらのドキュメントを読み込むときは事前にテストデータを作って書き込めば良いので便利です。

※現状の制限事項

もしfirestore.rulesを読み込ませる場合、js-sdkの方にあるfirebase/testingモジュールとは違って、admin権限でFirebaseAppを初期化する手立てがないので、
ルールを無視してテストデータを突っ込むのはやや大変そうです。

未検証ですが、project-IDをランダムなものではなく何かしら固定のものにして、Xcodeの外側でfirebase/testingモジュール使って、admin権限でFirebaseAppをセットアップした後にデータを突っ込む、、
といったやり方になるかもしれません。

※セキュリティルール自体を検証したい場合

個人的にはセキュリティルール自体の検証(テスト)をしたい場合は、Xcodeに拘らず、js-sdkの方にあるfirebase/testingモジュールを使ってテストを書いた方が良いかなと思います。
Xcode上ではあくまでも疎通確認というか、読み書きの部分をスタブさせてあげるくらいの用途がいいのかなと思います。
(もちろんXcode側でもしっかりrulesを適応してテストしていれば異常な書き込みは検知できるので良いですが、シンプルにrulesの検証だったらfirebase/testingモジュールを使ったテストの方が簡単です。)

参考

TechSwiftiOSFirebaseUnitTestCloud FirestoreTips

CI環境でFirestoreのEmulatorをキャッシュする

firebase-toolsでiOS/AndroidのconfigファイルをCLIからダウンロードする