Golang で api を作る・その4【GitHub Actions 上でコードの静的解析・テストを実行してみる】

だいぶ日が空いてしまいましたが、こちらの続きです。
今回は CI (Continuous Integration) の設定を行います。
具体的には、GitHub Actions 上でコードの静的解析とテストを実行します。
GitHub Actions って何?という方は、以前の記事でも合わせて読んでいただくと良いと思います。

利用するリポジトリ

毎度おなじみ以下を利用します。

何をやっているのか

まずは Go のフォーマットチェックとテストについて説明します。
Go にはどちらも標準で備わっています(もちろん外部ライブラリも利用可能です)。
フォーマットチェックは go vet コマンドを、テストは go test コマンドを実行して確認できます。

go vet

静的解析ツールです。
例えば以下のようなコードはエラーになります。

package main

type Authenticate struct {
    Account string
    Password string
}

func main () {
    activate := Authenticate{"testuser", "password"}
}
$ go vet ./...
# {エラーになったパッケージ}
{エラーになったファイル:行番号:列番号}: Authenticate composite literal uses unkeyed fields

これは、構造体を初期化する際にフィールドを明示していないというエラーです。
フィールドを指定しない場合、通常は定義した順に代入されます(上記例だと testuser が Account に、password が Password に設定されます)。
こういったコードは、フィールドの増減や順番が変わるたびに影響を受ける(型が異なればコンパイルエラーになりますが、同じ型で並び順を変えた場合などは機械的に気づけなくなる)ので、推奨されていません。
そういった、静的なコードチェックをしてくれるのが go vet です。

go test

こちらはその名のとおりテストを実行します。
State を取得するテストコードをもとにざっくり説明します。

// app/interface/api/server/state_test.go
// テストは同じパッケージに置くことが多いです
// private な関数や変数を参照できるほうが、単体テストとしては都合が良いためです
package server

// 中略

// テストファイル名は原則末尾を _test.go とし、関数も TestXXX とします
// テスト関数は引数に *testing.T を取ります
func TestGet(t *testing.T) {
    h := &stateHandler{}
    // routing が絡むテストのために、httptest というパッケージが標準で用意されている
    w := httptest.NewRecorder()
    _, r := gin.CreateTestContext(w)

    r.GET("/", h.Get)
    req, _ := http.NewRequest("GET", "/", nil)
    r.ServeHTTP(w, req)

    // Go には、標準で assertXXX のようなアサーション関数が存在しない
    // 外部ライブラリを利用することで実現できますが、まずは単純に値を比較して一致しない場合はエラーとしている
    if w.Code != http.StatusOK {
        t.Errorf("HTTP status code failed, actual: %d, expected: %d", http.StatusOK, w.Code)
    }

    // レスポンスを構造体にマッピングしてアサーションする
    s := entity.State{}
    if err := json.Unmarshal(w.Body.Bytes(), &s); err != nil {
        t.Error(err)
    }
    if s.Environment != gin.Mode() {
        t.Errorf("Environment is invalid, actual: %s, expected: %s", gin.Mode(), s.Environment)
    }
}

次はデータベースが絡むテストです。
このリポジトリでは sqlmock を使い、発行された SQL を比較することでテストとしています。
ただ、クエリが発行されたかどうかだけでは機能を見たせているとは言い辛いので、原則テスト用のデータベースを使うことが多いです。

// app/infrastructure/database/database_test.go
package database

import (
    "github.com/DATA-DOG/go-sqlmock"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var (
    mock sqlmock.Sqlmock
)

func init() {
    // モックデータベースを初期化
    // init 関数は main 同様特殊な関数で、パッケージがインポートされた時点で実行される
    mock = initDBMock()
}

func initDBMock() sqlmock.Sqlmock {
    db, mock, err := sqlmock.New()
    if err != nil {
        panic(err)
    }

    // sqlmock を使ったデータベースをセットアップします
    gdb, err := gorm.Open(mysql.New(mysql.Config{
        Conn:                      db,
        SkipInitializeWithVersion: true,
    }), &gorm.Config{
        SkipDefaultTransaction: true,
    })
    if err != nil {
        panic(err)
    }

    // 各コードが利用するデータベースインスタンスが格納された変数を上書きする
    dbManager = gdb
    return mock
}
// app/infrastructure/database/user_test.go
package database

// 中略

func TestExists(t *testing.T) {
    // 期待するクエリ文字列と返してほしい結果を定義する
    mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM `users`")).
        WithArgs("test").
        WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))

    r := userRepository{}

    v := "test"
    e, err := r.Exists(v)
    if err != nil {
        t.Error(err)
    }
    if !e {
        t.Errorf("User %s is not exists", v)
    }

    mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM `users`")).
        WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))

    e, err = r.Exists(v)
    if err != nil {
        t.Error(err)
    }
    if e {
        t.Errorf("User %s is exists", v)
    }
}

GitHub Actions のセットアップ

ここから GitHub Actions をセットアップしていきます。
といってもやることは単純で、yaml ファイルを追加するだけです。

name: Build

on: [push]

jobs:
  format_check:
    name: Format check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1

      # ホストランナー上に Golang をセットアップする
      - name: Set up Go
        uses: actions/setup-go@v1
        with:
          go-version: 1.17

      # go.sum を元にキャッシュのキーを算出し、あればリストア、なければジョブ完了時に生成する
      - name: Cache dependencies
        uses: actions/cache@v2
        id: backend_cache # id を指定することで、コマンドの結果を次ステップ以降で参照できる
        with:
          path: ~/go/pkg/mod
          key: ${{ runner.OS }}-${{ hashFiles('**/go.sum') }}

      - name: Get dependencies
        # 前ステップでキャッシュがヒットした場合、このタスクは実行しない
        if: ${{ steps.backend_cache.outputs.cache-hit != 'true' }}
        run: go mod download

      - name: Run format check
        run: go vet -v ./...

  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1

      - name: Set up Go
        uses: actions/setup-go@v1
        with:
          go-version: 1.17

      - name: Cache dependencies
        uses: actions/cache@v2
        id: backend_cache
        with:
          path: ~/go/pkg/mod
          key: ${{ runner.OS }}-${{ hashFiles('**/go.sum') }}

      - name: Get dependencies
        if: ${{ steps.backend_cache.outputs.cache-hit != 'true' }}
        run: go mod download

      - name: Run test
        # 環境変数も設定可能です
        # 今回は Gin が利用する変数 GIN_MODE を設定しています
        env:
          GIN_MODE: test
        run: go test -v ./...

ワークフローの結果は、リポジトリの Actions ページで確認可能です。
GitHub Actions 実行結果その1
キャッシュにヒットした場合、以下のようにスキップされたことがわかるマークで表示されています。
GitHub Actions 実行結果その2

まとめ

GitHub Actions を使ってコードの静的解析・テストを行う手順について説明しました。
コードチェックやテストの自動実行は言語を問わず有効なので、まだ導入されていない場合はぜひ導入を検討してみてください。
もっと良いやり方があるよという場合は、ぜひ Twitter や Contact Form から連絡いただけますと幸いです。
次回はデプロイについて触れるか Go のコードについてもう少し触れるか検討中です。