Опирается на правила: GOTEST-8, GOTEST-9, GOTEST-10, GOTEST-X4 из Go Test Strategy → раздел 3. Детерминизм — Clock и IDGenerator.

Важно знать

  • time.Now() в доменном коде запрещён — вызов прямо в Handler, Service, Aggregate делает тест недетерминированным (GOTEST-X4).
  • uuid.New() в доменном коде запрещён по той же причине — тест не может предсказать, какой ID будет присвоен записи.
  • Время инжектируется через интерфейс Clock — в продовом коде RealClock, в тесте fixedClock с заранее заданным значением.
  • UUID инжектируется через интерфейс IDGenerator — в тесте seqIDGenerator (счётчик) или staticIDGenerator (фиксированная строка).
  • Сравнение в тестеassert.Equal(t, clock.at.UTC(), gotOrder.CreatedAt.UTC()), не time.Now().
  • fixedClock и seqIDGenerator живут в тестовом пакете — они не экспортируются в продовый код.
  • newTestServer(t) передаёт реализации через конструктор — сборка зависимостей в одном месте, тест не знает про неё напрямую.
  • Параллелизм: детерминированные зависимости позволяют использовать t.Parallel() там, где изоляция обеспечена транзакцией.

Нетерминированный тест — это тест, который проходит «сегодня» и падает «завтра» не потому что код сломан, а потому что time.Now() вернул другое значение или uuid.New() сгенерировал другой ID. В базе данных появляется строка с непредсказуемым created_at, тест пытается сравнить её с «текущим» временем — и расходится. Единственный способ это устранить — вынести источник времени и идентификаторов за пределы доменного кода через интерфейс.

Clock — интерфейс и реализации

GOTEST-8: время в домене инжектируется через интерфейс Clock. Интерфейс объявляется в том же пакете, где используется — в core/order/ или в общем core/clock/.

// core/clock/clock.go
package clock

import "time"

type Clock interface {
    Now() time.Time
}

type RealClock struct{}

func (RealClock) Now() time.Time { return time.Now() }

В тесте — fixedClock с предзаданным значением:

// order_test.go (тестовый пакет)
type fixedClock struct{ at time.Time }

func (c *fixedClock) Now() time.Time { return c.at }

Service и Aggregate принимают Clock через конструктор и используют только его:

// core/order/service.go
package order

import (
    "context"
    "git.example.ru/sber/core/clock"
)

type Service struct {
    repo Repository
    clk  clock.Clock
    ids  IDGenerator
}

func NewService(repo Repository, clk clock.Clock, ids IDGenerator) *Service {
    return &Service{repo: repo, clk: clk, ids: ids}
}

func (s *Service) CreateOrder(ctx context.Context, customerID string, amount int64) (*Order, error) {
    now := s.clk.Now()
    id  := s.ids.Next()
    o := &Order{
        ID:         id,
        CustomerID: customerID,
        Amount:     amount,
        Status:     StatusPending,
        CreatedAt:  now,
    }
    return s.repo.Save(ctx, o)
}

IDGenerator — интерфейс и реализации

GOTEST-9: UUID/ID инжектируется через IDGenerator. В тесте используется seqIDGenerator (счётчик) — он делает каждый тест предсказуемым и уникальным в рамках одного запуска, или staticIDGenerator — когда тест проверяет конкретный ID.

// core/order/id.go
package order

type IDGenerator interface {
    Next() string
}
// order_test.go
import (
    "fmt"
    "sync/atomic"
)

type seqIDGenerator struct{ n atomic.Int64 }

func (g *seqIDGenerator) Next() string {
    return fmt.Sprintf("id-%d", g.n.Add(1))
}

type staticIDGenerator struct{ id string }

func (g *staticIDGenerator) Next() string { return g.id }

seqIDGenerator потокобезопасен через atomic.Int64 — при t.Parallel() несколько тестов не получат одинаковый ID.

Для доменов Product и Customer — отдельные аналогичные реализации в их тестовых пакетах:

// core/product/service_test.go
type productSeqIDs struct{ n atomic.Int64 }

func (g *productSeqIDs) Next() string {
    return fmt.Sprintf("product-id-%d", g.n.Add(1))
}

Сборка зависимостей в newTestServer

GOTEST-8 + GOTEST-9: fixedClock и seqIDGenerator передаются в newTestServer и через конструктор уходят в Service. Конкретная дата fixedClock фиксируется на известное значение — тест сравнивает createdAt именно с ней.

