Бюрократия семимильными шагами внедряется в процесс разработки. Людей в пиджаках интересуют лишь цифры, и это же относится к 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
cover report
cover report

Маловато однако, давайте разбираться. Посмотрим на покрытие нашего метода в html

82.4% покрыто, нас это вполне устраивает, но в чем тогда загвоздка? Смотрим здесь же на весь coverage report.

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

Видим мы то, что покрытие посчиталось там, где нам этого не нужно. Посмотрим на структуру нашего сервиса.

Результат вполне закономерен, утилита cover по дефолту считает покрытие для всех гошных файлов* в директории, если в директории* находится хотя бы один тестовый файл

Для начала, нужно избавиться от мок файлов в расчете покрытия, т.к. они обычно сгенерированы автоматически и могут содержать 1000+ строк кода. К сожалению, встроенные утилита в этом плане ограничена, поэтому можем воспользоваться такой хитростью.

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
  1. Записываем репорт покрытия в tmp файл;

  2. Копируем все в новый файл, кроме строк, заканчивающихся на тот паттерн, который вы используете при создании мок файлов. В данном случае это _mock.go;

  3. Удаляем tmp файл;

  4. Запускаем cover.

И теперь покрытие 65.2%:

Далее допустим, что в отделе мы договорились тестировать только бизнес логику, то бишь методы сервиса, а для проверки dal и app у нас есть e2e тесты, которые не отображаются в покрытии. Некоторые сущности тестировать нету никакого смысла, например методы моделек. В таком случае нам нужно слегка логически декомпозировать структуру сервиса.

Отделив компоненты логически, мы не берем в расчет ненужные нам выражения, и получаем 83.3% покрытия при повторном запуске скрипта.

Таким образом мы увеличили coverage с 20 до >80% не написав ни единой строчки кода.

Испытание

Но все ли у нас хорошо теперь? Посмотрим на структуру сервисного слоя еще разок.

А у нас оказывается есть еще и второй сервис без единого теста, который не отобразился в репорте, т.к. в нем нет тестов. Попробуем что-то с этим сделать: можно договориться, что все файлы в проекте под директорией bll должны быть протестированы. Давайте попробуем это отобразить в репорте добавив флаг -coverpkg.

go 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

Следовательно, получаем уже более подробную картину для нашего проекта, видя не протестированные компоненты логики.

Дописав недостающие тесты, получаем 84.2%.

Ретроспектива

Давайте вернемся к нашему первому тесту, тут еще остались некоторые тонкости.

первый тест

По поводу кейса 6. Протестировать его мы не можем, и странно, что такой блок вообще попадает в репорт. Уместен был бы вопрос: а есть ли способы пометить некоторые куски кода как недосягаемые для покрытия, чтобы лишний раз не портить статистику, или же игнорировать конструкции по типу паник при подсчете покрытия?

Этот вопрос обсуждался не раз на форумах и issue: 1, 2, но если вкратце, то примерный ответ на все предложения от комьюнити можно свести к этому комменту Роба Пайка в одном из похожих issue. Хотя, уже есть пару неофициальных библиотек, которые решают эту проблему.

Финал

В этой статье я попытался разобрать частые ошибки и недопонимания при покрытии сервисов тестами, лучшие практики и открытые дискуссии комьюнити.

Соц сети:

https://www.linkedin.com/in/dishanov/

Эпилог

Дополнение к -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 в coverpkg

go 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 ./...

Иной раз я встречал практику (крайне не рекомендую ее), когда все тесты писали в слое имплементации (app), а не в сервисном слое. В этом случае мы по факту тестируем полностью бизнес логику сервиса, но если не использовать coverpkg, то протестированный метод сервисного слоя в репорт не попадет, что не есть хорошо.

Комментарии (0)