🔥

【Go】Firebase Emulatorとdockertestでテストを書く

2024/05/23に公開

概要

本記事では、Firebase Emulatorとdockertestを使用して、Firebaseを使ったGoプログラムをテストする方法を紹介します。

対象読者

  • GoでFirebaseを使ったテストを簡単に書きたい方
  • Firebaseを使ったテストをCI/CDで自動化したい方

OS、言語、ツールのバージョン

  • MacOS Sonoma(14.2)
  • Docker(24.0.6)
  • Go(1.22.3)
    • github.com/ory/dockertest/v3(3.10.0)
    • cloud.google.com/go/firestore(1.15.0)

今回使用するツールの説明

Firebase Emulatorとは

上記画像の出典: Firebase ローカル エミュレータ スイートの概要 | Firebase Local Emulator Suite

Firebase Emulatorは、Firebaseの機能をローカル環境でテストするためのツールです。
対応しているFirebaseのサービスは以下の通りです。

  • Cloud Firestore
  • Realtime Database
  • Cloud Storage for Firebase
  • Authentication
  • Firebase Hosting
  • Cloud Functions(ベータ版)
  • Pub/Sub(ベータ版)
  • Firebase Extensions(ベータ版)

上記のサービスに加えて、Firebase Emulator UIが用意されており、本番環境と同じようにWeb画面からデータを直接編集することが可能です。

公式ドキュメントに詳細な説明が載っているので、気になる方はぜひ読んでみてください。
https://firebase.google.com/docs/emulator-suite?hl=ja

dockertestとは

上記画像の出典: ory/dockertest: Write better integration tests! Dockertest helps you boot up ephermal docker images for your Go tests with minimal work.

dockertestは、GoでDockerコンテナを起動できるパッケージです。
処理が完了したらコンテナを削除するなど、テストに便利な機能が揃っています。

詳細はGitHubのREADMEを参照してください。
https://github.com/ory/dockertest

ツールの動作に必要なファイルを作成する

テストコードを書く前に、Firebase EmulatorとDockerを動かすために必要な3つのファイルを作成しましょう。

  • .firebaserc
  • firebase.json
  • Dockerfile

Firebase Emulatorの設定ファイルは、firebase-toolsのコマンドを使って作成することもできますが、今回は記載量が少ないので手動で作成します。

.firebaserc

まず、Firebaseプロジェクトのデフォルト設定を.firebasercに記載します。

.firebaserc
{
  "projects": {
    "default": "firebase-emulator-test"
  }
}

firebase-emulator-testの部分は適当なプロジェクトIDに置き換えてください。
今回はローカルで完結するため、どのようなプロジェクトIDでも構いません。

firebase.json

次に、Firebase Emulatorで使用するサービスの設定をfirebase.jsonに記載します。

firebase.json
{
  "emulators": {
    "firestore": {
      "host": "0.0.0.0",
      "port": 8098
    },
    "singleProjectMode": true
  }
}

host0.0.0.0を指定しないと、ホストPCからDockerで動作しているFirestoreに接続できないので注意しましょう。
Firebase Emulatorは、デフォルトでlocalhostからのリクエストにのみ応答するため、ホストPCからDockerコンテナには接続できません。
詳細は公式ドキュメントを参照してください。
https://firebase.google.com/docs/emulator-suite/use_hosting?hl=ja#emulators-no-local-host

Dockerfile

最後に、dockertestで使用するDockerfileを作成します。

Dockerfile
FROM node:22.0.0-slim

