Мокинг интерфейсов Golang

Я подумал о том, чтобы сделать следующее, чтобы смоделировать / протестировать функции в Go, и попросил совета. Допустим, у вас есть интерфейс для репозитория:

    type Creator interface {
        CreateUser(User) (*User, error)
    }
    
    type Updater interface {
        UpdateUser(User) (*User, error)
    }
    
    type Retreiver interface {
        GetUserByEmail(string) (*User, error)
        GetUserByID(uint) (*User, error)
    }
    
    type Repository interface {
        Creator
        Updater
        Retreiver
    }

Мок-интерфейс реализует интерфейс репозитория. Он предоставляет фиктивные функции, которые можно заменить в зависимости от теста:

type MockUserRepo struct {
    CreateUserMock     func(user.User) (*user.User, error)
    UpdateUserMock     func(user.User) (*user.User, error)
    GetUserByEmailMock func(string) (*user.User, error)
    GetUserByIDMock    func(uint) (*user.User, error)
}

func (m *MockUserRepo) CreateUser(u user.User) (*user.User, error) {
    return m.CreateUserMock(u)
}

func (m *MockUserRepo) UpdateUser(u user.User) (*user.User, error) {
    return m.UpdateUserMock(u)
}

func (m *MockUserRepo) GetUserByEmail(s string) (*user.User, error) {
    return m.GetUserByEmailMock(s)
}

func (m *MockUserRepo) GetUserByID(i uint) (*user.User, error) {
    return m.GetUserByIDMock(i)
}

Затем в тестовой функции вы можете создать макет для единственной функции, которая может быть вызвана. Иногда это может вызвать ошибку нулевого указателя, если функция не была реализована / имитирована тестовым набором.

mockUserRepo := &MockUserRepo{
    GetUserByEmailMock: func(s string) (*user.User, error) {
        return nil, user.ErrUserDoesNotExist
   },
}

Есть мысли о том, является ли это плохой / хорошей практикой?

2 ответа
2

Мне это кажется прекрасным: это красиво и просто, и я раньше видел, как этот шаблон использовался «в дикой природе». Одна вещь, которую вы могли бы сделать, это в MockUserRepo проксирование функций, если переменная функции равна нулю, либо выполнить реализацию по умолчанию, либо вернуть более конкретную ошибку (или даже панику). Например:

func (m *MockUserRepo) GetUserByEmail(s string) (*user.User, error) {
    if m.GetUserByEmailMock == nil {
        // default implementation: return a dummy user object
        return &user.User{ID: generateID(), Email: s}, nil
    }
    return m.GetUserByEmailMock(s)
}

// or:

func (m *MockUserRepo) GetUserByEmail(s string) (*user.User, error) {
    if m.GetUserByEmailMock == nil {
        // default implementation: panic with a more specific error
        panic("MockUserRepo: GetUserByEmail not set")
    }
    return m.GetUserByEmailMock(s)
}

