GoでgRPCのAPIテスト環境を構築する

ogp

はじめに

こんにちは、enechainのGXデスクでエンジニアをしている@ejiです。

GXデスクは、『日本気候取引所 - Japan Climate Exchange』 (以下 JCEX) のサービス開発を担当しており、 私は主にBFFとバックエンドのAPIをGoで開発しています。バックエンドのAPIは gRPC を使用しています。

JCEXのコンポーネント図

私たちのチームでは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は静的型付け言語であり型の不一致を検出できるため、未然にバグを防ぐことができます。

しかし型の表現力には限界があり、型的に問題なくても返す値の中身が想定外の値になってしまい、バグになることがあります。

この問題を防ぐための一つの方法がテストになります。

テストはモジュール単体で行う単体テストと、複数のモジュールを組み合わせて行う結合テストがあります。

モジュールの単体テストは依存関係を持たないため、テストが容易に行えます。

しかし、モジュールの組み合わせ方を間違えると不具合が起きるため、モジュールの単体テストだけでは不十分です。 そのため、モジュールの結合テストが必要になります。

例1

モジュールの結合テストにより、モジュールの組み合わせや使い方、モジュール単体を含めた挙動を確認することができます。

APIで利用している全てのモジュールの結合テストが書ければ、リグレッションを防ぐことができます。

ただし、全てのモジュールの結合テストを書くのは時間がかかります。また、リファクタリングでモジュールの構成を変えた場合にテストも修正する必要があるため、コストがとても高いです。

JCEXで実践しているAPIの単体テストは、APIの入出力に着目したテストです。

APIテストのイメージ図

モジュールが全て結合された状態でAPIが期待通りに動くかをテストするためAPIが依存するモジュールの結合テストになり、リグレッションを防ぐことができます。

また、API内部の依存しないブラックボックステストになるため、モジュールのインタフェースを変更してもテストを修正する必要がなく、リファクタリングが容易になります。

上記のような理由から、JCEXではAPIの単体テストを必須にしています。

API単体テストで使用するパッケージ

今回のAPIの単体テストの紹介では以下のパッケージを使います。

APIの単体テストを書くには上記のパッケージで十分ですが、 テストのメンテナンス性を上げるために、JCEXでは Testifymockerygo-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 を実装していきます。

以下のように UserServerCreateUser メソッドを追加します。

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デスクでは、エンジニアリングによる環境価値取引のマーケットを作り上げていく仲間を募集しています。

herp.careers herp.careers