はじめに
こんにちは、enechainのGXデスクでエンジニアをしている@ejiです。
GXデスクは、『日本気候取引所 - Japan Climate Exchange』 (以下 JCEX) のサービス開発を担当しており、 私は主にBFFとバックエンドのAPIをGoで開発しています。バックエンドのAPIは gRPC を使用しています。
私たちのチームではAPIの品質を保つためにAPIテストを積極的に取り入れています。 APIテストはブラウザを使ったE2Eテストと比べて実行速度が速くブラックボックステストがしやすいため、リファクタリングが容易になるなど開発効率を向上させるメリットがあります。
ただし、APIテストを書く際には、テストケースの設計やテストデータの準備、テストの自動化など、様々な工夫が必要です。
今回は具体的な例を交えながら、gRPCのAPIテストについて紹介します。
JCEXで実践しているAPIテストについて
APIテストとはAPIが期待通りに動いているかを確認するテストです。 APIテストにはいくつかの種類がありますが、JCEXでは主に以下のAPIテストを実践しています。
- 単体テスト
- 負荷テスト
単体テスト
APIテストにおける単体テストは、API単体が期待するリクエストに対して正しいレスポンスを返し、期待する状態になるかを確認するテストです。 また想定外のリクエストに対して適切なエラーレスポンスを返すかも確認しています。
負荷テスト
負荷テストは、APIが想定される負荷に耐えられるかを確認するテストです。
テスト実施前に(Kubernetesの場合は)PodやDBのスペックを設定してから負荷テストを実施することにより、APIの性能を確認することができます。 これによって、用意するPodに割り当てるリソースやDBのスペックを最適化、非効率な実装をチューニングすることができます。
今回の記事では、主にAPIの単体テストについて紹介します。
なぜAPIの単体テストを行っているのか
JCEXでは次のような理由から必要最低限のテストでシステムが正しく動作することが保証できるAPIの単体テストを必須にしています。
- リグレッションの予防
- リファクタリングの容易化
バックエンドはGoで作成しています。 Goは静的型付け言語であり型の不一致を検出できるため、未然にバグを防ぐことができます。
しかし型の表現力には限界があり、型的に問題なくても返す値の中身が想定外の値になってしまい、バグになることがあります。
この問題を防ぐための一つの方法がテストになります。
テストはモジュール単体で行う単体テストと、複数のモジュールを組み合わせて行う結合テストがあります。
モジュールの単体テストは依存関係を持たないため、テストが容易に行えます。
しかし、モジュールの組み合わせ方を間違えると不具合が起きるため、モジュールの単体テストだけでは不十分です。 そのため、モジュールの結合テストが必要になります。
モジュールの結合テストにより、モジュールの組み合わせや使い方、モジュール単体を含めた挙動を確認することができます。
APIで利用している全てのモジュールの結合テストが書ければ、リグレッションを防ぐことができます。
ただし、全てのモジュールの結合テストを書くのは時間がかかります。また、リファクタリングでモジュールの構成を変えた場合にテストも修正する必要があるため、コストがとても高いです。
JCEXで実践しているAPIの単体テストは、APIの入出力に着目したテストです。
モジュールが全て結合された状態でAPIが期待通りに動くかをテストするためAPIが依存するモジュールの結合テストになり、リグレッションを防ぐことができます。
また、API内部の依存しないブラックボックステストになるため、モジュールのインタフェースを変更してもテストを修正する必要がなく、リファクタリングが容易になります。
上記のような理由から、JCEXではAPIの単体テストを必須にしています。
API単体テストで使用するパッケージ
今回のAPIの単体テストの紹介では以下のパッケージを使います。
APIの単体テストを書くには上記のパッケージで十分ですが、 テストのメンテナンス性を上げるために、JCEXでは Testifyや mockery、 go-cmp も採用しています。
実例によるAPI単体テストの環境構築
ここからは実際のgRPCのAPI作成の例を用いながら、API単体テストの環境構築の手順を紹介します。
今回は、商品IDと注文数量を受け取り、注文IDを返す注文作成APIを定義してAPIテストを書いてみようと思います。
前提
あらかじめ以下の環境が整っていることを前提とします。
- Goがインストールされている(本記事ではGo1.22.2を利用します)
- Dockerがインストールされている
- gRPCのAPIサーバーが動く状態になっている
今回の例では次のようなファイルを書き換えていく形で話を進めていきます。
ディレクトリ構成
example/ ├── buf.gen.yaml ├── gen │ ├── user.pb.go │ └── user_grpc.pb.go ├── go.mod ├── go.sum ├── main.go ├── Makefile └── user.proto
buf.gen.yaml
version: v1 managed: enabled: true plugins: - plugin: buf.build/grpc/go:v1.3.0 out: gen opt: paths=source_relative - plugin: buf.build/protocolbuffers/go out: gen opt: paths=source_relative
main.go
package main import ( user "github.com/enechain/example/gen" "google.golang.org/grpc" "log" "net" "os" "os/signal" ) // UserServer UserServiceのgRPCサーバー type UserServer struct { user.UnimplementedUserServiceServer } // NewGrpcServer gRPCサーバーを作成 func NewGrpcServer() *grpc.Server { // gRPCサーバーを作成 s := grpc.NewServer() // UserServiceのgRPCサーバーを登録 user.RegisterUserServiceServer(s, &UserServer{}) return s } func main() { s := NewGrpcServer() go func() { l, err := net.Listen("tcp", "localhost:8080") if err != nil { panic(err) } log.Println("start gRPC server") if err := s.Serve(l); err != nil { panic(err) } }() // Ctrl+Cで終了 quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt) <-quit s.GracefulStop() log.Println("stopped gRPC server") }
Makefile
.PHONY: proto.generate proto.generate: clean docker run -it --rm -v .:/workspace -w /workspace bufbuild/buf:1.26.1 generate .PHONY: clean clean: rm -rf ./gen
user.proto
// user.proto syntax = "proto3"; package user; option go_package = "github.com/enechain/example/user;user"; // ユーザー用のサービス service UserService { }
ステップ1: テストしたいAPIの定義
テストしたいAPIを定義して行きます。 今回はユーザーの名前を登録して、ユーザーIDを返すAPIを定義します。
// user.proto syntax = "proto3"; package user; option go_package = "github.com/enechain/example/user;user"; // ユーザー用のサービス service UserService { // ユーザー作成 rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {} } // ユーザー作成のリクエスト message CreateUserRequest { // ユーザー名 string name = 1; } // ユーザー作成のレスポンス message CreateUserResponse { // 作成されたユーザーID int64 user_id = 1; }
ステップ2: テストの作成
作成したいAPIの型が定義できたので、テストを書いていきたいと思います。
まず、テストファイルを作成します。
ここでは main_test.go
というファイルを作成して進めていきます。
func TestUserServer_CreateUser(t *testing.T) { t.Run("nameを渡して作成されたユーザーIDが返却される", func (t *testing.T) { ctx := context.Background() // (1) lis := bufconn.Listen(4096) // (2) srv := NewGrpcServer() go func() { if err := srv.Serve(lis); err != nil { panic(err) } }() defer srv.Stop() // (3) conn, err := grpc.DialContext( ctx, "test", // 以下で接続処理しているため、ここは何でも良い grpc.WithContextDialer(func (context.Context, string) (net.Conn, error) { return lis.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { t.Fatal(err) } client := user.NewUserServiceClient(conn) // (4) res, err := client.CreateUser(ctx, &user.CreateUserRequest{ Name: "user1", }) // (5) if err != nil { t.Fatal(err) } if res.UserId != 1 { t.Errorf("最初の登録のためユーザーIDは1になるはず: %d", res.UserId) } }) }
ここでは次のようなことを行っています。
- (1) gRPCのAPIとインメモリでやりとりするためのリスナーを作成
- (2)(3) このリスナーを使ってサーバーとクライアントを作成
- (4)(5) 作ったクライアントを使ってテスト対象のメソッドを呼び出して結果を確認
テストを実行すると、「CreateUser
メソッドが未実装です」というエラーになると思います。
追加したファイル
main_test.go
package main import ( "context" user "github.com/enechain/example/gen" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/test/bufconn" "testing" ) func TestUserServer_CreateUser(t *testing.T) { t.Run("nameを渡して作成されたユーザーIDが返却される", func (t *testing.T) { ctx := context.Background() lis := bufconn.Listen(4096) srv := NewGrpcServer() go func() { if err := srv.Serve(lis); err != nil { panic(err) } }() defer srv.Stop() conn, err := grpc.DialContext( ctx, "test", grpc.WithContextDialer(func (context.Context, string) (net.Conn, error) { return lis.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { t.Fatal(err) } client := user.NewUserServiceClient(conn) res, err := client.CreateUser(ctx, &user.CreateUserRequest{ Name: "user1", }) if err != nil { t.Fatal(err) } if res.UserId != 1 { t.Errorf("最初の登録のためユーザーIDは1になるはず: %d", res.UserId) } }) }
ステップ3: APIの実装
CreateUser
を実装していきます。
以下のように UserServer
に CreateUser
メソッドを追加します。
type UserServer struct { user.UnimplementedUserServiceServer } func (s *UserServer) CreateUser(ctx context.Context, request *user.CreateUserRequest) (*user.CreateUserResponse, error) { return &user.CreateUserResponse{ UserId: 1, }, nil }
これでテストを実行すると成功するはずです。
ただ、これではDBにユーザー情報を保存していないため、ユーザーIDが増えていきません。 先ほどのテストに以下のコードを追加します。
res, err = client.CreateUser(ctx, &user.CreateUserRequest{ Name: "user2", }) if err != nil { t.Fatal(err) } if res.UserId != 2 { t.Errorf("2回目の登録のためユーザーIDは2になるはず: %d", res.UserId) }
テストを実行するとテストが失敗すると思います。
DBにユーザー情報を保存していないため、ユーザーIDが増えていないためテストが失敗しています。 DBを利用するように修正していきます。
修正したファイル
main.go
package main import ( "context" user "github.com/enechain/example/gen" "google.golang.org/grpc" "log" "net" "os" "os/signal" ) type UserServer struct { user.UnimplementedUserServiceServer } func (s *UserServer) CreateUser(ctx context.Context, request *user.CreateUserRequest) (*user.CreateUserResponse, error) { return &user.CreateUserResponse{ UserId: 1, }, nil } func NewGrpcServer() *grpc.Server { s := grpc.NewServer() user.RegisterUserServiceServer(s, &UserServer{}) return s } func main() { s := NewGrpcServer() go func() { l, err := net.Listen("tcp", "localhost:8080") if err != nil { panic(err) } log.Println("start gRPC server") if err := s.Serve(l); err != nil { panic(err) } }() // Ctrl+Cで終了 quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt) <-quit s.GracefulStop() log.Println("stopped gRPC server") }
main_test.go
package main import ( "context" user "github.com/enechain/example/gen" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/test/bufconn" "testing" ) func TestUserServer_CreateUser(t *testing.T) { t.Run("nameを渡して作成されたユーザーIDが返却される", func (t *testing.T) { ctx := context.Background() lis := bufconn.Listen(4096) srv := NewGrpcServer() go func() { if err := srv.Serve(lis); err != nil { panic(err) } }() defer srv.Stop() conn, err := grpc.DialContext( ctx, "test", grpc.WithContextDialer(func (context.Context, string) (net.Conn, error) { return lis.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { t.Fatal(err) } client := user.NewUserServiceClient(conn) res, err := client.CreateUser(ctx, &user.CreateUserRequest{ Name: "user1", }) if err != nil { t.Fatal(err) } if res.UserId != 1 { t.Errorf("最初の登録のためユーザーIDは1になるはず: %d", res.UserId) } res, err = client.CreateUser(ctx, &user.CreateUserRequest{ Name: "user2", }) if err != nil { t.Fatal(err) } if res.UserId != 2 { t.Errorf("2回目の登録のためユーザーIDは2になるはず: %d", res.UserId) } }) }
ステップ4: DBを使ったテスト
CreateUser
メソッドが DB を使ってユーザー情報を保存するように以下のように修正します。
type UserServer struct { user.UnimplementedUserServiceServer DB *sql.DB } func (s *UserServer) CreateUser(ctx context.Context, request *user.CreateUserRequest) (*user.CreateUserResponse, error) { var id int64 if err := s.DB. QueryRow("INSERT INTO users (name) VALUES ($1) RETURNING id", request.Name). Scan(&id); err != nil { return nil, err } return &user.CreateUserResponse{ UserId: id, }, nil }
今度はテストでDBを使うために、テスト用のDBを立ち上げるためのコードを書いていきます。
main_test.go
にテスト用のDBを立ち上げる関数 startTestDB
を追加し、テストで呼び出すように修正してみます。
func startTestDB() { ... } func TestUserServer_CreateUser(t *testing.T) { t.Run("nameを渡して作成されたユーザーIDが返却される", func(t *testing.T) { ctx := context.Background() // テスト用のDBを起動 closer, err := startTestDB() if err != nil { t.Fatal(err) } defer closer() // テスト用のDBに接続 db, err := sql.Open("txdb", "") if err != nil { t.Fatal(err) } defer db.Close() lis := bufconn.Listen(4096) srv := NewGrpcServer(db) ...
startTestDB関数の詳細はこちら
テスト用のDBを立ち上げるために dockertest
を追加しています。
また、コネクション切断時にロールバックするように go-txdb
パッケージを追加しています。
go get -u github.com/ory/dockertest/v3 github.com/DATA-DOG/go-txdb
startTestDB
関数は以下のようになります。
func startTestDB() (func(), error) { // Dockerに接続するためのプールを作成 pool, err := dockertest.NewPool("") if err != nil { return nil, err } // Dockerに接続できるか確認 if err := pool.Client.Ping(); err != nil { return nil, err } // PostgreSQL 16.2のコンテナを起動 resource, err := pool.RunWithOptions(&dockertest.RunOptions{ Repository: "postgres", Tag: "16.2", Env: []string{ // パスワードを指定しないと起動できないため、パスワードを指定しています。 "POSTGRES_PASSWORD=postgres", }, }) if err != nil { return nil, err } // Dockerで動いているPostgreSQLのデフォルトポートを指定し、ホスト側のポートを取得 hostAndPort := resource.GetHostPort("5432/tcp") dbUrl := "postgres://postgres:postgres@%s/postgres?sslmode=disable", hostAndPort) // "txdb" という名前でテストDB接続用のドライバーを登録 txdb.Register("txdb", "postgres", dbUrl) // 何らかの原因でテストが止まってしまった場合のことを考慮して、コンテナを削除するための処理を追加します。 // ここでは120秒後にコンテナを削除するようにしています。 if err := resource.Expire(120); err != nil { return nil, err } // PostgreSQLが使える状態になるまで待ちます if err = pool.Retry(func() error { d, err := sql.Open("postgres", dbUrl) if err != nil { return err } defer d.Close() return d.Ping() }); err != nil { // DBに接続できなかった場合はコンテナを削除してテストを終了します if err := pool.Purge(resource); err != nil { return nil, err } return nil, err } return func() { resource.Close() }, nil }
再度テストを実行すると、 今度は users
テーブルが存在しないというエラーになると思います。
次のようなスキーマファイルを用意します。
-- schema.sql create table if not exists users ( id bigserial primary key, name varchar(255) not null );
main.go
にスキーマを初期化する関数 InitDB
を定義してみたいと思います。
//go:embed schema.sql var schema string func InitDB(db *sql.DB) error { // テーブルを作成 if _, err := db.Exec(schema); err != nil { return err } return nil }
次にテスト用のDBにもテーブルを作成するように修正します。
func TestUserServer_CreateUser(t *testing.T) { t.Run("nameを渡して作成されたユーザーIDが返却される", func(t *testing.T) { ctx := context.Background() closer, err := startTestDB() if err != nil { t.Fatal(err) } defer closer() db, err := sql.Open(TestDbDriverName, "") if err != nil { t.Fatal(err) } defer db.Close() // DBを初期化 if err := InitDB(db); err != nil { t.Fatal(err) } ...
これでテストを実行すると成功するはずです。
PASS ok github.com/enechain/example 1.833s
追加、修正したファイル
schema.sql
create table if not exists users ( id bigserial primary key, name varchar(255) not null );
main.go
package main import ( "context" "database/sql" _ "embed" user "github.com/enechain/example/gen" _ "github.com/lib/pq" "google.golang.org/grpc" "log" "net" "os" "os/signal" ) type UserServer struct { user.UnimplementedUserServiceServer DB *sql.DB } func (s *UserServer) CreateUser(ctx context.Context, request *user.CreateUserRequest) (*user.CreateUserResponse, error) { var id int64 if err := s.DB. QueryRow("INSERT INTO users (name) VALUES ($1) RETURNING id", request.Name). Scan(&id); err != nil { return nil, err } return &user.CreateUserResponse{ UserId: id, }, nil } func NewGrpcServer(db *sql.DB) *grpc.Server { s := grpc.NewServer() user.RegisterUserServiceServer(s, &UserServer{DB: db}) return s } //go:embed schema.sql var schema string func InitDB(db *sql.DB) error { // テーブルを作成 if _, err := db.Exec(schema); err != nil { return err } return nil } func main() { // DBの接続情報の設定 // サンプルのため、決めうちにしています。 dbUrl := "postgres://postgres:postgres@5432/postgres?sslmode=disable" db, err := sql.Open("postgres", dbUrl) if err != nil { log.Fatal(err) } defer db.Close() // データベースに接続 if err := db.Ping(); err != nil { log.Fatal(err) } // DBを初期化します if err := InitDB(db); err != nil { log.Fatal(err) } s := NewGrpcServer(db) go func() { l, err := net.Listen("tcp", "localhost:8080") if err != nil { panic(err) } log.Println("start gRPC server") if err := s.Serve(l); err != nil { panic(err) } }() // Ctrl+Cで終了 quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt) <-quit s.GracefulStop() log.Println("stopped gRPC server") }
test_main.go
package main import ( "context" "database/sql" "fmt" "github.com/DATA-DOG/go-txdb" user "github.com/enechain/example/gen" _ "github.com/lib/pq" "github.com/ory/dockertest/v3" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/test/bufconn" "net" "testing" ) const ( TestDbDriverName = "txdb" TestDbPassword = "postgres" ) // テスト用のDBを起動します func startTestDB() (func(), error) { // Dockerに接続するためのプールを作成します pool, err := dockertest.NewPool("") if err != nil { return nil, err } // Dockerに接続できるか確認します if err := pool.Client.Ping(); err != nil { return nil, err } // PostgreSQL 16.2のコンテナを起動します resource, err := pool.RunWithOptions(&dockertest.RunOptions{ Repository: "postgres", Tag: "16.2", Env: []string{ // パスワードを指定しないと起動できないため、パスワードを指定しています。 fmt.Sprintf("POSTGRES_PASSWORD=%s", TestDbPassword), }, }) if err != nil { return nil, err } // Dockerで動いているPostgreSQLのデフォルトポートを指定し、ホスト側のポートを取得します hostAndPort := resource.GetHostPort("5432/tcp") dbUrl := fmt.Sprintf("postgres://postgres:%s@%s/postgres?sslmode=disable", TestDbPassword, hostAndPort) // TestDbDriverName という名前でテストDB接続用のドライバーを登録します txdb.Register(TestDbDriverName, "postgres", dbUrl) // 何らかの原因でテストが止まってしまった場合のことを考慮して、コンテナを削除するための処理を追加します。 // ここでは120秒後にコンテナを削除するようにしています。 if err := resource.Expire(120); err != nil { return nil, err } // PostgreSQLが使える状態になるまで待ちます // デフォルトで1分間リトライします if err = pool.Retry(func() error { d, err := sql.Open("postgres", dbUrl) if err != nil { return err } defer d.Close() // DBに接続できるか確認します return d.Ping() }); err != nil { // DBに接続できなかった場合はコンテナを削除してテストを終了します if err := pool.Purge(resource); err != nil { return nil, err } return nil, err } return func() { resource.Close() }, nil } func TestUserServer_CreateUser(t *testing.T) { t.Run("nameを渡して作成されたユーザーIDが返却される", func(t *testing.T) { ctx := context.Background() // テスト用のDBを起動します closer, err := startTestDB() if err != nil { t.Fatal(err) } defer closer() // テスト用のDBに接続します db, err := sql.Open(TestDbDriverName, "") if err != nil { t.Fatal(err) } defer db.Close() // DBを初期化します if err := InitDB(db); err != nil { t.Fatal(err) } // gRPCクライアントとサーバーの通信をバッファリングするためのリスナーを作成 // テストで立ち上げたサーバーへの同時接続数は1つで、リクエストサイズも大きくないため4KB程度のバッファにしています。 // リクエストサイズが大きい場合は調整が必要です。 lis := bufconn.Listen(4096) // gRPCサーバーを作成 srv := NewGrpcServer(db) go func() { // リスナーを使ってサーバーを起動します。 if err := srv.Serve(lis); err != nil { // サーバー起動に失敗した場合はテストを失敗させています。 panic(err) } }() // テスト終了時にサーバーを止めます。 defer func() { srv.Stop() }() conn, err := grpc.DialContext( ctx, "test", // 以下で接続処理しているため、ここは何でも良いです。 grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { // 作成したリスナーに接続します。 return lis.Dial() }), // 今回のテストではTLSを無効にしています。 grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { // 接続に失敗した場合はテストを失敗させています。 t.Fatal(err) } // UserServiceClientを作成 client := user.NewUserServiceClient(conn) // OrderServiceのCreateOrderを呼び出す res, err := client.CreateUser(ctx, &user.CreateUserRequest{ Name: "user1", }) if err != nil { // リクエストに成功するはずなのでエラーが発生した場合はテストを失敗させています。 t.Fatal(err) } if res.UserId != 1 { t.Errorf("最初の登録のためユーザーIDは1になるはず: %d", res.UserId) } // OrderServiceのCreateOrderを呼び出す res, err = client.CreateUser(ctx, &user.CreateUserRequest{ Name: "user2", }) if err != nil { t.Fatal(err) } if res.UserId != 2 { t.Errorf("2回目の登録のためユーザーIDは2になるはず: %d", res.UserId) } }) }
テストファイルが長くなりとても読みづらく、他のパターンのテストを書くのが大変です。
ステップ5: ヘルパー関数化
テストDBの起動やgRPCサーバー、クライアントの初期化はテストごとに同じ処理を書く必要があります。 そのため、ヘルパー関数にしておくとテストの見通しが良くなります。
テストDBの起動関数とgRPCサーバーとクライアントの初期化処理をヘルパー関数化します。
test_helper.go
にテストDB起動関数 startTestDB
を移し、
gRPCサーバー起動とクライアント作成のコードを initTestGrpcServerClient
という関数定義してテストを書き直します。
t.Run("nameを渡して作成されたユーザーIDが返却される", func(t *testing.T) { ctx := context.Background() // テスト用のDBを起動 closer, err := startTestDB() if err != nil { t.Fatal(err) } defer closer() // テスト用のDBに接続 db, err := sql.Open(TestDbDriverName, "") if err != nil { t.Fatal(err) } defer db.Close() // DBを初期化 if err := InitDB(db); err != nil { t.Fatal(err) } // テスト用のgRPCサーバーを起動し、クライアントを作成 client, closer, err := initTestGrpcServerClient(ctx, db) if err != nil { t.Fatal(err) } defer closer() res, err := client.CreateUser(ctx, &user.CreateUserRequest{ Name: "user1", }) ... })
テストが少しスッキリしました。 ただ、他のパターンのテストを書くときにも同じような処理を書く必要があります。
追加、修正したファイル
test_helper.go
package main import ( "context" "database/sql" "fmt" "github.com/DATA-DOG/go-txdb" user "github.com/enechain/example/gen" "github.com/ory/dockertest/v3" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/test/bufconn" "net" ) const ( TestDbDriverName = "txdb" TestDbPassword = "postgres" ) // テスト用のDBを起動します func startTestDB() (func(), error) { // Dockerに接続するためのプールを作成します pool, err := dockertest.NewPool("") if err != nil { return nil, err } // Dockerに接続できるか確認します if err := pool.Client.Ping(); err != nil { return nil, err } // PostgreSQL 16.2のコンテナを起動します resource, err := pool.RunWithOptions(&dockertest.RunOptions{ Repository: "postgres", Tag: "16.2", Env: []string{ // パスワードを指定しないと起動できないため、パスワードを指定しています。 fmt.Sprintf("POSTGRES_PASSWORD=%s", TestDbPassword), }, }) if err != nil { return nil, err } // Dockerで動いているPostgreSQLのデフォルトポートを指定し、ホスト側のポートを取得します hostAndPort := resource.GetHostPort("5432/tcp") dbUrl := fmt.Sprintf("postgres://postgres:%s@%s/postgres?sslmode=disable", TestDbPassword, hostAndPort) // TestDbDriverName という名前でテストDB接続用のドライバーを登録します txdb.Register(TestDbDriverName, "postgres", dbUrl) // 何らかの原因でテストが止まってしまった場合のことを考慮して、コンテナを削除するための処理を追加します。 // ここでは120秒後にコンテナを削除するようにしています。 if err := resource.Expire(120); err != nil { return nil, err } // PostgreSQLが使える状態になるまで待ちます // デフォルトで1分間リトライします if err = pool.Retry(func() error { d, err := sql.Open("postgres", dbUrl) if err != nil { return err } defer d.Close() // DBに接続できるか確認します return d.Ping() }); err != nil { // DBに接続できなかった場合はコンテナを削除してテストを終了します if err := pool.Purge(resource); err != nil { return nil, err } return nil, err } return func() { resource.Close() }, nil } func InitTestGrpcServerClient(ctx context.Context, db *sql.DB) (_ user.UserServiceClient, _ func(), err error) { // gRPCクライアントとサーバーの通信をバッファリングするためのリスナーを作成 // テストで立ち上げたサーバーへの同時接続数は1つで、リクエストサイズも大きくないため4KB程度のバッファにしています。 // リクエストサイズが大きい場合は調整が必要です。 lis := bufconn.Listen(4096) // gRPCサーバーを作成 srv := NewGrpcServer(db) go func() { // リスナーを使ってサーバーを起動します。 if err := srv.Serve(lis); err != nil { // サーバー起動に失敗した場合はテストを失敗させています。 panic(err) } }() defer func() { // この関数がエラーになった場合はサーバーを止めます。 if err != nil { srv.Stop() } }() conn, err := grpc.DialContext( ctx, "test", // 以下で接続処理しているため、ここは何でも良いです。 grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { // 作成したリスナーに接続します。 return lis.Dial() }), // 今回のテストではTLSを無効にしています。 grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { return nil, nil, err } // UserServiceClientを作成 client := user.NewUserServiceClient(conn) return client, func() { srv.Stop() }, nil }
main_test.go
package main import ( "context" user "github.com/enechain/example/gen" "testing" ) func TestUserServer_CreateUser(t *testing.T) { t.Run("nameを渡して作成されたユーザーIDが返却される", func(t *testing.T) { ctx := context.Background() // テスト用のDBを起動 closer, err := startTestDB() if err != nil { t.Fatal(err) } defer closer() // テスト用のDBに接続 db, err := sql.Open(TestDbDriverName, "") if err != nil { t.Fatal(err) } defer db.Close() // DBを初期化 if err := InitDB(db); err != nil { t.Fatal(err) } // テスト用のgRPCサーバーを起動し、クライアントを作成 client, closer, err := initTestGrpcServerClient(ctx, db) if err != nil { t.Fatal(err) } defer closer() // OrderServiceのCreateOrderを呼び出す res, err := client.CreateUser(ctx, &user.CreateUserRequest{ Name: "user1", }) if err != nil { // リクエストに成功するはずなのでエラーが発生した場合はテストを失敗させています。 t.Fatal(err) } if res.UserId != 1 { t.Errorf("最初の登録のためユーザーIDは1になるはず: %d", res.UserId) } // OrderServiceのCreateOrderを呼び出す res, err = client.CreateUser(ctx, &user.CreateUserRequest{ Name: "user2", }) if err != nil { t.Fatal(err) } if res.UserId != 2 { t.Errorf("2回目の登録のためユーザーIDは2になるはず: %d", res.UserId) } }) }
ステップ6: テーブル駆動テストに変える
他のパターンのテストを書きやすくするようにテーブル駆動テストに変えてみます。
TestUserServer_CreateUser
を以下のように書き換えます。
func TestUserServer_CreateUser(t *testing.T) { tests := map[string]struct { req *user.CreateUserRequest want *user.CreateUserResponse }{ "1回目の登録の場合はユーザーIDは1になる": { req: &user.CreateUserRequest{ Name: "user1", }, want: &user.CreateUserResponse{ UserId: 1, }, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { ctx := context.Background() // テスト用のDBを起動 closer, err := startTestDB() if err != nil { t.Fatal(err) } defer closer() // テスト用のDBに接続 db, err := sql.Open(TestDbDriverName, "") if err != nil { t.Fatal(err) } defer db.Close() // DBを初期化 if err := InitDB(db); err != nil { t.Fatal(err) } // テスト用のgRPCサーバーを起動し、クライアントを作成 client, closer, err := InitTestGrpcServerClient(ctx, db) if err != nil { t.Fatal(err) } defer closer() // OrderServiceのCreateOrderを呼び出す res, err := client.CreateUser(ctx, tt.req) if err != nil { // リクエストに成功するはずなのでエラーが発生した場合はテストを失敗させています。 t.Fatal(err) } if tt.want.UserId != res.UserId { t.Errorf("want: %d, got: %d", tt.want.UserId, res.UserId) } }) } }
テストを実行すると成功するはずです。
ただし、これでは初回の登録しかテストできません。DBの状態をテストごとに変えられるようにしたいです。
修正したファイル
main_test.go
package main import ( "context" "database/sql" _ "embed" user "github.com/enechain/example/gen" _ "github.com/lib/pq" "testing" ) func TestUserServer_CreateUser(t *testing.T) { tests := map[string]struct { req *user.CreateUserRequest want *user.CreateUserResponse }{ "1回目の登録の場合はユーザーIDは1になる": { req: &user.CreateUserRequest{ Name: "user1", }, want: &user.CreateUserResponse{ UserId: 1, }, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { ctx := context.Background() // テスト用のDBを起動 closer, err := startTestDB() if err != nil { t.Fatal(err) } defer closer() // テスト用のDBに接続 db, err := sql.Open(TestDbDriverName, "") if err != nil { t.Fatal(err) } defer db.Close() // DBを初期化 if err := InitDB(db); err != nil { t.Fatal(err) } // テスト用のgRPCサーバーを起動し、クライアントを作成 client, closer, err := InitTestGrpcServerClient(ctx, db) if err != nil { t.Fatal(err) } defer closer() // OrderServiceのCreateOrderを呼び出す res, err := client.CreateUser(ctx, tt.req) if err != nil { // リクエストに成功するはずなのでエラーが発生した場合はテストを失敗させています。 t.Fatal(err) } if tt.want.UserId != res.UserId { t.Errorf("want: %d, got: %d", tt.want.UserId, res.UserId) } }) } }
ステップ7: フィクスチャを使ったテスト
テストごとにDBの状態を変えるために、フィクスチャを使ったテストを書いてみます。
最初にフィクスチャのファイルを追加します。
-- testdata/fixture1.sql insert into users (name) values ('user1');
次に main_test.go
を以下のように書き換えます。
//go:embed testdata/fixture1.sql var fixture1 string func TestUserServer_CreateUser(t *testing.T) { tests := map[string]struct { fixture string req *user.CreateUserRequest want *user.CreateUserResponse }{ "1回目の登録の場合はユーザーIDは1になる": { req: &user.CreateUserRequest{ Name: "user1", }, want: &user.CreateUserResponse{ UserId: 1, }, }, "ユーザーが既に1人いる場合はユーザーIDは2になる": { fixture: fixture1, req: &user.CreateUserRequest{ Name: "user2", }, want: &user.CreateUserResponse{ UserId: 2, }, }, } ...
これでテストを実行すると次のようなエラーになると思います。
--- FAIL: TestUserServer_CreateUser (1.05s) --- FAIL: TestUserServer_CreateUser/ユーザーが既に1人いる場合はユーザーIDは2になる (0.10s) panic: sql: Register called twice for driver txdb [recovered] panic: sql: Register called twice for driver txdb goroutine 82 [running]: ... exit status 2 FAIL github.com/enechain/example 1.330s
txdbの登録処理が2回呼ばれています。テスト用のDB立ち上げも2回呼ぶのは無駄なので、 テスト実行時に1回だけテストDBを立ち上げ、txdbを初期化するようにしてみます。
// 同一パッケージのテストの最初に1回だけ呼ばれます。 func TestMain(m *testing.M) { // テスト用のDBを起動します closer, err := startTestDB() if err != nil { panic(err) } defer closer() m.Run() } ... func TestUserServer_CreateUser(t *testing.T) { ... for name, tt := range tests { t.Run(name, func(t *testing.T) { ctx := context.Background() // テスト用のDBに接続します db, err := sql.Open(TestDbDriverName, "") if err != nil { t.Fatal(err) } defer db.Close() // DBを初期化します if err := InitDB(db); err != nil { t.Fatal(err) } if tt.fixture != "" { if _, err := db.Exec(fixture1); err != nil { t.Fatal(err) } } ...
テストを実行すると以下のように成功するはずです。
PASS ok github.com/enechain/example 1.325s
最終的にテストは以下のようになります。
package main import ( "context" "database/sql" _ "embed" user "github.com/enechain/example/gen" _ "github.com/lib/pq" "testing" ) func TestMain(m *testing.M) { // テスト用のDBを起動します closer, err := startTestDB() if err != nil { panic(err) } defer closer() m.Run() } //go:embed testdata/fixture1.sql var fixture1 string func TestUserServer_CreateUser(t *testing.T) { tests := map[string]struct { fixture string req *user.CreateUserRequest want *user.CreateUserResponse }{ "1回目の登録の場合はユーザーIDは1になる": { req: &user.CreateUserRequest{ Name: "user1", }, want: &user.CreateUserResponse{ UserId: 1, }, }, "ユーザーが既に1人いる場合はユーザーIDは2になる": { fixture: fixture1, req: &user.CreateUserRequest{ Name: "user2", }, want: &user.CreateUserResponse{ UserId: 2, }, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { ctx := context.Background() // テスト用のDBに接続します db, err := sql.Open(TestDbDriverName, "") if err != nil { t.Fatal(err) } defer db.Close() // DBを初期化します if err := InitDB(db); err != nil { t.Fatal(err) } if tt.fixture != "" { if _, err := db.Exec(fixture1); err != nil { t.Fatal(err) } } // テスト用のgRPCサーバーを起動し、クライアントを作成します client, closer, err := InitTestGrpcServerClient(ctx, db) if err != nil { t.Fatal(err) } defer closer() // OrderServiceのCreateOrderを呼び出す res, err := client.CreateUser(ctx, tt.req) if err != nil { // リクエストに成功するはずなのでエラーが発生した場合はテストを失敗させています。 t.Fatal(err) } if tt.want.UserId != res.UserId { t.Errorf("want: %d, got: %d", tt.want.UserId, res.UserId) } }) } }
まとめ
ここまででAPI単体テストの環境を整え、実際のAPIの例を用いながらテストを書きました。 新しいAPIが増えた場合は同様の方法でテストを追加していくことができます。
APIの単体テストは仕組みを構築するまでは大変ですが、一度環境を構築してしまえば あとはAPIの入出力やDBの状態変化のみに焦点を絞ってテストを書くことができます。
内部構造に依存しないブラックボックステストにできるため、モジュールのインタフェースや組み合わせを変えるようなリファクタリングも 安心して行うことができます。
また、APIに依存するモジュールが全て結合された状態でテストされるため、修正によるリグレッションを防ぐことができます。
おわりに
本記事では、JCEXで実施しているAPIの単体テストを実例を用いながら紹介しました。
本記事では紹介できませんでしたが、CIにAPI単体テストの自動実行を組み込むことにより、 サービスの品質を高く保つことができます。
本記事によってAPIテストの敷居が下がり、APIの品質向上に貢献できることを願っています。
enechainのGXデスクでは、エンジニアリングによる環境価値取引のマーケットを作り上げていく仲間を募集しています。