CircleCI で docker-compose を使ってコンテナを起動し、デカいテストを実行する

Jul 1, 2019 ( Jul 4, 2020 更新 )

(2020/07/04 Sat)
メモ帳を見返していたら、去年途中まで書いて完全に忘れていた謎の長文があったので公開します。


TL; DR

  • CircleCIdocker-compose を使ったデカいテストを実行するための設定を書いています

デカいテストとは

そもそもデカいテストとはなにかという話ですが、私個人が勝手に定義しているテストのレベルの1つです。

はじめて学ぶソフトウェアのテスト技法(コープランド著)の「第1章 テストのプロセス」では、テストのプロセスについて以下のような記述があります:

テストは通常(したがってテスト設計も)、4つのレベルで実施されます。

  • 単体テスト
  • 統合テスト
  • システムテスト
  • 受け入れテスト

このうち、「統合テスト」にあたるのが、ここでいうデカいテストです。 「統合テスト」について、コープランドは以下のように書いています:

統合テストでは、単体をサブシステム、最終的にはシステムとして結合します。単体としては完全に機能しても、結合すると障害が起こることがあります。

「単体テスト」はプログラムの最小単位1をテストするものですが、「統合テスト」はその単体の機能を組み合わせて、ある1つのシステムとして結合したものをテストするレベルです。

私は、これにあたるものとして「デカいテスト」というものを考えています。 具体的には、ある gRPC サーバを開発するときでは

  • gRPC リクエストを入力とする
  • gRPC レスポンスを出力とする
    • このレスポンスを検証する

といったものです。

この gRPC サーバは、サーバプログラム単体で動いているのではなく、データベースに接続しているものとします。 この場合は、データベースがサーバに接続されている状態で、上記のテストを行います。

デカいテストは早い段階で用意したい

デカいテストは、開発の早い段階で用意したほうがよいでしょう。 あるシステムを開発するときに、ユーザが使うインターフェースが仕様どおりであるかを早い段階で検証できる状態にしておくことは有益です。 実装がない状態でテストケースを作っておくのでもいいでしょう(テスト駆動ですね)。

もちろん単体テストも書きますが、様々なモジュールを組み合わせてシステムを構築したときに、思わぬ例外が発生したり、想定した結果にならない場合もあります。 具体的には、

  • 外部ライブラリと結合したとき
  • 外部ミドルウェアと結合したとき

に、プログラマが想定していなかった挙動を発見することが起こることもあり得ます。

また、ユーザが使用するインターフェースを変えずに内部の設計を変更したい場合にも、デカいテストを用意しておくと安心です。

docker-compose を使ってデカいテストを実行する

外部システム(ミドルウェア等)に依存するシステムを開発する場合、 Docker Compose を使うと便利です。

コンテナは起動も早いですし、環境を複数用意しておくことも容易です。 私は、 docker-compose で依存システムのコンテナを設定して、開発プラットフォームのテストツールを使ってデカいテストを書いています。 Go 言語の場合は go test です。これでデカいテストを実行すると、カバレッジも取得できます。

ファイル構成

Go 言語では、テスト対象のファイルと同じディレクトリにテストコードのファイルも置きますが、デカいテストの場合は適当にディレクトリを作ってそこの下に置いています。

.
├── grpc
│   ├── grpc.go
│   └── grpc_test.go
├── tests
│   └── integration
└── vendor

そうすることによって、単体テストとデカいテストの実行を分けるのが楽になります。 Makefile では以下のように書いています。

.PHONY: unittest
unittest:
	@go test -v -race $(shell go list ./... | grep -v /tests/integration)

.PHONY: integration-test
integration-test:
	@docker-compose up -d firestore
	@sleep 2
	@go test -v -race $(shell go list ./... | grep /tests/integration)
	@docker-compose stop firestore

デカいテストコード

デカいテストケース例です。 あらかじめ gRPC サーバを goroutine で起動しておき、 gRPC クライアントを使って実際にリクエストを送信しています。

