ペペロミア & memoir開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

moqを使ってGo言語のテストコードを書く

前にGoMockを使ったテストコードについて記事にした事がありました。

www.wheatandcat.me

当時はGoMockしか知りませんでしたが、moqというライブラリの方が簡単に使えるという話を記事をみかけたので、memoirではmoqを使用してGo言語のテストを書いてみました。

github.com

Pull Request

github.com

実装

まず、以下でコマンドをインストールする

$ go get github.com/matryer/moq

次にモックさせたいInterfaceを指定して、モックを作成します。 今回だと、テスト時にはfirestoreのアクセス部分をモックさせたかったので、以下のInterfaceを指定

repository/item.go

package repository

import (
    "context"
    "time"

    "cloud.google.com/go/firestore"
    "github.com/wheatandcat/memoir-backend/graph/model"
)

// UserRepositoryInterface is repository interface
type ItemRepositoryInterface interface {
    Create(ctx context.Context, f *firestore.Client, userID string, i *model.Item) error
    Update(ctx context.Context, f *firestore.Client, userID string, i *model.UpdateItem, updatedAt time.Time) error
    Delete(ctx context.Context, f *firestore.Client, userID string, i *model.DeleteItem) error
    GetItem(ctx context.Context, f *firestore.Client, userID string, id string) (*model.Item, error)
    GetItemsByDate(ctx context.Context, f *firestore.Client, userID string, date time.Time) ([]*model.Item, error)
}

モック作成では以下のようにInterfaceを指定します。

$ moq -out=repository/moq.go ./repository ItemRepositoryInterface

これで自生成されるファイルが以下の通りです。 ■ repository/moq.go

package repository

import (
    "cloud.google.com/go/firestore"
    "context"
    "github.com/wheatandcat/memoir-backend/graph/model"
    "sync"
    "time"
)

// Ensure, that ItemRepositoryInterfaceMock does implement ItemRepositoryInterface.
// If this is not the case, regenerate this file with moq.
var _ ItemRepositoryInterface = &ItemRepositoryInterfaceMock{}

// ItemRepositoryInterfaceMock is a mock implementation of ItemRepositoryInterface.
//
//  func TestSomethingThatUsesItemRepositoryInterface(t *testing.T) {
//
//      // make and configure a mocked ItemRepositoryInterface
//      mockedItemRepositoryInterface := &ItemRepositoryInterfaceMock{
//          CreateFunc: func(ctx context.Context, f *firestore.Client, userID string, i *model.Item) error {
//              panic("mock out the Create method")
//          },
//          DeleteFunc: func(ctx context.Context, f *firestore.Client, userID string, i *model.DeleteItem) error {
//              panic("mock out the Delete method")
//          },
//          GetItemFunc: func(ctx context.Context, f *firestore.Client, userID string, id string) (*model.Item, error) {
//              panic("mock out the GetItem method")
//          },
//          GetItemsByDateFunc: func(ctx context.Context, f *firestore.Client, userID string, date time.Time) ([]*model.Item, error) {
//              panic("mock out the GetItemsByDate method")
//          },
//          UpdateFunc: func(ctx context.Context, f *firestore.Client, userID string, i *model.UpdateItem, updatedAt time.Time) error {
//              panic("mock out the Update method")
//          },
//      }
//
//      // use mockedItemRepositoryInterface in code that requires ItemRepositoryInterface
//      // and then make assertions.
//
//  }
type ItemRepositoryInterfaceMock struct {
    // CreateFunc mocks the Create method.
    CreateFunc func(ctx context.Context, f *firestore.Client, userID string, i *model.Item) error

    // DeleteFunc mocks the Delete method.
    DeleteFunc func(ctx context.Context, f *firestore.Client, userID string, i *model.DeleteItem) error

    // GetItemFunc mocks the GetItem method.
    GetItemFunc func(ctx context.Context, f *firestore.Client, userID string, id string) (*model.Item, error)

    // GetItemsByDateFunc mocks the GetItemsByDate method.
    GetItemsByDateFunc func(ctx context.Context, f *firestore.Client, userID string, date time.Time) ([]*model.Item, error)

    // UpdateFunc mocks the Update method.
    UpdateFunc func(ctx context.Context, f *firestore.Client, userID string, i *model.UpdateItem, updatedAt time.Time) error

    // calls tracks calls to the methods.
    calls struct {
        // Create holds details about calls to the Create method.
        Create []struct {
            // Ctx is the ctx argument value.
            Ctx context.Context
            // F is the f argument value.
            F *firestore.Client
            // UserID is the userID argument value.
            UserID string
            // I is the i argument value.
            I *model.Item
        }
        // Delete holds details about calls to the Delete method.
        Delete []struct {
            // Ctx is the ctx argument value.
            Ctx context.Context
            // F is the f argument value.
            F *firestore.Client
            // UserID is the userID argument value.
            UserID string
            // I is the i argument value.
            I *model.DeleteItem
        }
        // GetItem holds details about calls to the GetItem method.
        GetItem []struct {
            // Ctx is the ctx argument value.
            Ctx context.Context
            // F is the f argument value.
            F *firestore.Client
            // UserID is the userID argument value.
            UserID string
            // ID is the id argument value.
            ID string
        }
        // GetItemsByDate holds details about calls to the GetItemsByDate method.
        GetItemsByDate []struct {
            // Ctx is the ctx argument value.
            Ctx context.Context
            // F is the f argument value.
            F *firestore.Client
            // UserID is the userID argument value.
            UserID string
            // Date is the date argument value.
            Date time.Time
        }
        // Update holds details about calls to the Update method.
        Update []struct {
            // Ctx is the ctx argument value.
            Ctx context.Context
            // F is the f argument value.
            F *firestore.Client
            // UserID is the userID argument value.
            UserID string
            // I is the i argument value.
            I *model.UpdateItem
            // UpdatedAt is the updatedAt argument value.
            UpdatedAt time.Time
        }
    }
    lockCreate         sync.RWMutex
    lockDelete         sync.RWMutex
    lockGetItem        sync.RWMutex
    lockGetItemsByDate sync.RWMutex
    lockUpdate         sync.RWMutex
}

