Опирается на правила:
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/Aggregate | GOTEST-X4 | s.clk.Now() через инжектированный Clock |
uuid.New() в Service/Handler/Aggregate | GOTEST-X4 | s.ids.Next() через инжектированный IDGenerator |
Сравнение createdAt с time.Now() в assert | GOTEST-10 | assert.Equal(t, clk.at.UTC(), row.CreatedAt.UTC()) |
time.Sleep(...) вместо детерминированного Clock | GOTEST-X1 | фиксированное время через fixedClock |
assert.Eventually(...) для ожидания записи в БД | GOTEST-X1 | синхронный HTTP-вызов + прямая проверка БД |
fixedClock экспортирован в продовый пакет | GOTEST-8 | fixedClock только в _test.go файлах |
Один seqIDGenerator на весь пакет (глобальная переменная) | GOTEST-9 | новый экземпляр на каждый newTestServer(t) |
Куда дальше
- go/base-integration-test.md —
TestMain,newTestServer, Testcontainers-инфраструктура. - go/database-preparer.md —
OrderDatabasePreparer,Clear(t), fluentCreate*. - go/one-test.md — AAA-структура, имена
Test<Action>_<Condition>_<Expected>,requirevsassert. - Test Strategy — нормативные формулировки — нормативные формулировки правил.