Как стать автором
Обновить

Комментарии 12

Используем интерфейсы при разработке

То, как это делаете вы, скорее плохой совет, нежели хороший.


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


Во-вторых, если вы пишете интерфейс только для тестирования — делайте его неэкспортируемым, потому что если вам нужен только функционал для работы с редисом, то для остальных пакетов такой интерфейс — это просто мусор и лишняя проблема для поддержки такого кода.
Для экспортируемых интерфейсов должна быть первостепенной именно цель однообразия и они должны быть маленькими, а не "для моков сделаем, а потом может быть понадобится когда-нибудь", потому что в таком случае это превратится в копипасту методов, а когда реально понадобится добавить другую имплементацию, окажется что у мемкашеда ключи в другом формате, какие-то методы принципиально нельзя реализовать или ошибки обрабатываются иначе и т.д., поэтому все придётся переделывать.

Во-первых, общая рекомендация такая, что интерфейсы нужно объявлять там, где они используются, а не там, где для них создаётся имплементация,

Да, Вы абсолютно правы. Здесь просто минимаьные примеры на пару файлов кода, который можно скопировать и понять что это работает. В каком пакете размещать интерфейсы и их реализацию мы не говорим. Надеюсь то, что мы в примере разместили всё в одном файле, никого не введёт в заблуждение что так делать правильно.

Во-вторых, если вы пишете интерфейс только для тестирования

Основная цель интерфейсов остаётся сделать независимыми работу одних пакетов от других. Но интерфейсы также сильно помогают нам в тестировании, позволяют тестировать маленькие «кучочки» независимо.

Для экспортируемых интерфейсов должна быть первостепенной именно цель однообразия и они должны быть маленькими

Пример с redis и memcahed был приведён как раз из-за минимального набора методов и однообразия работы работы с ними как с key-value хранилищами.
> Считаем покрытие при тестировании приложения как черного ящика
В случае если хандлеры динамически регистрируются (в цикле из мапы например) то покрытие не будет считаться
Мои тесты на колене показали не рассматривать в серьез этот пункт
Поправьте меня плиз если я что то упустил
В случае если хандлеры динамически регистрируются

В целом в примере они динамически регистрируются во время выполнения внутри функции main. Только вместо цикла две строчки. Но на всякий случай попробовал переписать на цикл и map: github.com/Grey-Fox/gomaintest.
Обратил внимание если вот так регистрировать хандлеры
github.com/gebv/go-bb-tests-metrics/blob/main/main.go#L43
То такое ветвление он уже не считает
github.com/gebv/go-bb-tests-metrics/blob/main/main.go#L17-L26

А если напрямую вызывать функцию — то считает
github.com/gebv/go-bb-tests-metrics/blob/main/main_test.go#L13

Есть мысль что где то граница «работает\не работает» все таки есть. В реальном проекте можно не досмотреть. В итоге есть риск получить цифры не отражающие действительность.
Обратил внимание если вот так регистрировать хандлеры
github.com/gebv/go-bb-tests-metrics/blob/main/main.go#L43

На 43 строчке Вы регистрируете handler'ы, которые описаны на строках github.com/gebv/go-bb-tests-metrics/blob/f90db0e/main.go#L86-L109.

То такое ветвление он уже не считает
github.com/gebv/go-bb-tests-metrics/blob/main/main.go#L17-L26

На этих строчках у Вас напина функция, которая нигде не используется, кроме как в TestCase2: github.com/gebv/go-bb-tests-metrics/blob/f90db0e/main_test.go#L13.

Да. Если Вы запускаете только TestCase1 (который тестирует функцию main, которая никак не использует функцию handler), то функция handler остаётся непокрытой. Но это не имеет никакого отношения к регистрации хэндлеров. Хендлеры, зарегистрированные на строчке github.com/gebv/go-bb-tests-metrics/blob/f90db0e/main.go#L43 как раз отображаются как покрытые тестами:
image
Спасибо за развернутый ответ grey-fox
На свежую голову — да работает, считает.
Поправил код теста

make test | grep --color «ok.*coverage:»
ok github.com/gebv/go-bb-tests-metrics 0.114s coverage: 62.7% of statements in ./…
ok github.com/gebv/go-bb-tests-metrics 0.110s coverage: 78.4% of statements in ./…
ok github.com/gebv/go-bb-tests-metrics 0.115s coverage: 82.4% of statements in ./…
ok github.com/gebv/go-bb-tests-metrics 0.117s coverage: 84.3% of statements in ./…
ok github.com/gebv/go-bb-tests-metrics 0.113s coverage: 86.3% of statements in ./…
ok github.com/gebv/go-bb-tests-metrics 0.116s coverage: 86.3% of statements in ./…
ok github.com/gebv/go-bb-tests-metrics 0.112s coverage: 86.3% of statements in ./…

PS Коль работает то становится интересно проверить как оно будет работать скажем с echo и grpc сервером.
Спасибо за статью, есть несколько вопросов по п.1 «Используем интерфейсы при разработке»:

1. как быть если методов относительно много? в той же redis библиотеке их довольно много — выходит что каждый внешний метод нужно оборачивать в свой или идти в сторону кодогенерации?

2. как быть если внешний метод возвращает какой-то свой интерфейс? например pgx.Begin, возвращает транзакцию pgx.Tx (которая тоже интерфейс) — для транзакции тоже нужно писать свой интерфейс обертку и затем писать обертки для методов?

