(2020/07/04 Sat)
メモ帳を見返していたら、去年途中まで書いて完全に忘れていた謎の長文があったので公開します。
TL; DR
- CircleCI で
docker-compose
を使ったデカいテストを実行するための設定を書いています- 前置きが長いので、 CircleCI の設定例を見てもらった方が早いです
デカいテストとは
そもそもデカいテストとはなにかという話ですが、私個人が勝手に定義しているテストのレベルの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
所感
- デカいテストが早い段階で用意できると安心
- というか自動生成したほうがいいね…
-
Go言語では
func
がそれにあたると考えています。 ↩︎