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/usecase/params"
	"errors"
	"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.LoginIn) params.LoginOut {
	args := m.Called(ctx, in)
	return args.Get(0).(params.LoginOut)
}

func (m *mockUserUsecase) Me(ctx context.Context, in params.MeIn) (params.MeOut, error) {
	args := m.Called(ctx, in)
	return args.Get(0).(params.MeOut), 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.NewLoginIn("nob", "passwd"),
				).Return(
					params.NewLoginOut(true),
				)
			},
			expectedStatusCode: http.StatusOK,
			expectedBody:       `{"valid": true}`,
		},
	}

	for _, testcase := range tests {

		// モックusecase初期化
		mockUsecase := new(mockUserUsecase)

		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の実行
			NewUserHandler(mockUsecase).Login(res, req)

			// レスポンスの検証
			assert.Equal(t, testcase.expectedStatusCode, res.Code)
			if testcase.expectedBody != "" {
				assert.JSONEq(t, testcase.expectedBody, res.Body.String())
			}
		})
	}
}

// Meのテスト
func TestMe(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(
					"Me",
					mock.Anything,
					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(m *mockUserUsecase) {
				m.On(
					"Me",
					mock.Anything,
					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(mockUserUsecase)

		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の実行
			NewUserHandler(mockUsecase).Me(res, req)

			// レスポンスの検証
			assert.Equal(t, testcase.expectedStatusCode, res.Code)
			if testcase.expectedBody != "" {
				assert.JSONEq(t, testcase.expectedBody, res.Body.String())
			}
		})
	}
}

usecaseテスト作成

user_usecase.goと同じパッケージにテストファイルを作成します。

  • user_usecase_test.go
go
package usecase

