38
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

HRBrainAdvent Calendar 2022

Day 21

Goでユニットテストを書くためのTips

Last updated at Posted at 2022-12-21

はじめに

本記事はHRBrainのAdvent Calendar21日目の記事です。

こんにちは!!!
HRBrainでバックエンドエンジニアをしている@darkroです。
寒さが一段と厳しい今日この頃ですが、サッカーW杯で盛り上がり、M-1グランプリで盛り上がり、アドベントカレンダーでも盛り上がっていきましょう!!!

さて、本日のテーマはGoのユニットテストについてです。
皆さんはユニットテストをちゃんと書いていますか?

  • 最速リリーススケジュール優先でユニットテストを書く時間を見積もっていない
  • 結合テストをやっているから大丈夫
  • 効果が実感できない、必要性がわからない

などなど、理由をつけてついつい疎かになっていませんか?
確かにユニットテストを省くことで動くものをそれだけ早く出せるかもしれません。
しかし、その分そのコードは信頼性が損なわれていると言っていいと思います。実際に私もユニットテストのお陰でリリース前に考慮漏れや事前にバグを検出できたケースをいくつも経験してきました。

そこで、今回はGoでユニットテストを書くにあたってのルールや便利な外部パッケージをいくつか紹介してみたいと思います。

標準パッケージ

まずは、Goの標準パッケージtestingについてです。
基本的にはこのtestingのみでユニットテストを実行することができます。

  • ファイル名はhoge_test.goのように、後ろに_testをつけます。
  • 関数名はTestHogeMogeのように、Testではじめ後ろはキャメルケースで命名します。
  • TableDrivenTestというテスト形式で記述することが推奨されています。
max_test.go
package main

import (
	"testing"
)

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

func TestMax(t *testing.T) {
	type args struct {
		a int
		b int
	}
	tests := []struct {
		name string
		args args
		want int
	}{
		{
			name: "a:1,b:2",
			args: args{a: 1, b: 2},
			want: 2,
		},
		{
			name: "a:2,b:1",
			args: args{a: 2, b: 1},
			want: 2,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := max(tt.args.a, tt.args.b); got != tt.want {
				t.Errorf("plus() = %v, want %v", got, tt.want)
			}
		})
	}
}

VS Codeを使っている場合、コマンドパレットからgo testと入力すると簡単に雛形を作成できるのでおすすめです。
go_test.png

基本的にこの標準パッケージのみでユニットテストを書くことができるのですが、あると便利な外部パッケージを少し紹介します。

アサーション

Goの標準パッケージではアサーションを提供していません。
理由は公式のFAQに書かれています。

そこで、testifyをアサーションとして利用すると便利です。

max_test.go
package main

import (
	"testing"

	"github.com/stretchr/testify/assert"
)
	// ...

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := max(tt.args.a, tt.args.b)
			assert.Equal(t, tt.want, got)
		})
	}
}

等価比較

よく構造体の比較には標準パッケージreflectDeepEqual()を使うことがあると思いますが、go-cmpを使うと一部のフィールドを無視して比較できたり、差分が見やすくなります。

gocmp_test.go
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {

			// ...

			if !reflect.DeepEqual(got, tt.want) {
				if diff := cmp.Diff(got, tt.want); diff != "" {
					t.Errorf("Hogefunc differs: (-got +want)n%s", diff)
				}
			}
		})
	}

モック

次に、インターフェース定義からモックの生成を行うことができるライブラリのgomockについてです。次のようなUserを更新する関数のモックを例にみていきます。

usecase.go
type User struct {
	Name  string
	Email string
}

type Usecase interface {
	Update(id uint64, user User) error
}

まずはmockgenコマンドを使ってモックを作成してみましょう。

mockgen -source=usecase.go -destination=./mock/mock_usecase.go -package mock

使い方としては、モック呼び出し用のControllerを作成してからモックを作成します。
その後テストで使用するモック関数とその戻り値を記述し、テストを実行するようにします。

usecase_test.go
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// モック呼び出しのcontroller作成
			ctrl := gomock.NewController(t)
			defer ctrl.Finish()

			// モック作成
			repo := mock.NewMockRepository(ctrl)
			repo.EXPECT().
				Update(user.ID(id), user.User{
					Name:  "名前",
					Email: "hogehoge@hrbrain.co.jp",
				}).
				Return(nil)
			usecase := uc.NewUsecase(repo)
			err := usecase.Update(tt.id, tt.user)

			// ...
		})
	}

SQLモック

最後に、sqlのMockを作ってDBを使った関数のユニットテストを書く場合について、go-sqlmockを使うと便利です。
SELECT文ではExpectQuery()、UPDATE、INSERT、DELETE文ではExpectExec()を使い、期待するクエリと結果をモック化します。

READMEより抜粋
package main

import (
	"fmt"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
)

// a successful case
func TestShouldUpdateStats(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
	}
	defer db.Close()

	mock.ExpectBegin()
	mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectExec("INSERT INTO product_viewers").WithArgs(2, 3).WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectCommit()

	// now we execute our method
	if err = recordStats(db, 2, 3); err != nil {
		t.Errorf("error was not expected while updating stats: %s", err)
	}

	// we make sure that all expectations were met
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Errorf("there were unfulfilled expectations: %s", err)
	}
}

// a failing test case
func TestShouldRollbackStatUpdatesOnFailure(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
	}
	defer db.Close()

	mock.ExpectBegin()
	mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectExec("INSERT INTO product_viewers").
		WithArgs(2, 3).
		WillReturnError(fmt.Errorf("some error"))
	mock.ExpectRollback()

	// now we execute our method
	if err = recordStats(db, 2, 3); err == nil {
		t.Errorf("was expecting an error, but there was none")
	}

	// we make sure that all expectations were met
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Errorf("there were unfulfilled expectations: %s", err)
	}
}

おわりに

以上、Goのユニットテストについての基本の書き方と、HRBrainでも使用している外部パッケージをいくつか紹介させていただきました。少しでも参考になりましたら幸いです。

そしてそしてHRBrainのDevelopmentチームでは、ドメインや市場環境を問わず優位性の高いプロダクトを早く作れるチームを目指して日々開発しており、一緒に働いてくれる仲間を募集しています。

38
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
38
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?