Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

YappliのGoのテストについて

サーバーサイドエンジニアの田実です!

今回は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を使ってプルリクエストに自動でコメントするようにしています。

reviewdogによる静的解析&プルリクへのコメント

まとめ

YappliのGoのテストについて紹介しました!

テストを書くことによって実装中の不具合を検知するほか、設計の悪さを検知するカナリアとしてコードの可読性・保守性を高める効果もあると思っています。 また、リグレッションテストとして品質を担保することで、リファクタリングだけではなくライブラリやミドルウェアのアップグレードも容易になります。

このようにヤプリでは自動テストや静的解析を活用して、お客様に高い価値を提供し続けるために日々改善を続けています!