3. в обертках всегда возвращается «успех», а как быть если нужно также тестировать и случаи когда возвращаются ошибки? Ну то есть как сделать так чтобы метод-обертка мог возвращать не только успех, но и error.
1. как быть если методов относительно много? в той же redis библиотеке их довольно много — выходит что каждый внешний метод нужно оборачивать в свой или идти в сторону кодогенерации?

2. как быть если внешний метод возвращает какой-то свой интерфейс? например pgx.Begin, возвращает транзакцию pgx.Tx (которая тоже интерфейс) — для транзакции тоже нужно писать свой интерфейс обертку и затем писать обертки для методов?


Мы в статье привели немного неудачные примеры, которые вводят в заблуждение.
Не надо оборачивать каждый метод redis. Если так приходится делать, то что-то пошло не так. Т.е. Вам не нужно создавать интерфейс «редис». Лучше создавать, например, интерфейс «хранилище книг» с минимальным набором методов для работы с книгами. С помощью интерфейсов вы отделяете «слой репозиториев» от бизнес логики вашего приложения. Можно рассмотреть другой популярный пример с просторов github.

Есть, например, приложение, которое работает со статьями и их авторами. Так как работа идёт с авторами, то нужно описать «модель» (или сущность, доменный класс, названия могут от статье к статье различаться) авторов: github.com/bxcodec/go-clean-arch/blob/d452858/domain/author.go#L6. Авторы будут где-то хранится, и поэтому описывается интерфейс, необходимый для работы с хранилищем авторов github.com/bxcodec/go-clean-arch/blob/d452858/domain/author.go#L14 (мы тут не поднимаем вопрос, где должен описываться интерфейс; кто-то может считать что описывать нужно там, где описаны модели, кто-то может считать что там, где интерфейс используется). Данный интерфейс реализуется, создавая слой «репозиториев» github.com/bxcodec/go-clean-arch/blob/d452858/author/repository/mysql/mysql_repository.go#L38.

Ещё раз подчеркну, тут не оборачиваются методы конкретной библиотеки работы с базой данных, тут реализуется интерфейс для работы с хранилищем авторов.

И дальше этот «репозиторий» участвует через интерфейс в реализации бизнес логики («сценариев использования») вашего приложения github.com/bxcodec/go-clean-arch/blob/master/article/usecase/article_ucase.go#L47.

И такое использование интерфейсов помогает в тестировании приложения: github.com/bxcodec/go-clean-arch/blob/d452858/article/usecase/article_ucase_test.go#L34

Да, при этом не тестируется код, непосредственно работающий с БД (или http сервисами), но тесты репозиториев можно вынести отдельно, усложнив для них CI (или не писать их на go, оставив для интеграционных тестов).

3. в обертках всегда возвращается «успех», а как быть если нужно также тестировать и случаи когда возвращаются ошибки? Ну то есть как сделать так чтобы метод-обертка мог возвращать не только успех, но и error.


Ну в примере в обёртке возвращается и ошибка:
Get(ctx context.Context, key string) (string, error)


И если нужно проверить как поведёт себя код, если при запросе некой сущности вернётся ошибка, то можно написать соответствующий mock:

type testRedis struct{}

func (t *testRedis) Get(ctx context.Context, key string) (string, error) {
    return "", fmt.Errorf("some error")
}
func (t *testRedis) Set(ctx context.Context, key string, v interface{}) error {
    return nil
}
Большое спасибо за развернутый ответ по пп 1 и 2, так стало сильно понятней.

Но по п.3 в итоге все равно получается так что может быть только один метод Get и он всегда возвращает только либо успех (err == nil), только либо ошибку (err != nil).
Ну всегда можно сделать несколько моков, какие-то для ошибок, какие-то для успехов. Также мок можно сделать универсальнее:
type testRedis struct{
    p1 string
    p2 error
}

func (t *testRedis) Get(ctx context.Context, key string) (string, error) {
    return t.p1, t.p2
}
func (t *testRedis) Set(ctx context.Context, key string, v interface{}) error {
    return t.p2
}

И дальше в тестах использовать
&testRedis{"", fmt.Errorf("some error")}


Довольно таки универсальные моки создаются утилитами генерации моков.
Например если взять мок из второго пункта статьи, то там моку можно сказать, чтобы он сначала возвращал значения, а потом ошибку:
func TestMock(t *testing.T) {
	ctx := context.Background()
	storage := new(MockStorage)

	// Говорим что на первый вызов Get ожидаем ключ k1 и возвращаем v1
	storage.On("Get", mock.Anything, "k1").Return("v1", nil)

	// Говорим что на второй вызов Get ожидаем ключ k2 и возвращаем v2
	storage.On("Get", mock.Anything, "k2").Return("v2", nil)

	// Говорим что на второй вызов Get ожидаем ключ k3 и возвращаем ошибку
	storage.On("Get", mock.Anything, "k3").Return("", errors.New("error"))

	got, err := storage.Get(ctx, "k1")
	if got != "v1" || err != nil {
		t.Errorf("Get returns (%v, %v)", got, err)
	}
	got, err = storage.Get(ctx, "k2")
	if got != "v2" || err != nil {
		t.Errorf("Get returns (%v, %v)", got, err)
	}
	got, err = storage.Get(ctx, "k3")
	if err == nil {
		t.Errorf("Get returns (%v, %v)", got, err)
	}

	// Проверяем что было вызвано всё, что устанавливали в моке
	storage.AssertExpectations(t)
}
круто, еще раз спасибо!
Зарегистрируйтесь на Хабре, чтобы оставить комментарий