Перейти к содержанию

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

Пирамида тестов: много 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) {
    // ...
}

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)
    }
}

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'ов:

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

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

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.

Варианты:

  1. Context с таймаутом: жди select { case <-ch: case <-ctx.Done(): }.
  2. eventually helper:
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)
}
  1. Синхронизация через канал: 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 запускает:

go test -race -count=1 -shuffle=on ./...
  • -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 — синтетика.

См. также