見出し画像

Go製プロダクトのCI構築に役立つツール&ライブラリ紹介


はじめに

こんにちは、くるふとです。
ナビタイムジャパンで、時刻表 API や地図描画 API の 開発・運用業務を主に担当しています。

今回は Go 製プロダクトの CI 構築に役立つツール&ライブラリ群を紹介します。

私は API の開発業務をこなしていく際、 CI の設計を常に意識しています。
継続的な機能のリリースを実現するにあたって、 CI の最適化は避けて通れない領域だと考えています。もちろん、 Go を用いた開発業務でもこの考えは変わりません。

昨今では Go の導入事例が増えつつあります。当社でも Go 製のプロダクトの数は増えてきており、Go 関連のツールやライブラリの知見が集まりつつあります。その過程で、Go 製プロダクトの CI 設計についての議論も多くなってきました。

これは Go 関連のツールやライブラリを記事にまとめる良いタイミングなのではと考え、この記事を執筆しました。

ツール&ライブラリ

stretchr/testify(テスト関数向けのライブラリ)

testify は go test で活用できるテストライブラリです。
返却値の検証、モックを利用したテストなど、go test で必要な機能が一通り揃っています。
例として、 testify を使用したテストコード例を紹介します。

main_test.go

package main

import (
	"fmt"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
	"github.com/stretchr/testify/suite"
)

type Messenger interface {
	getMessage() string
}

func Run(m Messenger) {
	fmt.Println(m.getMessage())
}

type MockedSample struct {
	mock.Mock
}

func (m *MockedSample) getMessage() string {
	return m.Called().String(0)
}

type SampleTestSuite struct {
	suite.Suite
	ID int
}

func (suite *SampleTestSuite) SetupTest() {
	suite.ID = 123456
}

func (suite *SampleTestSuite) TestExample() {
	assert.Equal(suite.T(), 123456, suite.ID)
}

func TestSample(t *testing.T) {
	want1 := 111
	got1 := 111
	want2 := 222
	got2 := 111
	var got3 *interface{} = nil

	// assert パッケージ
	// 値の比較、nil 判定などを行う
	assert.Equal(t, want1, got1, "want and got must match")
	assert.NotEqual(t, want2, got2, "want and got must not match")
	assert.Nil(t, got3)

	// require パッケージ
	// assert と基本同じ挙動だが、 require の場合は失敗したテストの行でテスト関数の処理を終了させる
	// (assert はテストが失敗しても最後まで関数の処理を実行する)
	require.Equal(t, want1, got1, "want and got must match")
	require.NotEqual(t, want2, got2, "want and got must not match")
	require.Nil(t, got3)

	// mock パッケージ
	// mock を用いたテストを提供する
	// サンプルコードでは MockedSample 生成し、 On() Return() でモック関数の振る舞いを定義している
	// (getMessage() が "Hello World!" という文字列を返却する)
	// AssertExpectations で定義されたモックの関数が期待通りに呼び出されたことを主張(テスト)する
	mock := new(MockedSample)
	mock.On("getMessage").Return("Hello World!")
	Run(mock)
	mock.AssertExpectations(t)

	// suite パッケージ
	// オブジェクト指向でのテスト機能を提供する
	// 構造体を用いてセットアップとテストの処理を実装できる
	// サンプルコードでは SetupTest() で SampleTestSuite.ID に値を挿入し、 TestExample で値の比較をしている
	suite.Run(t, new(SampleTestSuite))
}

golangci/golangci-lint(静的解析ツール)

golangci-lint は Go 向けの静的解析ツールです。
多岐にわたる Linter をまとめて実行することができる CLI です。

選択できる Linter は公式のドキュメントで確認することができます。

ドキュメントにも記載されていますが、デフォルトの設定で実行可能な Linter が複数存在します。
例として、デフォルト設定でのコマンド実行例を紹介します。

main.go( Linter がエラーを返却するように実装)

package main

import (
	"fmt"
)

// errcheck
// 戻り値のエラーが未チェックの場合にエラーを返却する
func errcheck() {
	f := func() error {
		return nil
	}
	f()
}

// gosimple
// 無駄なコードパターンが存在する場合にエラーを返却する
func gosimple() {
	flag := true
	if flag == true {
		fmt.Println("This is true.")
	}
}

// govet
// go vet コマンド相当の Linter
// https://pkg.go.dev/cmd/vet
type govet struct {
	hoge int `json:hoge`
}

// ineffassign
// 不要な代入処理が存在する場合にエラーを出力する
func ineffassign() {
	i := 100
	i = 100
	fmt.Println(i)
}

// staticcheck
// 以下 URL に相当する内容を検知する
// (サンプルコードは割愛)
// https://staticcheck.io/

// unused
// 使用されていない関数、変数がある場合にエラーを返却する
func unused() int {
	return 0
}

func main() {
	errcheck()
	gosimple()
	ineffassign()
}

コマンド実行例

$ golangci-lint run ./...
main.go:13:3: Error return value is not checked (errcheck)
        f()
         ^
