単体テスト作成
単体テストの書き方およびカバレッジの確認方法を説明します。
cf. https://github.com/stretchr/testify
事前準備
- testify 関連のモジュールをインストールします。
go get github.com/stretchr/testify
go get github.com/stretchr/testify/assert
go get github.com/stretchr/testify/mock
作成手順
前提
下記テーブルに対してデータを取得 / 登録するAPIを想定しています:
| カラム名 | 属性 |
|---|---|
| name | VARCHAR(8) PRIMARY KEY |
| password | VARCHAR(32) |
| age | INT |
handlerテスト作成
users_handler.goと同じパッケージにテストファイルを作成します。
users_handler_test.go
package handler
import (
"bytes"
"easyapp/internal/usecase/params"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// モックの定義
type mockUsersUsecase struct {
mock.Mock
}
func (m *mockUsersUsecase) Login(in params.LoginIn) params.LoginOut {
args := m.Called(in)
return args.Get(0).(params.LoginOut)
}
func (m *mockUsersUsecase) Me(in params.MeIn) (params.MeOut, error) {
args := m.Called(in)
return args.Get(0).(params.MeOut), args.Error(1)
}
// UsersHandler_Loginのテスト
func Test_UsersHandler_Login(t *testing.T) {
tests := []struct {
name string // テストケース名
requestBody string // リクエストボディ
setupMock func(mock *mockUsersUsecase) // モック設定
expectedStatusCode int // 期待されるHTTPステータス
expectedBody string // 期待されるレスポンスボディ
}{
{
name: "success",
requestBody: `{"name": "nob", "password": "passwd"}`,
setupMock: func(mock *mockUsersUsecase) {
mock.On(
"Login",
params.NewLoginIn(
"nob",
"passwd",
),
).Return(
params.NewLoginOut(true),
)
},
expectedStatusCode: http.StatusOK,
expectedBody: `{"valid": true}`,
},
}
for _, testcase := range tests {
// モックusecase初期化
mockUsecase := new(mockUsersUsecase)
t.Run(testcase.name, func(t *testing.T) {
// モックの期待される動作を定義
testcase.setupMock(mockUsecase)
// リクエストとレスポンスの準備
body := bytes.NewBuffer([]byte(testcase.requestBody))
req := httptest.NewRequest(http.MethodPost, "/login", body)
res := httptest.NewRecorder()
// handlerの実行
NewUsersHandler(mockUsecase).Login(res, req)
// レスポンスの検証
assert.Equal(t, testcase.expectedStatusCode, res.Code)
if testcase.expectedBody != "" {
assert.JSONEq(t, testcase.expectedBody, res.Body.String())
}
})
}
}
// UsersHandler_Meのテスト
func Test_UsersHandler_Me(t *testing.T) {
tests := []struct {
name string // テストケース名
requestParam map[string]string // リクエストパラメータ
setupMock func(mock *mockUsersUsecase) // モック設定
expectedStatusCode int // 期待されるHTTPステータス
expectedBody string // 期待されるレスポンスボディ
}{
{
name: "success",
requestParam: map[string]string{"name": "nob"},
setupMock: func(mock *mockUsersUsecase) {
mock.On("Me", params.NewMeIn("nob")).Return(params.NewMeOut("nob", 13), nil)
},
expectedStatusCode: http.StatusOK,
expectedBody: `{"name": "nob", "age": 13}`,
},
{
name: "no such user",
requestParam: map[string]string{"name": "nobody"},
setupMock: func(mock *mockUsersUsecase) {
mock.On("Me", params.NewMeIn("nobody")).Return(
*new(params.MeOut),
errors.New("no such user"),
)
},
expectedStatusCode: http.StatusNotFound,
expectedBody: `{"message": "no such user"}`,
},
}
for _, testcase := range tests {
// モックusecase初期化
mockUsecase := new(mockUsersUsecase)
t.Run(testcase.name, func(t *testing.T) {
// モックの期待される動作を定義
testcase.setupMock(mockUsecase)
// リクエストとレスポンスの準備
uri := "/me?name=" + testcase.requestParam["name"]
req := httptest.NewRequest(http.MethodGet, uri, nil)
res := httptest.NewRecorder()
// handlerの実行
NewUsersHandler(mockUsecase).Me(res, req)
// レスポンスの検証
assert.Equal(t, testcase.expectedStatusCode, res.Code)
if testcase.expectedBody != "" {
assert.JSONEq(t, testcase.expectedBody, res.Body.String())
}
})
}
}
usecaseテスト作成
users_usecase.goと同じパッケージにテストファイルを作成します。
users_usecase_test.go
package usecase
import (
"easyapp/internal/domain"
"easyapp/internal/usecase/params"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// モックの定義
type mockUsersRepository struct {
mock.Mock
}
func (m *mockUsersRepository) FindByName(name string) domain.Users {
args := m.Called(name)
return args.Get(0).(domain.Users)
}
// UsersUsecase_Loginのテスト
func Test_UsersUsecase_Login(t *testing.T) {
tests := []struct {
name string // テストケース名
requestBody params.LoginIn // リクエストボディ
setupMock func(mock *mockUsersRepository) // モック設定
expectedBody params.LoginOut // 期待されるレスポンスボディ
}{
{
name: "valid user",
requestBody: params.NewLoginIn("nob", "passwd"),
setupMock: func(mock *mockUsersRepository) {
mock.On("FindByName", "nob").Return(domain.NewUsers("nob", "passwd", 13), nil)
},
expectedBody: params.NewLoginOut(true),
},
{
name: "invalid user",
requestBody: params.NewLoginIn("nob", "invalid_password"),
setupMock: func(mock *mockUsersRepository) {
mock.On("FindByName", "nob").Return(domain.NewUsers("nob", "passwd", 13), nil)
},
expectedBody: params.NewLoginOut(false),
},
{
name: "repository error",
requestBody: params.NewLoginIn("nob", "passwd"),
setupMock: func(mock *mockUsersRepository) {
mock.On(
"FindByName",
"nob",
).Return(
*new(domain.Users),
errors.New("repository error"),
)
},
expectedBody: *new(params.LoginOut),
},
}
for _, testcase := range tests {
// モックリポジトリ初期化
mockRepository := new(mockUsersRepository)
t.Run(testcase.name, func(t *testing.T) {
// モックの期待される動作を定義
testcase.setupMock(mockRepository)
// usecaseの実行
result := NewUsersUsecase(mockRepository).Login(testcase.requestBody)
// レスポンスの検証
assert.Equal(t, testcase.expectedBody, result)
})
}
}
// UsersUsecase_Meのテスト
func Test_UsersUsecase_Me(t *testing.T) {
tests := []struct {
name string // テストケース名
requestBody params.MeIn // リクエストボディ
setupMock func(mock *mockUsersRepository) // モック設定
expectedBody params.MeOut // 期待されるレスポンスボディ
expectedError error // 期待されるエラー
}{
{
name: "success",
requestBody: params.NewMeIn("nob"),
setupMock: func(mock *mockUsersRepository) {
mock.On("FindByName", "nob").Return(domain.NewUsers("nob", "passwd", 13))
},
expectedBody: params.NewMeOut("nob", 13),
expectedError: nil,
},
{
name: "no such user",
requestBody: params.NewMeIn("nob"),
setupMock: func(mock *mockUsersRepository) {
mock.On("FindByName", "nob").Return(*new(domain.Users))
},
expectedBody: *new(params.MeOut),
expectedError: errors.New("no such user"),
},
}
for _, testcase := range tests {
// モックリポジトリ初期化
mockRepository := new(mockUsersRepository)
t.Run(testcase.name, func(t *testing.T) {
// モックの期待される動作を定義
testcase.setupMock(mockRepository)
// usecaseの実行
result, err := NewUsersUsecase(mockRepository).Me(testcase.requestBody)
// レスポンスの検証
assert.Equal(t, testcase.expectedBody, result)
if err != nil {
assert.Equal(t, testcase.expectedError, err)
}
})
}
}
repositoryテスト作成
データベースへのアクセスを行うrepositoryのテスト作成例です。
ディレクトリ構成
.
├── test
│ ├── data
│ │ └── users
│ │ ├── data.sql # テストデータ
│ │ └── schema.sql # テーブル定義
│ └── db.go # 一時データベースへの接続向けutil関数
├── users_repository.go
└── users_repository_test.go
一時データベース準備
- テスト向けの一時的なデータベースを用意するため、sqlite3をインストールします。
go get github.com/mattn/go-sqlite3
db.go
package test
import (
"database/sql"
"os"
"testing"
)
// テスト用データベースに接続します。
func ConnectTestDB(t *testing.T, table string) *sql.DB {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("failed to open in-memory db: %v", err)
}
// schema.sqlを読み込み・実行
schema, err := os.ReadFile("test/data/" + table + "/schema.sql")
if err != nil {
t.Fatalf("failed to read schema: %v", err)
}
_, err = db.Exec(string(schema))
if err != nil {
t.Fatalf("failed to execute schema: %v", err)
}
// data.sqlを読み込み・実行
data, err := os.ReadFile("test/data/" + table + "/data.sql")
if err != nil {
t.Fatalf("failed to read data: %v", err)
}
_, err = db.Exec(string(data))
if err != nil {
t.Fatalf("failed to execute data: %v", err)
}
return db
}
テストデータ準備
schema.sql
-- 一部SQLite特有の記法が必要なため注意 see; https://www.sqlite.org/docs.html
CREATE TABLE users (
name VARCHAR(8) PRIMARY KEY
, password VARCHAR(32)
, age INTEGER
);
data.sql
INSERT INTO users (
name
, password
, age
) VALUES (
'nob'
, 'passwd'
, 13
);
テストケース作成
users_repository_test.go
package repository
import (
"database/sql"
"easyapp/internal/domain"
"easyapp/internal/infrastructure/repository/test"
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
)
// UsersRepository_FindByNameのテスト
func Test_UsersRepository_FindByName(t *testing.T) {
tests := []struct {
name string // テストケース名
queryParam string // クエリパラメータ
setup func(db *sql.DB) // 事前セットアップ関数
expectedBody domain.Users // 期待されるレスポンスボディ
expectedErrorMessage string // 期待されるエラーメッセージ
}{
{
name: "success",
queryParam: "nob",
setup: func(db *sql.DB) {},
expectedBody: domain.NewUsers("nob", "passwd", 13),
},
{
name: "query error",
queryParam: "nob",
setup: func(db *sql.DB) {
db.Exec("DROP TABLE users") // クエリエラー時のテスト向けにテーブルを壊す
},
expectedBody: *new(domain.Users),
},
}
for _, testcase := range tests {
t.Run(testcase.name, func(t *testing.T) {
// テストデータベースおよびrepository初期化
db := test.ConnectTestDB(t, "users")
// 事前セットアップ
testcase.setup(db)
// repositoryの実行
result := NewUsersRepository(db).FindByName(testcase.queryParam)
// レスポンスの確認
assert.Equal(t, testcase.expectedBody, result)
})
}
}
テスト起動
# 特定のディレクトリ内の関数をテストする場合
go test {テスト対象ディレクトリ}
# 全ての関数をテストする場合
go test ./...
カバレッジ出力
- カバレッジレポートをテキストファイルで出力します(下記はhandlerの例):
go test -cover -coverprofile=./internal/handler/coverage.txt ./internal/handler/
- カバレッジレポートをhtmlで出力します:
go tool cover -html=./internal/handler/coverage.txt -o ./internal/handler/coverage.html