Skip to Content
ConventionsТестирование

Тестирование

Пирамида тестов: много unit’ов для internal/service/, умеренно integration для internal/repository/, единичные end-to-end прогонки. Тесты пишутся рядом с кодом, запускаются с race-детектором, не зависят от time.Sleep и внешних сетевых сервисов.

Содержание

Где лежат тесты

  • Файлы *_test.goрядом с тестируемым кодом, в той же папке. review_service.goreview_service_test.go в той же директории.
  • Package для тестов — foo_test (black-box). Это предпочтительный вариант: тест видит только exported API и не прибивается гвоздями к внутренним деталям.
  • Package foo (white-box) допустим только когда действительно нужен доступ к unexported-символам: например, для unit-теста сложного приватного алгоритма. Не используй white-box просто ради удобства.
// review_service_test.go package service_test import ( "testing" "github.com/example/review-service/internal/service" ) func TestReviewService_Create(t *testing.T) { // ... }

Тестирование unexported helpers

Два стиля тестов, различающиеся только пакетом файла:

  • Чёрный ящик — package foo_test (external test package). Тест видит только публичный API. Изолирует проверку от внутренних деталей: при рефакторинге unexported-функций тесты не падают без причины.
  • Белый ящик — package foo (internal). Тест сидит в том же пакете и может дёргать unexported-символы.

Правило. По умолчанию пиши package foo_test. Internal-пакет допустим только когда тест реально требует доступ к private — например, сложный приватный алгоритм без public API, который всё равно нельзя протестировать через exported-функции без подготовки массы input’ов.

Если тянет в internal просто ради удобства — это сигнал, что public API плохой. Варианты:

  • Выдели helper в отдельный internal-пакет: internal/foo/internal/alg с doc-comment «used only by sibling packages». Тогда тест package alg_test работает как чёрный ящик для alg, но сам пакет недоступен извне сервиса.
  • Перепроектируй API родительского пакета, чтобы нужное поведение стало тестируемым через exported-контракт.

Не смешивай package foo и package foo_test в одном файле — Go-компилятор это не позволит. Если для файла нужны оба стиля, раздели тесты на два файла: foo_test.go (external) и foo_internal_test.go (internal).

Table-driven tests — стандарт

Один сценарий → один ряд в таблице. Так легче видеть покрытие граничных случаев и легче добавлять новый случай.

func TestRatingValidate(t *testing.T) { tests := []struct { name string input int want error }{ {"min valid", 1, nil}, {"max valid", 5, nil}, {"zero", 0, service.ErrInvalidRating}, {"negative", -1, service.ErrInvalidRating}, {"over max", 6, service.ErrInvalidRating}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() err := service.ValidateRating(tc.input) if !errors.Is(err, tc.want) { t.Fatalf("got %v, want %v", err, tc.want) } }) } }

Особенности:

  • tc := tc обязательно перед t.Parallel() — иначе все под-тесты увидят один и тот же tc (последний в цикле).
  • t.Run(tc.name, …) даёт читаемый вывод и возможность запуска одного ряда: go test -run TestRatingValidate/zero.

Unit tests для internal/service/

Service-слой зависит от repository и publisher через интерфейсы. В тесте пробрасываем фейковую реализацию.

Способов два — оба валидны, но в рамках одного сервиса выбирай один:

  1. Сгенерированные моки. mockery или go generate с github.com/stretchr/testify/mock. Преимущество — один раз описал интерфейс, моки компилируются сами.
  2. Ручные fake’ы. Простой struct, реализующий интерфейс, с счётчиками вызовов и полями «последние аргументы». Преимущество — читается как обычный Go-код, не надо учить API mock-библиотеки.

Пример fake:

type fakeReviewRepo struct { saveCalls int lastArg *domain.Review err error } func (f *fakeReviewRepo) Save(ctx context.Context, r *domain.Review) error { f.saveCalls++ f.lastArg = r return f.err }

В тесте:

func TestCreateReview_PersistsAndPublishes(t *testing.T) { repo := &fakeReviewRepo{} pub := &fakePublisher{} svc := service.NewReviewService(repo, pub) _, err := svc.Create(context.Background(), service.CreateReviewCommand{ UserID: 42, PlaceID: 7, Rating: 5, }) if err != nil { t.Fatalf("create: %v", err) } if repo.saveCalls != 1 { t.Fatalf("save calls: got %d want 1", repo.saveCalls) } if pub.lastEventType != "review.created" { t.Fatalf("event: got %q want review.created", pub.lastEventType) } }

Моки через код-генерацию (mockery)