main.go:28:6: type `govet` is unused (unused)
type govet struct {
     ^
main.go:42:6: func `unused` is unused (unused)
func unused() int {
     ^
main.go:20:5: S1002: should omit comparison to bool constant, can be simplified to `flag` (gosimple)
        if flag == true {
           ^
main.go:29:2: structtag: struct field tag `json:hoge` not compatible with reflect.StructTag.Get: bad syntax for struct tag value (govet)
        hoge int `json:hoge`
        ^
main.go:35:2: ineffectual assignment to i (ineffassign)
        i := 100
        ^

また、.golangci.yaml で実行する Linter 及び実行時の設定を定義することがで来ます。設定ファイルの書き方については下記のページにまとめられています。

uber-go/mock(モックの自動作成ツール)

uber-go/mock は Go 向けのモックツールです。
指定したファイルのモックを自動で作成してくれる CLI です。
作成したモックは go test で利用可能です。

例として、以下のコードをもとに、モックを生成してテストコードを実装してみます。

employee.go(モックの元となるコード)

package main

type Employee struct {
	ID   int
	Name string
}

type EmployeeCreator interface {
	Create(e *Employee) error
}

実行コマンド

$ mockgen -source=employee.go -package=main -destination=./mock.go

mock.go(自動生成されたモックコード)

// Code generated by MockGen. DO NOT EDIT.
// Source: employee.go
//
// Generated by this command:
//
//	mockgen -source=employee.go -package=main -destination=./mock.go
//

// Package main is a generated GoMock package.
package main

import (
	reflect "reflect"

	gomock "go.uber.org/mock/gomock"
)

// MockEmployeeCreator is a mock of EmployeeCreator interface.
type MockEmployeeCreator struct {
	ctrl     *gomock.Controller
	recorder *MockEmployeeCreatorMockRecorder
}

// MockEmployeeCreatorMockRecorder is the mock recorder for MockEmployeeCreator.
type MockEmployeeCreatorMockRecorder struct {
	mock *MockEmployeeCreator
}

// NewMockEmployeeCreator creates a new mock instance.
func NewMockEmployeeCreator(ctrl *gomock.Controller) *MockEmployeeCreator {
	mock := &MockEmployeeCreator{ctrl: ctrl}
	mock.recorder = &MockEmployeeCreatorMockRecorder{mock}
	return mock
}

// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockEmployeeCreator) EXPECT() *MockEmployeeCreatorMockRecorder {
	return m.recorder
}

// Create mocks base method.
func (m *MockEmployeeCreator) Create(e *Employee) error {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "Create", e)
	ret0, _ := ret[0].(error)
	return ret0
}

// Create indicates an expected call of Create.
func (mr *MockEmployeeCreatorMockRecorder) Create(e any) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockEmployeeCreator)(nil).Create), e)
}

employee_test.go(モックを利用したテストコード)

package main

import (
	"testing"

	gomock "go.uber.org/mock/gomock"
)

func TestEmployee(t *testing.T) {
	e := &Employee{
		ID:   1,
		Name: "hoge",
	}

	// モック生成を担うコントローラーを生成する
	mockCtrl := gomock.NewController(t)
	defer mockCtrl.Finish()

	// モックの生成と振る舞いを定義
	// Create() が呼び出された際、 nil を返却するよう振る舞いを定義している
	mockEmployeeCreator := NewMockEmployeeCreator(mockCtrl)
	mockEmployeeCreator.EXPECT().Create(e).Return(nil)

	err := mockEmployeeCreator.Create(e)

	if err != nil {
		t.Error(err)
	}
}

ちなみに、 uber-go/mock は先に紹介した testify とよく比較されます。
testify のモックコードを自動生成してくれる、 mockery というツールもあります。

今回の記事では詳細な比較は割愛しますが、以下のページで Go のモックツールの比較表が紹介されています。
uber-go/mock と testify + mockery についても紹介されています。

Comparison of golang mocking libraries

cweill/gotests(テスト関数自動生成ツール)

gotests はソースコード上の関数からテスト関数の雛形を生成してくれる CLI です。
雛形はテーブル駆動テストに基づいて生成されます。テーブル駆動テストについては下記のページが参考になります。

例として、以下のコードをもとにテストの雛形を作成してみます。

main.go

package main

import "fmt"

func getNumberStr(msgType int) string {
	switch msgType {
	case 1:
		return "One"
	case 2:
		return "Two"
	case 3:
		return "Three"
	case 4:
		return "Four"
	case 5:
		return "Five"
	default:
		return "Unknown"
	}
}

func main() {
	ns := getNumberStr(1)
	fmt.Printf("getNumberStr: %s", ns)
}

実行コマンド例

$ # main.go の getNumberStr() のテスト関数の雛形を main_test.go として作成する
$ gotests -only getNumberStr -w ./main.go 
Generated Test_getNumberStr

main_test.go(CLI によって自動生成されたテストコード)

package main

import "testing"