import (
	"context"
	"easyapp/internal/domain"
	"easyapp/internal/usecase/params"
	"errors"
	"testing"

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

// モックの定義
type mockUserRepository struct {
	mock.Mock
}

func (m *mockUserRepository) FindByName(
	ctx context.Context,
	name string,
) domain.User {
	args := m.Called(ctx, name)
	return args.Get(0).(domain.User)
}

// Loginのテスト
func TestLogin(t *testing.T) {

	tests := []struct {
		name         string                      // テストケース名
		requestBody  params.LoginIn              // リクエストボディ
		setupMock    func(m *mockUserRepository) // モック設定
		expectedBody params.LoginOut             // 期待されるレスポンスボディ
	}{
		{
			name:        "valid user",
			requestBody: params.NewLoginIn("nob", "passwd"),
			setupMock: func(m *mockUserRepository) {
				m.On(
					"FindByName",
					mock.Anything,
					"nob",
				).Return(
					domain.NewUser("nob", "passwd", 13),
				)
			},
			expectedBody: params.NewLoginOut(true),
		},
		{
			name:        "invalid user",
			requestBody: params.NewLoginIn("nob", "invalid_password"),
			setupMock: func(m *mockUserRepository) {
				m.On(
					"FindByName",
					mock.Anything,
					"nob",
				).Return(
					domain.NewUser("nob", "passwd", 13),
				)
			},
			expectedBody: params.NewLoginOut(false),
		},
		{
			name:        "no such user",
			requestBody: params.NewLoginIn("nob", "passwd"),
			setupMock: func(m *mockUserRepository) {
				m.On(
					"FindByName",
					mock.Anything,
					"nob",
				).Return(
					domain.User{},
				)
			},
			expectedBody: params.LoginOut{},
		},
	}

	for _, testcase := range tests {

		// モックリポジトリ初期化
		mockRepository := new(mockUserRepository)

		t.Run(testcase.name, func(t *testing.T) {
			// モックの期待される動作を定義
			testcase.setupMock(mockRepository)

			// usecaseの実行
			result := NewUserUsecase(mockRepository).Login(
				context.Background(),
				testcase.requestBody,
			)

			// レスポンスの検証
			assert.Equal(t, testcase.expectedBody, result)
		})
	}
}

// Meのテスト
func TestMe(t *testing.T) {

	tests := []struct {
		name          string                      // テストケース名
		requestBody   params.MeIn                 // リクエストボディ
		setupMock     func(m *mockUserRepository) // モック設定
		expectedBody  params.MeOut                // 期待されるレスポンスボディ
		expectedError error                       // 期待されるエラー
	}{
		{
			name:        "success",
			requestBody: params.NewMeIn("nob"),
			setupMock: func(m *mockUserRepository) {
				m.On(
					"FindByName",
					mock.Anything,
					"nob",
				).Return(
					domain.NewUser("nob", "passwd", 13),
				)
			},
			expectedBody:  params.NewMeOut("nob", 13),
			expectedError: nil,
		},
		{
			name:        "no such user",
			requestBody: params.NewMeIn("nob"),
			setupMock: func(m *mockUserRepository) {
				m.On(
					"FindByName",
					mock.Anything,
					"nob",
				).Return(
					domain.User{},
				)
			},
			expectedBody:  params.MeOut{},
			expectedError: errors.New("no such user"),
		},
	}

	for _, testcase := range tests {

		// モックリポジトリ初期化
		mockRepository := new(mockUserRepository)

		t.Run(testcase.name, func(t *testing.T) {
			// モックの期待される動作を定義
			testcase.setupMock(mockRepository)

			// usecaseの実行
			result, err := NewUserUsecase(mockRepository).Me(
				context.Background(),
				testcase.requestBody,
			)

			// レスポンスの検証
			assert.Equal(t, testcase.expectedBody, result)
			assert.Equal(t, testcase.expectedError, err)

		})
	}
}

repositoryテスト作成

データベースへのアクセスを行うrepositoryのテスト作成例です。

ディレクトリ構成

shell
.
├── testdata
   ├── db.go                # 一時データベースへの接続向けutil関数
   └── users
       ├── data.sql         # テストデータ
       └── schema.sql       # テーブル定義
├── user_repository.go
└── user_repository_test.go

一時データベース準備

  • テスト向けの一時的なデータベースを用意するため、sqlite3をインストールします。
shell
go get github.com/mattn/go-sqlite3
  • db.go
go
package testdata

import (
	"database/sql"
	"os"
	"testing"

	_ "github.com/mattn/go-sqlite3"
)

// テスト用データベースに接続します。
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("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/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      // 期待されるレスポンスボディ
	}{
		{
			name:         "success",
			query:        "nob",
			setup:        func(db *sql.DB) {},
			expectedBody: domain.NewUser("nob", "passwd", 13),
		},
		{
			name:  "query error",
			query: "nob",
			setup: func(db *sql.DB) {
				db.Exec("DROP TABLE users") // クエリエラー時のテスト向けにテーブルを壊す
			},
			expectedBody: *new(domain.User),
		},
	}

	for _, testcase := range tests {

		t.Run(testcase.name, func(t *testing.T) {
			// テストデータベースに接続
			db := testdata.ConnectTestDB(t, "users")
			defer db.Close()

			// 事前セットアップ
			testcase.setup(db)

			// repositoryの実行
			result := NewUserRepository(db).FindByName(context.Background(), testcase.query)

			// レスポンスの検証
			assert.Equal(t, testcase.expectedBody, result)
		})
	}
}

テスト起動

shell
# 特定のディレクトリ内の関数をテストする場合
go test {テスト対象ディレクトリ}

# 全ての関数をテストする場合
go test ./...

カバレッジ出力

  • カバレッジレポートをテキストファイルで出力します(下記はhandlerの例):
shell
go test -cover -coverprofile=./internal/handler/coverage.txt ./internal/handler/
  • カバレッジレポートをhtmlで出力します:
shell
go tool cover -html=./internal/handler/coverage.txt -o ./internal/handler/coverage.html