func Test_XXX(t *testing.T) {
	// 初期化、クライアントの用意や defer での後処理など
	// gRPC サーバを goroutine で起動

	tests := map[string]struct {
		request  *pb.XXXRequest
		expected *pb.XXXResponse
		wantErr  bool
	}{
		"it should return true if ...": {
			request: &pb.XXXRequest{
				Key: "Value",
			},
			expected: &pb.XXXResponse{
				Res: true,
			},
			wantErr: false,
		},
		// ...
	}
	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			got, err := client.XXX(ctx, test.request)
			if (err != nil) != test.wantErr {
				t.Fatalf("XXX() error = %v, wantErr %v", err, test.wantErr)
			}
			// ...
		})
	}
}

上記の初期化処理のときに以下のようにして gRPC サーバを起動します。

	// start grpc server
	go func() {
		if err := grpcServer.Serve(grpcLn); err != nil {
			t.Errorf("failed to start grpc server: %v", err)
		}
	}()

	// ... clean up

CircleCI での設定例

CircleCI ではジョブの実行は docker コンテナベースか Linux VM ベースか選ぶことができます。 ジョブを実行するプラットフォームを、CircleCI では Executor と定義しています。 docker コンテナの方を docker , Linux VM の方を machine として設定できます。

Choosing an Executor Type - CircleCI

単体テストの実行であれば、 docker の方が起動が早くて使い勝手がよいでしょう。 けれども、CircleCI 上で docker-compose を使って別のコンテナへ通信をする場合には machine を使う必要があります。

machine の VM にはあらかじめ docker-composeインストールされているため、 step 中に追加でインストールを行う必要はありません。

config.yml の例

Executor

machine Executor を設定します。

version: 2.1

executors:
  default:
    machine:
      image: ubuntu-1604:201903-01
      docker_layer_caching: true
    working_directory: /home/circleci/.go_workspace/src/github.com/mookjp/xxx

machine の弱点は、サポートされているバージョンのバリエーションが少ないことです。 また、制約により独自に PATH を設定することができません。したがって、実行環境のバージョンは machine 依存になってしまいます。

実行環境を指定したジョブを作りたい場合には、 docker Executor も設定しておき、ジョブによって使い分けることもできます。

version: 2.1

executors:
  default:
    machine:
      image: ubuntu-1604:201903-01
      docker_layer_caching: true
    working_directory: /home/circleci/.go_workspace/src/github.com/mookjp/xxx
  container:
    docker:
      - image: golang
    working_directory: /go/src/github.com/mookjp/xxx

Job

以下の config.yml では

  • ビルド
  • テスト

の2つのジョブを定義しています。 ビルド後の依存モジュールを流用するために、 attach_workspace を使っています。

jobs:
  build:
    executor: default
    steps:
      - checkout
      - restore_cache:
          keys:
            - vendor-{{ checksum "Gopkg.lock" }}
      - run:
          name: Install dependencies
          command: |
            if [ ! -d vendor ]; then
              make dep
            fi
      - run:
          name: Build
          command: |
            make build
      - save_cache:
          key: vendor-{{ checksum "Gopkg.lock" }}
          paths:
            - vendor
      - persist_to_workspace:
          root: /home/circleci/.go_workspace/src/github.com/mookjp/xxx
          paths:
            - "vendor/*"
  integration-test:
    executor: default
    steps:
      - checkout
      - attach_workspace:
          at: /home/circleci/.go_workspace/src/github.com/mookjp/xxx
      - run:
          name: Run tests
          command: |
            make integration-test

# ...省略

workflows:
  version: 2.1
  build-and-test:
    jobs:
      - build
      - integration-test:
        requires:
          - build

所感

  • デカいテストが早い段階で用意できると安心
  • というか自動生成したほうがいいね…

  1. Go言語では func がそれにあたると考えています。 ↩︎

Retrun to top