// Create calls CreateFunc.
func (mock *ItemRepositoryInterfaceMock) Create(ctx context.Context, f *firestore.Client, userID string, i *model.Item) error {
    if mock.CreateFunc == nil {
        panic("ItemRepositoryInterfaceMock.CreateFunc: method is nil but ItemRepositoryInterface.Create was just called")
    }
    callInfo := struct {
        Ctx    context.Context
        F      *firestore.Client
        UserID string
        I      *model.Item
    }{
        Ctx:    ctx,
        F:      f,
        UserID: userID,
        I:      i,
    }
    mock.lockCreate.Lock()
    mock.calls.Create = append(mock.calls.Create, callInfo)
    mock.lockCreate.Unlock()
    return mock.CreateFunc(ctx, f, userID, i)
}

// CreateCalls gets all the calls that were made to Create.
// Check the length with:
//     len(mockedItemRepositoryInterface.CreateCalls())
func (mock *ItemRepositoryInterfaceMock) CreateCalls() []struct {
    Ctx    context.Context
    F      *firestore.Client
    UserID string
    I      *model.Item
} {
    var calls []struct {
        Ctx    context.Context
        F      *firestore.Client
        UserID string
        I      *model.Item
    }
    mock.lockCreate.RLock()
    calls = mock.calls.Create
    mock.lockCreate.RUnlock()
    return calls
}

// Delete calls DeleteFunc.
func (mock *ItemRepositoryInterfaceMock) Delete(ctx context.Context, f *firestore.Client, userID string, i *model.DeleteItem) error {
    if mock.DeleteFunc == nil {
        panic("ItemRepositoryInterfaceMock.DeleteFunc: method is nil but ItemRepositoryInterface.Delete was just called")
    }
    callInfo := struct {
        Ctx    context.Context
        F      *firestore.Client
        UserID string
        I      *model.DeleteItem
    }{
        Ctx:    ctx,
        F:      f,
        UserID: userID,
        I:      i,
    }
    mock.lockDelete.Lock()
    mock.calls.Delete = append(mock.calls.Delete, callInfo)
    mock.lockDelete.Unlock()
    return mock.DeleteFunc(ctx, f, userID, i)
}