Руками писать fake’и для 3 интерфейсов в пакете — терпимо. Для 10+ это антипаттерн: fake’и расходятся с интерфейсом (сигнатуры разъезжаются при рефакторинге), появляются дубликаты в разных тестовых файлах, вырастает boilerplate.

Инструмент — mockery/v2 (github.com/vektra/mockery).

Конфигурация

.mockery.yaml в корне сервис-репо:

with-expecter: true packages: github.com/kazmaps/review-service/internal/service: interfaces: Repo: {} MediaClient: {} Publisher: {}

Генерация запускается через go generate ./... с директивой в пакете:

//go:generate mockery

Правила

  • Моки живут в internal/mocks/ (или mocks/ рядом с интерфейсом) — никогда не в prod-пакете. Сгенерированный код не должен попадать в бинарь сервиса.
  • Интерфейсы, которые мокаются, объявлены на стороне потребителя (consumer-side interface). service объявляет тип Repo, который реализует repository.PostgresReviewRepo. Это делает моки локальными, а не глобальными — см. dependency-injection.
  • CI-проверка на drift. Job mockery --dry-run + git diff --exit-code валится, если после изменения интерфейса забыли перегенерить моки.

Когда fake руками — ok

Если пакет имеет 2–3 интерфейса и они стабильные, fake struct с счётчиками вызовов читается лучше сгенерированного кода с expecter’ом. Порог — 3 интерфейса: выше — переходи на mockery, ниже — пиши руками.

Integration tests для internal/repository/

Репозиторий работает с настоящим SQL — моки здесь бесполезны. Используем testcontainers-go с Postgres.

