Skip to content

単体テスト作成

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

cf. https://github.com/stretchr/testify

事前準備

  • testify 関連のモジュールをインストールします。
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テスト作成

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

  • users_handler_test.go
package handler

import (
    "bytes"
    "easyapp/internal/usecase/params"
    "errors"
    "net/http"
    "net/http/httptest"
    "testing"

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

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

func (m *mockUsersUsecase) Login(in params.LoginIn) params.LoginOut {
    args := m.Called(in)
    return args.Get(0).(params.LoginOut)
}

func (m *mockUsersUsecase) Me(in params.MeIn) (params.MeOut, error) {
    args := m.Called(in)
    return args.Get(0).(params.MeOut), args.Error(1)
}

// UsersHandler_Loginのテスト
func Test_UsersHandler_Login(t *testing.T) {

    tests := []struct {
        name               string                       // テストケース名
        requestBody        string                       // リクエストボディ
        setupMock          func(mock *mockUsersUsecase) // モック設定
        expectedStatusCode int                          // 期待されるHTTPステータス
        expectedBody       string                       // 期待されるレスポンスボディ
    }{
        {
            name:        "success",
            requestBody: `{"name": "nob", "password": "passwd"}`,
            setupMock: func(mock *mockUsersUsecase) {
                mock.On(
                    "Login",
                    params.NewLoginIn(
                        "nob",
                        "passwd",
                    ),
                ).Return(
                    params.NewLoginOut(true),
                )
            },
            expectedStatusCode: http.StatusOK,
            expectedBody:       `{"valid": true}`,
        },
    }

    for _, testcase := range tests {

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

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

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

// UsersHandler_Meのテスト
func Test_UsersHandler_Me(t *testing.T) {

    tests := []struct {
        name               string                       // テストケース名
        requestParam       map[string]string            // リクエストパラメータ
        setupMock          func(mock *mockUsersUsecase) // モック設定
        expectedStatusCode int                          // 期待されるHTTPステータス
        expectedBody       string                       // 期待されるレスポンスボディ
    }{
        {
            name:         "success",
            requestParam: map[string]string{"name": "nob"},
            setupMock: func(mock *mockUsersUsecase) {
                mock.On("Me", 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(mock *mockUsersUsecase) {
                mock.On("Me", 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(mockUsersUsecase)

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

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

usecaseテスト作成

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

  • users_usecase_test.go
package usecase

import (
    "easyapp/internal/domain"
    "easyapp/internal/usecase/params"
    "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)
}

// UsersUsecase_Loginのテスト
func Test_UsersUsecase_Login(t *testing.T) {

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

    for _, testcase := range tests {

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

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

            // usecaseの実行
            result := NewUsersUsecase(mockRepository).Login(testcase.requestBody)

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

// UsersUsecase_Meのテスト
func Test_UsersUsecase_Me(t *testing.T) {

    tests := []struct {
        name          string                          // テストケース名
        requestBody   params.MeIn                     // リクエストボディ
        setupMock     func(mock *mockUsersRepository) // モック設定
        expectedBody  params.MeOut                    // 期待されるレスポンスボディ
        expectedError error                           // 期待されるエラー
    }{
        {
            name:        "success",
            requestBody: params.NewMeIn("nob"),
            setupMock: func(mock *mockUsersRepository) {
                mock.On("FindByName", "nob").Return(domain.NewUsers("nob", "passwd", 13))
            },
            expectedBody:  params.NewMeOut("nob", 13),
            expectedError: nil,
        },
        {
            name:        "no such user",
            requestBody: params.NewMeIn("nob"),
            setupMock: func(mock *mockUsersRepository) {
                mock.On("FindByName", "nob").Return(*new(domain.Users))
            },
            expectedBody:  *new(params.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 := NewUsersUsecase(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  # テーブル定義   └── db.go               # 一時データベースへの接続向けutil関数
├── users_repository.go
└── users_repository_test.go

一時データベース準備

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

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"
    "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 := test.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