Причина, по которой я буду в порядке с panic вот потому что не устанавливаю GetUserByEmail почти наверняка является ошибкой кодирования в тесте, а не ошибкой времени выполнения. Этот последний подход мало что дает, если позволить панике доступа к нулевому указателю, за исключением того, что сообщение об ошибке является немного более конкретным.

    Когда дело доходит до «лучшие практики» Чтобы имитировать интерфейсы, нужно учитывать ряд вещей, не в последнюю очередь: простоту использования. На протяжении многих лет я использовал инструмент для создания макетов. Вместо того, чтобы просто реализовать рассматриваемый интерфейс, GoMock поддерживает много других полезных вещей. Вы можете прочитать об этом подробнее здесь.

    Способ его использования в вашем случае будет примерно таким:

    //go:generate go run github.com/golang/mock/mockgen -destination mocks/creator_mock.go -package mocks your.project.com/package Creator
    type Creator interface {
        CreateUser(User) (*User, error)
    }
    

    Затем в своем проекте просто запустите go generate ./package/... для генерации всех макетов (т.е. всех интерфейсов с go:generate комментарий).

    Это создаст новый пакет в your.project.com/package/mocks. В своих модульных тестах вы просто создаете такие макеты:

    func TestCreateFail(t *testing.T) {
        ctrl := gomock.Controller
        repo := mocks.NewMockCreator(ctrl)
        arg := User{}
        repo.EXPECT().CreateUser(arg).Times(1).Return(nil, SomeExpectedError)
        obj := NewWhateverYoureTesting(repo) // pass in mock dependency
        err := obj.CallYouWantToTest(arg)
        require.Error(t, err)
        ctrl.Finish() // checks if we received the expected number of calls
    }
    

    Вы можете делать много более сложных вещей с этими сгенерированными mock’ами, например, вводить настраиваемую функцию для более подробного управления поведением вашего mock’а или добавлять обратный вызов для проверки состояния аргументов, изменять поведение mock-функции, которая вызывается в зависимости от того, сколько раз он вызывается или какие именно аргументы передаются:

    func TestCreateSeveral(t *testing.T) {
        ctrl := gomock.Controller
        repo := mocks.NewMockCreator(ctrl)
        arg1, arg2 := User{ID: 1}, User{ID: 2} // 2 different users
        // in case arg1 is passed, all goes well
        repo.EXPECT().CreateUser(arg1).Times(1).Return(&arg1, nil)
        // in case arg2 is passed, we return an error and check some fields
        repo.EXPECT().CreateUser(arg2).Times(1).Return(nil, SomeError).Do(func(check User) {
            // this callback will get the arguments that were passed to the repository mock
            require.Equal(t, check.SomeField, "is equal to this value, set in the function we're testing")
        })
        obj := NewWhateverYoureTesting(repo) // pass in mock dependency
        err := obj.CallYouWantToTest(arg1)
        require.NoError(t, err)
        require.Error(t, obj,CallYouWantToTest(arg2)) // now this should error
        ctrl.Finish() // checks if we received the expected number of calls
    }
    

    Я бы предложил переместить шаблонный код, чтобы настроить макеты и все это в функцию, и обернуть все в тестовый тип, просто чтобы ваши тесты были чистыми:

    type testObj struct {
        TestedObj // embed the type you're testing
        ctrl *gomock.Controller
        repo *mocks.MockCreator
    }
    
    func getTestedObj(t *testing.T) *testObj {
        ctrl := gomock.NewController
        repo := mocks.NewMockCreator(ctrl)
        return &testObj{
            TestedObj: NewTestedObj(repo),
            ctrl:      ctrl,
            repo:      repo,
        }
    }
    

    Тогда ваши тесты действительно выглядят довольно чистыми:

    func TestCreateSeveral(t *testing.T) {
        obj := getTestedObj
        defer obj.ctrl.Finish() // defer this to the end
    
        // the test data
        arg1, arg2 := User{ID: 1}, User{ID: 2}
    
        // set up mock expectations & behaviour
        obj.repo.EXPECT().CreateUser(arg1).Times(1).Return(&arg1, nil)
        obj.repo.EXPECT().CreateUser(arg2).Times(1).Return(nil, SomeError).Do(func(check User) {
            require.Equal(t, check.SomeField, "is equal to this value, set in the function we're testing")
        })
    
        // the actual test
        err := obj.CallYouWantToTest(arg1)
        require.NoError(t, err)
        require.Error(t, obj.CallYouWantToTest(arg2))
    }
    

    Все сделано.

    Какие является весьма полезно иметь в виду, что при написании golang считается хорошей практикой определять интерфейс вместе с типом, который зависит от на нем, а не рядом с типами, которые в конечном итоге реализация интерфейс. Таким образом, везде, где используется что-то вроде репозитория, определяется свой собственный специфический интерфейс. Интерфейсы минимальны и должны содержать только методы, которые будет использовать пользователь. Такой интерфейс, как у вас:

    type Repository interface {
        Creator
        Updater
        Retreiver
    }
    

    на мой взгляд выглядит немного подозрительно. Этот интерфейс предназначен для обработки всего, от создания, обновления и получения данных. Это отдельные операции, которые я предпочитаю обрабатывать отдельными компонентами, поэтому вряд ли у меня получится такой универсальный интерфейс. В дополнение к этому, поскольку интерфейсы определяются вместе с пользователем, а не с реализацией (-ями), для меня маловероятно, что создание интерфейса будет таким же. Поскольку код для обработки создания / обновления (другими словами, записи) и чтения будет находиться в разных пакетах, я могу получить что-то вроде этого:

    package foo // read package
    
    //go:generate go run github.com/golang/mock/mockgen -destination mocks/repo_mock.go -package mocks your.project.com/foo Repo
    type Repo interface {
        GetUserByEmail(string) (*User, error)
        GetUserByID(uint) (*User, error)
    }
    

    А также:

    package bar // writes
    
    //go:generate go run github.com/golang/mock/mockgen -destination mocks/repo_mock.go -package mocks your.project.com/bar Creator
    type Creator interface {
        CreateUser(User) (*User, error)
    }
    
    //go:generate go run github.com/golang/mock/mockgen -destination mocks/repo_mock.go -package mocks your.project.com/bar Updater
    type Updater interface {
        UpdateUser(User) (*User, error)
    }
    
    // I'd probably only use this composed interface in my tests
    //go:generate go run github.com/golang/mock/mockgen -destination mocks/repo_mock.go -package mocks your.project.com/bar Repo
    type Repo interface {
        Creator
        Updater
    }
    

    Случайные комментарии:

    Некоторые другие мысли у меня возникли, когда я увидел опубликованный вами код

    Контекст

    Я не вижу, чтобы вы использовали context пакет где угодно. При работе с репозиторием или дескриптором любого хранилища, если на то пошло, в 99% случаев интерфейс позволяет передавать аргумент контекста. Из-за того, что методы CreateUser, а также GetUserByEmail, Я предполагаю, что вы обрабатываете какие-то запросы. Все запросы имеют связанный с ними контекст. Если соединение закрыто, дорогостоящий запрос следует отменить, а не разрешить продолжить. В этом случае контекст запроса будет отменен, и если вы передадите этот контекст в репозиторий (и в конечном итоге используете его при обращении к хранилищу), эта отмена распространяется автоматически. Для REST API это обычно не имеет большого значения, но при работе с веб-сокетами (graphQL) или потоковой передачей (protobuf) это важно. Точно так же, когда ваше приложение загружается, всегда полезно создать контекст приложения. В твоей main функция:

    ctx, cfunc := context.WithCancel(context.Background())
    defer cfunc() // cancels the context when the application shuts down
    

    Вы можете вызвать функцию отмены, когда вы получите сигнал TERM или KILL, или произойдет что-то неожиданное. Отмена контекста приложения, опять же при условии, что вы пропустили его при установлении соединения с магазином и любыми другими внешними процессами / службами, на которые вы полагаетесь), он позаботится о закрытии соединений и освобождении ресурсов в чистом и эффективный способ.

    Ваш интерфейс немного странный

    При создании или обновлении вы ожидаете, что вызывающий объект передаст объект по значению, а вы возвращаете указатель на тот же тип. Я предполагаю, что ваша функция создания пытается вставить данные и возвращает nil, err или же &userWithIDAfterInsert, nil. Зачем передавать копию данных, которые вы пытаетесь вставить? Почему бы просто не сделать это:

    type Creator interface {
        CreateUser(context.Context, *User) error // added context
    }
    

    Затем в самой реализации сделайте что-то вроде этого:

    id, err := r.db.Insert(ctx, user) // assuming insert returns the ID, it's a uint, so I'm assuming a SQL-style auto increment primary key
    if err != nil {
        return err
    }
    user.ID = id
    return nil
    

    То же самое и с вашей функцией обновления. Передаваемые аргументы представляют то же самое, что и возвращаемый вами объект. Зачем возиться с двумя копиями одного и того же, если вы можете делать все это с одним и тем же объектом?

      Добавить комментарий

      Ваш адрес email не будет опубликован. Обязательные поля помечены *