Appearance
単体テスト作成
単体テストの書き方およびカバレッジの確認方法を説明します。
cf. https://github.com/stretchr/testify
事前準備
- testify 関連のモジュールをインストールします。
shell
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テスト作成
user_handler.goと同じパッケージにテストファイルを作成します。
user_handler_test.go
go
package handler
import (
"bytes"
"context"
"easyapp/internal/apperrors"
"easyapp/internal/application/usecase/params"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// モックの定義
type mockUserUsecase struct {
mock.Mock
}
func (m *mockUserUsecase) Login(ctx context.Context, in params.LoginInput) (params.LoginOutput, error) {
args := m.Called(ctx, in)
return args.Get(0).(params.LoginOutput), args.Error(1)
}
func (m *mockUserUsecase) GetUser(ctx context.Context, in params.GetUserInput) (params.GetUserOutput, error) {
args := m.Called(ctx, in)
return args.Get(0).(params.GetUserOutput), args.Error(1)
}
// Loginのテスト
func TestLogin(t *testing.T) {
tests := []struct {
name string // テストケース名
requestBody string // リクエストボディ
setupMock func(m *mockUserUsecase) // モック設定
expectedStatusCode int // 期待されるHTTPステータス
expectedBody string // 期待されるレスポンスボディ
}{
{
name: "Success",
requestBody: `{"name": "nob", "password": "passwd"}`,
setupMock: func(m *mockUserUsecase) {
m.On(
"Login",
mock.Anything,
params.NewLoginInput("nob", "passwd"),
).Return(
params.NewLoginOutput(true),
nil,
)
},
expectedStatusCode: http.StatusOK,
expectedBody: `{"valid": true}`,
},
{
name: "BadRequest",
requestBody: `{"name" "nob", "password": "passwd"}`, // json形式が不正
setupMock: func(m *mockUserUsecase) {},
expectedStatusCode: http.StatusBadRequest,
expectedBody: `{"message": "bad request"}`,
},
{
name: "InvalidInput",
requestBody: `{"name": "", "password": "passwd"}`,
setupMock: func(m *mockUserUsecase) {
m.On(
"Login",
mock.Anything,
params.NewLoginInput("", "passwd"),
).Return(
params.LoginOutput{},
apperrors.ErrInvalidInput,
)
},
expectedStatusCode: http.StatusUnprocessableEntity,
expectedBody: `{"message": "invalid input"}`,
},
{
name: "DatabaseError",
requestBody: `{"name": "nob", "password": "passwd"}`,
setupMock: func(m *mockUserUsecase) {
m.On(
"Login",
mock.Anything,
params.NewLoginInput("nob", "passwd"),
).Return(
params.LoginOutput{},
apperrors.ErrDatabaseOperation,
)
},
expectedStatusCode: http.StatusInternalServerError,
expectedBody: `{"message": "database error"}`,
},
}
for _, test := range tests {
// モックusecase初期化
mockUsecase := new(mockUserUsecase)
t.Run(test.name, func(t *testing.T) {
// モックの期待される動作を定義
test.setupMock(mockUsecase)
// リクエストとレスポンスの準備
body := bytes.NewBuffer([]byte(test.requestBody))
req := httptest.NewRequest(http.MethodPost, "/login", body)
res := httptest.NewRecorder()
// handlerの実行
h := NewUserHandler(mockUsecase)
h.Login(res, req)
// レスポンスの検証
assert.Equal(t, test.expectedStatusCode, res.Code)
assert.JSONEq(t, test.expectedBody, res.Body.String())
})
}
}
// GetUserのテスト
func TestGetUser(t *testing.T) {
tests := []struct {
name string // テストケース名
requestParam map[string]string // リクエストパラメータ
setupMock func(m *mockUserUsecase) // モック設定
expectedStatusCode int // 期待されるHTTPステータス
expectedBody string // 期待されるレスポンスボディ
}{
{
name: "Success",
requestParam: map[string]string{"name": "nob"},
setupMock: func(m *mockUserUsecase) {
m.On(
"GetUser",
mock.Anything,
params.NewGetUserInput("nob"),
).Return(
params.NewGetUserOutput("nob", 13),
nil,
)
},
expectedStatusCode: http.StatusOK,
expectedBody: `{"name": "nob", "age": 13}`,
},
{
name: "InvalidInput",
requestParam: map[string]string{"name": ""},
setupMock: func(m *mockUserUsecase) {
m.On(
"GetUser",
mock.Anything,
params.NewGetUserInput(""),
).Return(
params.GetUserOutput{},
apperrors.ErrInvalidInput,
)
},
expectedStatusCode: http.StatusUnprocessableEntity,
expectedBody: `{"message": "invalid input"}`,
},
{
name: "DatabaseError",
requestParam: map[string]string{"name": "nob"},
setupMock: func(m *mockUserUsecase) {
m.On(
"GetUser",
mock.Anything,
params.NewGetUserInput("nob"),
).Return(
params.GetUserOutput{},
apperrors.ErrDatabaseOperation,
)
},
expectedStatusCode: http.StatusInternalServerError,
expectedBody: `{"message": "database error"}`,
},
}
for _, test := range tests {
// モックusecase初期化
mockUsecase := new(mockUserUsecase)
t.Run(test.name, func(t *testing.T) {
// モックの期待される動作を定義
test.setupMock(mockUsecase)
// リクエストとレスポンスの準備
uri := "/users?name=" + test.requestParam["name"]
req := httptest.NewRequest(http.MethodGet, uri, nil)
res := httptest.NewRecorder()
// handlerの実行
h := NewUserHandler(mockUsecase)
h.GetUser(res, req)
// レスポンスの検証
assert.Equal(t, test.expectedStatusCode, res.Code)
assert.JSONEq(t, test.expectedBody, res.Body.String())
})
}
}usecaseテスト作成
user_usecase.goと同じパッケージにテストファイルを作成します。
user_usecase_test.go
go
package usecase
import (
"context"
"easyapp/internal/apperrors"
"easyapp/internal/application/usecase/params"
"easyapp/internal/domain"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// モックの定義
type mockUserRepository struct {
mock.Mock
}
func (m *mockUserRepository) FindByName(
ctx context.Context,
name domain.Name,
) (domain.User, error) {
args := m.Called(ctx, name)
return args.Get(0).(domain.User), args.Error(1)
}
// Loginのテスト
func TestLogin(t *testing.T) {
tests := []struct {
name string // テストケース名
requestBody params.LoginInput // リクエストボディ
setupMock func(m *mockUserRepository) // モック設定
expectedBody params.LoginOutput // 期待されるレスポンスボディ
expectedError error // 期待されるエラー
}{
{
name: "Success",
requestBody: params.NewLoginInput("nob", "passwd"),
setupMock: func(m *mockUserRepository) {
m.On(
"FindByName",
mock.Anything,
func() domain.Name {
name, _ := domain.NewName("nob")
return name
}(),
).Return(
domain.NewUser("nob", "passwd", 13),
)
},
expectedBody: params.NewLoginOutput(true),
expectedError: nil,
},
{
name: "InvalidInput",
requestBody: params.NewLoginInput("", "password"),
setupMock: func(m *mockUserRepository) {},
expectedBody: params.LoginOutput{},
expectedError: apperrors.ErrInvalidInput,
},
{
name: "NoSuchUser",
requestBody: params.NewLoginInput("nobody", "passwd"),
setupMock: func(m *mockUserRepository) {
m.On(
"FindByName",
mock.Anything,
func() domain.Name {
name, _ := domain.NewName("nobody")
return name
}(),
).Return(
domain.User{},
domain.ErrNoSuchUser,
)
},
expectedBody: params.NewLoginOutput(false),
expectedError: nil,
},
{
name: "DatabaseError",
requestBody: params.NewLoginInput("nob", "passwd"),
setupMock: func(m *mockUserRepository) {
m.On(
"FindByName",
mock.Anything,
func() domain.Name {
name, _ := domain.NewName("nob")
return name
}(),
).Return(
domain.User{},
apperrors.ErrDatabaseOperation,
)
},
expectedBody: params.LoginOutput{},
expectedError: apperrors.ErrDatabaseOperation,
},
{
name: "InvalidUser",
requestBody: params.NewLoginInput("nob", "invalid_password"),
setupMock: func(m *mockUserRepository) {
m.On(
"FindByName",
mock.Anything,
func() domain.Name {
name, _ := domain.NewName("nob")
return name
}(),
).Return(
domain.NewUser("nob", "passwd", 13),
)
},
expectedBody: params.NewLoginOutput(false),
expectedError: nil,
},
}
for _, test := range tests {
// モックリポジトリ初期化
mockRepository := new(mockUserRepository)
t.Run(test.name, func(t *testing.T) {
// モックの期待される動作を定義
test.setupMock(mockRepository)
// usecaseの実行
result, err := NewUserUsecase(mockRepository).Login(
context.Background(),
test.requestBody,
)
// レスポンスの検証
assert.Equal(t, test.expectedBody, result)
assert.Equal(t, test.expectedError, err)
})
}
}
// GetUserのテスト
func TestGetUser(t *testing.T) {
tests := []struct {
name string // テストケース名
requestBody params.GetUserInput // リクエストボディ
setupMock func(m *mockUserRepository) // モック設定
expectedBody params.GetUserOutput // 期待されるレスポンスボディ
expectedError error // 期待されるエラー
}{
{
name: "Success",
requestBody: params.NewGetUserInput("nob"),
setupMock: func(m *mockUserRepository) {
m.On(
"FindByName",
mock.Anything,
func() domain.Name {
name, _ := domain.NewName("nob")
return name
}(),
).Return(
domain.NewUser("nob", "passwd", 13),
)
},
expectedBody: params.NewGetUserOutput("nob", 13),
expectedError: nil,
},
{
name: "InvalidInput",
requestBody: params.NewGetUserInput(""),
setupMock: func(m *mockUserRepository) {},
expectedBody: params.GetUserOutput{},
expectedError: apperrors.ErrInvalidInput,
},
{
name: "NoSuchUser",
requestBody: params.NewGetUserInput("nob"),
setupMock: func(m *mockUserRepository) {
m.On(
"FindByName",
mock.Anything,
func() domain.Name {
name, _ := domain.NewName("nob")
return name
}(),
).Return(
domain.User{},
domain.ErrNoSuchUser,
)
},
expectedBody: params.GetUserOutput{},
expectedError: nil,
},
{
name: "DatabaseError",
requestBody: params.NewGetUserInput("nob"),
setupMock: func(m *mockUserRepository) {
m.On(
"FindByName",
mock.Anything,
func() domain.Name {
name, _ := domain.NewName("nob")
return name
}(),
).Return(
domain.User{},
apperrors.ErrDatabaseOperation,
)
},
expectedBody: params.GetUserOutput{},
expectedError: apperrors.ErrDatabaseOperation,
},
}
for _, test := range tests {
// モックリポジトリ初期化
mockRepository := new(mockUserRepository)
t.Run(test.name, func(t *testing.T) {
// モックの期待される動作を定義
test.setupMock(mockRepository)
// usecaseの実行
result, err := NewUserUsecase(mockRepository).GetUser(
context.Background(),
test.requestBody,
)
// レスポンスの検証
assert.Equal(t, test.expectedBody, result)
assert.Equal(t, test.expectedError, err)
})
}
}repositoryテスト作成
データベースへのアクセスを行うrepositoryのテスト作成例です。
ディレクトリ構成
shell
.
├── testdata
│ ├── db.go # 一時データベースへの接続向けutil関数
│ └── user
│ ├── data.sql # テストデータ
│ └── schema.sql # テーブル定義
├── user_repository.go
└── user_repository_test.go一時データベース準備
- テスト向けの一時的なデータベースを用意するため、SQLiteをインストールします。
shell
go get modernc.org/sqlitedb.go
go
package testdata
import (
"database/sql"
"os"
"testing"
_ "modernc.org/sqlite"
)
// テスト用データベースに接続します。
func ConnectTestDB(t *testing.T, table string) *sql.DB {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open in-memory db: %v", err)
}
// schema.sqlを読み込み・実行
schema, err := os.ReadFile("testdata/" + 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("testdata/" + 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)
}
// テスト終了時に必ずDBを閉じる
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("failed to close db: %v", err)
}
})
return db
}テストデータ準備
schema.sql
sql
-- 一部SQLite特有の記法が必要なため注意 see; https://www.sqlite.org/docs.html
CREATE TABLE IF NOT EXISTS users(
name VARCHAR(8) PRIMARY KEY
, password VARCHAR(32)
, age INT
);data.sql
sql
INSERT INTO users (
name
, password
, age
) VALUES (
'nob'
, 'passwd'
, 13
);テストケース作成
user_repository_test.go
go
package repository
import (
"context"
"database/sql"
"easyapp/internal/apperrors"
"easyapp/internal/domain"
"easyapp/internal/infrastructure/repository/testdata"
"testing"
"github.com/stretchr/testify/assert"
)
// FindByNameのテスト
func TestFindByName(t *testing.T) {
tests := []struct {
name string // テストケース名
query string // クエリパラメータ
setup func(db *sql.DB) // 事前セットアップ関数
expectedBody domain.User // 期待されるレスポンスボディ
expectedError error // 期待されるエラー
}{
{
name: "Success",
query: "nob",
setup: func(db *sql.DB) {},
expectedBody: func() domain.User {
user, _ := domain.NewUser("nob", "passwd", 13)
return user
}(),
expectedError: nil,
},
{
name: "NoSuchUser",
query: "nobody",
setup: func(db *sql.DB) {},
expectedBody: domain.User{},
expectedError: domain.ErrNoSuchUser,
},
{
name: "DatabaseError",
query: "nob",
setup: func(db *sql.DB) {
db.Exec("DROP TABLE users")
},
expectedBody: domain.User{},
expectedError: apperrors.ErrDatabaseOperation,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// テストデータベースに接続
db := testdata.ConnectTestDB(t, "user")
defer db.Close()
// 事前セットアップ
test.setup(db)
// repositoryの実行
query, err := domain.NewName(test.query)
result, err := NewUserRepository(db).FindByName(context.Background(), query)
// レスポンスの検証
assert.Equal(t, test.expectedBody, result)
assert.Equal(t, test.expectedError, err)
})
}
}テスト起動
shell
# 特定のディレクトリ内の関数をテストする場合
go test -v {テスト対象ディレクトリ}
# 全ての関数をテストする場合
go test -v ./...カバレッジ出力
- カバレッジレポートをテキストファイルで出力します(下記はhandlerの例):
shell
go test -cover -coverprofile=./internal/presentation/handler/coverage.txt ./internal/presentation/handler/- カバレッジレポートをhtmlで出力します:
shell
go tool cover -html=./internal/presentation/handler/coverage.txt -o ./internal/presentation/handler/coverage.html