Тестирование в Go: table-driven, Testcontainers, -race

Практичное тестирование Go-сервиса: стандартный пакет testing и table-driven как фундамент, httptest для хендлеров, интеграционные тесты через testcontainers-go на реальных Postgres и Redis, детектор гонок -race и честное покрытие без самообмана — на одном сквозном стенде с живыми замерами

В Go тестирование встроено в язык и тулчейн: go test, пакет testing, бенчмарки и детектор гонок идут из коробки, без внешнего раннера. Это сильная сторона — но и источник двух перекосов. Минимализм стандартного пакета провоцирует либо писать многословно (копипаст if got != want по всему файлу), либо тащить лишние зависимости «чтобы было красиво». Рабочая лошадка юнит-тестов в Go — table-driven: для матрицы однотипных случаев это один тест, таблица случаев, t.Run на каждый (не универсальная форма любого теста, но основная идиома для проверки набора входов). А настоящую уверенность даёт не процент покрытия, а связка с реальными зависимостями через Testcontainers и обязательный -race в CI.

Эта статья — не пересказ документации, а разбор одного маленького, но полного стенда: read-through кэш профилей (Service поверх интерфейсов ProfileRepository и Cache), HTTP-хендлер к нему, и вокруг — основные уровни тестов. Основные фактические выводы, числа и логи ниже взяты из живых прогонов этого стенда, а не из общих рассуждений. Код целиком — в go/testing.

Это восьмая статья серии «Go: глубокое погружение». Сиблинг темы, но с высоты птичьего полёта над экосистемами — тестирование в разных языках.

Гофер QA-инспектор у трёхъярусного тестового стенда: конвейер юнит-тестов с table-driven карточками, верстак httptest, кран testcontainers с ящиками Postgres и Redis; слева пирамида «быстро / надёжно / изолированно / повторимо» и секундомеры; справа блок RACE DETECTOR — «без защиты» с WARNING: DATA RACE против «с защитой (mutex)»; в центре треснувшая золотая медаль «100%», из-под которой выглядывает баг-гремлин, и табличка «100% ≠ no bugs»

В статье

Пирамида на одном стенде

Классическая «пирамида тестов» звучит абстрактно, пока не приземлить её на конкретный код. Весь стенд крутится вокруг одного объекта под тестом (SUT) — cache.Service и его HTTP-обёртки — и показывает три уровня проверки одной и той же логики read-through, плюс два ортогональных инструмента (-race, -cover), которые накладываются на любой уровень.

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

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
Уровни тестов одного SUT (read-through кэш) и время их прогона из живых замеров стенда

Дальше — каждый уровень по отдельности, с реальным кодом и реальным выводом.

Стандартный 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)
PASS

PAUSE появляется, как только подтест вызвал 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/1X-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/1405 (роутер 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 statements

coverage: 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.041s

DiscountedPrice(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.

Обсуждение в Telegram

Присоединиться →

Комментарии