В Go тестирование встроено в язык и тулчейн: go test, пакет testing, бенчмарки и детектор гонок идут из коробки, без внешнего раннера. Это сильная сторона — но и источник двух перекосов. Минимализм стандартного пакета провоцирует либо писать многословно (копипаст if got != want по всему файлу), либо тащить лишние зависимости «чтобы было красиво». Рабочая лошадка юнит-тестов в Go — table-driven: для матрицы однотипных случаев это один тест, таблица случаев, t.Run на каждый (не универсальная форма любого теста, но основная идиома для проверки набора входов). А настоящую уверенность даёт не процент покрытия, а связка с реальными зависимостями через Testcontainers и обязательный -race в CI.
Эта статья — не пересказ документации, а разбор одного маленького, но полного стенда: read-through кэш профилей (Service поверх интерфейсов ProfileRepository и Cache), HTTP-хендлер к нему, и вокруг — основные уровни тестов. Основные фактические выводы, числа и логи ниже взяты из живых прогонов этого стенда, а не из общих рассуждений. Код целиком — в go/testing.
Это восьмая статья серии «Go: глубокое погружение». Сиблинг темы, но с высоты птичьего полёта над экосистемами — тестирование в разных языках.
В статье
- Пирамида на одном стенде
- Стандартный
testingи table-driven httptest: хендлеры без сети и по сети- Интеграция через testcontainers-go
-race: детектор гонок вживую- Честное покрытие: 100% при живом баге
- testify: когда помогает, когда лишний
- Golden files и флаг
-update - Моки: ручной интерфейс vs gomock/mockery
- Граница и что дальше
- Демо и версии
Пирамида на одном стенде
Классическая «пирамида тестов» звучит абстрактно, пока не приземлить её на конкретный код. Весь стенд крутится вокруг одного объекта под тестом (SUT) — cache.Service и его HTTP-обёртки — и показывает три уровня проверки одной и той же логики read-through, плюс два ортогональных инструмента (-race, -cover), которые накладываются на любой уровень.
table-driven + фейки repo/cache
весь пакет cache ~0.11с"] H["httptest: cache.Handler
recorder + NewServer, без сети/БД"] end subgraph slow["Pre-merge / CI"] I["Интеграция: testcontainers-go
реальные Postgres + Redis
~5.6с на прогретом кеше образов"] end U --> H --> I R["-race: инструментирует любой уровень"] -.-> U C["-cover: метрика операторов, не гарантия"] -.-> U
flowchart TB
subgraph fast["Быстрый цикл — на каждое сохранение файла"]
U["Юнит: cache.Service.GetProfile
table-driven + фейки repo/cache
весь пакет cache ~0.11с"]
H["httptest: cache.Handler
recorder + NewServer, без сети/БД"]
end
subgraph slow["Pre-merge / CI"]
I["Интеграция: testcontainers-go
реальные Postgres + Redis
~5.6с на прогретом кеше образов"]
end
U --> H --> I
R["-race: инструментирует любой уровень"] -.-> U
C["-cover: метрика операторов, не гарантия"] -.-> U
Дальше — каждый уровень по отдельности, с реальным кодом и реальным выводом.
Стандартный testing и table-driven
Логика read-through проста на словах и коварна в деталях: при промахе кэша — сходить в репозиторий и записать результат обратно в кэш; при попадании — не трогать репозиторий вовсе; неизвестного пользователя — пробросить как ErrNotFound. Три поведения, и каждое надо проверять не по принципу «тест зелёный», а по конкретным побочным эффектам.
Вот сам SUT — обратите внимание, что зависимости заданы интерфейсами, и это ключ ко всей тестируемости:
type ProfileRepository interface {
GetByID(ctx context.Context, id int64) (Profile, error)
}
type Cache interface {
Get(ctx context.Context, key string) (string, bool, error)
Set(ctx context.Context, key, val string, ttl time.Duration) error
}
// GetProfile: при попадании в кэш — decode и (p, true, nil);
// при промахе — Repo.GetByID, затем backfill кэша (best-effort).
func (s *Service) GetProfile(ctx context.Context, id int64) (Profile, bool, error) {
key := CacheKey(id)
if raw, ok, err := s.Cache.Get(ctx, key); err == nil && ok {
var p Profile
if err := json.Unmarshal([]byte(raw), &p); err == nil {
return p, true, nil
}
}
p, err := s.Repo.GetByID(ctx, id)
if err != nil {
return Profile{}, false, err
}
if raw, err := json.Marshal(p); err == nil {
_ = s.Cache.Set(ctx, key, string(raw), s.TTL) // кэш best-effort
}
return p, false, nil
}Интерфейсы малы (одна и две функции), поэтому фейк для теста — это два десятка строк поверх map, без всякой генерации кода. fakeRepo дополнительно считает обращения, чтобы тест мог утверждать «в репозиторий сходили ровно один раз» или «ни разу»:
type fakeRepo struct {
profiles map[int64]cache.Profile
calls int
}
func (f *fakeRepo) GetByID(_ context.Context, id int64) (cache.Profile, error) {
f.calls++
p, ok := f.profiles[id]
if !ok {
return cache.Profile{}, cache.ErrNotFound
}
return p, nil
}Теперь table-driven идиома: таблица случаев, у каждого — своё имя, свой setup, свои ожидания. Цикл превращает каждую строку в подтест через t.Run(tc.name, …). t.Parallel() стоит и в родительском тесте, и в каждом подтесте — случаи независимы, гонять их параллельно безопасно. t.Helper() в setup и в mustMarshalProfile делает так, что при падении go test показывает строку вызова помощника, а не его нутро. (t.Cleanup фейкам тут не нужен — им нечего чистить; реальный t.Cleanup появится ниже, в интеграционном тесте, где надо гасить контейнеры.)
func TestGetProfile(t *testing.T) {
t.Parallel()
alice := cache.Profile{ID: 1, Name: "Alice", Email: "alice@example.com"}
cases := []struct {
name string
setup func(t *testing.T) (*fakeRepo, *fakeCache)
id int64
wantProfile cache.Profile
wantFromCache bool
wantErr error
wantRepoCalls int
wantCached bool // должен ли ключ появиться в кэше после вызова
}{
{
name: "cache miss reads through repo and backfills cache",
setup: func(t *testing.T) (*fakeRepo, *fakeCache) {
return &fakeRepo{profiles: map[int64]cache.Profile{1: alice}},
&fakeCache{data: map[string]string{}}
},
id: 1, wantProfile: alice, wantFromCache: false,
wantErr: nil, wantRepoCalls: 1, wantCached: true,
},
// ... ещё 6 кейсов (полный список — в стенде): hit → repo=0;
// unknown → ErrNotFound, cached=false; Cache.Get падает → miss,
// repo=1; битый JSON → miss + бэкфилл; Cache.Set падает → запрос ок,
// cached=false; произвольная ошибка repo пробрасывается наружу.
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
repo, c := tc.setup(t)
svc := &cache.Service{Repo: repo, Cache: c, TTL: time.Minute}
got, fromCache, err := svc.GetProfile(context.Background(), tc.id)
// 1) профиль/ошибка
if !errors.Is(err, tc.wantErr) {
t.Fatalf("GetProfile(%d) error = %v, want %v", tc.id, err, tc.wantErr)
}
if tc.wantErr == nil && got != tc.wantProfile {
t.Errorf("profile = %+v, want %+v", got, tc.wantProfile)
}
// 2) флаг fromCache и 3) число обращений к репозиторию
if fromCache != tc.wantFromCache {
t.Errorf("fromCache = %v, want %v", fromCache, tc.wantFromCache)
}
if repo.calls != tc.wantRepoCalls {
t.Errorf("repo.calls = %d, want %d", repo.calls, tc.wantRepoCalls)
}
// 4) содержимое кэша — напрямую по data (часть кейсов ломает Get
// через getErr), и не только НАЛИЧИЕ ключа, но и что лежащее
// значение декодируется в нужный профиль. Иначе кейс с битым JSON
// остался бы зелёным, даже перестань сервис перезаписывать кэш:
// ключ-то присутствует и до вызова.
raw, cached := c.data[cache.CacheKey(tc.id)]
if cached != tc.wantCached {
t.Errorf("cached = %v, want %v", cached, tc.wantCached)
}
if cached && tc.wantErr == nil {
var stored cache.Profile
if err := json.Unmarshal([]byte(raw), &stored); err != nil {
t.Fatalf("значение в кэше не декодируется: %v (raw=%q)", err, raw)
}
if stored != tc.wantProfile {
t.Errorf("cached profile = %+v, want %+v", stored, tc.wantProfile)
}
}
})
}
}Каждый из семи случаев проверяет четыре инварианта разом: профиль/ошибку, флаг fromCache, число обращений fakeRepo.calls и содержимое fakeCache после вызова (напрямую по fakeCache.data, а не через Cache.Get — часть кейсов намеренно ломает Get, и проверка через него дала бы ложный результат). Причём для кэша проверяем не только наличие ключа, но и что лежащее значение декодируется в нужный профиль: иначе кейс с битым JSON остался бы зелёным, даже перестань сервис перезаписывать повреждённое значение — ключ-то в data присутствует и до вызова. Это и отличает содержательный тест от «прошёл / не прошёл»: мы утверждаем не только результат, но и побочные эффекты read-through.
Первые три случая — счастливый путь и базовые контракты (промах с бэкфиллом, попадание без похода в репозиторий, ErrNotFound). Остальные четыре целят в негативные контракты, которые легко проглядеть: ошибка Cache.Get трактуется как промах (запрос всё равно успешен, читаем из репозитория), битый JSON в кэше — тоже промах с бэкфиллом валидного значения, ошибка Cache.Set не роняет запрос (кэш best-effort — значение просто не кэшируется), а произвольная ошибка репозитория (не ErrNotFound) пробрасывается наружу как есть. Отдельный TestGetProfile_PassesTTLToCache проверяет пятый инвариант вне таблицы: TTL, заданный в Service, реально доходит до Cache.Set (gotTTL == 42s), а не теряется по дороге.
Живой прогон go test ./cache/ -run TestGetProfile -v -count=1 (нативный Windows) показывает, что параллельность реальна — сам раннер go test при сборе параллельных подтестов выстраивает PAUSE/CONT (вывод сокращён — семь табличных подтестов и отдельный TestGetProfile_PassesTTLToCache идут вперемешку):
=== RUN TestGetProfile
=== PAUSE TestGetProfile
=== RUN TestGetProfile_PassesTTLToCache
=== PAUSE TestGetProfile_PassesTTLToCache
=== CONT TestGetProfile
=== RUN TestGetProfile/cache_miss_reads_through_repo_and_backfills_cache
=== PAUSE TestGetProfile/cache_miss_reads_through_repo_and_backfills_cache
=== CONT TestGetProfile_PassesTTLToCache
=== RUN TestGetProfile/cache_get_error_is_treated_as_miss,_request_still_succeeds
=== PAUSE TestGetProfile/cache_get_error_is_treated_as_miss,_request_still_succeeds
...
--- PASS: TestGetProfile_PassesTTLToCache (0.00s)
--- PASS: TestGetProfile (0.00s)
--- PASS: TestGetProfile/repo_arbitrary_error_propagates_as-is_(not_just_ErrNotFound) (0.00s)
--- PASS: TestGetProfile/unknown_user_propagates_ErrNotFound (0.00s)
--- PASS: TestGetProfile/cache_miss_reads_through_repo_and_backfills_cache (0.00s)
--- PASS: TestGetProfile/cache_get_error_is_treated_as_miss,_request_still_succeeds (0.00s)
--- PASS: TestGetProfile/cache_set_error_does_not_fail_the_request_(best-effort) (0.00s)
--- PASS: TestGetProfile/cache_hit_does_not_call_repo (0.00s)
--- PASS: TestGetProfile/corrupted_json_in_cache_is_treated_as_miss,_repo_backfills_valid_value (0.00s)
PASSPAUSE появляется, как только подтест вызвал t.Parallel() — он приостанавливается и ждёт, пока родитель соберёт все параллельные подтесты, а затем все они запускаются вместе (блок CONT). Это не косметика: если случаи делят изменяемое состояние, параллельность выведет гонку на свет (а -race её поймает — см. ниже).
httptest: хендлеры без сети и по сети
Хендлер — тонкая обёртка над сервисом: разбирает id из пути, зовёт GetProfile, раскладывает результат по HTTP-кодам и ставит заголовок X-Cache: HIT|MISS. Тестировать его можно двумя способами, и оба есть в стандартном net/http/httptest.
httptest.NewRecorder — самый дешёвый: вызываем ServeHTTP напрямую, без сокета, и читаем записанный ответ. Один тест прогоняет успешные коды на одном экземпляре хендлера (200 MISS→HIT, 404, 400) и проверяет не только статус, но и Content-Type и точный набор JSON-полей тела, а заодно — что кэш read-through виден между запросами в рамках процесса (первый GET /user/1 → X-Cache: MISS, повторный → HIT):
func TestHandlerRecorder(t *testing.T) {
t.Parallel()
alice := cache.Profile{ID: 1, Name: "Alice", Email: "alice@example.com"}
h := cache.Handler(newTestService(map[int64]cache.Profile{1: alice}))
// первый запрос — промах кэша
rec1 := httptest.NewRecorder()
h.ServeHTTP(rec1, httptest.NewRequest(http.MethodGet, "/user/1", nil))
if rec1.Code != http.StatusOK || rec1.Header().Get("X-Cache") != "MISS" {
t.Fatalf("1st: code=%d X-Cache=%q", rec1.Code, rec1.Header().Get("X-Cache"))
}
if ct := rec1.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
// ТОЧНЫЙ набор wire-полей, а не декодирование обратно в cache.Profile
// (декод в ту же структуру замаскировал бы смену схемы). Разбор в
// map[string]json.RawMessage ловит и лишнее, и переименованное поле:
var fields map[string]json.RawMessage
if err := json.Unmarshal(rec1.Body.Bytes(), &fields); err != nil {
t.Fatalf("тело — не JSON-объект: %v", err)
}
for _, k := range []string{"ID", "Name", "Email"} {
if _, ok := fields[k]; !ok {
t.Errorf("нет ожидаемого поля %q: %s", k, rec1.Body.String())
}
delete(fields, k)
}
if len(fields) != 0 {
t.Errorf("в теле лишние поля: %v", fields)
}
// повторный запрос того же id — попадание в кэш
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/user/1", nil))
if rec2.Header().Get("X-Cache") != "HIT" {
t.Errorf("2nd: X-Cache = %q, want HIT", rec2.Header().Get("X-Cache"))
}
// неизвестный id -> 404, нечисловой id -> 400
rec3 := httptest.NewRecorder()
h.ServeHTTP(rec3, httptest.NewRequest(http.MethodGet, "/user/999", nil))
// ... rec3.Code == http.StatusNotFound
rec4 := httptest.NewRecorder()
h.ServeHTTP(rec4, httptest.NewRequest(http.MethodGet, "/user/abc", nil))
// ... rec4.Code == http.StatusBadRequest
}/user/999 даёт 404, потому что fakeRepo вернул ErrNotFound, а хендлер сматчил её через errors.Is. /user/abc даёт 400 ещё до похода в сервис: strconv.ParseInt падает раньше. Это важное свойство теста — он фиксирует, что валидация входа отсекается на границе, а не проваливается внутрь бизнес-логики.
Две ветки хендлера happy-path не задевает, и их держит отдельный тест: POST /user/1 → 405 (роутер GET /user/{id} из net/http сам отвечает Method Not Allowed на зарегистрированный путь с чужим методом), а произвольная не-ErrNotFound ошибка репозитория → 500 (иначе эта ветка handler.go в тестах не исполнялась бы вовсе):
func TestHandler_MethodAndError(t *testing.T) {
t.Parallel()
// 405: путь зарегистрирован, но метод не тот
h := cache.Handler(newTestService(map[int64]cache.Profile{1: {ID: 1, Name: "Alice"}}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/user/1", nil))
// ... rec.Code == http.StatusMethodNotAllowed
// 500: репозиторий вернул произвольную (не ErrNotFound) ошибку
repo := &fakeRepo{err: errors.New("db: connection refused")}
svc := &cache.Service{Repo: repo, Cache: &fakeCache{data: map[string]string{}}, TTL: time.Minute}
rec5 := httptest.NewRecorder()
cache.Handler(svc).ServeHTTP(rec5, httptest.NewRequest(http.MethodGet, "/user/1", nil))
// ... rec5.Code == http.StatusInternalServerError
}Теперь матрица кодов действительно полная: 200/404/400/405/500 плюс Content-Type и ключи тела — все ветки handler.go под тестом.
httptest.NewServer поднимает настоящий TCP-сокет на 127.0.0.1 и даёт готовый http.Client. Это медленнее recorder’а, зато проходит весь путь: сериализацию, реальный round-trip, декодирование тела на стороне клиента. Уместно, когда важен именно транспорт (заголовки, коды, тело по проводу), а не только логика хендлера:
func TestHandlerServer(t *testing.T) {
t.Parallel()
bob := cache.Profile{ID: 2, Name: "Bob", Email: "bob@example.com"}
srv := httptest.NewServer(cache.Handler(newTestService(map[int64]cache.Profile{2: bob})))
defer srv.Close()
resp, err := srv.Client().Get(srv.URL + "/user/2")
if err != nil {
t.Fatalf("GET /user/2: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK || resp.Header.Get("X-Cache") != "MISS" {
t.Fatalf("status=%d X-Cache=%q", resp.StatusCode, resp.Header.Get("X-Cache"))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
var got cache.Profile
if err := json.Unmarshal(body, &got); err != nil { // want {ID:2, Name:"Bob", ...}
t.Fatalf("unmarshal body: %v", err)
}
}Весь пакет cache — юниты TestGetProfile (+TestGetProfile_PassesTTLToCache), три httptest-теста (TestHandlerRecorder, TestHandler_MethodAndError, TestHandlerServer) и пропущенный при -short интеграционный — прогоняется за один заход:
--- PASS: TestGetProfile (0.00s)
--- PASS: TestGetProfile_PassesTTLToCache (0.00s)
--- PASS: TestHandlerRecorder (0.00s)
--- PASS: TestHandler_MethodAndError (0.00s)
--- PASS: TestHandlerServer (0.00s)
--- SKIP: TestIntegration_ReadThrough (0.00s)
PASS
ok khorost.tech/go-testing/cache 0.092sОколо 0.1 секунды на весь пакет — включая старт тестового бинаря (единичный прогон; нативный тайминг заметно шумит от запуска к запуску). Запомните порядок: через раздел он сыграет против интеграции.
Интеграция через testcontainers-go
Фейки проверяют логику, но не проверяют контракт с реальной БД: тот же ли SQL, так ли ведёт себя redis.Nil, совпадает ли сериализация. Для этого — testcontainers-go: библиотека поднимает настоящие Postgres и Redis в Docker прямо из теста, а t.Cleanup их гасит. Никаких заранее развёрнутых сервисов и общего состояния между прогонами.
Тот же Service, но теперь на реальных адаптерах NewPGRepo(pool) и NewRedisCache(rdb). Листинг ниже — сокращённый фрагмент: подключение к контейнерам (создание pool/rdb из ConnectionString, схема и сид) свёрнуто в комментарий, полный компилирующийся тест — в стенде.
func TestIntegration_ReadThrough(t *testing.T) {
if testing.Short() {
t.Skip("integration: требуется Docker; пропуск при -short")
}
ctx := context.Background()
// пиновые теги (17.2 / 7.4), а не плавающие 17 / 7 — уменьшают дрейф версий
// (строгая воспроизводимость — через digest @sha256:…, зафиксирован в FIXTURES)
pgC, err := postgres.Run(ctx, "postgres:17.2-alpine",
postgres.WithDatabase("app"),
postgres.WithUsername("app"),
postgres.WithPassword("app"),
postgres.BasicWaitStrategies(), // ждёт двойной лог "ready" + порт
)
if err != nil {
t.Fatalf("запуск postgres-контейнера: %v", err)
}
t.Cleanup(func() {
if err := pgC.Terminate(context.Background()); err != nil {
t.Logf("terminate postgres: %v", err)
}
})
redisC, err := tcredis.Run(ctx, "redis:7.4-alpine",
testcontainers.WithWaitStrategy(
wait.ForLog("Ready to accept connections").WithStartupTimeout(30*time.Second),
),
)
if err != nil {
t.Fatalf("запуск redis-контейнера: %v", err)
}
// ... t.Cleanup(Terminate) для redis; подключение к pool/rdb, схема +
// сид профиля {42, "Ada Lovelace", ...} — полностью в стенде
svc := &Service{Repo: NewPGRepo(pool), Cache: NewRedisCache(rdb), TTL: time.Minute}
// операции — под контекстом с дедлайном, а не context.Background():
// заодно фиксируем, что реальные адаптеры работают с отменяемым ctx
opCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
// 1-й вызов: кэш пуст → идём в БД. Проверяем флаг И профиль, а не «нет ошибки»
p1, fromCache1, err := svc.GetProfile(opCtx, 42)
if err != nil {
t.Fatalf("GetProfile (1-й вызов): %v", err)
}
if fromCache1 {
t.Fatalf("1-й вызов: ожидали fromCache=false")
}
if p1 != (Profile{ID: 42, Name: "Ada Lovelace", Email: "ada@example.com"}) {
t.Fatalf("1-й вызов: неожиданный профиль: %+v", p1)
}
// побочный эффект: ключ user:42 реально в Redis, значение == профилю из БД
key := CacheKey(42)
raw, err := rdb.Get(opCtx, key).Result()
if err != nil {
t.Fatalf("значение отсутствует в Redis после 1-го вызова: %v", err)
}
var cached Profile
if err := json.Unmarshal([]byte(raw), &cached); err != nil {
t.Fatalf("значение в Redis не JSON-профиль: %v", err)
}
if cached != p1 {
t.Fatalf("Redis: %+v != профиль из БД %+v", cached, p1)
}
// TTL реально доехал до Redis: положительный PTTL ≤ сконфигурированного
if pttl, err := rdb.PTTL(opCtx, key).Result(); err != nil || pttl <= 0 || pttl > time.Minute {
t.Fatalf("PTTL(%s) = %v, err=%v — TTL не доехал до Redis", key, pttl, err)
}
// 2-й вызов: тот же id → попадание в кэш, тот же профиль
p2, fromCache2, err := svc.GetProfile(opCtx, 42)
if err != nil {
t.Fatalf("GetProfile (2-й вызов): %v", err)
}
if !fromCache2 || p2 != p1 {
t.Fatalf("2-й вызов: fromCache2=%v (want true), p2=%+v (want %+v)", fromCache2, p2, p1)
}
// негативный контракт через РЕАЛЬНЫЙ pg-адаптер: неизвестный id →
// pgx.ErrNoRows внутри pg.go → cache.ErrNotFound
if _, _, err := svc.GetProfile(opCtx, 999); !errors.Is(err, ErrNotFound) {
t.Fatalf("GetProfile(999) на реальном Postgres: err = %v, want ErrNotFound", err)
}
}Тонкость wait-стратегии стоит комментария в коде: postgres.BasicWaitStrategies() ждёт двойной лог database system is ready to accept connections (Postgres перезапускается после первичной инициализации) и готовность порта — на не-Linux Docker (Windows/Mac) порт проксируется отдельно и может подняться позже лога. Собственный WithWaitStrategy тут не нужен — он бы заменил стратегию целиком и потерял ожидание порта.
Тест проверяет контракт с БД на assert-уровне, а не «нет ошибки»: (1) первый вызов идёт в Postgres (fromCache1 == false), профиль {42, "Ada Lovelace", "ada@example.com"} из засеянной таблицы; (2) ключ user:42 реально лежит в Redis, и значение после json.Unmarshal по значению структуры равно профилю из БД (сравниваются поля Profile, а не сырые байты); (3) у ключа положительный PTTL ≤ минуты — TTL реально доехал до Redis, а не был записан без срока жизни (юнит-тест проверял лишь передачу TTL фейку — это разное); (4) второй вызов возвращает fromCache2 == true и тот же профиль; (5) запрос неизвестного id проходит весь реальный путь pgx.ErrNoRows → cache.ErrNotFound через адаптер pg.go — именно то, что фейк подменял вручную. Операции идут под context.WithTimeout, а не context.Background() — тест заодно фиксирует, что адаптеры работают с отменяемым контекстом.
Живой прогон в WSL2. Оговорка про окружение: testcontainers-go официально поддерживает Docker Desktop на Windows, но в конкретном авторском окружении (нативный Windows + Docker Desktop через named pipe npipe://) прогон упирался в таймаут проброса host-порта; рабочим обходом оказался WSL2 с сокетом unix:///var/run/docker.sock. Это окруженческое наблюдение, а не общее ограничение библиотеки:
2026/07/13 22:20:22 🐳 Creating container for image postgres:17.2-alpine
2026/07/13 22:20:23 🐳 Creating container for image testcontainers/ryuk:0.14.0
2026/07/13 22:20:26 🐳 Creating container for image redis:7.4-alpine
2026/07/13 22:20:26 🔔 Container is ready: 091fa01a522a
...
--- PASS: TestIntegration_ReadThrough (5.63s)
PASS
ok khorost.tech/go-testing/cache 5.642sЧисла стоит читать как порядок величины, а не точный множитель. Юниты + httptest пакета cache — десятки миллисекунд; интеграция с реальными Postgres+Redis — секунды (здесь 5.642 с). Разница — два порядка, и это устойчивый вывод; конкретный множитель — иллюстративная точка одиночного прогона, а не усреднённый бенчмарк. И тут важна методическая оговорка: ~0.1 с юнитов сняты на нативном Windows (тайминг там заметно шумит от прогона к прогону), а 5.642 с интеграции — в WSL2, поэтому их прямое отношение смешивает окружения и точный множитель называть не стоит. Замер в одном окружении честнее: в WSL2 -short укладывается в ~0.035–0.041 с против ~5.6 с интеграции — это ~150×. Оба варианта говорят одно и то же: миллисекунды против секунд — разница на два порядка, а не конкретное число. И это не накладные расходы теста, а реальное время: ~4 с уходит на старт трёх контейнеров (Postgres + Redis + Ryuk-reaper testcontainers/ryuk:0.14.0) с их wait-стратегиями, остальное — схема, сид, вызовы GetProfile и Terminate в cleanup. Образы уже были в локальном кеше Docker — на «холодной» машине к этому добавится сетевая загрузка слоёв.
Практический вывод для дизайна пирамиды прямой: юниты с фейками (десятки-сотни миллисекунд) — для цикла «сохранил — увидел результат», их не жалко гонять на каждое сохранение. Интеграция на testcontainers (секунды) — для pre-merge/CI, где проверяется настоящий контракт с БД, но не для inner loop. Про клиенты Redis из Go, Java и Rust, которые здесь скрыты за адаптером NewRedisCache, — отдельный разбор в сравнении redis-клиентов.
-race: детектор гонок вживую
Детектор гонок — динамический инструмент: он не анализирует код статически, а инструментирует каждое обращение к памяти и ловит конкурентный доступ без happens-before. Флаг -race вешается на go test. Чтобы показать его в деле, стенд держит две реализации кэша с идентичной конкурентной нагрузкой (50 пар горутин Set/Get) в отдельном минимальном пакете race/ — намеренно не в cache.Service, чтобы демонстрация инструмента не смешивалась с логикой SUT: UnsyncCache — прямой доступ к map без синхронизации, и SyncCache — та же логика под sync.RWMutex.
type UnsyncCache struct{ m map[string]string } // broken: голая map
func (c *UnsyncCache) Get(key string) string { return c.m[key] }
func (c *UnsyncCache) Set(key, val string) { c.m[key] = val }
type SyncCache struct { // fixed: под RWMutex
mu sync.RWMutex
m map[string]string
}
func (c *SyncCache) Get(key string) string {
c.mu.RLock(); defer c.mu.RUnlock(); return c.m[key]
}
func (c *SyncCache) Set(key, val string) {
c.mu.Lock(); defer c.mu.Unlock(); c.m[key] = val
}Ключевое свойство UnsyncCache: гонка недетерминирована — её проявление зависит от реально исполненного конкурентного пути. Без -race под такой нагрузкой рантайм с очень высокой вероятностью (но не гарантированно) роняет процесс фаталом concurrent map writes — это не panic, recover его не ловит; при ином переплетении прогон может и завершиться зелёным. Детектор -race тоже динамический: он рапортует о гонке на фактически исполненном доступе — при такой нагрузке отчёт весьма вероятен, но формальной гарантии «поймает на любом прогоне» нет (детектор видит только реально исполненные пути, а не все возможные переплетения). Именно поэтому -race обязателен в CI, а не «по желанию»: он не доказывает отсутствие гонок, но систематически прогоняя код под нагрузкой, вы ловите те, что реально проявляются, — и делаете это до релиза, а не в проде. Под -race детектор рапортует:
==================
WARNING: DATA RACE
Read at 0x00c00012e690 by goroutine 10:
runtime.mapaccess1_faststr()
khorost.tech/go-testing/race.(*UnsyncCache).Get()
race/cache_race.go:31 +0x9e
Previous write at 0x00c00012e690 by goroutine 9:
runtime.mapassign_faststr()
khorost.tech/go-testing/race.(*UnsyncCache).Set()
race/cache_race.go:36 +0xa5
...
testing.go:1712: race detected during execution of test
--- FAIL: TestUnsyncCache_Race (0.01s)
FAIL khorost.tech/go-testing/race 0.013sЗа один прогон детектор выдал четыре отдельных WARNING: DATA RACE: два read/write между Get и Set (mapaccess1_faststr против mapassign_faststr без happens-before) и два write/write между конкурентными Set — все по одному ключу "k" (тест других ключей не использует). Разные адреса в отчёте (0x00c00012e690 и т. п.) — это внутренняя память карты (заголовок, бакеты, слоты), а не разные ключи: гонка идёт за один и тот же элемент несинхронизированной map. Тест завершился FAIL со специальной строкой testing.go:1712: race detected during execution of test — это ожидаемый «сломанный» артефакт, UnsyncCache небезопасен намеренно.
Та же нагрузка на SyncCache под -race — абсолютно чисто:
--- PASS: TestSyncCache_NoRace (0.00s)
PASS
ok khorost.tech/go-testing/race 1.012sОбратите внимание на цифры: сам тест — 0.00s, но ok-строка показывает 1.012 с. Это не компиляция: race-инструментированный бинарь собирается один раз до запуска тестового процесса и в ok-строку go test (время выполнения) не входит. Эта секунда — дефолтная послетестовая пауза самого race-рантайма: детектор гонок при выходе из процесса дополнительно спит atexit_sleep_ms миллисекунд (по умолчанию 1000) — фиксированная пауза перед выходом (документация описывает её именно как задержку на выходе, без обещаний, что за это время что-то доуспеет). Подавив паузу через GORACE=atexit_sleep_ms=0, ту же команду укладываем в 0.010 с — то есть та секунда была целиком послетестовой паузой, а не работой детектора. Это не значит, что -race «бесплатен»: инструментирование доступов к памяти обычно замедляет исполнение в 2–20× (и увеличивает память в 5–10 раз) — просто на этом микротесте оверхед теряется в шуме. Здесь мы лишь отделили паузу atexit_sleep_ms от собственно прогона, а не измерили стоимость инструментирования:
$ go test ./race/ -run TestSyncCache_NoRace -race -count=1
ok khorost.tech/go-testing/race 1.012s
$ GORACE=atexit_sleep_ms=0 go test ./race/ -run TestSyncCache_NoRace -race -count=1
ok khorost.tech/go-testing/race 0.010sКомпиляция под -race действительно дороже обычной, но эта конкретная цифра её не измеряет. Сам -race не бесплатен и в рантайме (кратный оверхед по CPU и памяти), поэтому его место — в CI и в отладочных прогонах, а не в каждом локальном go test. Как читать отчёт детектора построчно, где искать happens-before и как ловить утечки горутин — в отладке конкурентности в Go.
Честное покрытие: 100% при живом баге
Покрытие соблазняет: цифра растёт, приятно ставить её целью в CI. Стенд показывает, почему это самообман. Пакет coverage/ — одна функция без ветвлений:
// БАГ: pct не валидируется. При pct > 100 цена уходит в минус.
func DiscountedPrice(base int, pct int) int {
discount := base * pct / 100
return base - discount
}Тест покрывает «нормальные» входы — pct = 0, 10, 50, 100:
func TestDiscountedPrice(t *testing.T) {
cases := []struct{ name string; base, pct, want int }{
{"no discount", 1000, 0, 1000},
{"ten percent", 1000, 10, 900},
{"half price", 1000, 50, 500},
{"full discount", 1000, 100, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := DiscountedPrice(tc.base, tc.pct); got != tc.want {
t.Errorf("DiscountedPrice(%d, %d) = %d, want %d", tc.base, tc.pct, got, tc.want)
}
})
}
}Прогон:
--- PASS: TestDiscountedPrice (0.00s)
PASS
coverage: 100.0% of statements
ok khorost.tech/go-testing/coverage 0.044s coverage: 100.0% of statementscoverage: 100.0% of statements — оба оператора функции исполнены (go test -cover меряет именно покрытие операторов (statements), а не строк). И при этом функция содержит живой баг. Запустим тест, который его ловит (в стенде он по умолчанию SKIP, чтобы основной прогон оставался зелёным, и включается переменной DEMO_BUG_CAUGHT=1):
validate_test.go:61: DiscountedPrice(1000, 150) = -500 — цена ушла в минус: pct>100 не валидируется (баг пойман)
--- FAIL: TestDiscountedPrice_BugCaught (0.00s)
FAIL khorost.tech/go-testing/coverage 0.041sDiscountedPrice(1000, 150) = -500 — реальное отрицательное число. Суть в том, что у бага нет отдельного оператора или ветки, которые тесты «не задели»: оба оператора исполняются на любом входе, включая pct=150. Метрика покрытия операторов (statements) отвечает на вопрос «был ли оператор исполнен хотя бы раз», а не «проверены ли граничные и некорректные значения входа». Пропущен не участок кода — пропущено значение (pct вне диапазона 0..100), а его go test -cover в принципе обнаружить не способен. Чтобы поймать такое, нужен либо явный тест на границу, либо property-based/fuzz-проверка инварианта «цена после скидки не бывает отрицательной». Отсюда практика: покрытие полезно как индикатор непокрытых участков (упало — значит появился мёртвый для тестов код), и порог покрытия в CI имеет право на жизнь как регрессионный ограничитель («не опускаться ниже достигнутого»). Опасно другое — делать процент единственной целью: 100% операторов ничего не гарантируют о корректности, как показывает DiscountedPrice.
Вокруг стенда: три врезки на будущее
Всё выше опирается на компилирующийся стенд: каждое число и лог сняты с живых прогонов, а листинги в статье местами сокращены до фрагментов (таблица кейсов, setup контейнеров, часть ассертов свёрнуты в комментарии // ...) — полный исполняемый код в go/testing. Следующие три темы — концептуальные врезки, а не части стенда: они очерчивают инструменты, которые понадобятся, когда задача перерастёт голый testing, но в самом стенде намеренно не задействованы (он специально держится на стандартной библиотеке). Читать их стоит как карту, а не как разобранный пример.
testify: когда помогает, когда лишний
Все тесты стенда написаны на голом testing — ручные ассерты t.Fatalf/t.Errorf. При этом github.com/stretchr/testify присутствует в go.mod — но только транзитивно, как зависимость testcontainers-go, сами тесты его не зовут. Это осознанный выбор, и вот граница.
testify/assert и require дают читаемые проверки: require.NoError(t, err) вместо if err != nil { t.Fatalf(...) }, assert.Equal(t, want, got) с аккуратным diff при падении. Разница между пакетами принципиальна: require.* останавливает тест при провале (как t.Fatalf), assert.* — фиксирует ошибку и продолжает (как t.Errorf). Правило простое: require для предусловий, без которых дальше нет смысла (соединение с БД не поднялось), assert — для независимых проверок, где хочется увидеть все провалы разом.
Когда testify лишний: если проверка — это got != want на сравнимом типе, ручной if короче и не добавляет зависимость. Стенд именно такой, поэтому и обходится без него. Когда помогает: много ассертов подряд, сложные структуры (где diff testify экономит минуты чтения), или assert.Eventually для проверок с ретраями. Не «всегда» и не «никогда» — по месту.
Golden files и флаг -update
Golden file — эталон ожидаемого вывода, лежащий в testdata/. Тест сравнивает свежий вывод с содержимым файла; при осмысленном изменении вывода файл перегенерируют флагом. Идиома в двух строках:
var update = flag.Bool("update", false, "перезаписать golden-файлы")
func TestRender(t *testing.T) {
got := render(input)
golden := filepath.Join("testdata", "render.golden")
if *update {
if err := os.WriteFile(golden, got, 0o644); err != nil {
t.Fatalf("запись golden %s: %v", golden, err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("чтение golden %s: %v", golden, err)
}
if !bytes.Equal(got, want) {
t.Errorf("вывод разошёлся с %s (обновить: go test -update)", golden)
}
}Golden files окупаются на больших стабильных выводах: сгенерированный JSON/HTML, отформатированный отчёт, кодоген. Ревью diff’а .golden-файла показывает влияние изменения нагляднее, чем длинный литерал в коде. Дисциплина одна: -update запускают осознанно и читают получившийся diff — иначе golden превращается в штамп «что вышло, то и правильно».
Моки: ручной интерфейс vs gomock/mockery
Go-интерфейсы малы — часто одна-две функции, — поэтому ручной фейк тривиален. fakeRepo и fakeCache из раздела table-driven — это весь «мок-фреймворк», который понадобился стенду: map плюс счётчик обращений. Пока интерфейс мал и фейк пишется за минуту, генераторы избыточны.
Инструменты вроде gomock (go.uber.org/mock) или mockery генерируют моки из интерфейса и дают декларативные ожидания: «метод GetByID должен быть вызван ровно раз с аргументом 42 и вернуть вот это». Они окупаются, когда интерфейс широкий (5–10+ методов — ручной фейк раздувается), когда важна проверка вызовов (порядок, аргументы, число — а не только возвращаемое значение), или когда таких интерфейсов десятки и хочется единообразия. testify/mock — та же ниша, но ожидания задаются в рантайме, без кодогена.
Оборотная сторона генераторов — over-mocking: если замокать то, что дёшево поднять по-настоящему, тест начинает проверять собственные ожидания, а не поведение системы. Стенд показывает альтернативу прямо: вместо мока Postgres и Redis — реальные контейнеры через testcontainers. Правило границы: мок — для того, что дорого, недетерминированно или недоступно в тесте (внешний платёжный API); реальная зависимость — для того, что поднимается за секунды в Docker.
Граница и что дальше
Что этот стенд намеренно не покрывает, чтобы не растекаться: бенчмарки (testing.B, -benchmem) и профилирование под нагрузкой; fuzzing (testing.F) — тот самый инструмент, что поймал бы баг DiscountedPrice через инвариант, а не точечный кейс; property-based тесты; end-to-end через поднятый бинарь; и тест-контейнеры для очередей (Kafka/NATS) — механика та же, что для Postgres/Redis.
Практический минимум, который вытекает из фактов выше: table-driven как основа юнитов, httptest для HTTP-границы, testcontainers для контракта с БД в CI, обязательный -race в пайплайне и трезвое отношение к покрытию. Про то, как эти уровни устроены в других языках и что в Go сделано иначе, — тестирование в разных языках. Про недетерминизм, из-за которого тесты «мигают», и как его лечить системно — диагностика и лечение flaky-тестов.
Демо и версии
Весь код — стенд go/testing: пакеты cache/ (table-driven, httptest, интеграция), race/ (гонка и её исправление), coverage/ (100% при живом баге). Числа и логи в статье — из живых прогонов на даты 2026-07-13–14 (базовые прогоны сняты 13-го; расширенная HTTP-матрица и пересъём таймингов -short — 14-го при доработке по ревью).
| Компонент | Версия |
|---|---|
| Go | go1.26.3 (нативный Windows windows/amd64 и WSL2 linux/amd64) |
| Docker Engine (WSL2) | 29.6.1 |
github.com/jackc/pgx/v5 |
v5.10.0 |
github.com/redis/go-redis/v9 |
v9.21.0 |
github.com/testcontainers/testcontainers-go (+modules/postgres, modules/redis) |
v0.43.0 |
github.com/stretchr/testify |
v1.11.1 (транзитивно, тесты его не используют) |
| Образ Postgres / Redis (интеграция) | postgres:17.2-alpine / redis:7.4-alpine (пиновые теги уменьшают дрейф версий; строгая репро — через digest, см. FIXTURES) |
| Ryuk (testcontainers reaper) | testcontainers/ryuk:0.14.0 |
Интеграция и -race прогонялись в WSL2 Ubuntu-24.04: в авторском окружении нативный Windows + Docker Desktop через named pipe упирался в таймаут host-порта (testcontainers-go Docker Desktop на Windows поддерживает — это частное окруженческое наблюдение, WSL2 стал рабочим обходом), а -race требует CGO/gcc. Юниты, httptest и coverage — нативный Windows.
Комментарии