func setupPostgres(t *testing.T) *pgxpool.Pool { t.Helper() // Start с явным дедлайном: pull образа на холодной CI-ноде может // занять минуты, но вешать тест бесконечно на сломанной Docker-daemon // нельзя — без timeout'а `go test -timeout 10m` ловит только // глобальный тайм-аут, и тест-файл валится после 10 минут ожидания. startCtx, startCancel := context.WithTimeout(context.Background(), 90*time.Second) defer startCancel() pgCt, err := postgres.Run(startCtx, "postgres:16-alpine", postgres.WithDatabase("test"), postgres.WithUsername("test"), postgres.WithPassword("test"), testcontainers.WithWaitStrategy(wait.ForLog("ready to accept connections")), ) if err != nil { t.Fatalf("start postgres: %v", err) } // Terminate — тоже с явным дедлайном; не хотим ждать зависший контейнер // при t.Cleanup. t.Cleanup(func() { termCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() _ = pgCt.Terminate(termCtx) }) dsn, err := pgCt.ConnectionString(startCtx, "sslmode=disable") if err != nil { t.Fatal(err) } pool, err := pgxpool.New(startCtx, dsn) if err != nil { t.Fatal(err) } t.Cleanup(func() { pool.Close() }) if err := applyMigrations(startCtx, pool); err != nil { t.Fatal(err) } return pool }

Ключевое: start-контекст, terminate-контекст, миграции — все с явными дедлайнами. Тестовый helper, использующий context.Background() напрямую для long-running операций, блокирует Go-test-runner на неопределённое время при сбое Docker (socket не отвечает, образа нет, контейнер не поднялся). go test -timeout 10m ловит проблему, но только после 10 минут ожидания — это проваленный CI-run без полезного диагностического сообщения.

Timeout 90 секунд на Run — покрывает pull postgres:16-alpine с холодным cache (~30s) и healthcheck (~5s) с запасом; 15 секунд на Terminate — более чем достаточно для graceful stop.

Используется так:

func TestReviewRepo_SaveAndGet(t *testing.T) { pool := setupPostgres(t) repo := postgres.NewReviewRepo(pool) ctx := context.Background() r := &domain.Review{PlaceID: 7, UserID: 42, Rating: 5} if err := repo.Save(ctx, r); err != nil { t.Fatalf("save: %v", err) } got, err := repo.Get(ctx, r.ID) if err != nil { t.Fatalf("get: %v", err) } if got.Rating != 5 { t.Fatalf("rating: got %d want 5", got.Rating) } }

Integration-тесты можно разделить build-тегом integration, если они заметно медленнее unit’ов:

//go:build integration

В make test по умолчанию они включены (-tags=integration), на CI — тоже. Тэг нужен, только если тебе нужен «быстрый» прогон локально.

HTTP handler tests

func TestCreateReviewHandler(t *testing.T) { fakeSvc := &fakeReviewService{createResult: &domain.Review{ID: 1001}} h := handler.NewReviewHandler(fakeSvc) body := strings.NewReader(`{"place_id":7,"rating":5}`) req := httptest.NewRequest(http.MethodPost, "/v1/reviews", body) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.CreateReview(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("status: got %d want 201, body: %s", rec.Code, rec.Body.String()) } want := `{"data":{"id":1001}}` if diff := compareJSON(want, rec.Body.String()); diff != "" { t.Fatalf("body mismatch: %s", diff) } }

Для сравнения JSON:

func compareJSON(want, got string) string { var w, g any if err := json.Unmarshal([]byte(want), &w); err != nil { return "invalid want" } if err := json.Unmarshal([]byte(got), &g); err != nil { return "invalid got" } if !reflect.DeepEqual(w, g) { return fmt.Sprintf("\nwant: %s\ngot: %s", want, got) } return "" }

Либо github.com/stretchr/testify/assert.JSONEq — на усмотрение сервиса.

Watermill handler tests

Для тестов не поднимай Kafka — используй gochannel.NewGoChannel:

func TestOnReviewCreated(t *testing.T) { pubsub := gochannel.NewGoChannel(gochannel.Config{}, nil) defer pubsub.Close() svc := &fakeReviewOnCreatedService{} h := event.NewReviewCreatedHandler(svc) router, _ := message.NewRouter(message.RouterConfig{}, nil) router.AddNoPublisherHandler("test", "review.created", pubsub, h.Handle) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { _ = router.Run(ctx) }() <-router.Running() msg := message.NewMessage("evt-1", []byte(`{"review_id":1001,"place_id":7}`)) msg.Metadata.Set("Event-Type", "review.created") if err := pubsub.Publish("review.created", msg); err != nil { t.Fatalf("publish: %v", err) } // Ждём обработку через условие, не через sleep. eventually(t, 2*time.Second, func() bool { return svc.handled == 1 }) }

Больше примеров — в ../conventions/events.

Race detector — обязательно

go test -race -count=1 ./...
  • -race находит data race. Каждый тест в make test прогоняется с -race. PR без этого флага не мержится.
  • -count=1 выключает test caching — иначе Go считает флаги неизменившимися и отдаёт старый результат.

Makefile-цель:

test: go test -race -count=1 ./...

Goroutine leak detection

  • Инструмент — go.uber.org/goleak (https://github.com/uber-go/goleak ).
  • Правило. Обязательно для пакетов, где стартуют goroutines: consumer, forwarder, background worker, worker pool, любые go func() с долгоживущим циклом.

Package-level setup через TestMain

package consumer_test import ( "testing" "go.uber.org/goleak" ) func TestMain(m *testing.M) { goleak.VerifyTestMain(m, // whitelist goroutines, которые стартуют внешние либы // и не управляются нашим кодом goleak.IgnoreTopFunction("github.com/IBM/sarama.(*asyncProducer).dispatcher"), ) }

Per-test проверка

func TestConsumer(t *testing.T) { defer goleak.VerifyNone(t) // ... test body, включая явный shutdown consumer'а ... }

Whitelist и CI

  • CI уже запускает go test -race ./... — при VerifyTestMain goleak ловит leak’и автоматически и валит прогон.
  • Любой новый IgnoreTopFunction в whitelist — с justification в описании PR: какая goroutine, из какой либы, почему её нельзя закрыть явно. Без justification ревьюер блокирует.

Coverage

  • internal/service/** — минимум 70%.
  • internal/handler/**, internal/repository/** — минимум 50%.
  • internal/config/, cmd/server/ — coverage не требуем (wiring, env parsing).

Локально:

go test -race -count=1 -coverprofile=cover.out ./... go tool cover -func=cover.out | tail -n 1 # total: (statements) 73.2%

Покрытие не самоцель. 100% покрытия без проверки want == got — это 0% полезной работы. Ревьюер смотрит что проверяют тесты, а не только цифру.

Parallel

func TestSomething(t *testing.T) { t.Parallel() // ... }
  • t.Parallel() ускоряет прогон, но требует: тест не делится shared state с соседом. Глобальные переменные, общий временный файл, общий контейнер — нельзя.
  • В table-driven тестах: tc := tc + t.Parallel() внутри t.Run.
  • Integration-тесты на testcontainers — можно делать параллельными, если каждый тест поднимает свой контейнер (не шарит один на всех).

Fixtures и golden files

Тестовые данные — в testdata/:

internal/service/ ├── review_service.go ├── review_service_test.go └── testdata/ ├── review_valid.json └── review_golden.json

Golden-файлы (ожидаемый вывод) через хелпер:

func golden(t *testing.T, name string, actual []byte) { t.Helper() path := filepath.Join("testdata", name) if *updateGolden { if err := os.WriteFile(path, actual, 0o644); err != nil { t.Fatal(err) } return } want, err := os.ReadFile(path) if err != nil { t.Fatalf("read golden: %v", err) } if !bytes.Equal(want, actual) { t.Fatalf("golden mismatch\nwant:\n%s\ngot:\n%s", want, actual) } } var updateGolden = flag.Bool("update", false, "regenerate golden files")

Запуск с regen: go test -update ./.... Коммить обновлённый testdata/*.json отдельным коммитом, ревьюер смотрит diff отдельно.

Никаких time.Sleep в тестах

Запах: time.Sleep(100 * time.Millisecond) для «дождаться обработки async». Это flaky на CI: под нагрузкой реальная обработка занимает 200ms, и тест падает только на медленной раннере.

Варианты:

  1. Context с таймаутом: жди select { case <-ch: case <-ctx.Done(): }.
  2. Eventually helper (см. следующий раздел).
  3. Синхронизация через канал: fake-publisher закрывает канал после получения сообщения, тест ждёт <-ch.

Eventually: polling interval и timeout

Helper с явными опциями:

type options struct { interval time.Duration } type Option func(*options) func WithInterval(d time.Duration) Option { return func(o *options) { o.interval = d } } func Eventually(t *testing.T, condition func() bool, timeout time.Duration, opts ...Option) { t.Helper() o := options{interval: 50 * time.Millisecond} for _, opt := range opts { opt(&o) } deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if condition() { return } time.Sleep(o.interval) } t.Fatalf("condition not met in %v", timeout) }

Default polling interval — 50ms, не 10ms

Обоснование:

  • 10ms на loaded CI приводит к busy-loop: syscall-контеншн, повышенный CPU-usage раннера, flaky-падения из-за runtime-задержек.
  • 50ms даёт 20 проверок в секунду — достаточно для ловли события, но не душит CI.

Меньший интервал (20ms, 5ms) оправдан только при явной причине: тест проверяет debounce с таймингом 100ms — тогда interval=20ms имеет смысл, иначе можно пропустить переключение.

Default timeout

  • 5s — для большинства unit/integration-проверок.
  • 30s — для «сообщение дошло до consumer’а», поднятия testcontainer’а, чего-то с заведомо длинной инициализацией.
  • > 30s — сигнал, что тест не unit/integration, а e2e. E2e-тесты живут отдельно (tag e2e), не в основном прогоне.

Правило

Тест с time.Sleep(5 * time.Second)review-блокер. Допускается только:

  • Eventually(t, cond, 5*time.Second) с явным условием.
  • Event-driven wait: <-doneCh, <-ctx.Done(), wg.Wait().

Детерминированность

  • Random: если тест использует случайность, фиксируй seed (rand.New(rand.NewSource(1))). Не используй глобальный rand.Intn.
  • Время: не вызывай time.Now() напрямую в коде. Передавай в struct поле clock func() time.Time (дефолт — time.Now), в тестах подставляй фиксированное значение. Либо используй github.com/benbjohnson/clock.
  • UUID/ULID: если id прокидывается в ответ, делай генератор инъектируемым (idGen func() string).

CI runs

CI запускает:

go test -race -count=1 -shuffle=on ./...
  • -shuffle=on — случайный порядок тестов. Ловит тесты, зависящие от порядка выполнения (например, общий глобальный state между ними).
  • -race и -count=1 — как локально.

Troubleshooting

Тест висит / не завершается — см. ../troubleshooting/test-hangs. Поднять полный локальный стек для integration-тестов — см. ../onboarding/03-local-stack.

Что не делать

  • Не вызывай t.Skip без комментария, почему тест скипается. Пропущенный тест без причины = молчаливо сломанный тест.
  • Не используй init() в тестах для setup. Заведи TestMain или helper-функцию с t.Helper().
  • Не пиши в тестах fmt.Println. Используй t.Logf — оно выводится только при -v и попадает в test log.
  • Не делай тесты, зависящие от сети на реальные сервисы (google.com, S3 prod, внешние API). Всё внешнее — мок / testcontainer.
  • Не коммить testdata/*.json с реальными PII. В fixture — синтетика.

См. также

  • eventsgochannel.NewGoChannel как in-memory Watermill pub-sub для тестов.
  • ../troubleshooting/test-hangs — как диагностировать зависший тест.
Last updated on