Skip to content

単体テスト作成

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

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