// DeleteCalls gets all the calls that were made to Delete.
// Check the length with:
//     len(mockedItemRepositoryInterface.DeleteCalls())
func (mock *ItemRepositoryInterfaceMock) DeleteCalls() []struct {
    Ctx    context.Context
    F      *firestore.Client
    UserID string
    I      *model.DeleteItem
} {
    var calls []struct {
        Ctx    context.Context
        F      *firestore.Client
        UserID string
        I      *model.DeleteItem
    }
    mock.lockDelete.RLock()
    calls = mock.calls.Delete
    mock.lockDelete.RUnlock()
    return calls
}

// GetItem calls GetItemFunc.
func (mock *ItemRepositoryInterfaceMock) GetItem(ctx context.Context, f *firestore.Client, userID string, id string) (*model.Item, error) {
    if mock.GetItemFunc == nil {
        panic("ItemRepositoryInterfaceMock.GetItemFunc: method is nil but ItemRepositoryInterface.GetItem was just called")
    }
    callInfo := struct {
        Ctx    context.Context
        F      *firestore.Client
        UserID string
        ID     string
    }{
        Ctx:    ctx,
        F:      f,
        UserID: userID,
        ID:     id,
    }
    mock.lockGetItem.Lock()
    mock.calls.GetItem = append(mock.calls.GetItem, callInfo)
    mock.lockGetItem.Unlock()
    return mock.GetItemFunc(ctx, f, userID, id)
}

// GetItemCalls gets all the calls that were made to GetItem.
// Check the length with:
//     len(mockedItemRepositoryInterface.GetItemCalls())
func (mock *ItemRepositoryInterfaceMock) GetItemCalls() []struct {
    Ctx    context.Context
    F      *firestore.Client
    UserID string
    ID     string
} {
    var calls []struct {
        Ctx    context.Context
        F      *firestore.Client
        UserID string
        ID     string
    }
    mock.lockGetItem.RLock()
    calls = mock.calls.GetItem
    mock.lockGetItem.RUnlock()
    return calls
}

// GetItemsByDate calls GetItemsByDateFunc.
func (mock *ItemRepositoryInterfaceMock) GetItemsByDate(ctx context.Context, f *firestore.Client, userID string, date time.Time) ([]*model.Item, error) {
    if mock.GetItemsByDateFunc == nil {
        panic("ItemRepositoryInterfaceMock.GetItemsByDateFunc: method is nil but ItemRepositoryInterface.GetItemsByDate was just called")
    }
    callInfo := struct {
        Ctx    context.Context
        F      *firestore.Client
        UserID string
        Date   time.Time
    }{
        Ctx:    ctx,
        F:      f,
        UserID: userID,
        Date:   date,
    }
    mock.lockGetItemsByDate.Lock()
    mock.calls.GetItemsByDate = append(mock.calls.GetItemsByDate, callInfo)
    mock.lockGetItemsByDate.Unlock()
    return mock.GetItemsByDateFunc(ctx, f, userID, date)
}

// GetItemsByDateCalls gets all the calls that were made to GetItemsByDate.
// Check the length with:
//     len(mockedItemRepositoryInterface.GetItemsByDateCalls())
func (mock *ItemRepositoryInterfaceMock) GetItemsByDateCalls() []struct {
    Ctx    context.Context
    F      *firestore.Client
    UserID string
    Date   time.Time
} {
    var calls []struct {
        Ctx    context.Context
        F      *firestore.Client
        UserID string
        Date   time.Time
    }
    mock.lockGetItemsByDate.RLock()
    calls = mock.calls.GetItemsByDate
    mock.lockGetItemsByDate.RUnlock()
    return calls
}

// Update calls UpdateFunc.
func (mock *ItemRepositoryInterfaceMock) Update(ctx context.Context, f *firestore.Client, userID string, i *model.UpdateItem, updatedAt time.Time) error {
    if mock.UpdateFunc == nil {
        panic("ItemRepositoryInterfaceMock.UpdateFunc: method is nil but ItemRepositoryInterface.Update was just called")
    }
    callInfo := struct {
        Ctx       context.Context
        F         *firestore.Client
        UserID    string
        I         *model.UpdateItem
        UpdatedAt time.Time
    }{
        Ctx:       ctx,
        F:         f,
        UserID:    userID,
        I:         i,
        UpdatedAt: updatedAt,
    }
    mock.lockUpdate.Lock()
    mock.calls.Update = append(mock.calls.Update, callInfo)
    mock.lockUpdate.Unlock()
    return mock.UpdateFunc(ctx, f, userID, i, updatedAt)
}