func Test_getNumberStr(t *testing.T) {
	type args struct {
		msgType int
	}
	tests := []struct {
		name string
		args args
		want string
	}{
		// TODO: Add test cases.
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := getNumberStr(tt.args.msgType); got != tt.want {
				t.Errorf("getNumberStr() = %v, want %v", got, tt.want)
			}
		})
	}
}

雛形の TODO にテストケースを追加してあげれば、テストの実装が完了します。

go test -coverprofile & go tool cover(テストカバレッジの可視化)

go のサブコマンドである go test, go tool cover を用いてテストカバレッジをブラウザ上に可視化することができます。
例として、以下のコードのテストカバレッジを可視化します。

main.go

package main

import "fmt"

func getMessage(msgType int) string {
	switch msgType {
	case 1:
		return "Hello"
	case 2:
		return "Good bye"
	default:
		return ""
	}
}

func main() {
	fmt.Printf("getMessage: %s", getMessage(1))
}

main_test.go

package main

import (
	"testing"
)

func TestGetMessage(t *testing.T) {
	tests := []struct {
		msgType int
		want    string
	}{
		{1, "Hello"},
	}

	for _, tt := range tests {
		got := getMessage(tt.msgType)
		if got != tt.want {
			t.Error("got and want should be equal.")
		}
	}
}

コマンド実行例(テスト結果からカバレッジのプロファイルを取得し、 html 化してローカルのブラウザ上で閲覧する)

$ go test -coverprofile=index.out ./...
$ go tool cover -html=index.out -o index.html
$ open index.html

ブラウザのスクリーンショット

緑色: テストでカバーできている箇所
赤色: テストでカバーできていない箇所

govulncheck(脆弱性診断ツール)

govulncheck は脆弱性診断の CLI です。Go のソースコード及びビルドしたバイナリファイルから脆弱性の情報を出力します。
例として、govulncheck コマンドを用いてソースコードから脆弱性を検知した例を紹介します。

mian.go

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
)

func main() {
	req, err := http.NewRequest("GET", "https://httpstat.us/200", nil)
	if err != nil {
		log.Fatal(err)
	}

	c := new(http.Client)
	resp, err := c.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	b, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(b))
}

実行コマンド例(Go v1.20.5 の脆弱性を検知した際の実行ログ)

$ go version
go version go1.20.5 linux/arm64
$ govulncheck ./...
Scanning your code and 127 packages across 3 dependent modules for known vulnerabilities...

=== Symbol Results ===

Vulnerability #1: GO-2023-2382
    Denial of service via chunk extensions in net/http
  More info: https://pkg.go.dev/vuln/GO-2023-2382
  Standard library
    Found in: net/http/internal@go1.20.5
    Fixed in: net/http/internal@go1.20.12
    Example traces found:
      #1: main.go:23:22: sandbox.main calls io.ReadAll, which eventually calls internal.chunkedReader.Read

Vulnerability #2: GO-2023-2185
    Insecure parsing of Windows paths with a \??\ prefix in path/filepath
  More info: https://pkg.go.dev/vuln/GO-2023-2185
  Standard library
    Found in: path/filepath@go1.20.5
    Fixed in: path/filepath@go1.20.11
    Platforms: windows
    Example traces found:
      #1: main.go:21:2: sandbox.main calls http.cancelTimerBody.Close, which eventually calls filepath.Join
      #2: main.go:21:2: sandbox.main calls http.cancelTimerBody.Close, which eventually calls filepath.Join

Vulnerability #3: GO-2023-1987
    Large RSA keys can cause high CPU usage in crypto/tls
  More info: https://pkg.go.dev/vuln/GO-2023-1987
  Standard library
    Found in: crypto/tls@go1.20.5
    Fixed in: crypto/tls@go1.20.7
    Example traces found:
      #1: main.go:17:19: sandbox.main calls http.Client.Do, which eventually calls tls.Conn.HandshakeContext
      #2: main.go:23:22: sandbox.main calls io.ReadAll, which eventually calls tls.Conn.Read
      #3: main.go:21:2: sandbox.main calls http.http2transportResponseBody.Close, which eventually calls tls.Conn.Write
      #4: main.go:17:19: sandbox.main calls http.Client.Do, which eventually calls tls.Dialer.DialContext

Vulnerability #4: GO-2023-1878
    Insufficient sanitization of Host header in net/http
  More info: https://pkg.go.dev/vuln/GO-2023-1878
  Standard library
    Found in: net/http@go1.20.5
    Fixed in: net/http@go1.20.6
    Example traces found:
      #1: main.go:17:19: sandbox.main calls http.Client.Do

Your code is affected by 4 vulnerabilities from the Go standard library.
This scan also found 0 vulnerabilities in packages you import and 4
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.

まとめ

いかがでしたでしょうか?
Go は標準ライブラリも含めて、テスト系のツールが充実しているのがメリットの1つだと感じています。
今回の記事で紹介したツールは導入コストも低く、業務で導入しやすいものと思っています。特に golangci-lint と govulncheck はソースコードの修正が不要且つ、デフォルトの設定で十分機能するためとりあえず使ってみたいという方におすすめです。
今回紹介した内容が皆さんの業務の助けになると幸いです。

参考にした記事