Skip to content

単体テスト作成 ​

単体テストの書き方およびカバレッジの確認方法を説明します。

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を想定しています:

カラム名属性
nameVARCHAR(8) PRIMARY KEY
passwordVARCHAR(32)
ageINT

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/sqlite
  • db.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