サーバーサイドエンジニアの田実です!
今回はYappliのGoのテストコードについて紹介したいと思います。
基本編
基本的には標準ライブラリを使ってテストを書いています。 ただし、assert周りは標準ライブラリだけでカバーするのが厳しいため、 stretchr/testify を使って検証しています。
func TestHoge(t *testing.T) { tests := []struct { input string want string }{ { "xxx", "yyy", }, // ... } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := hoge.Find(tt.input) assert.Equal(t, tt.want, got) }) } }
外部サービスのリクエストなどモックが必要な場合は、 vektra/mockery を使ってモックを生成しています。 vektra/mockeryで生成されるコードは前述のstretchr/testifyを使っています。
モック生成はmockeryのコマンドを直接叩くか、 go generate
で一括生成できるように go:generate
の記述を入れています。
//go:generate mockery --dir . --name HogeRepository --outpkg repository_mock --output ../repository_mock --case underscore type HogeRepository interface { Get(ctx context.Context, id string) (entity.Hoge, error) Create(ctx context.Context, hoge entity.Hoge) (entity.Hoge, error) }
生成したモックを使ってmockeryのメソッドを呼び出すことで一部の処理をDIで差し替えてテストしています。
r := new(repository_mock.HogeRepository) r.On("Create", mock.Anything, "xxx").Return(entity.Hoge{}, tc.err) uc := &FugaUsecase{r: r} res, err := uc.CreateHoge(ctx, entity.Hoge{}) assert.Equal(t, res, tc.want)
DBを使ったテスト
モックで差し替えるだけではなく実際にデータを入れて検証するテストも行っています。 MySQLの場合はromanyx/polluterを使ってデータを入れています。
import "github.com/romanyx/polluter" p := polluter.New(polluter.MySQLEngine(a.GetDB())) seed, err := os.Open(path) if err != nil { t.Fatalf("failed to open seed file: %s", err) } defer seed.Close() if err := p.Pollute(seed); err != nil { t.Fatalf("failed to pollute: %s", err) }
また、DATA-DOG/go-txdb というDBのコネクションを1つのトランザクションで囲ってくれるツールも併用しています。
これにより、 db.Close()
したときに全てのデータがロールバックするようにしています。
import "github.com/DATA-DOG/go-txdb" txdb.Register("test", "mysql", "{dsn}") db, err = sqlx.Open("test", "")
HTTPリクエストのテスト
httptest.Server
を使ってモック用のサーバーを立ち上げてテストをしています。
func setupTestServer(t *testing.T) (*httptest.Server, func()) { t.Helper() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var b []byte var err error switch r.URL.Path { case "/api/hoge": b, err = json.Marshal(entity.Hoge{ ID: 1, }) w.WriteHeader(200) } if err != nil { t.Fatal(err) } w.Header().Set("Content-Type", "application/json") _, err = fmt.Fprint(w, string(b)) if err != nil { t.Fatal(err) } })) return ts }
ts.URL
を使って任意のAPIクライアントのエンドポイントを差し替えることで、向き先を httptest.Server
に変えてテストをしています。
後述のインテグレーションテストでも使っていますが、インテグレーションテストだと様々なリクエストが飛ぶことになり、モックのロジック(上記のswitch文でハンドリングしている部分)も複雑になってしまいます。そのため、HTTPに関するテストは単体テストや単純な結合テストで行うのが良いのかなと個人的には思っていたりします。
インテグレーションテスト
YappliがGoで実装しているアプリケーションは主にAPIサーバー(BFF)とSQLite3にアクセスするマイクロサービスの2つになります。
SQLite3にはコンテンツを格納しており、APIサーバからマイクロサービスにアクセスすることでアプリのコンテンツのCRUDができるようになります。 コンテンツのCRUDを一気通貫でテストできることは品質担保の上で重要となるため、マイクロサービスを含めたインテグレーションテストを導入しています。 テスト上でSQLite3アクセス用のサーバーを実際に立ち上げて、マイクロサービスのモックをしないAPIハンドラーのテストをすることで実現しています。
以下のように、事前にSQLite3アクセス用のアプリケーションをビルドしておきテストコード上でサーバーを立ち上げます。ポート番号は0を指定して自動的に空いているポートを割り当てます。
dbServer, err := net.Listen("tcp", "0.0.0.0:0") if err != nil { t.Fatal(err) } err = dbServer.Close() if err != nil { t.Fatal(err) } var ( port = dbServer.Addr().(*net.TCPAddr).Port buf = bytes.NewBufferString("") w = bufio.NewWriter(buf) ) cmd := exec.Cmd{ Path: DataPath("sqlite3-access-server"), Env: []string{ fmt.Sprintf("LISTEN_PORT=%d", port), "ENV=LOCAL", }, Dir: dir, Stdout: w, Stderr: w, } err = cmd.Start() if err != nil { t.Fatal(err) }
マイクロサービスが立ち上がるまでTCPでポーリングして確認します。
timeout := time.Now().Add(3 * time.Second) for { conn, _ := net.DialTimeout("tcp", net.JoinHostPort("", fmt.Sprint(port)), 100*time.Millisecond) if conn != nil { _ = conn.Close() break } if time.Now().After(timeout) { t.Fatalf("dbServer's boot timeout") } time.Sleep(100 * time.Millisecond) }
割り当てたポート番号をマイクロサービス向けのAPIクライアントにDIして、テスト内で立ち上げたマイクロサービスにリクエストが向くようにします。 テスト後はteardown用の関数を使ってマイクロサービスのプロセスを終了します。
err = cmd.Process.Signal(os.Interrupt) if err != nil { return err } err = cmd.Wait() if err != nil { return err }
インテグレーションテストでは極力モックを行わず、外部サービスへのHTTPリクエストやメール送信などどうしてもモックせざるを得ない処理に関してのみモックしています。 インテグテーションテストの場合、モック対象の階層が深くなる場合があるのですが、その場合はモックの差し替えをDIコンテナ経由で行っています。
DIコンテナは以下のように実装しており、構造体の生成と取得ができるようになっています。
type Container struct { HogeHandler handler.HogeHandler HogeRepository repository.HogeRepository MailAdaptor adaptor.MailAdaptor // ... } func (c *Container) GetHogeRepository() repository.HogeRepository { if c.HogeRepository == nil { c.HogeRepository = repository.NewHogeRepository(adaptor.GetMailAdaptor()) } return c.HogeRepository } func (c *Container) GetMailAdapator() adaptor.MailAdaptor { if c.MailAdaptor == nil { c.MailAdaptor = adaptor.NewMailAdaptor() } return c.MailAdaptor }
インテグレーションテスト時はDIコンテナに保持している各構造体をモックに差し替えることで、メールやHTTPなどの外部リクエストをモックすることができます。
m := &adaptor_mock.MailAdaptor{} m.On("SendEmail", mock.Anything, mock.Anything).Return(nil) c := container.Container{} c.MailAdaptor = m h := c.GetHogeHandler() h.Get()
テストの実行とカバレッジの集計
go testはデフォルトでパッケージ単位で並列に実行されますが、並列に実行すると衝突するようなテストもあるため、逐次実行するパッケージを分けてテストしています。具体的には go test
のオプションで -p 1
を指定することで特定のパッケージを逐次実行しています。
cat <<EOS | sed 's/ //g' > ignore.txt /hoge/repository EOS target_parallel=$(go list ./... | grep -vf ignore.txt) go test -race ${target_parallel} go test -p 1 -race github.com/fastmedia/hoge/repository/...
カバレッジの集計も同様に逐次実行・並列実行で分けてテスト・カバレッジ集計を行っています。
mode="atomic" echo "mode: $mode" > coverage.txt len="${#PWD}" target=$(go list ./... | cut -f 1-4 -d / | uniq) for pkg in $target; do dir="/home/circleci/go/src/$pkg" dir_relative="${dir:$len+1}" if [[ $pkg == "github.com/fastmedia/hoge" ]]; then continue fi # 直列・並列実行で分岐 if [[ $pkg == "github.com/fastmedia/hoge/repository" ]]; then go test -p 1 -race -coverprofile="$dir_relative/profile.tmp" -covermode=$mode -coverpkg=./... ${pkg}/... else go test -race -coverprofile="$dir_relative/profile.tmp" -covermode=$mode -coverpkg=./... ${pkg}/... fi # カバレッジファイルの連結 if [ -f "$dir_relative/profile.tmp" ] then cat "$dir_relative/profile.tmp" | tail -n +2 >> coverage.txt rm "$dir_relative/profile.tmp" fi done
複数のgo testの結果をマージする必要があるため、ヘッダを除いたデータ行を連結していって1つのファイルにカバレッジを集約させています。
カバレッジは coveralls で見れるようにするために mattn/goveralls を使ってテスト結果をcoverallsに送信しています。
goveralls -coverprofile=coverage.txt -service=circle-ci -repotoken ${COVERALLS_REPO_TOKEN}
その他
テストとは別に golangci-lint, gosec, go vet などのツールを使って静的解析を行い、コードの検査をしています。
go vetに関しては自作したAnalyserを利用して、ドメイン固有の検査も行っています。 また、これらの検査の結果はreviewdogを使ってプルリクエストに自動でコメントするようにしています。
まとめ
YappliのGoのテストについて紹介しました!
テストを書くことによって実装中の不具合を検知するほか、設計の悪さを検知するカナリアとしてコードの可読性・保守性を高める効果もあると思っています。 また、リグレッションテストとして品質を担保することで、リファクタリングだけではなくライブラリやミドルウェアのアップグレードも容易になります。
このようにヤプリでは自動テストや静的解析を活用して、お客様に高い価値を提供し続けるために日々改善を続けています!