Я подумал о том, чтобы сделать следующее, чтобы смоделировать / протестировать функции в 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 ответа
Мне это кажется прекрасным: это красиво и просто, и я раньше видел, как этот шаблон использовался «в дикой природе». Одна вещь, которую вы могли бы сделать, это в 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
То же самое и с вашей функцией обновления. Передаваемые аргументы представляют то же самое, что и возвращаемый вами объект. Зачем возиться с двумя копиями одного и того же, если вы можете делать все это с одним и тем же объектом?