単体テスト作成
単体テストの書き方およびカバレッジの確認方法を説明します。
cf. https://github.com/stretchr/testify
事前準備
- testify 関連のモジュールをインストールします。
go get github.com/stretchr/testify
作成手順
前提
下記テーブルに対してデータを取得 / 登録する API を想定しています:
| カラム名 | 属性 |
|---|---|
| name | VARCHAR(8) PRIMARY KEY |
| password | VARCHAR(32) |
| age | INT |
handler テスト作成
auth_handler.go と同じパッケージにテストファイルを作成します。
- auth_handler_test.go
package handler
import (
"bytes"
"easyapp/internal/usecase/payload"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// モックの定義
type mockAuthUsecase struct {
mock.Mock
}
func (m *mockAuthUsecase) Login(in payload.LoginIn) payload.LoginOut {
args := m.Called(in)
return args.Get(0).(payload.LoginOut)
}
func (m *mockAuthUsecase) Me(in payload.MeIn) (payload.MeOut, error) {
args := m.Called(in)
return args.Get(0).(payload.MeOut), args.Error(1)
}
// AuthHandler_Loginのテスト
func Test_AuthHandler_Login(t *testing.T) {
tests := []struct {
name string // テストケース名
requestBody string // リクエストボディ
setupMock func(mock *mockAuthUsecase) // モック設定
expectedStatusCode int // 期待されるHTTPステータス
expectedBody string // 期待されるレスポンスボディ
}{
{
name: "success",
requestBody: `{"name": "nob", "password": "passwd"}`,
setupMock: func(mock *mockAuthUsecase) {
mock.On(
"Login",
payload.NewLoginIn(
"nob",
"passwd",
),
).Return(
payload.NewLoginOut(true),
)
},
expectedStatusCode: http.StatusOK,
expectedBody: `{"valid": true}`,
},
}
for _, testcase := range tests {
// モックusecase初期化
mockUsecase := new(mockAuthUsecase)
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の実行
NewAuthHandler(mockUsecase).Login(res, req)
// レスポンスの検証
assert.Equal(t, testcase.expectedStatusCode, res.Code)
if testcase.expectedBody != "" {
assert.JSONEq(t, testcase.expectedBody, res.Body.String())
}
})
}
}
// AuthHandler_Meのテスト
func Test_AuthHandler_Me(t *testing.T) {
tests := []struct {
name string // テストケース名
requestParam map[string]string // リクエストパラメータ
setupMock func(mock *mockAuthUsecase) // モック設定
expectedStatusCode int // 期待されるHTTPステータス
expectedBody string // 期待されるレスポンスボディ
}{
{
name: "success",
requestParam: map[string]string{"name": "nob"},
setupMock: func(mock *mockAuthUsecase) {
mock.On("Me", payload.NewMeIn("nob")).Return(payload.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 *mockAuthUsecase) {
mock.On("Me", payload.NewMeIn("nobody")).Return(
*new(payload.MeOut),
errors.New("no such user"),
)
},
expectedStatusCode: http.StatusNotFound,
expectedBody: `{"message": "no such user"}`,
},
}
for _, testcase := range tests {
// モックusecase初期化
mockUsecase := new(mockAuthUsecase)
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の実行
NewAuthHandler(mockUsecase).Me(res, req)
// レスポンスの検証
assert.Equal(t, testcase.expectedStatusCode, res.Code)
if testcase.expectedBody != "" {
assert.JSONEq(t, testcase.expectedBody, res.Body.String())
}
})
}
}
usecase テスト作成
auth_usecase.go と同じパッケージにテストファイルを作成します。
- auth_usecase_test.go
package usecase
import (
"easyapp/internal/domain"
"easyapp/internal/usecase/payload"
"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)
}
// AuthUsecase_Loginのテスト
func Test_AuthUsecase_Login(t *testing.T) {
tests := []struct {
name string // テストケース名
requestBody payload.LoginIn // リクエストボディ
setupMock func(mock *mockUsersRepository) // モック設定
expectedBody payload.LoginOut // 期待されるレスポンスボディ
}{
{
name: "valid user",
requestBody: payload.NewLoginIn("nob", "passwd"),
setupMock: func(mock *mockUsersRepository) {
mock.On("FindByName", "nob").Return(domain.NewUsers("nob", "passwd", 13), nil)
},
expectedBody: payload.NewLoginOut(true),
},
{
name: "invalid user",
requestBody: payload.NewLoginIn("nob", "invalid_password"),
setupMock: func(mock *mockUsersRepository) {
mock.On("FindByName", "nob").Return(domain.NewUsers("nob", "passwd", 13), nil)
},
expectedBody: payload.NewLoginOut(false),
},
{
name: "repository error",
requestBody: payload.NewLoginIn("nob", "passwd"),
setupMock: func(mock *mockUsersRepository) {
mock.On(
"FindByName",
"nob",
).Return(
*new(domain.Users),
errors.New("repository error"),
)
},
expectedBody: *new(payload.LoginOut),
},
}
for _, testcase := range tests {
// モックリポジトリ初期化
mockRepository := new(mockUsersRepository)
t.Run(testcase.name, func(t *testing.T) {
// モックの期待される動作を定義
testcase.setupMock(mockRepository)
// usecaseの実行
result := NewAuthUsecase(mockRepository).Login(testcase.requestBody)
// レスポンスの検証
assert.Equal(t, testcase.expectedBody, result)
})
}
}
// AuthUsecase_Meのテスト
func Test_AuthUsecase_Me(t *testing.T) {
tests := []struct {
name string // テストケース名
requestBody payload.MeIn // リクエストボディ
setupMock func(mock *mockUsersRepository) // モック設定
expectedBody payload.MeOut // 期待されるレスポンスボディ
expectedError error // 期待されるエラー
}{
{
name: "success",
requestBody: payload.NewMeIn("nob"),
setupMock: func(mock *mockUsersRepository) {
mock.On("FindByName", "nob").Return(domain.NewUsers("nob", "passwd", 13))
},
expectedBody: payload.NewMeOut("nob", 13),
expectedError: nil,
},
{
name: "no such user",
requestBody: payload.NewMeIn("nob"),
setupMock: func(mock *mockUsersRepository) {
mock.On("FindByName", "nob").Return(*new(domain.Users))
},
expectedBody: *new(payload.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 := NewAuthUsecase(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 # テーブル定義
│ └── util
│ └── testdb.go # 一時データベースへの接続向けutil関数
├── users_repository.go
└── users_repository_test.go
一時データベース準備
- テスト向けの一時的なデータベースを用意するため、sqlite3 をインストールします。
go get github.com/mattn/go-sqlite3
- testdb.go
package util
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/util"
"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 := util.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