Тестирование¶
Пирамида тестов: много unit'ов для internal/service/, умеренно integration
для internal/repository/, единичные end-to-end прогонки. Тесты пишутся
рядом с кодом, запускаются с race-детектором, не зависят от
time.Sleep и внешних сетевых сервисов.
Содержание¶
- Где лежат тесты
- Table-driven tests — стандарт
- Unit tests для
internal/service/ - Integration tests для
internal/repository/ - HTTP handler tests
- Watermill handler tests
- Race detector — обязательно
- Coverage
- Parallel
- Fixtures и golden files
- Никаких
time.Sleepв тестах - Детерминированность
- 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) {
// ...
}
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)
}
}
Integration tests для internal/repository/¶
Репозиторий работает с настоящим SQL — моки здесь бесполезны. Используем
testcontainers-go с Postgres.
func setupPostgres(t *testing.T) *pgxpool.Pool {
t.Helper()
ctx := context.Background()
pgCt, err := postgres.Run(ctx,
"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)
}
t.Cleanup(func() { _ = pgCt.Terminate(ctx) })
dsn, err := pgCt.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatal(err)
}
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pool.Close() })
if err := applyMigrations(ctx, pool); err != nil {
t.Fatal(err)
}
return pool
}
Используется так:
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'ов:
В 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.md.
Race detector — обязательно¶
-raceнаходит data race. Каждый тест вmake testпрогоняется с-race. PR без этого флага не мержится.-count=1выключает test caching — иначе Go считает флаги неизменившимися и отдаёт старый результат.
Makefile-цель:
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¶
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.
Варианты:
- Context с таймаутом: жди
select { case <-ch: case <-ctx.Done(): }. eventuallyhelper:
func eventually(t *testing.T, timeout time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(10 * time.Millisecond) // короткий poll, НЕ фиксированная пауза
}
t.Fatalf("condition not met within %s", timeout)
}
- Синхронизация через канал: fake-publisher закрывает канал после
получения сообщения, тест ждёт
<-ch.
Детерминированность¶
- 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 запускает:
-shuffle=on— случайный порядок тестов. Ловит тесты, зависящие от порядка выполнения (например, общий глобальный state между ними).-raceи-count=1— как локально.
Troubleshooting¶
Тест висит / не завершается — см.
../troubleshooting/test-hangs.md.
Поднять полный локальный стек для integration-тестов — см.
../onboarding/03-local-stack.md.
Что не делать¶
- Не вызывай
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.md—gochannel.NewGoChannelкак in-memory Watermill pub-sub для тестов.../troubleshooting/test-hangs.md— как диагностировать зависший тест.