
Бюрократия семимильными шагами внедряется в процесс разработки. Людей в пиджаках интересуют лишь цифры, и это же относится к test coverage сервисов. Однако, покрытие зачастую (в том числе, благодаря создателям языка) не отображает полной картины мира. Так ли все плохо на самом деле?
Экспозиция
Разберем на примере простого сервиса
type Service1 interface { DoSmth(ctx context.Context, ids []int) ([]Model, error) } type service1 struct { repo Repository }
С таким методом, который мы хотим протестировать
package service1 import ( "context" "github.com/google/uuid" "github.com/pkg/errors" "habr-test-coverage/internal/helper" ) var ( ErrLogic = errors.New("logical error") ErrValidation = errors.New("validation error") ) func (s *service1) DoSmth(ctx context.Context, ids []int) ([]Model, error) { if len(ids) == 0 { // 1. invalid params return []Model{}, nil } if helper.HasDuplicates(ids) { // 2. bll validation error return nil, errors.Wrapf(ErrValidation, "duplicates detected in ids: %v", ids) } res, err := s.repo.GetSmth(ctx, ids) if err != nil { // 3. mockable dependency error return nil, err } if len(res) != len(ids) { // 4. logical error return nil, ErrLogic } for i := range res { randData, err := uuid.NewRandom() if err != nil { // 5. external package error return nil, err } if randData == uuid.Nil { // 6. unreachable code panic("should not happen ever, we lost faith in humanity") } res[i].Data = randData.String() } // 7. result return res, nil }
Завязка
В этом методе представлены большинство видов ошибок, которые могут быть в какой-либо функции. Давайте напишем тест. Вот только что для нас важно тестировать?
Ошибки вида 3 тестировать не имеет смысла, т.к. все равно возвращаемое значение мы определяем сами в ходе теста за счет моков. Единственная польза от них, уберечь себя от случайного удаления существующей проверки на ошибку в функции, от чего отыгрывать странно и затратно по времени.
Тоже и с ошибками вида 5, они либо же не под нашим контролем, либо же имеют специфичные кейсы для возникновения ошибок: например послать неверный формат данных в аргументы, что в теории воспроизводимо в тест кейсе, однако, почти не вероятны с реальными данными.
Ошибки вида 6 по-хорошему никогда не должны воспроизводиться, но они часто встречаются, в том числе и при вызове сторонних библиотек. Протестировать такой кейс зачастую не представляется возможным.
Развитие действия
Тестируем кейсы вида 1, 2, 4, 7
тест
package service1 import ( "context" "testing" "github.com/gojuno/minimock/v3" "github.com/google/uuid" "github.com/stretchr/testify/require" ) type mocksService struct { repo *RepositoryMock } func newMocksService(t *testing.T) *mocksService { ctrl := minimock.NewController(t) return &mocksService{ repo: NewRepositoryMock(ctrl), } } func TestDoSmth(t *testing.T) { t.Parallel() ctx := context.TODO() require := require.New(t) type in struct { ids []int } type out struct { res []Model err error } tests := []struct { name string in in setup func(*mocksService, *in) assert func(*in, *out) }{ { name: "check invalid params", in: in{ids: nil}, setup: func(service *mocksService, in *in) {}, assert: func(in *in, out *out) { require.NoError(out.err) require.Empty(out.res) }, }, { name: "check validation error", in: in{ids: []int{1, 2, 2}}, setup: func(service *mocksService, in *in) {}, assert: func(in *in, out *out) { require.ErrorIs(out.err, ErrValidation) require.Nil(out.res) }, }, { name: "check logical error", in: in{ids: []int{1, 2, 3}}, setup: func(m *mocksService, in *in) { m.repo.GetSmthMock. Expect(ctx, in.ids). Return([]Model{{}, {}}, nil) }, assert: func(in *in, out *out) { require.ErrorIs(out.err, ErrLogic) require.Nil(out.res) }, }, { name: "success", in: in{ids: []int{1, 2, 3}}, setup: func(m *mocksService, in *in) { m.repo.GetSmthMock. Expect(ctx, in.ids). Return([]Model{{ID: 1}, {ID: 2}, {ID: 3}}, nil) }, assert: func(in *in, out *out) { for i, r := range out.res { require.Equal(r.ID, in.ids[i]) _, err := uuid.Parse(r.Data) require.NoError(err) } require.NoError(out.err) }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() m := newMocksService(t) s := NewService1(m.repo) tt.setup(m, &tt.in) res, err := s.DoSmth(ctx, tt.in.ids) tt.assert(&tt.in, &out{ res: res, err: err, }) }) } }
Кульминация
На выходе получаем 21.1% покрытия
go test ./... -coverprofile cover.out && go tool cover -func cover.out
Маловато однако, давайте разбираться. Посмотрим на покрытие нашего метода в 82.4% покрыто, нас это вполне устраивает, но в чем тогда загвоздка? Смотрим здесь же на весь coverage report Видим мы то, что покрытие посчиталось там, где нам этого не нужно. Посмотрим на структуру нашего сервиса Результат вполне закономерен, утилита cover по дефолту считает покрытие для всех go test ./... -coverprofile cover.out.tmp && cat cover.out.tmp | grep -v "_mock.go" > cover.out && rm cover.out.tmp && go tool cover -func cover.out
Записываем репорт покрытия в tmp файл Копируем все в новый файл, кроме строк, заканчивающихся на тот паттерн, который вы используете при создании мок файлов. В данном случае это Удаляем tmp файл Запускаем cover И теперь покрытие 65.2%: Далее допустим, что в отделе мы договорились тестировать только бизнес логику, то бишь методы сервиса, а для проверки Отделив компоненты логически, мы не берем в расчет ненужные нам выражения, и получаем 83.3% покрытия при повторном запуске скрипта Таким образом мы увеличили coverage с 20 до >80% не написав ни единой строчки кода. Но все ли у нас хорошо теперь? Посмотрим на структуру сервисного слоя еще разок А у нас оказывается есть еще и второй сервис без единого теста, который не отобразился в репорте, т.к. в нем нет тестов. Попробуем что-то с этим сделать: можно договориться, что все файлы в проекте под директорией Следовательно, получаем уже более подробную картину для нашего проекта, видя не протестированные компоненты логики Дописав недостающие тесты, получаем 84.2% Давайте вернемся к нашему первому тесту, тут еще остались некоторые тонкости По поводу кейса 6. Протестировать его мы не можем, и странно, что такой блок вообще попадает в репорт. Уместен был бы вопрос: а есть ли способы пометить некоторые куски кода как недосягаемые для покрытия, чтобы лишний раз не портить статистику, или же игнорировать конструкции по типу паник при подсчете покрытия? Этот вопрос обсуждался не раз на форумах и issue: 1, 2, но если вкратце, то примерный ответ на все предложения от комьюнити можно свести к этому комменту Роба Пайка в одном из похожих issue. Хотя, уже есть пару неофициальных библиотек, которые решают эту проблему. В этой статье я попытался разобрать частые ошибки и недопонимания при покрытии сервисов тестами, лучшие практики и открытые дискуссии комьюнити. Соц сети: https://www.linkedin.com/in/dishanov/ Еще одна особенность флага В нашем тесте мы вызываем функцию Проверяем, включая Видим, что выражение считается протестированным Иногда это бывает полезным, а зачастую может вредить как в нашем случае, ведь мы специально не тестировали этот метод в различных сценариях. *Есть один неочевидный момент. Баг это или фича, но даже если указать только Иной раз я встречал практику (крайне не рекомендую ее), когда все тесты писали в слое имплементации (app), а не в сервисном слое. В этом случае мы по факту тестируем полностью бизнес логику сервиса, но если не использовать

Расследование

_mock.go

Испытание

bll должны быть протестированы. Давайте попробуем это отобразить в репорте добавив флаг -coverpkggo test ./... -coverpkg='./internal/service/.../bll/...' -coverprofile cover.out.tmp && cat cover.out.tmp | grep -v "_mock.go" > cover.out && rm cover.out.tmp && go tool cover -func cover.out
Ретроспектива
первый тест

Финал
Эпилог
Дополнение к -coverpkg
coverpkg, что если директория A добавлена в аргументы флага, то все выражения в ней при их вызове с теста директории B будут считаться покрытыми тестами в репорте, даже если к выражению не написаны точечные тесты. HasDuplicates из директории helper, у которой нет тестовHasDuplicates
func HasDuplicates(slice []int) bool { set := make(map[int]bool) for _, v := range slice { if set[v] { return true } set[v] = true } return false }internal/helper в coverpkggo test ./... -coverpkg='./internal/helper/...','./internal/service/.../bll/...' -coverprofile cover.out.tmp && cat cover.out.tmp | grep -v "_mock.go" > cover.out && rm cover.out.tmp && go tool cover -html cover.out 
'./internal/helper/...'вcoverpkg, то функция будет тоже считаться покрытой из-за того, что мы по факту ее вызываем в тесте DoSmth и в скрипте мы все еще тестируем все файлы в проекте: go test ./...coverpkg, то протестированный метод сервисного слоя в репорт не попадет, что не есть хорошо.
ссылка на оригинал статьи https://habr.com/ru/post/721958/
Добавить комментарий