RUN apt-get -y update && \
  apt-get -y --no-install-recommends install default-jdk && \
  apt-get clean && \
  rm -rf /var/lib/apt/lists/*
RUN npm install -g firebase-tools@13.8.3

ENTRYPOINT [ "firebase", "emulators:start" ]

Dockerfileでは、Firebase Emulatorをインストールするために必要なライブラリをインストールします。

  • Firebase CLI 8.14.0+
  • Node.js 16.0 +
  • Java JDK 11 +

https://firebase.google.com/docs/emulator-suite/install_and_configure?hl=ja

実際にテストを書く

ツールを動かすためのファイルが準備できたので、実際にテストを書いていきましょう。
今回は以下のディレクトリ構成で進めていきます。

.
└── firestore/
    ├── firestore.go
    └── firestore_test.go

テスト対象のコード(firestore.go)

まずはテスト対象のサンプルコードを記載します。
Firestoreの会員情報を操作するだけのシンプルな実装です。

firestore.go
package firestore

import (
	"context"

	"cloud.google.com/go/firestore"
)

const membersCollectionID = "members"

// Client Firestoreクライアント
type Client struct {
	client *firestore.Client
	ctx    context.Context
}

// Member 会員情報
type Member struct {
	Name string
}

// NewClient Firestoreクライアントを初期化する
func NewClient() (*Client, error) {
	ctx := context.Background()
	client, err := firestore.NewClient(ctx, "firebase-emulator-test")
	if err != nil {
		return nil, err
	}
	return &Client{client, ctx}, nil
}

// CreateMember 会員情報を作成する
func (c *Client) CreateMember(id string, name string) error {
	m := &Member{
		Name: name,
	}
	_, err := c.client.Collection(membersCollectionID).Doc(id).Create(c.ctx, m)
	return err
}

// FetchMember 会員情報を取得する
func (c *Client) FetchMember(id string) (*Member, error) {
	snap, err := c.client.Collection(membersCollectionID).Doc(id).Get(c.ctx)
	if err != nil {
		return nil, err
	}

	m := new(Member)
	if err := snap.DataTo(m); err != nil {
		return nil, err
	}
	return m, nil
}

// Close Firestoreクライアントが保持するリソースを開放する
func (c *Client) Close() {
	c.client.Close()
}

テスト対象のコード(firestore_test.go)

FetchMemberメソッドのテストコードを記載します。

firestore_test.go
package firestore_test

import (
	"log"
	"os"
	"testing"
	"time"

	"github.com/ory/dockertest/v3"
	"github.com/ory/dockertest/v3/docker"
	"github.com/stretchr/testify/assert"
	"github.com/wasuwa/firebase-emulator/firestore"
)

const hostEnvKey = "FIRESTORE_EMULATOR_HOST"

var (
	client *firestore.Client
	host   = os.Getenv("FIRESTORE_HOST") + ":8098"
	seeds  = map[string]*firestore.Member{
		"1EA27028-FC44-4388-9999-FC8DD1AEBF47": {"鈴木太郎"},
		"5AECA4E6-CBEB-4033-AA4A-0F6DF429D3B7": {"佐藤一郎"},
	}
)

func TestMain(m *testing.M) {
	resource, err := testMain()
	if err != nil {
		log.Fatalln(err)
	}
	defer func() {
		os.Unsetenv(hostEnvKey)
		client.Close()
		resource.Close()
	}()
	m.Run()
}

func testMain() (*dockertest.Resource, error) {
	// カレントディレクトリをプロジェクトのルートに変更する
	if err := os.Chdir("../"); err != nil {
		return nil, err
	}
	// Firestoreに接続するために必要な環境変数を設定する
	if err := os.Setenv(hostEnvKey, host); err != nil {
		return nil, err
	}
	pool, resource, err := initFirebaseEmulator()
	if err != nil {
		return nil, err
	}
	client, err = firestore.NewClient()
	if err != nil {
		return nil, err
	}
	if err := insertSeedData(pool); err != nil {
		return nil, err
	}
	return resource, nil
}

func TestFetchMember(t *testing.T) {
	tests := []struct {
		name      string
		arg       string
		want      *firestore.Member
		expectErr bool
	}{
		{
			name: "Firestoreから会員を取得できること",
			arg:  "1EA27028-FC44-4388-9999-FC8DD1AEBF47",
			want: &firestore.Member{
				Name: seeds["1EA27028-FC44-4388-9999-FC8DD1AEBF47"].Name,
			},
			expectErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := client.FetchMember(tt.arg)
			if tt.expectErr {
				assert.Error(t, err)
			} else {
				assert.NoError(t, err)
				assert.Equal(t, tt.want, got)
			}
		})
	}
}

// initFirebaseEmulator Firebase Local Emulator Suiteを初期化する
func initFirebaseEmulator() (*dockertest.Pool, *dockertest.Resource, error) {
	pwd, err := os.Getwd()
	if err != nil {
		return nil, nil, err
	}

	pool, err := dockertest.NewPool("")
	if err != nil {
		return nil, nil, err
	}
	pool.MaxWait = time.Second * 20

	if err := pool.Client.Ping(); err != nil {
		return nil, nil, err
	}

	options := &dockertest.RunOptions{
		Name: "firebase-emulator-test",
		Mounts: []string{
			pwd + "/.firebaserc:/.firebaserc",
			pwd + "/firebase.json:/firebase.json",
		},
		ExposedPorts: []string{
			"8098/tcp",
		},
		PortBindings: map[docker.Port][]docker.PortBinding{
			"8098/tcp": {{HostIP: "", HostPort: "8098"}},
		},
	}
	// テスト狩猟後にインスタンスを削除する
	resource, err := pool.BuildAndRunWithOptions(pwd+"/Dockerfile", options, deleteInstance)
	if err != nil {
		return nil, nil, err
	}
	return pool, resource, nil
}

// deleteInstance インスタンスを削除する
func deleteInstance(config *docker.HostConfig) {
	config.AutoRemove = true
	config.RestartPolicy = docker.RestartPolicy{
		Name: "no",
	}
}

// insertSeedData 初期データを投入する
func insertSeedData(p *dockertest.Pool) error {
	// Firestoreへの接続が失敗した場合は自動でリトライされる
	f := func() error {
		for k, v := range seeds {
			if err := client.CreateMember(k, v.Name); err != nil {
				return err
			}
		}
		return nil
	}
	return p.Retry(f)
}

FIRESTORE_HOSTは独自に定義した環境変数です。
ローカル実行時はlocalhostを設定してください。
後述するGitLab CI/CDでテストを動かす場合は、ホストにlocalhostではなくdockerを指定する必要があるので、あえて環境変数で値を管理しています。

テストコードが書けたら、実際にテストを実行してみましょう。

ターミナル
go test -v ./firebase/...

結果を確認すると、無事にテストが成功しています。

ターミナル
> go test -v ./firestore/...
=== RUN   TestFetchMember
=== RUN   TestFetchMember/Firestoreから会員が取得できること
--- PASS: TestFetchMember (0.01s)
    --- PASS: TestFetchMember/Firestoreから会員が取得できること (0.01s)
PASS
ok      github.com/wasuwa/firebase-emulator/firestore   108.842s

GitLab CI/CDでテストを実行する

手動でテストを実行するのではなく、自動でテストを実行できると便利です。
.gitlab-ci.ymlを作成し、Gitlab CI/CDでテストを自動実行してみましょう。

.gitlab-ci.yml
test:
  image: golang:1.22.3
  services:
    - docker:dind
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_DRIVER: overlay2
    DOCKER_TLS_CERTDIR: ""
    FIRESTORE_HOST: docker
  script:
    - go test -v ./...
  rules:
    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'

マージリクエストのソースブランチに変更を加えるたびにパイプラインを立ち上げ、テストを実行するように設定しています。

では.gitlab.ci.ymlが完成したので、実際にマージリクエストを作成し、GitLab CI/CDでパイプラインを動かしてみましょう。

パイプラインのログ
...
go: downloading golang.org/x/text v0.14.0
go: downloading github.com/gogo/protobuf v1.3.2
go: downloading github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f
go: downloading golang.org/x/crypto v0.19.0
=== RUN   TestFetchMember
=== RUN   TestFetchMember/Firestoreから会員が取得できること
--- PASS: TestFetchMember (0.01s)
    --- PASS: TestFetchMember/Firestoreから会員が取得できること (0.01s)
PASS
ok  	github.com/wasuwa/firebase-emulator/firestore	114.863s
Cleaning up project directory and file based variables
00:00
Job succeeded

ジョブが成功しているので、特に問題なさそうです。

参考

Discussion