Тестирование
Пирамида тестов: много unit’ов для internal/service/, умеренно integration
для internal/repository/, единичные end-to-end прогонки. Тесты пишутся
рядом с кодом, запускаются с race-детектором, не зависят от
time.Sleep и внешних сетевых сервисов.
Содержание
- Где лежат тесты
- Тестирование unexported helpers
- Table-driven tests — стандарт
- Unit tests для
internal/service/ - Моки через код-генерацию (mockery)
- Integration tests для
internal/repository/ - HTTP handler tests
- Watermill handler tests
- Race detector — обязательно
- Goroutine leak detection
- Coverage
- Parallel
- Fixtures и golden files
- Никаких
time.Sleepв тестах - Eventually: polling interval и timeout
- Детерминированность
- CI runs
- Troubleshooting
- Что не делать
- См. также
Где лежат тесты
- Файлы
*_test.go— рядом с тестируемым кодом, в той же папке.review_service.go→review_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 через интерфейсы.
В тесте пробрасываем фейковую реализацию.
Способов два — оба валидны, но в рамках одного сервиса выбирай один:
- Сгенерированные моки.
mockeryилиgo generateсgithub.com/stretchr/testify/mock. Преимущество — один раз описал интерфейс, моки компилируются сами. - Ручные 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 ./...— приVerifyTestMaingoleak ловит 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.jsonGolden-файлы (ожидаемый вывод) через хелпер:
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, и тест падает только на медленной раннере.
Варианты:
- Context с таймаутом: жди
select { case <-ch: case <-ctx.Done(): }. Eventuallyhelper (см. следующий раздел).- Синхронизация через канал: 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 — синтетика.
См. также
events—gochannel.NewGoChannelкак in-memory Watermill pub-sub для тестов.../troubleshooting/test-hangs— как диагностировать зависший тест.