// order_test.go
func newTestServer(t *testing.T) (*httptest.Server, *OrderDatabasePreparer) {
    t.Helper()
    pool, _ := pgxpool.New(context.Background(), testDSN)
    t.Cleanup(pool.Close)

    q    := db.New(pool)
    repo := order.NewRepository(q)
    clk  := &fixedClock{at: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)}
    ids  := &seqIDGenerator{}
    svc  := order.NewService(repo, clk, ids)

    r := chi.NewRouter()
    orderhttp.Mount(r, svc)
    srv := httptest.NewServer(r)
    t.Cleanup(srv.Close)

    return srv, &OrderDatabasePreparer{pool: pool}
}

Если нужен конкретный ID для конкретного теста — staticIDGenerator передаётся напрямую:

func newTestServerWithStaticID(t *testing.T, orderID string) (*httptest.Server, *OrderDatabasePreparer) {
    t.Helper()
    pool, _ := pgxpool.New(context.Background(), testDSN)
    t.Cleanup(pool.Close)

    q    := db.New(pool)
    repo := order.NewRepository(q)
    clk  := &fixedClock{at: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)}
    ids  := &staticIDGenerator{id: orderID}
    svc  := order.NewService(repo, clk, ids)

    r := chi.NewRouter()
    orderhttp.Mount(r, svc)
    srv := httptest.NewServer(r)
    t.Cleanup(srv.Close)

    return srv, &OrderDatabasePreparer{pool: pool}
}

Сравнение временных полей в тесте

GOTEST-10: тест сравнивает createdAt из базы с ожидаемым значением из fixedClock.at, не с time.Now().

// TestCreateOrder_Success проверяет BR-ORDER-1: заказ создаётся с корректным createdAt.
func TestCreateOrder_Success(t *testing.T) {
    clk := &fixedClock{at: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)}
    ids := &seqIDGenerator{}
    srv, prep := newTestServerWith(t, clk, ids)

    prep.Clear(t).CreateCustomer(t, "customer-sber-1")

    resp, err := http.Post(srv.URL+"/orders", "application/json",
        strings.NewReader(`{"customerId":"customer-sber-1","amount":5000}`))
    require.NoError(t, err)
    defer resp.Body.Close()

    require.Equal(t, http.StatusCreated, resp.StatusCode)

    var body struct {
        ID        string    `json:"id"`
        CreatedAt time.Time `json:"createdAt"`
    }
    require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))

    assert.Equal(t, "id-1", body.ID)
    assert.Equal(t, clk.at.UTC(), body.CreatedAt.UTC())
}

Если доменный объект проверяется через базу напрямую через OrderDatabasePreparer:

func TestCreateOrder_PersistsCreatedAt(t *testing.T) {
    clk := &fixedClock{at: time.Date(2024, 3, 20, 9, 30, 0, 0, time.UTC)}
    ids := &staticIDGenerator{id: "order-sber-42"}
    srv, prep := newTestServerWith(t, clk, ids)

    prep.Clear(t).CreateCustomer(t, "customer-sber-1")

    resp, err := http.Post(srv.URL+"/orders", "application/json",
        strings.NewReader(`{"customerId":"customer-sber-1","amount":3000}`))
    require.NoError(t, err)
    require.Equal(t, http.StatusCreated, resp.StatusCode)

    row := prep.FindOrder(t, "order-sber-42")
    assert.Equal(t, clk.at.UTC(), row.CreatedAt.UTC())
}

.UTC() обязательно с обеих сторон — PostgreSQL хранит timestamptz в UTC, Go может вернуть time.Time в локальной зоне, сравнение без .UTC() даёт ложный негатив на машине с нелокальным time.Local.

Что запрещено

АнтипаттернПравилоЧто взамен
time.Now() в Service/Handler/AggregateGOTEST-X4s.clk.Now() через инжектированный Clock
uuid.New() в Service/Handler/AggregateGOTEST-X4s.ids.Next() через инжектированный IDGenerator
Сравнение createdAt с time.Now() в assertGOTEST-10assert.Equal(t, clk.at.UTC(), row.CreatedAt.UTC())
time.Sleep(...) вместо детерминированного ClockGOTEST-X1фиксированное время через fixedClock
assert.Eventually(...) для ожидания записи в БДGOTEST-X1синхронный HTTP-вызов + прямая проверка БД
fixedClock экспортирован в продовый пакетGOTEST-8fixedClock только в _test.go файлах
Один seqIDGenerator на весь пакет (глобальная переменная)GOTEST-9новый экземпляр на каждый newTestServer(t)

Куда дальше

  • go/base-integration-test.md — TestMain, newTestServer, Testcontainers-инфраструктура.
  • go/database-preparer.md — OrderDatabasePreparer, Clear(t), fluent Create*.
  • go/one-test.md — AAA-структура, имена Test<Action>_<Condition>_<Expected>, require vs assert.
  • Test Strategy — нормативные формулировки — нормативные формулировки правил.