// UpdateCalls gets all the calls that were made to Update.
// Check the length with:
//     len(mockedItemRepositoryInterface.UpdateCalls())
func (mock *ItemRepositoryInterfaceMock) UpdateCalls() []struct {
    Ctx       context.Context
    F         *firestore.Client
    UserID    string
    I         *model.UpdateItem
    UpdatedAt time.Time
} {
    var calls []struct {
        Ctx       context.Context
        F         *firestore.Client
        UserID    string
        I         *model.UpdateItem
        UpdatedAt time.Time
    }
    mock.lockUpdate.RLock()
    calls = mock.calls.Update
    mock.lockUpdate.RUnlock()
    return calls
}

自動生成されるファイルは大きいですが、使用方法は単純でテスト時に使用箇所を上記のInterfaceで置き換えてればOKです

graph/common_test.go

package graph_test

import (
    "github.com/wheatandcat/memoir-backend/client/timegen"
    "github.com/wheatandcat/memoir-backend/client/uuidgen"
    "github.com/wheatandcat/memoir-backend/graph"
    "github.com/wheatandcat/memoir-backend/repository"
)

func newGraph() graph.Graph {
    client := &graph.Client{
        UUID: &uuidgen.UUID{},
        Time: &timegen.Time{},
    }

    app := &graph.Application{
        ItemRepository: &repository.ItemRepositoryInterfaceMock{}, // ←ここにMockを設定
    }

 

上記をテスト実行時に使用しつつ、必要な箇所のみ以下のように置き換えればテストが行えます

graph/item_test.go

package graph_test

import (
    "context"
    "testing"
    "time"

    "cloud.google.com/go/firestore"
    "github.com/google/go-cmp/cmp"
    "github.com/wheatandcat/memoir-backend/client/timegen"
    "github.com/wheatandcat/memoir-backend/client/uuidgen"
    "github.com/wheatandcat/memoir-backend/graph"
    "github.com/wheatandcat/memoir-backend/graph/model"
    "github.com/wheatandcat/memoir-backend/repository"
    "gopkg.in/go-playground/assert.v1"
)

func TestGetItemsByDate(t *testing.T) {
    ctx := context.Background()

    client := &graph.Client{
        UUID: &uuidgen.UUID{},
        Time: &timegen.Time{},
    }

    date := client.Time.ParseInLocation("2019-01-01T00:00:00")

    items := []*model.Item{{
        ID:         "test1",
        CategoryID: 1,
        Title:      "test-title",
        Date:       date,
        CreatedAt:  date,
        UpdatedAt:  date,
    }}

    g := newGraph()

    // テストでGetItemsByDateを使用するので↓のみreturnを設定する
    itemRepositoryMock := &repository.ItemRepositoryInterfaceMock{
        GetItemsByDateFunc: func(ctx context.Context, f *firestore.Client, userID string, date time.Time) ([]*model.Item, error) {
            return items, nil
        },
    }

    g.App.ItemRepository = itemRepositoryMock

    tests := []struct {
        name   string
        param  time.Time
        result []*model.Item
    }{
        {
            name:   "日付でアイテムを取得する",
            param:  date,
            result: items,
        },
    }

    for _, td := range tests {
        t.Run(td.name, func(t *testing.T) {
            r, _ := g.GetItemsByDate(ctx, td.param)
            diff := cmp.Diff(r, td.result)
            if diff != "" {
                t.Errorf("differs: (-got +want)\n%s", diff)
            } else {
                assert.Equal(t, diff, "")
            }
        })
    }
}

これでテスト実行で成功しました。

$ go test ./graph

ok      github.com/wheatandcat/memoir-backend/graph

GoMockで実装していた時はメソッドの引数の辻褄が合わないとか、テストの本質じゃないところで詰まることが多かったが、moqだとその辺の対応無しにシンプルにモックが行えて、良いツールだなと思いました。