Тема 05

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

Секция, которую большинство кандидатов проваливает не незнанием, а поверхностностью: «ну, табличные тесты, testify, моки». Middle отличается тем, что может объяснить механику — почему t.Cleanup и defer в родительском тесте срабатывают в разное время, почему 100 % покрытия ничего не доказывают и зачем моку вообще нужен интерфейс, объявленный не там, где реализация.

Что реально проверяют этой секцией

Два навыка. Первый — умеешь ли ты писать тесты, которые не флакают (от flaky — «ненадёжный»: так называют тест, который на неизменном коде то падает, то проходит; в речи — «флак», «флейк»): без time.Sleep, без глобального состояния, с честной синхронизацией. Второй — умеешь ли ты проектировать код так, чтобы его вообще можно было протестировать: зависимости за узкими интерфейсами, время и случайность инжектированы, побочные эффекты вынесены на границу. Вопрос «как замокать time.Now()» — это на самом деле вопрос про архитектуру, и собеседующий это знает.

5.1Юнит-тесты

Пакет testing фреймворком не назовёшь: это тонкая обвязка над обычным Go-кодом. Большинство «странностей» тестов (почему t.Fatal нельзя из горутины, почему параллельный сабтест видит закрытую базу) объясняется одной строчкой из рантайма. По ней и видно, кто механику понял, а кто ответ заучил.

Сначала — шесть слов, которые дальше идут как общеизвестные

Тесту можно верить, когда он падает при сломанном коде и молчит, когда всё в порядке. Инструменты для такого теста зовутся шестью словами, и половина из них так и осталась без перевода с английского.

1. Табличный тест — список случаев плюс один общий цикл проверки

Есть двадцать пар «вход → ожидаемый выход». Можно написать двадцать почти одинаковых функций, а можно один слайс структур (это и есть «таблица») и один цикл, который прогоняет каждую строку через одну и ту же проверку. Второе и называется табличным тестом (table-driven test), и в Go так пишут по умолчанию.

Строк выходит меньше, но ценно другое: тест отделяет данные от логики проверки. Новый случай добавляется одной строкой в таблице, копировать функцию не нужно. И читается такой тест как список требований к коду.

2. Фикстура — заранее заготовленные входные данные для теста

Фикстурой (fixture, «нечто закреплённое») называют подготовленное состояние, с которого тест начинает: заполненный объект Order, JSON-пейлоад настоящего запроса, дамп базы, набор строк в таблице. Живёт либо файлом в директории testdata/, либо функцией-конструктором в коде теста.

Слово путает тем, что в других языках (pytest, JUnit) «фикстурой» называют ещё и setup/teardown — код подготовки и уборки. В Go эту роль играют TestMain и t.Cleanup, а фикстурой обычно зовут именно данные.

3. Golden file — файл-эталон, с которым сравнивают вывод

Иногда ожидаемый результат слишком велик, чтобы писать его литералом прямо в тесте: отрендеренный HTML на 200 строк, сгенерированный SQL, большой JSON-ответ. Тогда эталон кладут отдельным файлом рядом с тестом, а тест сравнивает свой вывод с содержимым этого файла. Такой файл называют golden file («золотой», то есть образцовый) или просто «эталоном».

У приёма есть фирменная особенность: специальный флаг (обычно -update) перезаписывает эталон текущим выводом. В этом его удобство и главная опасность, к которой ещё вернёмся.

4. Покрытие — доля кода, которую тесты хотя бы один раз исполнили

Покрытие (coverage) отвечает на вопрос «сколько кода вообще побывало под тестом». Считается механически: при сборке в каждый кусок кода вставляется счётчик, прогон тестов их взводит, а go test -cover печатает долю взведённых. «Покрытие 72 %» означает буквально «72 % операторов пакета хоть раз выполнились, пока шли тесты».

На этом часто ловят: покрытие показывает, что строка исполнилась, а не что её результат проверили. Тест без единого сравнения даст те же проценты, что и тест с двадцатью. Цифра хорошо находит места, куда «вообще никто не заходил», а качество тестов по ней не оценить (об этом отдельный раздел ниже).

5. Фаззинг — тест, который сам придумывает входные данные

Фаззинг (fuzzing, от fuzz — «пух», «размытость») переворачивает обычную схему. В обычном тесте ты сам пишешь вход и проверяешь конкретный ответ. В фаззинге входы, миллионы вариантов, генерирует движок, а проверяешь ты уже свойство, которое обязано выполняться всегда: «не паникует», «не зацикливается», «разобрал и собрал обратно — получилось то же самое».

Фаззер в Go coverage-guided, то есть «направляемый покрытием». Случайные байты вслепую он не бросает: следит, какие ветки кода открыл очередной вход, и дальше мутирует те входы, что залезли в новый код. Так он находит то, до чего руками не додумаешься.

6. Корпус — накопленный набор входов для фаззера

Корпусом (corpus, «свод») называют набор входных данных, на которых работает фаззер. Частей у него две. Семенной корпус (seed corpus) ты собираешь руками: несколько реальных примеров и несколько заведомо злых. Сгенерированный корпус движок наполняет сам и сохраняет в него каждый вход, который открыл новую ветку кода.

Когда фаззер находит падение, он записывает виновный вход файлом в testdata/fuzz/. С этого момента вход становится обычным тест-кейсом и прогоняется на каждом go test, так что у бага сразу появляется регрессионный тест.

Что происходит, когда ты набираешь go test

go test и компилирует, и генерирует код. Он собирает три группы файлов в один временный бинарь и запускает его:

  • Сам пакетorder.go, package order.
  • Внутренние тесты лежат в order_test.go с package order. Видят неэкспортируемые идентификаторы, могут тестировать приватную функцию напрямую.
  • Внешние тесты объявлены в отдельном файле, например order_ext_test.go, как package order_test. Единственный случай, когда в одной директории вместе собираются два имени пакета. Видят только публичный API, зато могут импортировать пакеты, которые импортируют order, и циклов не возникает. Так тестируется net/http изнутри стандартной библиотеки.

Дальше go test генерирует файл _testmain.go: в нём func main(), списки найденных TestXxx, BenchmarkXxx, FuzzXxx, ExampleXxx и вызов testing.MainStart, а за ним m.Run() или твой TestMain. Никакой рефлексии в рантайме, никакого сканирования — список тестов фиксируется на этапе сборки, поэтому «динамически зарегистрировать тест» в Go можно только сабтестом через t.Run.

Имя теста ищется по префиксу: после Test должна идти не строчная буква. func Testfoo не запустится. Раньше это случалось молча — классическая потеря часа жизни; с Go 1.24 go test сам гоняет vet-анализатор tests, и он такое имя ловит. Тест запускается из директории своего пакета, поэтому относительные пути вида testdata/x.json всегда работают.

Что go test делает с одним TestXxx 1. Сборка order.go + order_test.go + package order_test → бинарь order.test 2. TestMain(m) если объявлен в пакете setup на весь пакет обязан вызвать m.Run() 3. m.Run() фильтр -run, -shuffle семафор -parallel N go tRunner(t, fn) на тест 4. tRunner своя горутина на тест defer: стек Cleanup и сигнал родителю Тело теста — по порядку выполнения регистрация (push) Стек Cleanup — LIFO t.Setenv("DSN", "...") d := t.TempDir() t.Cleanup(db.Close) t.Fatal("не сошлось") db.Close() os.RemoveAll(d) DSN = прежнее значение порядок обратный регистрации t.Fatal вызывает runtime.Goexit: тело теста обрывается прямо на этой строке — но defer'ы tRunner уже стоят, поэтому весь стек Cleanup всё равно отработает.
Жизненный цикл теста. Каждый тест живёт в горутине tRunner с предустановленным defer. Отсюда два следствия: Cleanup переживает t.Fatal, а вызывать t.Fatal из чужой горутины нельзя — Goexit убьёт её, а не тест.

Анатомия: t.Run, t.Helper, t.Cleanup и компания

func TestOrderService(t *testing.T) {
    // 1. Изолируем окружение: после теста значение восстановится само.
    t.Setenv("APP_ENV", "test")          // паникует, если тест параллельный
    dir := t.TempDir()                   // своя директория, удалится сама
    ctx := t.Context()                   // Go 1.24: отменяется перед Cleanup

    db := openDB(t, dir)
    t.Cleanup(func() { db.Close() })     // не defer: разница видна на параллельных сабтестах

    // 2. Сабтест: своё имя, свой *testing.T, своя горутина.
    t.Run("создание заказа", func(t *testing.T) {
        got, err := New(db).Create(ctx, "user-1")
        requireNoErr(t, err)             // хелпер, см. ниже
        if got.Status != "new" {
            t.Errorf("status = %q, хотим %q", got.Status, "new")
        }
    })

    t.Run("пустой пользователь", func(t *testing.T) {
        _, err := New(db).Create(ctx, "")
        if !errors.Is(err, ErrNoUser) {
            t.Fatalf("err = %v, хотим ErrNoUser", err)
        }
    })
}

// С t.Helper() строка ошибки укажет на вызывающего, а не на эту функцию.
func requireNoErr(t *testing.T, err error) {
    t.Helper()
    if err != nil {
        t.Fatalf("неожиданная ошибка: %v", err)
    }
}

// $ go test -v
// === RUN   TestOrderService
// === RUN   TestOrderService/создание_заказа
// === RUN   TestOrderService/пустой_пользователь
// --- PASS: TestOrderService (0.00s)
//     --- PASS: TestOrderService/создание_заказа (0.00s)
//     --- PASS: TestOrderService/пустой_пользователь (0.00s)
// PASS
// ok  	order	0.273s
//
// Сабтест печатается отдельной строкой «=== RUN» с именем через слэш;
// пробелы в имени заменяются на подчёркивания. Время у каждого своё,
// а последнее число меняется от запуска к запуску.
МетодЧто делает на самом делеПодвох
t.Run(name, f) Запускает f в новой горутине и блокируется до её завершения Если f вызовет t.Parallel()Run вернётся сразу, не дожидаясь
t.Helper() Помечает функцию как хелпер: при выводе ошибки её кадр пропускается Пометка живёт на *testing.T, а не на горутине: работает и когда хелпер зовут из побочной горутины. Ставить принято первой строкой, главное — раньше, чем хелпер сообщит об ошибке
t.Cleanup(f) Кладёт f в стек, который разматывается LIFO после теста и всех его сабтестов Это не defer: ждёт параллельных детей
t.TempDir() Новая уникальная директория, удаляется в Cleanup Каждый вызов — новая директория, а не одна и та же
t.Setenv(k,v) Ставит переменную окружения и восстанавливает прежнюю в Cleanup Паникует в паре с t.Parallel() — окружение процессное, общее
t.Chdir(dir) Go 1.24: меняет рабочую директорию с возвратом Так же несовместим с t.Parallel()
t.Context() Go 1.24: контекст, отменяемый прямо перед запуском Cleanup Не заменяет context.WithTimeout для проверки таймаутов
t.Attr(k,v) Go 1.25: печатает в лог теста пару ключ-значение как атрибут — машиночитаемую метку теста (номер тикета, окружение, версия схемы). Видна в go test -json Ключ без пробелов, значение без переводов строки. Смысл ключей не задан — его придумывает твоя CI-система
t.Output() Go 1.25: io.Writer, пишущий в тот же поток, что и t.Log, но без файла-строки и без автоматического перевода строки Ровно то, чего не хватало, чтобы отдать *testing.T в slog.New(slog.NewTextHandler(t.Output(), nil)) — раньше приходилось писать переходник
t.ArtifactDir() Go 1.26: директория, куда тест складывает файлы-артефакты — дампы, скриншоты, HAR-логи, тела ответов Без флага -artifacts это временная директория, которую удалят после теста. С флагом — сохранится
t.Error / t.Fatal Error — пометить провал и продолжить; Fatal — пометить и runtime.Goexit() Fatal из побочной горутины убьёт только её: тест помечен проваленным, но не остановлен и может зависнуть
t.Log Копит вывод в буфер теста Видно только с -v (тогда печатается сразу, мимо буфера) либо если тест упал
t.Skip Goexit с пометкой SKIP Пропущенный тест зелёный в CI — «скипнул и забыл» живёт годами
Go 1.26: t.ArtifactDir() и флаг -artifacts для дампов, которые CI должен сохранить

Артефактом здесь называют любой файл, который тест произвёл и который хочется посмотреть после прогона: дамп упавшего ответа, скриншот из браузерного теста, HAR-лог, профиль, сгенерированный отчёт. До 1.26 канонического места для них не было — писали в t.TempDir() (удаляется сразу) или руками в /tmp (не подбирается CI и никогда не убирается).

func TestRenderPDF(t *testing.T) {
    got, err := Render(fixtureInvoice(t))
    if err != nil { t.Fatal(err) }

    if !bytes.Equal(got, want) {
        // Кладём фактический результат рядом: в CI его можно скачать и открыть.
        path := filepath.Join(t.ArtifactDir(), "actual.pdf")
        if err := os.WriteFile(path, got, 0o644); err != nil { t.Fatal(err) }
        t.Fatalf("рендер разошёлся с эталоном, факт сохранён в %s", path)
    }
}
$ go test ./...                                   # ArtifactDir = временная папка, удалится
$ mkdir -p ./out
$ go test -artifacts -outputdir=./out ./...       # файлы останутся в ./out/_artifacts/

Три детали, на которых спотыкаются. Первая: без флага -artifacts метод всё равно работает — просто отдаёт временную директорию, которую удалят после теста. Поэтому код теста один для обоих режимов, а сохранять ли файлы, решает флаг. Вторая: директория из -outputdir должна существовать заранее, иначе прогон падает с mkdir ... no such file or directory. Третья: у каждого теста и каждого сабтеста своя уникальная директория, и директория сабтеста лежит не внутри родительской, а рядом с ней. В -json путь приезжает отдельным событием {"Action":"artifacts","Test":"...","Path":"..."}, по нему CI-система и собирает файлы.

Go 1.25: t.Attr и t.Output — две мелочи, которые упрощают жизнь в CI
  • t.Attr(key, value) вешает на тест машиночитаемую метку. В выводе с -v это строка === ATTR TestFoo issue PROJ-123, а в go test -json приходит отдельное событие {"Action":"attr","Key":"issue","Value":"PROJ-123"}. Смысл ключей стандарт не задаёт, их придумывает твоя CI-система или тест-репортер — номер тикета, имя окружения, версия схемы БД, владелец теста. Раньше метки гоняли через t.Log и регулярку, теперь есть штатный канал.
  • t.Output() возвращает io.Writer в тот же поток, что и t.Log, но без префикса «файл:строка» и без автоматического перевода строки. С ним логгер приложения можно отдать прямо в тест. slog.New(slog.NewTextHandler(t.Output(), nil)) — и логи сервиса едут в вывод того теста, который их вызвал, а не в общий stderr, где при -parallel всё перемешано.
Самая частая ошибка новичка в тестах на конкурентность

t.Fatal, t.FailNow, require.* из testify — всё это в итоге зовёт runtime.Goexit(). Вызванные внутри go func(){ ... }(), они убивают эту горутину, но не тест: провал запишется, а тело теста пойдёт дальше. В лучшем случае тест покраснеет, доработав до конца на заведомо плохих данных, в худшем — висит до -timeout (10 минут по умолчанию), потому что кто-то ждал результат из убитой горутины. Правило: из побочных горутин можно только t.Error/t.Log, а лучше — вернуть ошибку в канал и упасть уже в горутине теста.

TestMain: единственная точка глобального setup

var testDB *sql.DB

func TestMain(m *testing.M) {
    // flag.Parse() вызывать не нужно, m.Run() сделает это сам,
    // но свои флаги надо объявить до m.Run().
    ctx := context.Background()
    ctr, dsn := startPostgres(ctx)        // контейнер поднимается один раз на пакет
    testDB = mustOpen(dsn)
    mustMigrate(testDB)

    code := m.Run()                       // здесь бегут все тесты пакета

    testDB.Close()
    ctr.Terminate(ctx)                    // os.Exit не выполнит defer, убираем руками
    os.Exit(code)
}

Если TestMain объявлен, тестовый бинарь вызывает его вместо прямого запуска тестов. С Go 1.15 os.Exit(code) необязателен: если TestMain просто вернётся, обёртка сама передаст результат m.Run() в os.Exit. Но привычка писать os.Exit живёт, и вместе с ней живёт баг: os.Exit не выполняет defer. Контейнер остаётся висеть, файлы не удаляются.

Практика

TestMain заводит общее на весь пакет состояние, которое связывает тесты между собой и с порядком запуска. Оставляй в нём только то, что действительно дорого поднимать на каждый тест: контейнер с БД, миграции, goleak.VerifyTestMain(m). Остальное уходит в фабрики-хелперы с t.Cleanup. И проверяй пакет флагом -shuffle=on: если тесты падают при перемешивании, значит они завязаны на порядок.

Табличные тесты: как их пишут, чтобы они не сгнили

Табличный тест держится на разделении данных и проверки, и отсюда главное правило: тело цикла должно быть единственным путём исполнения. Если внутри появились if tc.kind == ..., таблица распалась на два разных теста и её пора разделить.

// --- код под тестом -------------------------------------------------
var (
    ErrLoginEmpty   = errors.New("login is empty")
    ErrLoginTooLong = errors.New("login is too long")
    ErrBadEmail     = errors.New("email is invalid")
    ErrWeakPassword = errors.New("password is too weak")
)

type Signup struct {
    Login, Email, Password string
}

func Validate(s Signup) error {
    switch {
    case s.Login == "":
        return ErrLoginEmpty
    case utf8.RuneCountInString(s.Login) > 32:
        return fmt.Errorf("%w: %d рун", ErrLoginTooLong, utf8.RuneCountInString(s.Login))
    case !strings.Contains(s.Email, "@"):
        return ErrBadEmail
    case len(s.Password) < 8:
        return ErrWeakPassword
    }
    return nil
}
// --- тест -----------------------------------------------------------
func TestValidate(t *testing.T) {
    valid := Signup{Login: "gopher", Email: "g@example.com", Password: "hunter22"}

    // Хелпер-мутатор копирует валидный объект и вносит одну правку,
    // так негативный кейс отличается от позитивного ровно одним полем.
    with := func(f func(*Signup)) Signup { s := valid; f(&s); return s }

    tests := []struct {
        name string
        in   Signup
        want error          // nil = ожидаем успех
    }{
        {"валидный", valid, nil},
        {"логин пустой", with(func(s *Signup) { s.Login = "" }), ErrLoginEmpty},
        {"логин ровно 32 руны", with(func(s *Signup) { s.Login = strings.Repeat("я", 32) }), nil},
        {"логин 33 руны", with(func(s *Signup) { s.Login = strings.Repeat("я", 33) }), ErrLoginTooLong},
        {"email без собаки", with(func(s *Signup) { s.Email = "gexample.com" }), ErrBadEmail},
        {"пароль 7 символов", with(func(s *Signup) { s.Password = "1234567" }), ErrWeakPassword},
        {"пароль ровно 8", with(func(s *Signup) { s.Password = "12345678" }), nil},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            err := Validate(tc.in)
            // errors.Is, а не err == tc.want и не сравнение строк:
            // ErrLoginTooLong обёрнут через %w и содержит количество рун.
            if !errors.Is(err, tc.want) {
                t.Fatalf("Validate() = %v, хотим %v", err, tc.want)
            }
        })
    }
}
Что в этом примере оценят на собесе
  • Границы, а не «пример из середины»: 32 и 33 руны, 7 и 8 символов. Баги живут на границах.
  • Руны, а не байты: len("яяя") == 6. Кейс с кириллицей ловит эту ошибку.
  • Одно отличие от валидного объекта: негативный кейс проверяет ровно одну причину отказа, а не «всё сломано сразу».
  • errors.Is вместо ==: код может обернуть ошибку контекстом, и тест от этого падать не должен. Сравнение err.Error() == "login is empty" сочтут красным флагом.
  • Имена кейсов по-человечески: они попадают в вывод и в -run (пробелы превращаются в подчёркивания: -run 'TestValidate/логин_33_руны').

Когда сравниваются структуры, а не ошибки, reflect.DeepEqual даёт бесполезное «not equal». Стандартом де-факто стал github.com/google/go-cmp: он печатает diff и умеет игнорировать поля.

if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(Order{}, "CreatedAt", "ID")); diff != "" {
    t.Errorf("Order mismatch (-want +got):\n%s", diff)
}

t.Parallel: что происходит на самом деле

t.Parallel() ничего не отправляет в фон, он означает «поставь меня на паузу и разбуди вместе с остальными». Внутри метод делает две вещи: сигналит родителю, что тот может продолжать, и блокируется на канале-барьере родителя. Родительский t.Run возвращает управление немедленно, цикл идёт дальше, и так все сабтесты копятся на барьере. Барьер закрывается только тогда, когда тело родительской функции полностью вернулось.

Родитель и три сабтеста с t.Parallel на одной оси времени TestX тело: 3 x t.Run tRunner родителя ждёт завершения всех сабтестов t.Cleanup sub #1 старт тело sub #1 sub #2 старт тело sub #2 sub #3 старт тело sub #3 defer родителя t.Cleanup родителя t0 t1 t2 t3 t4 t0 — старт TestX: цикл вызывает t.Run на каждый кейс, каждый сабтест стартует в своей горутине. t1 — каждый сабтест дошёл до t.Parallel(), заснул на барьере, а t.Run вернулся сразу. Тело родителя закончилось — и его defer'ы срабатывают ЗДЕСЬ, когда сабтесты ещё не выполнили ни строчки полезного кода. t2 — tRunner родителя закрывает барьер: сабтесты просыпаются; одновременно бежит не больше -parallel N штук. t3/t4 — все дети завершились, и только теперь родитель разматывает свой стек t.Cleanup и рапортует результат.
t.Parallel на оси времени. Между t1 и t2 лежит вся разница между defer и t.Cleanup: первый закрывает ресурс до того, как параллельные сабтесты им воспользуются, второй ждёт их завершения.
// Плохо: база закроется в t1
func TestRepo(t *testing.T) {
    db := openDB(t)
    defer db.Close()

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            // sql: database is closed
            _ = db.PingContext(t.Context())
        })
    }
}
// Хорошо: Cleanup дождётся детей (t3)
func TestRepo(t *testing.T) {
    db := openDB(t)
    t.Cleanup(func() { db.Close() })

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            // соединение живо
            _ = db.PingContext(t.Context())
        })
    }
}

Что выведет код?

Модуль с go 1.21 в go.mod, запуск go test -v:

func TestSum(t *testing.T) {
    cases := []struct {
        name     string
        a, b, want int
    }{
        {"два плюс два", 2, 2, 4},
        {"два плюс три", 2, 3, 6}, // намеренно сломанный кейс
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            if got := tc.a + tc.b; got != tc.want {
                t.Errorf("%s: %d, хотим %d", tc.name, got, tc.want)
            }
        })
    }
}
Ответ: упадут оба сабтеста, и оба напечатают «два плюс три»

До Go 1.22 переменная цикла tc заводится одна на весь цикл. Имя сабтеста вычисляется в момент вызова t.Run, поэтому имена два_плюс_два и два_плюс_три корректные. А тела запускаются после завершения цикла (момент t2 на схеме), когда tc уже указывает на последний элемент. Вывод:

$ go test -v
=== RUN   TestSum
=== RUN   TestSum/два_плюс_два
=== PAUSE TestSum/два_плюс_два
=== RUN   TestSum/два_плюс_три
=== PAUSE TestSum/два_плюс_три
=== CONT  TestSum/два_плюс_два
=== CONT  TestSum/два_плюс_три
=== NAME  TestSum/два_плюс_два
    sum_test.go:17: два плюс три: 5, хотим 6
=== NAME  TestSum/два_плюс_три
    sum_test.go:17: два плюс три: 5, хотим 6
--- FAIL: TestSum (0.00s)
    --- FAIL: TestSum/два_плюс_два (0.00s)
    --- FAIL: TestSum/два_плюс_три (0.00s)
FAIL
exit status 1
FAIL	sum	0.348s

Под -v текст t.Errorf печатается не внутри блока --- FAIL, а отдельно, под маркером === NAME: параллельные сабтесты пишут в общий поток вперемешку, и === NAME помечает, чья это строка. Порядок строк от запуска к запуску разный: если ошибка идёт сразу за строкой «=== CONT» того же сабтеста, метки «=== NAME» перед ней нет. Без -v вывод короче и текст ошибки вложен прямо в --- FAIL:

$ go test
--- FAIL: TestSum (0.00s)
    --- FAIL: TestSum/два_плюс_два (0.00s)
        sum_test.go:17: два плюс три: 5, хотим 6
    --- FAIL: TestSum/два_плюс_три (0.00s)
        sum_test.go:17: два плюс три: 5, хотим 6
FAIL
exit status 1
FAIL	sum	0.172s

До 1.22 это лечили строкой tc := tc первой в теле цикла. С Go 1.22 переменная цикла создаётся заново на каждой итерации, и код работает как ожидается — но только если в go.mod написано go 1.22 или новее: семантика привязана к языковой версии файла, а не к версии тулчейна. Поэтому старый модуль, собранный свежим компилятором, ведёт себя по-старому. go vet (анализатор loopclosure) ловит именно этот паттерн с t.Parallel.

Глубже: три разных «параллельно» в go test
  • -parallel N (по умолчанию GOMAXPROCS) задаёт, сколько тестов внутри одного бинаря могут бежать одновременно. Это семафор: сабтест, разбуженный барьером, ещё ждёт свободного слота.
  • -p N (тоже GOMAXPROCS) ограничивает число пакетов, которые собираются и тестируются параллельно при go test ./.... Разные бинари, разные процессы. Интеграционные тесты на общей БД в CI обычно взрывает как раз он, а не -parallel.
  • Верхнеуровневые тесты без t.Parallel выполняются строго по очереди, и каждый успевает дождаться своих параллельных детей. Поэтому параллельные сабтесты разных последовательных родителей друг с другом не пересекаются. На эту гарантию можно опираться, когда фикстуры общие.
Когда параллелить не стоит
  • Тест трогает процессное состояние: os.Setenv, os.Chdir, глобальные переменные, prometheus.DefaultRegisterer, flag, подменённый http.DefaultTransport. Рантайм это даже проверяет: t.Setenv в параллельном тесте паникует.
  • Тесты делят одни и те же строки в БД или общий брокер: параллельность превращает их в генератор случайных падений.
  • Тест измеряет время (таймауты, дедлайны, rate limiter): под нагрузкой от соседей измерение поплывёт. Бенчмарки параллелят отдельно — через b.RunParallel.
  • Тестов мало и они быстрые: параллельность добавит 0 мс выигрыша и класс новых багов. Смысл появляется там, где тесты ждут сеть, диск или контейнеры.
Измерения и параллельность: что поменяли в Go 1.25 и 1.26

Пункт про «тест измеряет время» распространяется и на замеры аллокаций. testing.AllocsPerRun(runs, f) считает, сколько раз в среднем f выделяет память в куче, и для этого он на время замера ставит GOMAXPROCS в 1. Если рядом бежит параллельный тест, он аллоцирует в тот же момент — и число уезжает. Отсюда флейк: локально «2 аллокации», в CI то 2, то 3.

Go 1.25 закрыл эту дыру грубо и правильно: AllocsPerRun теперь паникует с текстом testing: AllocsPerRun called during parallel test, если в момент вызова выполняется хоть один параллельный тест. Учитываются только бегущие параллельные тесты. Сабтест, который вызвал t.Parallel() и спит на барьере, ещё не запущен — паники не будет. А вот два верхнеуровневых t.Parallel()-теста, один из которых меряет аллокации, уронят прогон сразу. Хватит и второго в одиночку: он сам считается бегущим параллельным тестом.

func TestA(t *testing.T) { t.Parallel(); time.Sleep(200 * time.Millisecond) }

func TestB(t *testing.T) {
    t.Parallel()
    // panic: testing: AllocsPerRun called during parallel test
    _ = testing.AllocsPerRun(10, func() { _ = make([]byte, 16) })
}

Вторая новость касается b.Loop(), формы бенчмарка из Go 1.24 (for b.Loop() { ... } вместо for range b.N: сама сбрасывает таймер после setup и не даёт компилятору выкинуть тело цикла как мёртвый код). За эту защиту сначала платили оптимизациями: в 1.24–1.25 наличие b.Loop мешало компилятору инлайнить код в теле цикла, и бенчмарк заодно мерил накладные расходы на вызов. Результат искажался ровно в ту сторону, которую обычно и оптимизируют. В Go 1.26 это исправили отдельным пунктом релиза: b.Loop больше не мешает инлайну. Вместо запрета оптимизаций компилятор точечно оборачивает результаты присваиваний и вызовов внутри тела цикла в runtime.KeepAlive — код живой, но инлайнится. Проверить можно одной командой go test -bench . -gcflags=-m: в выводе должно быть inlining call to ... для функции из тела цикла.

Покрытие: как считается и почему врёт

Go меряет покрытие операторов (statement coverage). При сборке с -cover исходник ещё до компилятора переписывается: каждая функция разбивается на базовые блоки, и в начало каждого блока вставляется инкремент счётчика. В конце прогона счётчики сериализуются в профиль. Отсюда режимы:

ФлагЧто делает
-covermode=setПо умолчанию: блок посещали или нет (bool)
-covermode=countСколько раз посещали — видно горячие и «единожды задетые» ветки
-covermode=atomicТо же, но атомарным инкрементом: счёт не теряется, когда код бежит в нескольких горутинах. Обязателен с -race
-coverpkg=./...Считать покрытие чужих пакетов: интеграционный тест в tests/ покрывает internal/
-coverprofile=c.outЗаписать профиль; дальше go tool cover -func=c.out или -html=c.out
$ go test -covermode=atomic -coverpkg=./... -coverprofile=cover.out ./...
$ go tool cover -func=cover.out | tail -1        # итоговый процент
$ go tool cover -html=cover.out -o cover.html    # построчная раскраска
$ go build -cover -o app ./cmd/app               # Go 1.20+: покрытие обычного бинаря
$ mkdir -p /tmp/cov                              # каталог должен существовать заранее
$ GOCOVERDIR=/tmp/cov ./app                      # прогнали e2e, собрали счётчики
$ go tool covdata percent -i=/tmp/cov
100 % строк — и половина путей не проверена все строки зелёные: coverage 100.0 % func Discount(u User, sum int) int { if u.VIP && sum > 1000 { return sum / 2 } if u.Coupon != "" { sum -= 100 } return sum } два теста: VIP без купона и обычный с купоном Coupon == "" Coupon != "" VIP = false VIP = true не проверено тест 2 тест 1 не проверено Пройдено 2 пути из 4. И ни один тест не спрашивает, что вернёт функция при sum = 50 — минус пятьдесят. Чего покрытие не видит в принципе 1) комбинации условий; 2) значения на границах; 3) отсутствующий код — забытая проверка nil не имеет строки, которую можно покрыть; 4) качество ассертов — тест без единого t.Error даёт ровно те же 100 %; 5) порядок и гонки в конкурентном коде.
Почему процент врёт. Покрытие показывает, какие строки исполнялись, а не какие свойства проверены. Метрика отвечает на вопрос «где точно не смотрели», и только на него.
Формулировка про «хороший процент»

Правильный ответ звучит как «числа зависят от слоя». Для чистой доменной логики, парсеров, валидаторов и расчётов нормально 90 % и выше, там это дёшево. Хендлерам и репозиториям хватает 60–80 %, выше начинается тестирование фреймворка. Сгенерированный код, моки, DTO, main.go и обвязку миграций исключают из подсчёта, иначе метрика превращается в шум. Полезнее абсолютного процента правило «не падает»: покрытие нового кода в PR не ниже текущего. Гонка за 100 % рождает тесты на геттеры и тесты вида «вызвали функцию, ошибку не проверили».

Глубже: чем проверить качество самих тестов

Мутационное тестирование: инструмент вносит в код мелкие правки (>>=, +-, удаление вызова) и смотрит, упадёт ли хоть один тест. Если код изменён, а тесты зелёные — покрытие было декоративным. В Go это go-mutesting и gremlins. Медленно, поэтому применяют точечно к критичному пакету. Ответ «мы смотрим не на процент покрытия, а на mutation score в биллинге» на собесе звучит сильно.

testify: assert vs require

Разница ровно одна: assert.X при провале зовёт t.Errorf и возвращает false, тест продолжается. require.X зовёт t.FailNow() (по сути runtime.Goexit()), и тест обрывается на этой строке.

func TestGetUser(t *testing.T) {
    u, err := svc.Get(ctx, 42)

    require.NoError(t, err)   // без этого проверки ниже упадут на nil pointer dereference
    require.NotNil(t, u)      // предусловие: дальше без объекта смысла нет

    assert.Equal(t, "gopher", u.Login)   // а это независимые проверки:
    assert.Equal(t, 42, u.ID)            // хотим увидеть все расхождения за один прогон
    assert.True(t, u.Active)
}
Правило выбора в одну строку

require ставят на предусловия, после провала которых остальной тест либо упадёт паникой, либо напечатает бессмысленный мусор (err, nil-указатель, длина слайса перед индексацией). assert оставляют для независимых утверждений о результате. Тест, который весь состоит из require, отдаёт по одному расхождению за прогон; тест, который весь на assert, падает паникой на первом же nil.

Три ловушки testify, на которых ловят
  • require из побочной горутины зовёт тот же Goexit, что и t.Fatal: убьёт горутину, а не тест. В горутинах только assert.
  • assert.Equal чувствителен к типу: assert.Equal(t, 1, int64(1)) падает, потому что под капотом ObjectsAreEqual сравнивает через reflect.DeepEqual после проверки байтов. Для «равны по значению» есть assert.EqualValues, но чаще правильнее привести типы явно.
  • assert.Nilassert.NoError: assert.Nil идёт через рефлексию и проходит для типизированного nil-указателя внутри интерфейса, хотя err != nil в бою вернёт true. Для ошибок годятся только NoError/ErrorIs/ErrorAs.

У противоположного лагеря своя позиция: команда Go и большая часть стандартной библиотеки принципиально обходятся без testify и пишут if got != want { t.Errorf(...) } плюс cmp.Diff для структур. Аргументы: нет магии в стектрейсах, сообщение об ошибке пишет автор теста, порядок аргументов в assert.Equal(t, want, got) постоянно путают местами. На собесе полезно назвать оба подхода и сказать, что важнее единообразие внутри репозитория.

Время, случайность, конкурентность: три источника недетерминизма

Тест обязан быть функцией от входа. Всё, что приходит «из внешнего мира» (time.Now(), rand, планировщик горутин), надо либо инжектировать, либо накрыть детерминированной синхронизацией.

Время: инъекция часов

// Узкий интерфейс: столько методов, сколько реально нужно коду.
type Clock interface {
    Now() time.Time
}

type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }

type Session struct {
    clock Clock
    ttl   time.Duration
}

func (s *Session) Expired(issuedAt time.Time) bool {
    return s.clock.Now().Sub(issuedAt) > s.ttl
}

// --- в тесте ---
type fakeClock struct {
    mu  sync.Mutex
    now time.Time
}
func (f *fakeClock) Now() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.now }
func (f *fakeClock) Advance(d time.Duration) { f.mu.Lock(); f.now = f.now.Add(d); f.mu.Unlock() }

func TestSessionExpired(t *testing.T) {
    start := time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC)
    clk := &fakeClock{now: start}
    s := &Session{clock: clk, ttl: 30 * time.Minute}

    if s.Expired(start) { t.Fatal("свежая сессия не должна протухнуть") }
    clk.Advance(31 * time.Minute)               // мгновенно, без единого Sleep
    if !s.Expired(start) { t.Fatal("через 31 минуту сессия обязана протухнуть") }
}

Готовые реализации: github.com/benbjohnson/clock, k8s.io/utils/clock/testing (умеют фейковые таймеры и тикеры). Если код зависит от таймаута, делай таймаут параметром конфигурации, а не константой в теле функции: тест поставит 10 мс вместо 30 секунд.

Глубже: testing/synctest — фейковое время для целой горутинной группы

В Go 1.24 появился экспериментальный пакет testing/synctest (за GOEXPERIMENT=synctest), а в Go 1.25 стал стабильным и доступен без всяких GOEXPERIMENT; API устоялся на двух функциях, Test и Wait. Код запускается в «пузыре», внутри которого время фиктивное и прыгает вперёд мгновенно, как только все горутины пузыря надёжно заблокированы. time.Sleep(time.Hour) внутри пузыря отрабатывает за наносекунды, а synctest.Wait() ждёт, пока все горутины пузыря не встанут на блокировку, и получается надёжная точка синхронизации вместо time.Sleep(100*time.Millisecond). Именно этого не хватало, чтобы тестировать таймауты, ретраи с backoff и context-дедлайны.

Go 1.27 добавил третью функцию, synctest.Sleep(d): это time.Sleep(d) плюс synctest.Wait() одним вызовом. Нужна она из-за неприятной мелочи: если тест и тестируемый код спят одинаковое время, часы пузыря двигаются один раз и будят обоих — а кто из них побежит первым, не определено. Тест почти всегда хочет побежать вторым: сначала система под тестом дорабатывает то, что проснулось по таймеру, и снова встаёт на блокировку, а проверка идёт только потом. Sleep так и поступает: зовёт Wait() сразу после пробуждения. Отсюда правило: внутри пузыря пиши synctest.Sleep, а не time.Sleep.

// Go 1.25
func TestRetryBackoff(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        c := NewClient(WithBackoff(time.Second, 5))
        done := make(chan error, 1)
        go func() { done <- c.Do(context.Background()) }()

        // Пока тест ждёт результат, спят все горутины пузыря, и его часы
        // перескакивают через паузы бэкоффа: реально никто не ждёт.
        if err := <-done; err == nil {
            t.Fatal("ждали ошибку после исчерпания попыток")
        }
    })
}
// Go 1.27: synctest.Sleep поспит и дождётся, пока все успокоятся
func TestCacheTTL(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        c := NewCache(time.Minute)   // внутри фоновая горутина выкидывает протухшее по таймеру
        defer c.Close()              // без этого горутина переживёт тест, и Test упадёт с deadlock

        c.Set("k", "v")
        synctest.Sleep(time.Minute)  // ровно time.Sleep(time.Minute) + synctest.Wait()

        // уборщик проснулся от того же тика часов пузыря и уже отработал.
        // С голым time.Sleep порядок пробуждения двух горутин не определён, и тест флейкает.
        if _, ok := c.Get("k"); ok {
            t.Fatal("ключ должен был протухнуть")
        }
    })
}

Случайность

  • Инжектируй источник: поле rnd *rand.Rand или интерфейс Shuffler. Тест получает rand.New(rand.NewSource(42)) с детерминированной последовательностью.
  • Глобальный math/rand с Go 1.20 сам засеивается случайно при старте (rand.Seed объявлен устаревшим, а с Go 1.24 вообще ничего не делает), так что «зафиксировать seed глобально» больше не вариант. В math/rand/v2 (Go 1.22) функции Seed нет вовсе.
  • crypto/rand подменяется через io.Reader-параметр: io.ReadFull(r, buf) в своём коде, а в тесте bytes.Reader с фиксированными байтами. В стандартных crypto-пакетах с Go 1.26 часть функций (генерация ключей RSA и ECDSA, подпись ECDSA) такой параметр игнорирует, для них есть testing/cryptotest.SetGlobalRandom.
  • Если нужна именно случайность, это уже property-based подход: фаззинг (см. ниже) либо testing/quick (заморожен, но рабочий).

Конкурентность

  • -race обязателен в CI. Детектор ловит только те гонки, которые реально произошли в этом прогоне. Стоит он замедления в 2–20 раз и роста памяти, зато ложных срабатываний почти нет. Отсюда практика: go test -race -count=5 ./... ночью.
  • Никаких time.Sleep для «подождать, пока горутина доработает». На загруженном CI-раннере Sleep даёт флак, на свободном теряет секунды. Синхронизируй каналом, sync.WaitGroup или errgroup.
  • Любое ожидание в тесте ограничивай таймаутом: select { case <-done: case <-time.After(2*time.Second): t.Fatal("таймаут") }. Без этого упавший тест вешает весь бинарь до -timeout, а потом печатает дамп всех горутин.
  • -cpu=1,4 прогоняет тест при разных GOMAXPROCS — иногда вылезают сценарии, невидимые на одном ядре.

Golden files и фикстуры

Golden file («эталон») берут, когда ожидаемый результат не влезает литералом в код: отрендеренный HTML, сгенерированный SQL, JSON-ответ API, форматированный отчёт. Эталон лежит рядом в testdata/, тест сравнивает выход с файлом, а флаг -update перезаписывает эталон.

var update = flag.Bool("update", false, "перезаписать golden-файлы")

func TestRenderInvoice(t *testing.T) {
    got := Render(fixtureInvoice(t))
    golden := filepath.Join("testdata", "invoice.golden.json")

    if *update {
        if err := os.WriteFile(golden, got, 0o644); err != nil { t.Fatal(err) }
    }
    want, err := os.ReadFile(golden)
    if err != nil { t.Fatal(err) }

    if diff := cmp.Diff(string(want), string(got)); diff != "" {
        t.Errorf("рендер разошёлся с эталоном (-want +got):\n%s", diff)
    }
}
$ go test ./render -run TestRenderInvoice -update
$ git diff render/testdata/     # обязательный шаг: глазами посмотреть, что изменилось
Почему именно testdata

Директория с именем testdata игнорируется инструментами Go: её не обходит ./..., файлы в ней не компилируются, туда можно положить хоть битый Go-код (что и делают тесты компилятора). А раз тест запускается из директории пакета, путь testdata/x.json работает без всяких runtime.Caller. Для эталонов лучше os.ReadFile, чем //go:embed: embed вшивает содержимое на этапе сборки, и флаг -update в том же прогоне уже ничего не изменит.

Как golden-тесты гниют
  • Ритуал -update: сломал логику, перегенерировал эталон, зелёно, поехали в прод. Лечится только код-ревью диффа testdata/ и читаемым форматом (отформатированный JSON построчно, а не одна строка на 40 КБ).
  • Нестабильные поля: time.Now(), UUID, порядок обхода мапы. Перед сравнением их нормализуют фиксированными часами, детерминированным генератором ID и сортировкой ключей (json.Marshal сортирует ключи мапы, но не элементы слайса).
  • Golden на маленькое значение: если ожидание помещается в одну строку, файл только прячет смысл. Golden оправдан от десятков строк.

Фикстуры описывают входные данные. Подходов два: файлы в testdata/ (JSON-пейлоады, SQL-дампы, примеры протокола) и билдеры в коде — функция-конструктор с валидным объектом по умолчанию и точечными правками. Билдеры почти всегда лучше: тест читается как «валидный заказ, но со скидкой 100 %», а не «двадцать полей, из которых важно одно». Общий глобальный набор фикстур на весь пакет считается антипаттерном: он связывает тесты друг с другом, и через год никто не знает, кто на какое поле опирается.

Fuzzing: тесты, которые придумывают вход сами

С Go 1.18 фаззинг встроен в go test. Пишется цель FuzzXxx(f *testing.F), а в ней семена через f.Add и сама проверка через f.Fuzz. Движок coverage-guided: он мутирует байты входа, следит за счётчиками покрытия и оставляет в корпусе те входы, которые открыли новый код. Поэтому фаззинг и не сводится к «случайным данным»: он целенаправленно лезет вглубь.

func FuzzParseRange(f *testing.F) {
    // Семенной корпус: реальные примеры и заведомо злые.
    f.Add("bytes=0-499")
    f.Add("bytes=-1")
    f.Add("")
    f.Add("bytes=9223372036854775807-9223372036854775807")

    f.Fuzz(func(t *testing.T, in string) {
        r, err := ParseRange(in)
        if err != nil {
            return               // отказать на мусоре можно, паниковать нельзя
        }
        // Эти свойства должны выполняться для любого принятого входа:
        if r.Start < 0 || r.End < r.Start {
            t.Fatalf("ParseRange(%q) вернул некорректный диапазон %+v", in, r)
        }
        // Round-trip: разобрали, собрали обратно, снова разобрали, результат тот же.
        again, err := ParseRange(r.String())
        if err != nil || again != r {
            t.Fatalf("round-trip сломался: %q -> %+v -> %q", in, r, r.String())
        }
    })
}

Запускается фаззинг отдельным флагом и только по одной цели за прогон — движку нужен весь процесс целиком:

$ go test ./httpx -run '^$' -fuzz FuzzParseRange -fuzztime 60s
fuzz: elapsed: 0s, gathering baseline coverage: 0/4 completed
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 14 workers
fuzz: minimizing 72-byte failing input file
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzParseRange (0.11s)
    --- FAIL: FuzzParseRange (0.00s)
        range_test.go:19: ParseRange("bytes=10-0") вернул некорректный диапазон bytes=10-0

    Failing input written to testdata/fuzz/FuzzParseRange/c55933f636ba0316
    To re-run:
    go test -run=FuzzParseRange/c55933f636ba0316
FAIL
exit status 1
FAIL	app/httpx	0.303s

$ go test ./httpx                 # найденный вход теперь прогоняется как обычный кейс

Корпусов у фаззера два. Семенной складывается из f.Add в коде и файлов в testdata/fuzz/FuzzXxx/; он лежит в репозитории и прогоняется при обычном go test, без всякого -fuzz. Сгенерированный корпус живёт в кеше сборки ($GOCACHE/fuzz), в git не попадает и между машинами не переносится. Найдя падение, фаззер минимизирует вход и записывает его в testdata/fuzz/. Этот файл надо закоммитить, и краш превратится в постоянный регрессионный тест.

Почему это не «случайные байты»: цикл с обратной связью по покрытию Корпус f.Add + testdata/fuzz + то, что нашли сами Мутация битфлип, вставка, обрезка, дублирование Запуск f.Fuzz в отдельном процессе-воркере, со счётчиками покрытия Новые блоки задеты? да → вход «интересный», в корпус нет → выбросить обратная связь: корпус растёт только полезными входами Падение или паника 1) минимизация: движок урезает вход, пока падение воспроизводится — например, с 72 до 10 байт; 2) запись в testdata/fuzz/FuzzXxx/<hash>; 3) коммитим файл → это уже обычный регрессионный кейс. Что считается «падением» паника, t.Error/t.Fatal внутри цели, дедлок, срабатывание -race, os.Exit, таймаут воркера. Ошибка, ВОЗВРАЩЁННАЯ функцией, падением не считается: на мусоре корректный отказ — это правильное поведение.
Coverage-guided loop. Фаззер держит корпус входов, мутирует их и оставляет только те, что открыли новые базовые блоки. Поэтому он за минуты добирается до веток, куда случайная генерация не попала бы никогда.
Внутри f.Fuzz проверяют свойства, а не ожидаемые значения
  • Не паникует. Дешевле и ценнее инварианта нет, половина находок именно такие: индекс за границей слайса, разыменование nil, деление на ноль.
  • Round-trip: Decode(Encode(x)) == x, Parse(v.String()) == v.
  • Differential: сравнить свою быструю реализацию с медленной эталонной или со стандартной библиотекой — они обязаны давать один результат на любом входе.
  • Инварианты домена: длина результата не больше исходной, сумма сохранилась, отсортировано.
Ограничения, о которых спрашивают

Типы аргументов f.Fuzz ограничены списком: []byte, string, все int*/uint*, float32/64, bool, rune, byte. Структуру напрямую фаззить нельзя — генерируют []byte и разбирают его в структуру внутри цели. Сигнатуры f.Add и f.Fuzz должны совпадать по типам, иначе тест не запустится: с Go 1.24 расхождение ловит vet-анализатор tests, который go test гоняет сам. Сама цель обязана быть детерминированной и без побочных эффектов: писать в общий файл или в реальную БД внутри f.Fuzz нельзя, воркеров несколько. И -fuzz работает только с одной целью за прогон, поэтому в CI это отдельный ночной job с -fuzztime, а не часть обычного go test ./....

Глубже: где фаззинг реально окупается

Фаззить стоит там, где вход приходит извне и представим как байты: парсеры (JSON/YAML/protobuf-обёртки, свои DSL, заголовки HTTP, формат файла), декодеры, санитайзеры, нормализация путей и URL, разбор пользовательских выражений, всё вокруг безопасности. Бизнес-логика с десятью доменными структурами фаззится плохо: больше кода на генерацию входа, чем самой проверки, и большая часть сгенерированных входов отсекается валидацией на первой строке. В стандартной библиотеке фаззинг нашёл десятки багов в encoding/*, net/http и image/* — почти все именно этого класса.

Вопросы

8
Суть: тест — это обычная функция TestXxx(t *testing.T), которую go test находит на этапе сборки и запускает в отдельной горутине; t.Run заводит вложенную горутину-подтест, t.Helper правит номер строки в отчёте, t.Cleanup кладёт функцию в стек отката именно этого теста.

Механика

go test компилирует пакет вместе с _test.go-файлами и генерирует _testmain.go со списком найденных тестов. Рефлексии в рантайме нет, поэтому динамически тест регистрируется только через t.Run. Имя ищется по префиксу Test + не-строчная буква: func Testfoo не запустится (с Go 1.24 на это ругнётся vet-анализатор tests, который go test гоняет сам). Каждый тест выполняется в своей горутине, и отсюда главное правило: t.Fatal внутри вызывает runtime.Goexit() и завершает горутину теста. Позвал t.Fatal из побочной горутины — убил её, а тест поехал дальше и, скорее всего, упал паникой строчкой ниже. Из побочных горутин можно только t.Error.

t.Run — сабтесты

t.Run(name, func(t *testing.T)) создаёт дочерний *testing.T и по умолчанию блокируется до его завершения (если только ребёнок не позвал t.Parallel). Что это даёт:

  • Изоляция провала: t.Fatal в сабтесте обрывает только его, остальные кейсы таблицы отработают.
  • Фильтрация: в go test -run 'TestValidate/пустой_email' флаг -run принимает имена через /, по уровням вложенности. Пробелы в имени заменяются на _, дубликаты получают суффикс #01.
  • Общий setup: код до первого t.Run выполняется один раз на всю группу.

t.Helper

Вызов t.Helper() в первой строке функции помечает её как вспомогательную. При провале testing идёт вверх по стеку и печатает первую строку, которая не помечена хелпером. Без этого все ошибки показывают одну и ту же строку внутри assertUser, и непонятно, какой из двадцати вызовов сломался. Пометка хранится на самом *testing.T, а не на горутине, поэтому она действует и когда хелпер зовут из побочной горутины: отчёт всё равно покажет строку вызова, а не строку внутри хелпера. Толку от этого, правда, немного — «строкой вызова» там окажется тело go func().

func mustUser(t *testing.T, id int) User {
    t.Helper()                      // без этой строки отчёт покажет строку с t.Fatal ниже,
    u, err := repo.Get(ctx, id)     // а не строку вызова mustUser в самом тесте
    if err != nil {
        t.Fatalf("mustUser(%d): %v", id, err)
    }
    return u
}

t.Cleanup vs defer

t.Cleanup(f) кладёт f в стек (LIFO) конкретного *testing.T и вызывает его, когда этот тест и все его подтесты, включая параллельные, завершились. Разница с defer принципиальна в двух местах:

  • Хелперы: defer внутри setupDB(t) сработает при выходе из setupDB, то есть до начала самого теста, а t.Cleanup после него. Поэтому фабрики ресурсов пишут именно через Cleanup.
  • Параллельные подтесты: defer в родителе выполняется, когда родитель вернул управление, — а параллельные дети в этот момент ещё не начинались. Отсюда баг «база закрыта, тест падает». t.Cleanup родителя ждёт всех детей.
Чем добить ответ

Назвать TestMain(m *testing.M), единственную точку setup на весь пакет: она обязана вызвать m.Run(). Передавать код в os.Exit с Go 1.15 необязательно: если TestMain просто вернётся, обёртка сделает это сама. И упомянуть t.Setenv / t.TempDir, которые сами регистрируют cleanup, причём t.Setenv ещё и запрещает t.Parallel в этом тесте: переменные окружения глобальны для процесса.

Суть: срез структур «имя + вход + ожидание», цикл с t.Run(tc.name, ...); негативные кейсы проверяются не «есть ошибка», а errors.Is/errors.As на конкретную сентинель или тип.
func TestValidateUser(t *testing.T) {
    valid := User{Login: "gopher", Email: "g@example.com", Age: 30}

    tests := []struct {
        name    string
        mutate  func(*User)          // точечная правка валидного объекта
        wantErr error                // nil = ожидаем успех
        wantField string
    }{
        {name: "валидный пользователь", mutate: func(*User) {}},
        {name: "пустой логин",  mutate: func(u *User) { u.Login = "" },
            wantErr: ErrRequired, wantField: "login"},
        {name: "логин 1 символ", mutate: func(u *User) { u.Login = "a" },
            wantErr: ErrTooShort, wantField: "login"},
        {name: "логин на границе 2", mutate: func(u *User) { u.Login = "ab" }},
        {name: "email без собаки", mutate: func(u *User) { u.Email = "gopher.example.com" },
            wantErr: ErrFormat, wantField: "email"},
        {name: "отрицательный возраст", mutate: func(u *User) { u.Age = -1 },
            wantErr: ErrRange, wantField: "age"},
        {name: "юникод в логине", mutate: func(u *User) { u.Login = "гофер" }},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            u := valid          // своя копия на каждый кейс, общего состояния нет
            tc.mutate(&u)

            err := ValidateUser(u)

            if tc.wantErr == nil {
                if err != nil {
                    t.Fatalf("ожидали успех, получили %v", err)
                }
                return
            }
            if !errors.Is(err, tc.wantErr) {          // не reflect.DeepEqual и не сравнение строк
                t.Fatalf("ожидали %v, получили %v", tc.wantErr, err)
            }
            var ve *ValidationError
            if errors.As(err, &ve) && ve.Field != tc.wantField {
                t.Errorf("поле: got %q, want %q", ve.Field, tc.wantField)
            }
        })
    }
}

Как организуют негативные кейсы

  • Мутация валидного объекта, а не двадцать литералов целиком. Тогда в кейсе видно ровно то, что его отличает, и новое обязательное поле не ломает всю таблицу.
  • Ожидаем сентинель или тип, а не строку. Сравнение err.Error() == "..." привязывает тест к формулировке сообщения; поменяли текст — упало двадцать тестов. errors.Is переживает оборачивание через %w.
  • Границы отдельными кейсами: длина 1 / 2 / 3 при минимуме 2, возраст 0 / -1, пустая строка / пробелы / юникод / очень длинная строка.
  • Валидатору, собирающему все ошибки сразу, нужна отдельная группа кейсов: проверяем, что при трёх плохих полях вернулось три ошибки, и что errors.Is находит каждую (в Go 1.20+ это errors.Join).
  • Когда кейсов на один вход набираются сотни, пора переходить к фаззингу, а не растить таблицу на 400 строк.
Ловушки табличных тестов
  • Общий изменяемый объект между кейсами (мапа, слайс, указатель в структуре кейса) особенно опасен с t.Parallel: получишь гонку или зависимость от порядка.
  • Логика внутри цикла: раз понадобился if tc.name == "особый" { ... }, кейс не влезает в таблицу, выноси его в отдельный func Test....
  • Безымянные кейсы: без поля name отчёт покажет #03, и по логу упавшего CI невозможно понять, что сломалось.
  • Таблица без ассерта на конкретное значение: «ошибка не nil» пропускает подмену ErrTooShort на ErrFormat.
Суть: t.Parallel() приостанавливает тест и возвращает управление родителю; все «припаркованные» подтесты стартуют разом после того, как родительская функция дошла до конца, с ограничением -parallel (по умолчанию GOMAXPROCS).

Что происходит по шагам

  1. Тест зовёт t.Parallel(), метод сигналит родителю и блокируется на канале. Функция теста останавливается ровно на этой строке.
  2. Родитель (или tRunner верхнего уровня) продолжает: запускает следующий t.Run, тот тоже паркуется, и так далее.
  3. Когда тело родителя закончилось, testing разблокирует всех припаркованных детей сразу, но одновременно бегут не больше -parallel, остальные ждут в очереди.
  4. Родитель не завершается, пока не отработают все параллельные дети; его t.Cleanup выполнится после них, а defer уже выполнился до их старта.

Отсюда два следствия, которые и спрашивают. Первое: вызов t.Parallel() должен идти первой строкой подтеста — код до него исполняется последовательно и в другой момент времени. Второе: параллелятся тесты внутри одного пакета; разные пакеты и так идут параллельно (флаг -p, по умолчанию GOMAXPROCS), это независимые процессы.

Ловушка с переменной цикла

// До Go 1.22 классический баг: все подтесты видят последний tc
for _, tc := range tests {
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel()             // тест паркуется; цикл успевает докрутиться до конца
        check(tc.input)          // tc одна на весь цикл, к моменту старта в ней последний кейс
    })
}

// До 1.22 лечилось так:
for _, tc := range tests {
    tc := tc                     // копия на итерацию (или параметр функции)
    t.Run(tc.name, func(t *testing.T) { t.Parallel(); check(tc.input) })
}

В Go 1.22 семантику изменили: при go-директиве в go.mod не ниже 1.22 переменные for-цикла создаются заново на каждой итерации, и строка tc := tc больше не нужна (линтер copyloopvar и модернизаторы gopls подсвечивают её как лишнюю). Решает при этом версия в go.mod, а не версия компилятора: старый модуль с go 1.19, собранный новым тулчейном, работает по-старому. И баг проявлялся именно из-за парковки: без t.Parallel замыкание выполнялось сразу и видело правильное значение, поэтому «непараллельная» версия того же теста была зелёной.

Когда параллелить не стоит

  • Глобальное состояние: переменные окружения (t.Setenv прямо паникует в параллельном тесте), рабочая директория, глобальные синглтоны, http.DefaultClient, подмена time.Now через пакетную переменную.
  • Общая БД без изоляции: тесты видят чужие строки. Либо схема/транзакция на тест, либо последовательно.
  • Тесты, чувствительные к времени и ресурсам: бенчмарки, замеры таймаутов, тесты с ограничением по памяти. Под нагрузкой соседей они флакают. С Go 1.25 один такой случай стал явной ошибкой: testing.AllocsPerRun паникует (AllocsPerRun called during parallel test), если в момент замера бежит параллельный тест — раньше это молча давало плавающее число аллокаций.
  • Быстрые тесты чистых функций: накладные расходы на планировщик больше выигрыша, а риск словить скрытую связанность вполне реален. Параллелить осмысленно там, где тест ждёт: сеть, диск, контейнеры.
Глубже

-parallel N ограничивает параллельные тесты внутри одного бинаря, а -p N задаёт число одновременно тестируемых пакетов. Итоговая конкурентность доходит до их произведения. По умолчанию оба равны GOMAXPROCS, так что на 2 ядрах это до 4 тестов разом, но стоит поднять -parallel ради скорости — и за одну базу дерутся уже десятки. А -race вместе с t.Parallel лучше всего находит реальные гонки: тесты начинают дёргать общий код одновременно.

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

Как считается

При сборке с -cover инструмент cover переписывает исходник ещё до компилятора: режет каждую функцию на блоки и вставляет в начало блока инкремент счётчика. Режимы: set (был/не был, по умолчанию), count (сколько раз), atomic (атомарный инкремент; с -race он обязателен и ставится сам, иначе сам профилировщик стал бы источником гонки). -coverpkg=./... считает покрытие чужих пакетов, и тогда интеграционный тест из tests/ засчитывается в internal/. С Go 1.20 покрытие снимается и с обычного бинаря: go build -cover, затем GOCOVERDIR=... ./app и go tool covdata. До этого e2e-покрытие снимали обходным путём: тестовым бинарём с -coverpkg, в котором тест просто зовёт main().

Почему 100 % ничего не гарантируют

  • Комбинации условий не видны. if a && b занимает одну строку: два теста закрывают её на 100 %, а из четырёх комбинаций проверены две.
  • Границы не видны. i <= n против i < n — та же строка, тот же процент, другой результат.
  • Отсутствующий код покрыть нельзя. Забытая проверка nil или необработанная ветка err не имеет строки, которая могла бы стать красной.
  • Качество ассертов не учитывается. Тест, который вызвал функцию и ничего не проверил, даёт ровно те же проценты. Такие тесты и появляются, когда в KPI записан порог покрытия.
  • Конкурентность. Гонка исполняет те же строки; покрытие про порядок ничего не знает.

Что отвечать про «хороший процент»

Что число зависит от слоя: доменная логика, парсеры, расчёты требуют 90 %+, там это дёшево и полезно; хендлерам и репозиториям хватает 60–80 %, выше начинается тестирование фреймворка; сгенерированный код, моки, DTO, main.go и обвязку миграций исключают из подсчёта. Полезнее абсолютного числа два правила: покрытие diff'а в PR (новый код покрыт) и покрытие не падает. А качество самих тестов проверяет мутационное тестирование (go-mutesting, gremlins): инструмент меняет > на >=, удаляет вызовы, и если тесты остались зелёными — покрытие было декоративным.

На чём ловят

Вопрос-ловушка: «у нас 100 % покрытия — значит, багов нет?». Спорить про число не нужно, лучше привести пример: функция на четыре ветки, два теста, 100 % строк и непроверенная комбинация. Часто спрашивают и другое: «почему go test -cover ./... показывает 0 % для пакета, который точно тестируется?». Потому что по умолчанию считается покрытие только тестируемого пакета, а тесты лежат в другом; лечится -coverpkg.

Суть: assert зовёт t.Errorf, помечает тест провалившимся и возвращает false — выполнение продолжается; require зовёт t.FailNow() (то есть runtime.Goexit) и обрывает тест на этой строке.

Правило выбора

require нужен для предусловий: если они провалились, остаток теста либо упадёт паникой, либо напечатает бессмысленный мусор. Типичные примеры: require.NoError(t, err), require.NotNil, require.Len перед индексацией слайса. На assert пишут независимые утверждения о результате, чтобы увидеть все расхождения за один прогон и не чинить их по одному. Тест целиком на require отдаёт по одной ошибке за запуск; тест целиком на assert падает nil-паникой на первой же неудаче.

u, err := svc.Get(ctx, 42)
require.NoError(t, err)              // без этого проверки ниже упадут на nil pointer dereference
require.NotNil(t, u)

assert.Equal(t, "gopher", u.Login)   // независимые проверки: хотим весь список расхождений
assert.Equal(t, 42, u.ID)
assert.True(t, u.Active)

Три ловушки, на которых ловят

  • require из побочной горутины. FailNow сводится к Goexit: он убьёт горутину, провал запишется, а тест пойдёт дальше и зависнет на WaitGroup, если Done вызывается не через defer. В горутинах пиши только assert.
  • assert.Equal строг к типам: assert.Equal(t, 1, int64(1)) падает. Есть EqualValues, но надёжнее привести типы явно.
  • assert.Nilassert.NoError: Nil работает через рефлексию и пройдёт для типизированного nil-указателя в интерфейсе, хотя в бою err != nil вернёт true. Для ошибок только NoError/ErrorIs/ErrorAs.
Чем добить

Сказать, что у assert есть форма assert.Eventually(t, cond, wait, tick) — нормальная замена time.Sleep для «дождаться асинхронного эффекта», и require.EventuallyWithT для ассертов внутри условия. И упомянуть противоположный лагерь: команда Go и стандартная библиотека обходятся без testify, пишут if got != want { t.Errorf }, для структур берут cmp.Diff (в самой стандартной библиотеке — reflect.DeepEqual: сторонних зависимостей там нет) и ценят стектрейсы без магии и отсутствие путаницы в порядке (want, got). Единообразие в репозитории важнее выбора лагеря.

Суть: всё недетерминированное надо вынести в зависимость и подменить в тесте: время — интерфейсом Clock, случайность — *rand.Rand с фиксированным seed или io.Reader, конкурентность — честной синхронизацией вместо time.Sleep плюс -race.

Время

Прямой вызов time.Now() внутри доменной логики делает её непроверяемой: тест «токен истёк через час» пришлось бы ждать час. Лечится это узким интерфейсом на стороне потребителя:

type Clock interface {
    Now() time.Time
    After(d time.Duration) <-chan time.Time
}

type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
func (realClock) After(d time.Duration) <-chan time.Time { return time.After(d) }

type fakeClock struct {
    mu  sync.Mutex
    now time.Time
    waiters []chan time.Time     // «спящих» будим вручную
}
func (c *fakeClock) Advance(d time.Duration) { /* двигаем now и будим тех, чей дедлайн прошёл */ }

Тогда тест «через 61 минуту токен протух» выполняется за микросекунды: clk.Advance(61 * time.Minute). Готовые реализации есть в github.com/benbjohnson/clock и k8s.io/utils/clock/testing. В Go 1.23 переписали таймеры: Timer/Ticker, на которые не осталось ссылок, собираются GC даже без Stop, а канал стал небуферизованным, и часть старых трюков с time.After в тестах поменяла поведение. И ещё: time.Time содержит монотонную часть, поэтому reflect.DeepEqual на двух «одинаковых» временах может дать false. Сравнивают через .Equal() или cmpopts.EquateApproxTime.

Случайность

  • Инжектировать *rand.Rand, в тесте подставить rand.New(rand.NewSource(42)): последовательность детерминирована.
  • С Go 1.20 глобальный math/rand сам засеивается случайно, rand.Seed устарел (а с Go 1.24 ничего не делает); в math/rand/v2 (Go 1.22) функции Seed нет вовсе — «зафиксировать глобальный seed» больше не выйдет.
  • crypto/rand подменяется параметром io.Reader: в тест передают bytes.NewReader с фиксированными байтами. Но в стандартных crypto-пакетах с Go 1.26 часть функций (генерация ключей RSA и ECDSA, подпись ECDSA) этот параметр игнорирует, для них есть testing/cryptotest.SetGlobalRandom.
  • Если случайность нужна именно как источник входов, это уже property-based подход: фаззинг или testing/quick.

Конкурентность

  • Никакого time.Sleep для «подождать горутину»: на нагруженном раннере это флак, а в зелёном прогоне — потерянные секунды. Синхронизируют каналом, sync.WaitGroup или errgroup. Go 1.25 добавил wg.Go(func(){...}), который сам делает Add(1)/Done.
  • Любое ожидание делают с таймаутом: select { case <-done: case <-time.After(2*time.Second): t.Fatal("таймаут") }. Иначе повисший тест держит весь бинарь до -timeout и печатает дамп всех горутин.
  • -race в CI обязателен. Детектор находит только те гонки, которые реально произошли в этом прогоне, зато практически без ложных срабатываний. Отсюда практика go test -race -count=5 ./... ночью и -cpu=1,4 для разных GOMAXPROCS.
  • Ассерты делают только из горутины теста; из побочных t.Fatal/require запрещены (это Goexit). Результаты собирают через канал.
  • Утечки горутин ловит go.uber.org/goleak, подключённый одной строкой в TestMain.
Как это звучит на собесе

«Вопрос про мок time.Now() на самом деле про архитектуру. Если время, случайность и I/O лежат за узкими интерфейсами, которые я объявляю у потребителя, то тест становится чистой функцией от входа, и ни sleep'ов, ни флаков в нём не появляется в принципе.»

Суть: golden file — эталонный вывод в testdata/, с которым сравнивается результат, плюс флаг -update для перегенерации; фикстуры — входные данные, и их лучше собирать билдерами в коде, чем таскать общий глобальный набор.

Golden files

Применяют, когда ожидаемый результат слишком велик для литерала в коде: отрендеренный HTML, сгенерированный SQL, JSON-ответ API, форматированный отчёт, вывод CLI. Схема стандартная: с флагом -update файл перезаписывается, без него идёт сравнение через cmp.Diff, чтобы в отчёте был читаемый дифф, а не два блоба по 40 КБ. После -update обязательно просмотреть git diff testdata/ глазами, иначе golden-тест превращается в «сломал логику, перегенерировал, зелёно».

Почему именно testdata: эту директорию тулчейн Go игнорирует, не обходит по ./... и не компилирует, туда можно класть хоть заведомо битый Go-код. А поскольку тест запускается из директории пакета, testdata/x.json находится без runtime.Caller. //go:embed для эталонов хуже: содержимое вшивается на этапе сборки, и -update в том же прогоне уже ничего не изменит.

Как они гниют и что с этим делать

  • Нестабильные поля: time.Now(), UUID, порядок обхода мапы, порядок элементов из БД без ORDER BY. Нормализуй перед сравнением: фиксированные часы, детерминированный генератор ID, явная сортировка. (json.Marshal сортирует ключи мапы, но не элементы слайса.)
  • Формат в одну строку: дифф превращается в «строка 1 отличается». Храни эталон отформатированным построчно.
  • Golden на две строки: если ожидание помещается в литерал, файл только прячет смысл. Оправдано от десятков строк.

Фикстуры

Два рабочих подхода: файлы в testdata/ (JSON-пейлоады, SQL-дампы, примеры протокола) и билдеры в коде — конструктор валидного объекта плюс точечные правки. Билдеры почти всегда лучше: тест читается как «валидный заказ, но со скидкой 100 %», а не «двадцать полей, из которых важно одно», и новое обязательное поле правится в одном месте. От общего глобального набора фикстур на весь пакет лучше отказаться: он связывает тесты друг с другом, и через год никто не знает, кто на какое поле опирается. Фикстуры-файлы уместны там, где данные пришли из внешнего мира и важна их дословность: реальный ответ платёжного шлюза, конкретный дамп протокола.

Суть: встроенный с Go 1.18 coverage-guided фаззер: FuzzXxx(f *testing.F) мутирует входы, следит за счётчиками покрытия и оставляет в корпусе те, что открыли новый код; найденный краш минимизируется и записывается в testdata/fuzz/ как обычный регрессионный кейс.

Чем отличается от «случайных данных»

Обратной связью. Фаззер инструментирует код счётчиками покрытия, мутирует байты входа (битфлипы, вставки, обрезка, дублирование кусков) и, если мутация задела новый базовый блок, кладёт этот вход в корпус как «интересный». Дальше мутирует уже его. Поэтому за минуты он добирается до веток, куда чистый рандом не попал бы никогда: чтобы дойти до третьего уровня вложенности парсера, случайной строке надо угадать префикс, а фаззеру — нет.

Как устроено

  • f.Add(...) задаёт семенной корпус в коде, файлы в testdata/fuzz/FuzzXxx/ хранят его в репозитории. Оба прогоняются при обычном go test, без -fuzz.
  • f.Fuzz(func(t *testing.T, in string) {...}) объявляет саму цель. Типы аргументов ограничены: []byte, string, int*/uint*, float32/64, bool, rune, byte. Структуру фаззят через []byte с разбором внутри цели.
  • Запуск: go test -run '^$' -fuzz FuzzXxx -fuzztime 60s ./pkgодна цель за прогон. Сгенерированный корпус лежит в $GOCACHE/fuzz и в git не попадает.
  • Падением считаются паника, t.Error/t.Fatal внутри цели, дедлок, срабатывание -race, os.Exit, таймаут воркера. Возвращённая функцией ошибка падением не считается: корректный отказ на мусоре нормален.

Что проверять внутри цели

Свойства, а не конкретные значения: «не паникует» (самая дешёвая и самая результативная проверка), round-trip (Parse(v.String()) == v), differential (своя быстрая реализация против эталонной или стандартной библиотеки), доменные инварианты (длина результата не больше входа, сумма сохранилась).

Когда полезен

Там, где вход приходит извне и представим как байты: парсеры и декодеры, свои DSL, разбор заголовков и URL, санитайзеры, форматы файлов, всё вокруг безопасности. Бизнес-логика с десятком доменных структур фаззится плохо — кода на генерацию входа больше, чем самой проверки, и почти всё отсекается валидацией на первой строке. В CI фаззинг ставят отдельным ночным job'ом с -fuzztime: он занимает все ядра и по своей природе не завершается сам. Цель обязана быть детерминированной и без побочных эффектов — воркеров несколько.

Чем добить

Упомянуть, что фаззинг в Go нашёл десятки багов в самой стандартной библиотеке (encoding/*, net/http, image/*), и что найденный вход после минимизации коммитится в testdata/fuzz/. Выходит, фаззер юнит-тесты не заменяет, а производит.

5.2Моки и интеграционные тесты

Вопрос «как замокать базу» на собесе почти никогда не про библиотеку. Он про то, понимаешь ли ты, откуда вообще берётся точка подмены — а берётся она из того, где объявлен интерфейс. Потом разговор уходит в другую сторону: что моками нельзя проверить в принципе и когда честнее поднять настоящий PostgreSQL в контейнере.

Сначала — пять слов, которыми называют подделки

Тестируемый код лезет в базу, в платёжный шлюз и в Kafka. Что подставить вместо них, чтобы тест был быстрым и повторяемым? Подставляют «подделку», а подделки бывают пяти сортов. В повседневной речи их все зовут «моками», но на собесе разницу спрашивают почти всегда, потому что за ней стоит выбор: подставить данные или проверить факт вызова.

1. Test double — общее слово для любой подделки

Test double значит «тестовый дублёр», по аналогии с каскадёром, который заменяет актёра в опасной сцене. Термин зонтичный: так зовут любой объект, который подставили вместо настоящей зависимости на время теста. Четыре слова ниже называют его разновидности; классификацию придумал Джерард Месарош, и спрашивают обычно именно её.

2. Стаб — отвечает заранее заготовленным, ничего не проверяет

Стаб (stub — «заглушка», «корешок») нужен, чтобы поставить систему в нужное состояние. Ты заранее говоришь: «на Get(42) верни вот этот заказ», или «верни ошибку 503». Стаб просто отдаёт это, и всё. Провалить тест он не может, решение принимает проверка в конце теста. Самый частый и самый безобидный дублёр.

3. Мок — заранее объявленные ожидания, проваливает тест сам

Мок (mock, «имитация», «пародия») добавляет к стабу ожидания, записанные до вызова: «метод Charge должен быть вызван ровно один раз, с суммой 500». Если вызвали не так, не столько раз или не в том порядке, мок роняет тест сам, без единого ассерта в коде теста. Отсюда и его польза, и его беда: тест начинает знать, как код устроен внутри, и ломается на любом рефакторинге, даже если поведение не изменилось.

4. Спай — стаб, который ещё и ведёт журнал вызовов

Спай (spy, «шпион») стоит посередине между стабом и моком. Он отвечает как стаб, но попутно записывает в слайс, кто его звал и с какими аргументами. Проверяют это потом, обычной строчкой в теле теста: if len(s.calls) != 1 { ... }. От мока спай отличается тем, где живёт проверка: у спая — в тесте и после факта, у мока — внутри библиотеки и заранее.

5. Фейк — упрощённая, но по-настоящему работающая реализация

Фейк (fake — «подделка», в смысле «копия, которая работает») стоит особняком: это не заглушка, а настоящая реализация интерфейса, только простая. Репозиторий на map[int]Order с мьютексом вместо PostgreSQL, очередь в памяти вместо Kafka, каталог на диске вместо S3. У фейка есть своё состояние: положил — прочитал, как в бою. Поэтому тест на фейке проверяет результат, а не последовательность вызовов, и рефакторинг его не ломает.

Одной фразой, если спросят «в чём разница»

Стаб подставляет данные. Мок проверяет взаимодействие. Спай проверяет то же, что мок, но руками и постфактум. Фейк работает по-настоящему, просто упрощённо. Пятый дублёр Месароша, самый скучный, называется dummy: пустышка, которую передают только ради компиляции и никогда не вызывают.

Точка подмены: интерфейс объявляется у потребителя

В Java или C# нормально написать UserRepository и рядом UserRepositoryImpl: интерфейс живёт вместе с реализацией. В Go так делать не принято, и дело не во вкусе, а в неявной реализации интерфейсов. Типу не надо писать implements: он подходит под интерфейс, если у него есть нужные методы. Значит, интерфейс можно объявить там, где он нужен, и никого не трогать.

Формулировки две: «The bigger the interface, the weaker the abstraction» из Go Proverbs Роба Пайка и «Accept interfaces, return structs» — правило, которое популяризовало сообщество. Отсюда правило:

Правило, которое и хотят услышать

Интерфейс объявляет потребитель, а не поставщик. Пакет order сам определяет, что ему нужен Repo с двумя методами. Пакет postgres ничего не знает про order и просто возвращает свою структуру. Тогда: (1) интерфейс маленький, в нём ровно то, чем пользуются, а не 30 методов «на всякий случай»; (2) стрелка зависимости смотрит внутрь, к домену, что и называют dependency inversion; (3) моки генерируются тривиально, потому что мокать надо два метода, а не тридцать; (4) если добавить метод в postgres.OrderRepo, ни один мок не сломается.

Один и тот же сервис, два графа зависимостей: подменяется только нижний слой ПРОДАКШН — cmd/api/main.go собирает граф order.Service order.Repo 2 метода, объявлен здесь order.Payments 1 метод, объявлен здесь реализует реализует postgres.OrderRepo про order ничего не знает paygate.Client про order ничего не знает PostgreSQL по сети внешний HTTP API Стрелки реализации идут ВНУТРЬ, к домену: инфраструктура зависит от домена, а не наоборот. ТЕСТ — тот же конструктор, другие аргументы order.Service order.Repo тот же интерфейс order.Payments тот же интерфейс fakeRepo map[int]Order + мьютекс mockPayments gomock / testify ни докера, ни сети, ни секретов — тест идёт 200 мкс Тот же конструктор, тот же код сервиса: подменился только нижний слой, а не «версия для тестов». Антипаттерн, из-за которого подмена не работает Сервис сам создаёт зависимости внутри: sql.Open в конструкторе, http.DefaultClient в методе, глобальная var db *sql.DB. Тогда точки подмены не существует физически, и остаются только грязные приёмы: подмена пакетной переменной, build-теги, monkey patching через unsafe.
Прод-граф и тест-граф. Интерфейс объявлен в пакете-потребителе, а реализации лежат снаружи. Конструктор принимает интерфейсы, поэтому, чтобы «поднять сервис в тесте», достаточно вызвать тот же NewService с другими аргументами.

Полный пример: сервис + репозиторий + клиент + тест

Через всю главу пройдёт один пример, сервис оформления заказа. Он читает заказ из БД, дёргает внешний платёжный API и сохраняет результат. Обе зависимости спрятаны за интерфейсами, объявленными в пакете order.

// ---------- internal/order/service.go ----------
package order

import (
    "context"
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("order: не найден")

type Order struct {
    ID     int
    UserID int
    Amount int64
    Status string
}

// Интерфейсы объявлены здесь, у потребителя, и в них ровно то, чем пользуется сервис.
type Repo interface {
    Get(ctx context.Context, id int) (Order, error)
    UpdateStatus(ctx context.Context, id int, status string) error
}

type Payments interface {
    Charge(ctx context.Context, userID int, amount int64) (txID string, err error)
}

type Service struct {
    repo Repo
    pay  Payments
}

// Конструктор принимает интерфейсы и возвращает структуру: accept interfaces, return structs.
func NewService(r Repo, p Payments) *Service {
    return &Service{repo: r, pay: p}
}

func (s *Service) Pay(ctx context.Context, id int) (string, error) {
    o, err := s.repo.Get(ctx, id)
    if err != nil {
        return "", fmt.Errorf("получить заказ %d: %w", id, err)
    }
    if o.Status == "paid" {
        return "", errors.New("order: заказ уже оплачен")   // идемпотентность
    }
    if o.Amount <= 0 {
        return "", errors.New("order: некорректная сумма")
    }

    txID, err := s.pay.Charge(ctx, o.UserID, o.Amount)
    if err != nil {
        return "", fmt.Errorf("списание по заказу %d: %w", id, err)
    }
    if err := s.repo.UpdateStatus(ctx, id, "paid"); err != nil {
        // Деньги списаны, а статус не обновлён: ради этого кейса тест и пишут.
        return txID, fmt.Errorf("обновить статус %d (списание %s прошло): %w", id, txID, err)
    }
    return txID, nil
}
// ---------- internal/postgres/order_repo.go ----------
package postgres

// Никакого import "internal/order" ради интерфейса: пакет про него не знает.
// Он просто возвращает структуру, которая случайно подходит под order.Repo.
// Querier — общее у *sql.DB и *sql.Tx: в интеграционном тесте сюда придёт транзакция.
type Querier interface {
    QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row
    ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error)
}

type OrderRepo struct {
    db Querier
}

func NewOrderRepo(db Querier) *OrderRepo { return &OrderRepo{db: db} }

func (r *OrderRepo) Get(ctx context.Context, id int) (order.Order, error) { /* SQL */ }
func (r *OrderRepo) UpdateStatus(ctx context.Context, id int, status string) error { /* SQL */ }
Мелочь, на которой валятся

Если postgres.OrderRepo возвращает order.Order, импорт домена всё-таки появляется — но только ради типа данных, а не ради интерфейса. Это нормально и стрелку зависимости не переворачивает. Плохо, когда домен импортирует postgres. Проверить легко: go list -deps ./internal/order не должен содержать ни драйвера БД, ни HTTP-клиента.

Теперь тест. Он написан на «ручных» дублёрах, без единой библиотеки: так видно, что моки собираются из обычных структур с полями-функциями и никакой магии в них нет.

// ---------- internal/order/service_test.go ----------
package order_test

// stubRepo: подставляет данные, поведение задаётся полями-функциями.
type stubRepo struct {
    getFn    func(ctx context.Context, id int) (order.Order, error)
    updateFn func(ctx context.Context, id int, status string) error

    updateCalls []string          // а это уже spy: записываем, что с нами делали
}

func (s *stubRepo) Get(ctx context.Context, id int) (order.Order, error) {
    return s.getFn(ctx, id)
}
func (s *stubRepo) UpdateStatus(ctx context.Context, id int, status string) error {
    s.updateCalls = append(s.updateCalls, status)
    if s.updateFn == nil {
        return nil
    }
    return s.updateFn(ctx, id, status)
}

type stubPayments struct {
    txID string
    err  error
    gotAmount int64
}

func (s *stubPayments) Charge(ctx context.Context, userID int, amount int64) (string, error) {
    s.gotAmount = amount
    return s.txID, s.err
}

func TestService_Pay(t *testing.T) {
    t.Parallel()

    okOrder := order.Order{ID: 1, UserID: 7, Amount: 500, Status: "new"}

    t.Run("успешная оплата", func(t *testing.T) {
        t.Parallel()
        repo := &stubRepo{getFn: func(context.Context, int) (order.Order, error) { return okOrder, nil }}
        pay := &stubPayments{txID: "tx-42"}

        got, err := order.NewService(repo, pay).Pay(context.Background(), 1)

        require.NoError(t, err)
        require.Equal(t, "tx-42", got)
        require.Equal(t, int64(500), pay.gotAmount)          // передали правильную сумму
        require.Equal(t, []string{"paid"}, repo.updateCalls) // статус обновили ровно один раз
    })

    t.Run("платёж упал — статус не трогаем", func(t *testing.T) {
        t.Parallel()
        repo := &stubRepo{getFn: func(context.Context, int) (order.Order, error) { return okOrder, nil }}
        pay := &stubPayments{err: errors.New("gateway 503")}

        _, err := order.NewService(repo, pay).Pay(context.Background(), 1)

        require.Error(t, err)
        require.Empty(t, repo.updateCalls, "нельзя помечать оплаченным, если списание не прошло")
    })

    t.Run("деньги списаны, БД упала — ошибка несёт txID", func(t *testing.T) {
        t.Parallel()
        repo := &stubRepo{
            getFn:    func(context.Context, int) (order.Order, error) { return okOrder, nil },
            updateFn: func(context.Context, int, string) error { return errors.New("connection reset") },
        }
        pay := &stubPayments{txID: "tx-99"}

        txID, err := order.NewService(repo, pay).Pay(context.Background(), 1)

        require.Error(t, err)
        require.Equal(t, "tx-99", txID, "txID обязан вернуться наружу, иначе деньги потеряются")
        require.Contains(t, err.Error(), "tx-99")
    })

    t.Run("заказа нет", func(t *testing.T) {
        t.Parallel()
        repo := &stubRepo{getFn: func(context.Context, int) (order.Order, error) {
            return order.Order{}, order.ErrNotFound
        }}
        _, err := order.NewService(repo, &stubPayments{}).Pay(context.Background(), 1)

        require.ErrorIs(t, err, order.ErrNotFound)   // обёртка через %w сохранила цепочку
    })
}
Что в этом тесте важно назвать на собесе

Проверяется не «сервис позвал репозиторий», а решения: при неудачном списании статус не меняется, при упавшей БД txID обязан вернуться наружу (иначе деньги ушли, а система о них не знает), ошибка не потеряла цепочку и ловится errors.Is. Интеграционным тестом такие кейсы воспроизводить дорого, а юнит-тест укладывает каждый в три строки. Ради этого дублёры и нужны, а не ради «покрытия сервисного слоя».

Терминология: dummy, stub, spy, mock, fake

Те же пять дублёров, но теперь на одной шкале и с ответом на вопрос «чем платишь за каждый». Шкала выстроена не по «мощности», а по связанности теста с внутренним устройством кода: чем правее, тем больше тест знает о том, как именно код делает свою работу, — и тем чаще он ломается от рефакторинга, который ничего не сломал.

Шкала дублёров: чем правее, тем больше тест знает о внутреннем устройстве кода dummy Заполнитель. Нужен только чтобы удовлетворить сигнатуру. Никогда не вызывается. type nopLogger struct{} func (nopLogger) Log(string) {} stub Отвечает заранее заданным. Ставит систему в нужное состояние. Ничего не проверяет. func (s stubRepo) Get(...) { return s.order, s.err } spy Стаб, который ещё и записывает вызовы. Проверка потом, в теле теста. s.calls = append(s.calls, arg) require.Len(t, s.calls, 1) mock Ожидания заданы ЗАРАНЕЕ. Сам падает, если вызвали не так, не столько, не в том порядке. m.EXPECT().Charge(...) .Return("tx", nil).Times(1) проверяем СОСТОЯНИЕ (что вернулось) проверяем ВЗАИМОДЕЙСТВИЕ (кто кого позвал) связанность теста с реализацией растёт вправо fake — стоит в стороне от шкалы Это не заглушка, а УПРОЩЁННАЯ РАБОЧАЯ РЕАЛИЗАЦИЯ: у неё есть настоящее поведение и настоящее состояние. Репозиторий на map[int]Order с мьютексом, in-memory очередь, sqlite вместо PostgreSQL, локальная файловая «S3». Плюс: тест ничего не знает о порядке вызовов — записали и прочитали, как в бою. Рефакторинг сервиса его не ломает. Минус: фейк надо писать и поддерживать, и он может разъехаться с настоящей реализацией — лечится общим набором контрактных тестов, который гоняется и по фейку, и по настоящему репозиторию.
Пять дублёров. Слева направо растёт не «мощность», а связанность теста с внутренним устройством кода. Фейк живёт отдельно: у него есть собственное поведение, поэтому тест на нём проверяет состояние, а не вызовы.
ДублёрЧто делаетЧто проверяет тестЛомается при рефакторинге
dummyПросто существует, чтобы скомпилировалосьничегонет
stubВозвращает заготовленные значениярезультат вызоваредко
spyСтаб + журнал вызововрезультат + факт вызова (постфактум)иногда
mockЗаранее объявленные ожидания, сам верифицируетпротокол взаимодействиячасто
fakeУпрощённая, но работающая реализациясостояние после операцийпочти нет
Чем мок отличается от стаба, если отвечать одним предложением

Стаб подставляет данные, мок проверяет взаимодействие. Стаб не может провалить тест: он просто отвечает, а решение о провале принимает ассерт в конце. Мок провалит тест сам, если метод вызвали не с теми аргументами, не столько раз или не в том порядке. В этом его и польза, и опасность. Отсюда практическое правило: стаб отвечает на вопрос «что вернулось», мок — на вопрос «был ли сделан вызов, которого снаружи не видно». Второй вопрос осмыслен ровно тогда, когда вызов и есть наблюдаемый результат: отправили письмо, списали деньги, опубликовали событие в Kafka, инвалидировали кеш.

gomock: go.uber.org/mock

Оригинальный github.com/golang/mock заархивирован в 2023 году; поддерживается теперь форк go.uber.org/mock, туда и переехали генератор и рантайм. Поэтому ответ «пользуюсь golang/mock» звучит как «я давно не смотрел на экосистему».

$ go install go.uber.org/mock/mockgen@latest
$ mockgen -source=internal/order/service.go -destination=internal/order/mocks/mock_deps.go -package=mocks

У генератора три режима, в ходу два. Source mode (-source) читает конкретный файл и делает моки на все интерфейсы в нём — просто и предсказуемо. Package mode, в старых версиях reflect mode (mockgen database/sql/driver Conn,Driver), грузит пакет и достаёт интерфейс; он нужен для чужих пакетов. Раньше он делал это через рефлексию, в go.uber.org/mock с v0.5.0 — разбором исходников. Третий, archive mode, появился в v0.6.0: он берёт интерфейсы из собранного архива пакета и нужен в основном под Bazel. Директиву обычно кладут прямо рядом с интерфейсом:

//go:generate mockgen -source=service.go -destination=mocks/mock_deps.go -package=mocks
func TestService_Pay_gomock(t *testing.T) {
    ctrl := gomock.NewController(t)   // ещё с golang/mock 1.5 Finish сам регистрируется в Cleanup
    repo := mocks.NewMockRepo(ctrl)
    pay  := mocks.NewMockPayments(ctrl)

    ctx := context.Background()
    o := order.Order{ID: 1, UserID: 7, Amount: 500, Status: "new"}

    // Матчеры: gomock.Any() (любой), gomock.Eq(x) (ровно x), gomock.Cond(func) (предикат).
    repo.EXPECT().
        Get(gomock.Any(), 1).
        Return(o, nil).
        Times(1)

    pay.EXPECT().
        Charge(gomock.Any(), 7, int64(500)).
        Return("tx-42", nil)          // без Times(...) ждём ровно один вызов

    repo.EXPECT().
        UpdateStatus(gomock.Any(), 1, "paid").
        Return(nil)

    got, err := order.NewService(repo, pay).Pay(ctx, 1)

    require.NoError(t, err)
    require.Equal(t, "tx-42", got)
    // Ожидания проверятся сами в t.Cleanup: любой невыполненный EXPECT
    // валит тест с внятным сообщением «missing call».
}
КонструкцияСмысл
.Times(n), .MinTimes(n), .MaxTimes(n)Сколько раз ждём вызов. По умолчанию — ровно один
.AnyTimes()Ноль и более. Для «шумных» зависимостей вроде логгера или метрик
gomock.Any()Любое значение аргумента. Стандартный приём для ctx
gomock.Eq/Nil/Not/Len/InAnyOrderМатчеры на аргумент
gomock.Cond(func(x any) bool)Произвольный предикат — когда важна часть структуры
gomock.InOrder(a, b, c)Строгий порядок вызовов
b.After(a)Частичный порядок: b только после a
.Do(f) / .DoAndReturn(f)Побочный эффект или динамический ответ в зависимости от аргументов
ctrl.Finish()Верификация. С Go 1.14+ и golang/mock 1.5 (в форке Uber — во всех версиях) вызывается автоматически через t.Cleanup
Ловушки gomock
  • По умолчанию мок строгий. Незаявленный вызов сразу валит тест с Unexpected call. Это фича: забыли ожидание — узнали сразу. Но логгер, метрики и трейсер приходится закрывать .AnyTimes(), иначе тест разваливается на каждой новой строке логирования.
  • Порядок по умолчанию не проверяется. Три EXPECT() подряд задают множество, а не последовательность. Порядок надо просить явно (InOrder/After), и просить его стоит, только когда порядок входит в контракт (сначала списать, потом пометить оплаченным), а не «так получилось».
  • Times(1) на «любой аргумент» почти ничего не проверяет. repo.EXPECT().UpdateStatus(gomock.Any(), gomock.Any(), gomock.Any()) проходит и при статусе "paid", и при "cancelled".
  • Мок из побочной горутины. ctrl потокобезопасен, но провал ожидания вызывает t.Fatalf, а он из чужой горутины работает как Goexit. Асинхронные вызовы синхронизируй каналом внутри .Do(...).
  • Мок на структуру нельзя. mockgen работает только по интерфейсам — ещё один аргумент за то, чтобы зависимости были интерфейсами у потребителя.

testify/mock

Альтернатива без кодогенерации: мок пишется руками поверх встраиваемой структуры mock.Mock. Так гибче в мелочах, но больше ручной работы, и мок легко расходится с интерфейсом: имена методов в ожиданиях — просто строки, и опечатку в них компилятор не поймает. Сигнатуры самого мока сверит строчка var _ order.Repo = (*MockRepo)(nil): ошибка всплывёт рядом с моком, а не там, где его передают в конструктор.

type MockRepo struct {
    mock.Mock
}

var _ order.Repo = (*MockRepo)(nil)   // страховка: расхождение с интерфейсом всплывёт прямо здесь

func (m *MockRepo) Get(ctx context.Context, id int) (order.Order, error) {
    args := m.Called(ctx, id)                  // регистрируем вызов и достаём заготовленный ответ
    return args.Get(0).(order.Order), args.Error(1)
}
func (m *MockRepo) UpdateStatus(ctx context.Context, id int, status string) error {
    return m.Called(ctx, id, status).Error(0)
}

func TestService_Pay_testify(t *testing.T) {
    repo := new(MockRepo)
    pay  := new(MockPayments)

    o := order.Order{ID: 1, UserID: 7, Amount: 500, Status: "new"}

    repo.On("Get", mock.Anything, 1).Return(o, nil).Once()
    pay.On("Charge", mock.Anything, 7, int64(500)).Return("tx-42", nil).Once()
    repo.On("UpdateStatus", mock.Anything, 1, "paid").Return(nil).Once()

    // Динамический ответ и произвольный матчер. Функцию из Return testify сам не вызывает:
    // её распознаёт метод мока (так сделаны моки mockery), а ручной args.String(0) на ней упадёт.
    // pay.On("Charge", mock.Anything, mock.MatchedBy(func(u int) bool { return u > 0 }), mock.Anything).
    //     Return(func(ctx context.Context, u int, a int64) string { return fmt.Sprintf("tx-%d", u) }, nil)

    _, err := order.NewService(repo, pay).Pay(context.Background(), 1)
    require.NoError(t, err)

    repo.AssertExpectations(t)          // без этой строки ожидания вообще не проверяются
    pay.AssertExpectations(t)
    repo.AssertNumberOfCalls(t, "UpdateStatus", 1)
    repo.AssertNotCalled(t, "UpdateStatus", mock.Anything, 1, "cancelled")
}
Главное отличие testify/mock от gomock

testify по умолчанию не верифицирует ничего. Забыл AssertExpectations(t) — тест зелёный, даже если ни один On(...) не сработал. gomock проверяет ожидания сам, через t.Cleanup. Второе отличие: незаявленный вызов в testify роняет тест паникой с текстом «mock: I don't know what to return», если на метод нет ни одного ожидания, или «mock: Unexpected Method Call», если не совпали аргументы. Паника обрывает прогон всего пакета, а не один тест, и в чужом коде её часто принимают за баг сервиса. И третье: .Once()/.Times(n) в testify надо писать явно, без них ожидание отработает любое число раз.

Когда моки вредят и почему фейк на map часто лучше

Мок фиксирует протокол: кто кого позвал, с чем и сколько раз. Значит, тест начинает знать внутреннее устройство метода. Допустим, ты переписал Pay, и вместо двух вызовов Get + UpdateStatus теперь делается один UpdateStatusIfNew. Поведение снаружи не изменилось ни на байт, а двадцать тестов красные. Тест, который падает при рефакторинге без изменения поведения, ничего не ловит, он просто берёт налог с каждой правки.

Мок: тест описывает внутренности

repo.EXPECT().Get(gomock.Any(), 1).
    Return(o, nil).Times(1)
repo.EXPECT().UpdateStatus(gomock.Any(), 1, "paid").
    Return(nil).Times(1)

_, err := svc.Pay(ctx, 1)
require.NoError(t, err)
// Проверили: "сервис сделал ровно эти два вызова" (порядок — только с InOrder или After).
// Не проверили: а заказ-то стал оплаченным?
// Поменяли реализацию на один запрос, и тест покраснел.

Фейк: тест описывает результат

repo := newFakeRepo(o)          // map внутри
_, err := svc.Pay(ctx, 1)
require.NoError(t, err)

got, _ := repo.Get(ctx, 1)
require.Equal(t, "paid", got.Status)
// Проверили наблюдаемый эффект: заказ оплачен.
// Как именно сервис этого добился, уже его дело.
// Рефакторинг тест не ломает.
// Весь фейк-репозиторий: три десятка строк, пишется один раз на пакет.
type fakeRepo struct {
    mu     sync.Mutex
    orders map[int]order.Order
    failOn map[int]error        // чтобы смоделировать сбой конкретной строки
}

func newFakeRepo(seed ...order.Order) *fakeRepo {
    r := &fakeRepo{orders: map[int]order.Order{}, failOn: map[int]error{}}
    for _, o := range seed {
        r.orders[o.ID] = o
    }
    return r
}

func (r *fakeRepo) Get(_ context.Context, id int) (order.Order, error) {
    r.mu.Lock(); defer r.mu.Unlock()
    if err := r.failOn[id]; err != nil {
        return order.Order{}, err
    }
    o, ok := r.orders[id]
    if !ok {
        return order.Order{}, order.ErrNotFound      // ведёт себя как настоящий: тот же сентинель
    }
    return o, nil
}

func (r *fakeRepo) UpdateStatus(_ context.Context, id int, status string) error {
    r.mu.Lock(); defer r.mu.Unlock()
    o, ok := r.orders[id]
    if !ok {
        return order.ErrNotFound
    }
    o.Status = status
    r.orders[id] = o
    return nil
}
Что когда выбирать на практике
  • Фейк бери для всего, у чего есть состояние: репозитории, кеши, хранилища файлов, очереди. Тест проверяет состояние после операции и переживает рефакторинг.
  • Стаб подходит для чтения без состояния: «конфиг вернул вот это», «курс валюты равен 90», «фича-флаг включён». Ещё им удобно моделировать ошибки: таймаут, 503, битый JSON.
  • Мок — только когда сам факт вызова и есть требование: отправили письмо, списали деньги, опубликовали событие в брокер, инвалидировали кеш, записали аудит. Такой вызов снаружи не наблюдается никак иначе, и проверять его законно.
  • Пустой реализации хватит для логгеров, метрик и трейсеров: они не влияют на решения. Строго по Месарошу это уже стаб, а не dummy: его вызывают, просто он ничего не делает.
  • Мок на собственный код внутри одного пакета почти всегда ошибка. Если тебе понадобился мок, чтобы протестировать соседнюю функцию, скорее всего, у них слишком тесная связь.
Глубже: контрактные тесты, чтобы фейк не разъехался с реальностью

Фейки обычно упрекают в том, что они врут: реальный PostgreSQL вернёт unique violation, а твой map просто перезапишет. Лечится это общим набором тестов на интерфейс: пишется функция func testRepoContract(t *testing.T, newRepo func(t *testing.T) order.Repo), в неё собраны все требования к репозиторию (не найден → ErrNotFound, повторная вставка → ErrDuplicate, обновление несуществующего → ошибка). Дальше её запускают дважды: на фейке в обычном прогоне и на настоящем PostgreSQL под тегом integration. Если фейк соврал, интеграционный прогон это поймает. В стандартной библиотеке так проверяют fs.FS через testing/fstest.TestFS.

httptest: обе стороны HTTP

Пакет net/http/httptest закрывает два разных сценария, и их регулярно путают. ResponseRecorder нужен, когда тестируем свой хендлер: сети нет вообще, хендлер вызывается как обычная функция. httptest.NewServer берут, когда тестируем свой клиент: поднимается настоящий сервер на 127.0.0.1 со свободным портом.

Сторона сервера: ResponseRecorder

func TestPayHandler(t *testing.T) {
    tests := []struct {
        name       string
        body       string
        svcTxID    string
        svcErr     error
        wantCode   int
        wantBody   string
    }{
        {name: "успех", body: `{"order_id":1}`, svcTxID: "tx-42",
            wantCode: http.StatusOK, wantBody: `{"tx_id":"tx-42"}`},
        {name: "битый json", body: `{`,
            wantCode: http.StatusBadRequest},
        {name: "нет заказа", body: `{"order_id":9}`, svcErr: order.ErrNotFound,
            wantCode: http.StatusNotFound},
        {name: "шлюз лёг", body: `{"order_id":1}`, svcErr: paygate.ErrUnavailable,
            wantCode: http.StatusBadGateway},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            h := NewPayHandler(stubPayer{txID: tc.svcTxID, err: tc.svcErr})

            req := httptest.NewRequest(http.MethodPost, "/orders/pay", strings.NewReader(tc.body))
            req.Header.Set("Content-Type", "application/json")
            rec := httptest.NewRecorder()          // реализует http.ResponseWriter, копит ответ в памяти

            h.ServeHTTP(rec, req)                  // прямой вызов: ни сокета, ни порта, ни таймаутов

            res := rec.Result()
            defer res.Body.Close()
            require.Equal(t, tc.wantCode, res.StatusCode)
            if tc.wantBody != "" {
                body, _ := io.ReadAll(res.Body)
                require.JSONEq(t, tc.wantBody, string(body))
            }
        })
    }
}
Детали, которые отличают знающего
  • httptest.NewRequest уже проставляет RemoteAddr, Host и RequestURI, как у запроса, пришедшего на сервер. http.NewRequest так не делает: он собирает клиентский запрос, и на сервере тот может вести себя иначе.
  • Роутер тестируется вместе с хендлером, если проверяются path-параметры: с Go 1.22 http.ServeMux умеет "POST /orders/{id}/pay" и r.PathValue("id"), и без прогона через мультиплексор значение будет пустым.
  • rec.Result() возвращает полноценный *http.Response; поля rec.Code и rec.Body тоже доступны напрямую. Тело rec.Result().Body лежит в памяти, утечки не будет, и закрывают его разве что по привычке: линтер bodyclose на рекордер не ругается с октября 2024 года (в golangci-lint — с 1.63).
  • Мидлварь тестируется тем же способом: оборачиваем заглушечный хендлер и смотрим, что мидлварь сделала с запросом и ответом.

Сторона клиента: httptest.NewServer

func TestPaygateClient_Charge(t *testing.T) {
    var gotBody []byte
    var gotAuth string
    var calls int32

    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        atomic.AddInt32(&calls, 1)
        gotAuth = r.Header.Get("Authorization")
        gotBody, _ = io.ReadAll(r.Body)

        assert.Equal(t, "/v1/charges", r.URL.Path)    // горутина сервера: только assert, не require
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte(`{"id":"tx-42","status":"succeeded"}`))
    }))
    defer srv.Close()          // Close ждёт завершения всех активных запросов

    // Клиенту подсовываем базовый URL тестового сервера, ради этого он и параметризован.
    c := paygate.New(srv.URL, "secret-token", srv.Client())

    tx, err := c.Charge(context.Background(), 7, 500)

    require.NoError(t, err)
    require.Equal(t, "tx-42", tx)
    require.Equal(t, "Bearer secret-token", gotAuth)
    require.JSONEq(t, `{"user_id":7,"amount":500}`, string(gotBody))
    require.EqualValues(t, 1, atomic.LoadInt32(&calls))
}
Тонкость: require внутри хендлера тестового сервера

Хендлер выполняется в горутине сервера, а не в горутине теста. require.X там вызывает t.FailNow()runtime.Goexit(): горутина умрёт посреди обработки, клиент получит оборванное соединение, и под настоящей причиной в логе окажется ещё и клиентское «EOF», которое сбивает с толку. Безопаснее складывать наблюдения в переменные и проверять их в теле теста после вызова (как выше) либо использовать только assert/t.Errorf. Формально t.Errorf из другой горутины тоже требует, чтобы тест ещё не завершился, поэтому все проверки лучше делать после того, как клиент вернул управление.

Ещё три приёма, которые стоит назвать:

  • httptest.NewTLSServer поднимает сервер по HTTPS с самоподписанным сертификатом. Ходить в него надо клиентом srv.Client(): у него уже подложен нужный корневой сертификат. Отключать проверку через InsecureSkipVerify в тестах не нужно.
  • Подмена транспорта выручает, если клиент не даёт передать базовый URL: можно подсунуть свой http.RoundTripper. Это точка расширения ниже уровня URL, через неё удобно моделировать таймауты, обрывы и ретраи без реального сервера.
  • Моделирование сбоев тестовому серверу даётся легко: он отдаёт 500, 429 с Retry-After, обрезанное тело, зависание дольше клиентского таймаута (time.Sleep в хендлере — редкое место, где sleep оправдан: ты моделируешь медленную сеть, а не ждёшь горутину).
// Подмена транспорта: клиент даже не подозревает, что сети нет.
type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

func TestClient_RetriesOn503(t *testing.T) {
    var n int
    rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
        n++
        if n < 3 {
            return &http.Response{StatusCode: 503, Body: io.NopCloser(strings.NewReader(""))}, nil
        }
        return &http.Response{
            StatusCode: 200,
            Body:       io.NopCloser(strings.NewReader(`{"id":"tx-1"}`)),
            Header:     http.Header{"Content-Type": []string{"application/json"}},
        }, nil
    })

    c := paygate.New("https://api.example.com", "tok", &http.Client{Transport: rt})
    tx, err := c.Charge(context.Background(), 7, 500)

    require.NoError(t, err)
    require.Equal(t, "tx-1", tx)
    require.Equal(t, 3, n, "ожидали две неудачные попытки и одну успешную")
}

Пирамида тестирования и антипаттерн «рожок мороженого»

Модель Майка Кона: чем ниже уровень, тем тестов больше, тем они быстрее, дешевле и точнее локализуют поломку. Чем выше — тем меньше, медленнее и дороже, зато они проверяют то, чего не видит ни один юнит-тест: что все части собраны вместе и реально работают.

Пирамида: так должно быть «Рожок мороженого»: так бывает e2e интеграционные 15 % · секунды · нужен docker юнит-тесты 80 % · миллисекунды · без внешних зависимостей падение сразу указывает на функцию e2e: 1–5 % · минуты · весь стек поднят самые хрупкие и самые медленные ручное тестирование перед релизом e2e через UI интеграционные юнит Прогон — часы. Красный билд не говорит, ЧТО сломалось. Флак лечат перезапуском, дальше — «а, это опять он». Ось, по которой всё и различается: стоимость написания и прогона, время обратной связи, точность локализации поломки, хрупкость. Юнит падает за 5 мс и называет функцию; e2e падает через 12 минут и говорит «кнопка не нажалась».
Форма распределения. Пирамида не про «e2e не нужны», а про цену обратной связи. Перевёрнутая форма означает, что команда узнаёт о поломках последней и дороже всех.
УровеньЧто проверяетЧто подменяемСкоростьЧто там тестировать НЕ надо
UnitЛогика одной единицы: расчёты, валидация, ветвления, обработка ошибоквсё внешнее (фейки, стабы)мкс–мсSQL, сериализацию по сети, конфиг
IntegrationГраницы: SQL и маппинг, миграции, транзакции, HTTP-контракт, сериализация в брокертолько сторонние сервисы (или testcontainers на них)сотни мс – секундыперебор всех веток бизнес-логики
ContractЧто провайдер и потребитель понимают формат одинаково (Pact, схемы protobuf/Avro)вторую сторону — записанным контрактомсекундыбизнес-правила
E2EНесколько сервисов вместе: критичные пользовательские сценарии, smoke после деплояничего или только платные внешние APIминутывсё, что можно проверить ниже
Как отвечать про пирамиду, чтобы не звучать как учебник

Назвать критерий разделения, а не проценты. Мой рабочий критерий: юнит-тест не выходит за границы процесса — ни сети, ни диска, ни времени; тест, который выходит, уже интеграционный. Дальше добавить, что для сервиса с тонким доменом и толстым слоем работы с БД буквальная пирамида вредна: если 80 % ценности кода приходится на SQL и транзакции, то честные 40 % интеграционных тестов полезнее, чем 80 % юнит-тестов на маппинг структур. Эту форму называют «тестовым трофеем» (Кент Доддс): широкий слой интеграционных, узкие юнит и e2e снизу и сверху. И упомянуть цену флака: тест, который падает раз в двадцать прогонов, хуже отсутствующего, потому что учит команду перезапускать пайплайн не глядя.

Интеграционные тесты: testcontainers-go

Идея простая: раз тесту нужен настоящий PostgreSQL, пусть тест сам его и поднимет. Библиотека github.com/testcontainers/testcontainers-go запускает контейнер через Docker API, дожидается готовности и отдаёт хост со случайным свободным портом. Это лучше «поднятой заранее базы на 5432»: тесты не дерутся за порт, прогон воспроизводится на ноутбуке и в CI одинаково, а версия PostgreSQL зафиксирована тегом образа.

Жизненный цикл: пять шагов, из которых дорогие только первый и второй 1. Старт postgres.Run(ctx, "postgres:16-alpine") docker pull при первом запуске, дальше кеш ~1–3 с 2. Готовность wait.ForSQL / ForLog / ForListeningPort опрос по условию, а НЕ time.Sleep(5s) ~0,3–2 с 3. Схема миграции (goose, golang-migrate) ОДИН раз на контейнер, а не на каждый тест ~0,2 с 4. Кейсы на каждый тест: BEGIN ... ROLLBACK или TRUNCATE CASCADE изоляция без рестарта ~1–20 мс 5. Уборка t.Cleanup → c.Terminate(ctx) плюс контейнер Ryuk: убьёт всё, если тест упал паникой или по Ctrl+C Вывод из цифр: шаги 1–2 стоят секунды, шаг 4 — миллисекунды. Значит, контейнер поднимается ОДИН раз на пакет (TestMain или sync.Once), а изоляция между тестами делается на уровне данных. Контейнер на каждый тест превращает прогон в часы. Изоляция транзакцией tx, _ := db.Begin(); t.Cleanup(func(){ tx.Rollback() }) Быстро, чужих строк не видно, можно параллелить. Не годится, если код сам управляет транзакциями. Изоляция очисткой TRUNCATE t1, t2 RESTART IDENTITY CASCADE Работает с любым кодом, видит реальные коммиты. Медленнее и требует последовательного прогона.
Где уходит время. Дороже всего обходятся старт и ожидание готовности, поэтому их выносят на весь пакет, а между тестами чистят только данные.
//go:build integration

package postgres_test

import (
    "context"
    "database/sql"
    "fmt"
    "os"
    "testing"
    "time"

    _ "github.com/jackc/pgx/v5/stdlib"
    "github.com/stretchr/testify/require"
    "github.com/testcontainers/testcontainers-go"
    tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"

    "example.com/shop/internal/order"
    "example.com/shop/internal/postgres"   // пакет под тестом: имя совпадает с модулем, отсюда tcpostgres
)

var testDB *sql.DB

// TestMain: контейнер поднимается один раз на весь пакет.
func TestMain(m *testing.M) {
    ctx := context.Background()

    pg, err := tcpostgres.Run(ctx, "postgres:16-alpine",
        tcpostgres.WithDatabase("app_test"),
        tcpostgres.WithUsername("test"),
        tcpostgres.WithPassword("test"),
        // Ждём готовности базы, а не порта: entrypoint образа сначала поднимает
        // PostgreSQL только на unix-сокете для инициализации, останавливает
        // и запускает снова, отсюда Occurrence(2).
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2).
                WithStartupTimeout(60*time.Second),
        ),
    )
    if err != nil {
        fmt.Fprintf(os.Stderr, "поднять postgres: %v\n", err)
        os.Exit(1)
    }

    dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        fmt.Fprintf(os.Stderr, "dsn: %v\n", err)
        os.Exit(1)
    }
    testDB, err = sql.Open("pgx", dsn)
    if err != nil {
        fmt.Fprintf(os.Stderr, "подключиться: %v\n", err)
        os.Exit(1)
    }
    if err := applyMigrations(testDB); err != nil {   // миграции один раз на контейнер
        fmt.Fprintf(os.Stderr, "миграции: %v\n", err)
        os.Exit(1)
    }

    code := m.Run()

    // os.Exit не выполняет defer: убираем руками и до выхода.
    _ = testDB.Close()
    _ = testcontainers.TerminateContainer(pg)
    os.Exit(code)
}

// Изоляция: каждый тест работает в своей транзакции, которая всегда откатывается.
func withTx(t *testing.T) *sql.Tx {
    t.Helper()
    tx, err := testDB.BeginTx(context.Background(), nil)
    if err != nil {
        t.Fatalf("begin: %v", err)
    }
    t.Cleanup(func() { _ = tx.Rollback() })   // именно Cleanup, а не defer в хелпере
    return tx
}

func TestOrderRepo_GetUpdate(t *testing.T) {
    ctx := context.Background()

    t.Run("несуществующий заказ", func(t *testing.T) {
        repo := postgres.NewOrderRepo(withTx(t))   // репозиторий умеет работать с любым Querier
        _, err := repo.Get(ctx, 999999)
        require.ErrorIs(t, err, order.ErrNotFound)   // проверяем именно маппинг sql.ErrNoRows
    })

    t.Run("уникальный индекс", func(t *testing.T) {
        // Транзакция своя на каждый подтест: после ошибки 23505 PostgreSQL
        // отвергает в ней любой запрос (25P02) до самого отката.
        repo := postgres.NewOrderRepo(withTx(t))
        require.NoError(t, repo.Create(ctx, order.Order{ID: 1, UserID: 7, Amount: 500}))
        err := repo.Create(ctx, order.Order{ID: 1, UserID: 7, Amount: 500})
        require.ErrorIs(t, err, order.ErrDuplicate) // 23505 превратился в доменную ошибку
    })

    t.Run("маппинг NULL и timestamptz", func(t *testing.T) {
        // То, чего фейк на map не проверит никогда:
        // NULL в nullable-поле, часовой пояс, точность numeric, длина varchar.
    })
}
Что имеет смысл проверять интеграционным тестом

Только то, чего принципиально не видно на фейке: правильность самого SQL и маппинга (включая NULL, часовые пояса, numeric, массивы, jsonb), превращение кодов ошибок PostgreSQL в доменные (23505ErrDuplicate, 23503ErrForeignKey), поведение уникальных индексов и каскадов, транзакции и уровни изоляции, работу миграций «вверх и вниз», блокировки и SELECT ... FOR UPDATE. Если перебирать в интеграционном тесте все ветки бизнес-логики, пирамида как раз и превращается в рожок.

Билд-теги: разделение прогонов

Строка //go:build integration исключает файл из обычной сборки. Пустую строку после неё и до package gofmt ставит сам, а обязательной она была только у старого синтаксиса «// +build». Дальше:

$ go test ./...                          # быстрые: секунды, докер не нужен
$ go test -tags=integration ./...        # медленные: нужен docker
$ go test -tags=integration -run TestOrderRepo -count=1 ./internal/postgres
# .github/workflows/ci.yml: два независимых job'а
jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with: { go-version: '1.27' }
      - run: go test -race -covermode=atomic -coverprofile=cover.out ./...

  integration:
    runs-on: ubuntu-latest          # docker доступен из коробки, testcontainers сам всё поднимет
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with: { go-version: '1.27' }
      - run: go test -tags=integration -race -timeout=15m ./...

Вместо билд-тегов можно поставить переключатель в рантайме. Он проще (не надо помнить про тег и про то, что IDE по умолчанию файл не подсвечивает), но файл всё равно компилируется:

func requireDocker(t *testing.T) {
    t.Helper()
    if testing.Short() {
        t.Skip("пропуск интеграционного теста: -short")   // go test -short ./...
    }
    // Проверка по переменным вроде DOCKER_HOST врёт: Docker Desktop её не ставит.
    testcontainers.SkipIfProviderIsNotHealthy(t)   // пропуск, если docker недоступен
}

Третий вариант, docker-compose, выручает, когда сервисов много и они должны видеть друг друга (база + Kafka + Redis + сам сервис). В GitHub Actions есть ещё services: — контейнеры поднимает сам раннер, а тест получает готовый адрес через переменные окружения.

# docker-compose.test.yml: вариант для локального прогона и «тяжёлого» CI
services:
  postgres:
    image: postgres:16-alpine
    environment: { POSTGRES_PASSWORD: test, POSTGRES_DB: app_test }
    healthcheck:                       # без healthcheck тесты стартуют раньше базы
      # -h localhost: через unix-сокет проверка пройдёт ещё на время инициализации
      test: ["CMD-SHELL", "pg_isready -h localhost -U postgres -d app_test"]
      interval: 2s
      timeout: 3s
      retries: 15
  tests:
    build: { context: ., dockerfile: Dockerfile.test }
    depends_on:
      postgres: { condition: service_healthy }
    environment:
      DATABASE_DSN: postgres://postgres:test@postgres:5432/app_test?sslmode=disable
    command: ["go", "test", "-tags=integration", "-race", "./..."]
testcontainers-godocker-compose / services в CI
Кто управляет жизненным цикломсам тест, из кодавнешний оркестратор
Портыслучайные свободные, конфликтов нетфиксированные, параллельные прогоны дерутся
Готовностьwait-стратегии в кодеhealthcheck + depends_on
Локальный запускgo test -tags=integration и всёнадо помнить про docker compose up
Уборка при паденииконтейнер Ryuk убивает всё самdown -v в always()-шаге
Много связанных сервисовсложнее (сети, зависимости руками)естественно
Цена+1–3 с на старт пакетастарт вынесен из измерения
Грабли интеграционных тестов
  • Ожидание порта вместо ожидания сервиса. Порт на хосте Docker открывает сразу, а PostgreSQL сначала поднимается для инициализации только на unix-сокете и потом перезапускается. Поэтому ждут wait.ForLog(...).WithOccurrence(2) или, надёжнее, wait.ForSQL с реальным SELECT 1.
  • os.Exit в TestMain не исполняет defer. Убирать за собой надо явно перед выходом, иначе контейнеры останутся висеть (спасает только Ryuk).
  • Тесты, зависящие от порядка. Первый тест вставил строку, второй на неё рассчитывает — и при -run одного теста всё красное. Каждый тест готовит свои данные сам.
  • Общая база на параллельные тесты. Либо транзакция на тест (и тогда параллелить можно, если тесты не пишут одни и те же ключи: такие вставки ждут друг друга, а встречные ловят deadlock), либо схема на тест (CREATE SCHEMA test_xxx + search_path), либо прогон последовательный.
  • Забытый -count=1. Go кеширует успешные результаты тестов; если тест зависит от внешнего состояния, второй прогон вернёт закешированное (cached) и ничего не выполнит.
  • Таймаут не по лимиту CI. У go test свой -timeout, по умолчанию 10 минут. Если лимит job'а меньше, зависший контейнер съест его целиком, и вместо дампа горутин ты увидишь «cancelled». Ставь -timeout явно и меньше лимита job'а.
Глубже: как ускорить интеграционный прогон

Шаблонная база: применить миграции один раз в базу app_template, а перед каждым пакетом делать CREATE DATABASE app_test_x TEMPLATE app_template. PostgreSQL копирует файлы за десятки миллисекунд вместо прогона всех миграций. Небезопасные настройки PostgreSQL для тестов: fsync=off, synchronous_commit=off, full_page_writes=off — данные не переживут падение, но тестам это и не нужно, а ускорение бывает в разы. tmpfs для PGDATA держит базу целиком в памяти. Переиспользование контейнера между прогонами (Reuse: true и имя контейнера в запросе) — локально, в CI не нужно. И снапшоты: модуль postgres умеет Snapshot()/Restore(), то есть откат к чистому состоянию без TRUNCATE.

Тестирование кода с горутинами и каналами

Всё остальное здесь вытекает из одного правила: тест не имеет права зависеть от скорости планировщика. Планировщик Go не даёт никаких гарантий на порядок и момент запуска горутины; на ноутбуке с 10 ядрами и на CI-раннере с двумя, да ещё под -race (замедление в 2–20 раз), тайминги отличаются на порядок. Значит, любая «пауза» в тесте рано или поздно станет флаком.

Так нельзя

func TestWorker_Bad(t *testing.T) {
    w := NewWorker()
    go w.Run()

    w.Submit(Job{ID: 1})
    time.Sleep(100 * time.Millisecond)  // «ну, должно хватить»

    if got := w.Processed(); got != 1 {
        t.Fatalf("got %d", got)
    }
}
// Три беды сразу:
// 1) на нагруженном раннере 100 мс не хватит, и билд упадёт на ровном месте;
// 2) даже в зелёном прогоне каждый такой тест выбрасывает 100 мс в мусор;
// 3) горутина w.Run() живёт и после теста, это утечка.

Так надо

func TestWorker_Good(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    t.Cleanup(cancel)

    w := NewWorker()
    done := make(chan struct{})
    go func() { defer close(done); w.Run(ctx) }()

    processed := make(chan int, 1)
    w.OnDone = func(id int) { processed <- id }   // сигнал вместо угадывания времени

    w.Submit(Job{ID: 1})

    select {
    case id := <-processed:
        require.Equal(t, 1, id)
    case <-time.After(2 * time.Second):
        t.Fatal("задача не обработана за 2 с")     // таймаут здесь только страховка
    }

    cancel()
    select {
    case <-done:                                   // дождались завершения горутины
    case <-time.After(time.Second):
        t.Fatal("Run не завершился по отмене контекста")
    }
}
Чеклист теста на конкурентный код
  • Сигнал вместо сна. Канал, sync.WaitGroup, errgroup.Wait(), колбэк — что угодно, что закрывается ровно тогда, когда работа сделана. Go 1.25 добавил wg.Go(func(){...}), который сам делает Add(1)/Done().
  • Таймаут на каждое ожидание. Не для ожидания, а чтобы упавший тест сообщил «таймаут в TestWorker», а не повесил весь бинарь до -timeout с дампом на 500 строк.
  • Отмена и завершение входят в проверку. Тест обязан убедиться, что по ctx.Done()/закрытию канала горутина действительно вышла. Это тот самый баг, который в проде выглядит как медленно растущая память.
  • Никаких t.Fatal/require из побочных горутин. Оба вызывают Goexit, а он завершает только эту горутину: тест помечен проваленным, но идёт дальше, и если Done вызывается не через defer, повиснет на WaitGroup. Результаты передавай каналом, ассерты делай в горутине теста.
  • -race и -count. go test -race -count=10 ./... для конкурентных пакетов поймает гонку, которая проявляется в одном прогоне из пяти. -cpu=1,4 прогоняет при разных GOMAXPROCS.
  • Детерминизм через инъекцию. Если код внутри спит или ждёт тикер, подменяй часы (см. 5.1), а не подстраивай тест под реальное время.
  • assert.Eventually / require.EventuallyWithT легально заменяют sleep там, где сигнала нет физически (внешняя система, файл на диске): опрашивают с интервалом и общим дедлайном вместо одной фиксированной паузы.

goleak: поиск утечек горутин

Горутина утекает, когда остаётся заблокированной навсегда: чтение из канала, в который больше никто не пишет, отправка в канал без читателя, забытый ticker.Stop(), worker без отмены. Рантайм её не соберёт: горутина служит корнем для GC, так что вместе с ней утекает весь её стек и всё, на что она ссылается. В проде это выглядит как ползущий вверх график памяти и runtime.NumGoroutine().

go.uber.org/goleak делает простую вещь: снимает дамп стеков через runtime.Stack и сравнивает список горутин с ожидаемым, отфильтровывая системные (GC, финализаторы, testing сам). Подключается одной строкой:

// Вариант 1: на весь пакет.
func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m,
        // Пропускаем «вечные» горутины сторонних библиотек, иначе будет ложное срабатывание.
        goleak.IgnoreTopFunction("github.com/jackc/pgx/v5/pgxpool.(*Pool).backgroundHealthCheck"),
        goleak.IgnoreAnyFunction("go.opencensus.io/stats/view.(*worker).start"),
    )
}

// Вариант 2: точечно, на один тест. Так удобнее: видно, какой тест течёт.
func TestPipeline(t *testing.T) {
    defer goleak.VerifyNone(t)     // именно defer: проверка после завершения теста
    ...
}
$ go test ./pipeline
--- FAIL: TestPipeline (0.45s)
    pipeline_test.go:12: found unexpected goroutines:
        [Goroutine 20 in state chan receive, with app/pipeline.worker on top of the stack:
        app/pipeline.worker(0x1ce287422230)
        	/app/pipeline/worker.go:7 +0x68
        created by app/pipeline.Start in goroutine 19
        	/app/pipeline/pipeline.go:11 +0xcc
        ]
FAIL
FAIL	app/pipeline	0.454s
FAIL
Как goleak даёт ложные срабатывания

Проверка идёт в момент вызова, а горутина может как раз корректно завершаться, и тест начинает мигать. Внутри goleak есть ретраи с растущими паузами (в сумме около 0,4 с, поэтому упавший тест выше и шёл 0,45 с), но полностью проблему это не снимает. Правильнее дождаться завершения явно (закрыть канал, отменить контекст, wg.Wait()), а goleak держать как страховку, а не как способ синхронизации. Ещё ложные срабатывания дают фоновые горутины библиотек: пулы соединений (database/sql держит connectionOpener), HTTP-транспорт с keep-alive (лечится t.Cleanup(http.DefaultTransport.(*http.Transport).CloseIdleConnections)), клиенты метрик и трейсинга. Их закрывают явно или добавляют в Ignore*, но каждый такой Ignore надо уметь объяснить, иначе он рано или поздно спрячет настоящую утечку.

Глубже: чем ещё ловят конкурентные баги

-race работает как happens-before детектор (алгоритм на векторных часах, реализация ThreadSanitizer): находит только те гонки, что реально произошли, зато почти без ложных срабатываний. Встроенный детектор дедлоков рантайма ловит только полную остановку («all goroutines are asleep»); если жива хоть одна горутина или взведён хоть один таймер — а в go test по умолчанию взведён таймер самого -timeout, — дедлок остальных не заметят, и тест просто повиснет до -timeout. Настройки GODEBUG (schedtrace, gctrace, asyncpreemptoff=1) иногда остаются единственным способом увидеть, что происходит в пайплайне. testing/synctest (экспериментальный в Go 1.24, стабилизирован в 1.25) выполняет тест в изолированном «пузыре» с фейковым временем, где time.Sleep мгновенен, а synctest.Wait() ждёт, пока все горутины пузыря заблокируются. В Go 1.27 к ним добавился synctest.Sleep(d), то есть time.Sleep и Wait одним вызовом: так тест гарантированно просыпается после кода, который проснулся от того же тика. Пакет закрывает вопрос «как детерминированно тестировать таймауты и ретраи»; упомянешь его на собесе, и будет видно, что ты следишь за релизами.

Вопросы

6
Суть: вынести обе зависимости за узкие интерфейсы, объявленные в пакете-потребителе, принимать их в конструкторе — и в тесте передать дублёры. Точка подмены появляется не от библиотеки моков, а от инверсии зависимостей.

Шаг 1. Интерфейс объявляет потребитель

В Go интерфейсы реализуются неявно, поэтому интерфейс определяется там, где он нужен, а не там, где лежит реализация. Пакет order объявляет Repo с двумя методами и Payments с одним — ровно тем, чем пользуется. Пакет postgres про order ничего не знает и просто возвращает структуру. В итоге интерфейсы маленькие, стрелка зависимости смотрит внутрь домена, а моки тривиальны.

type Repo interface {
    Get(ctx context.Context, id int) (Order, error)
    UpdateStatus(ctx context.Context, id int, status string) error
}
type Payments interface {
    Charge(ctx context.Context, userID int, amount int64) (string, error)
}

func NewService(r Repo, p Payments) *Service { return &Service{repo: r, pay: p} }

Шаг 2. В тесте — другие аргументы того же конструктора

func TestPay_gomock(t *testing.T) {
    ctrl := gomock.NewController(t)             // Finish регистрируется в t.Cleanup сам
    repo := mocks.NewMockRepo(ctrl)
    pay  := mocks.NewMockPayments(ctrl)

    o := order.Order{ID: 1, UserID: 7, Amount: 500, Status: "new"}

    repo.EXPECT().Get(gomock.Any(), 1).Return(o, nil)
    pay.EXPECT().Charge(gomock.Any(), 7, int64(500)).Return("tx-42", nil)
    repo.EXPECT().UpdateStatus(gomock.Any(), 1, "paid").Return(nil)

    got, err := order.NewService(repo, pay).Pay(context.Background(), 1)
    require.NoError(t, err)
    require.Equal(t, "tx-42", got)
}

Генерация: mockgen -source=service.go -destination=mocks/mock_deps.go -package=mocks, библиотека go.uber.org/mock (оригинальный golang/mock заархивирован в 2023-м). На testify то же делают рукописным моком поверх mock.Mock: repo.On("Get", mock.Anything, 1).Return(o, nil).Once() и обязательный repo.AssertExpectations(t) в конце. Без него лишний вызов и чужие аргументы ещё уронят тест паникой, а вот ожидаемый вызов, который так и не случился, никто не заметит.

Шаг 3. Что именно проверять

Не «сервис позвал репозиторий», а решения, которые дорого воспроизвести на живой инфраструктуре: платёж упал — статус не поменялся; списание прошло, а БД отвалилась, но txID вернулся наружу вместе с ошибкой, иначе деньги потеряны; за уже оплаченный заказ повторно не списывают; ошибка обёрнута через %w и ловится errors.Is. Плюс сбои сети: таймаут, 503, битый JSON.

Чем добить ответ

Сказать, что для БД я предпочитаю фейк-репозиторий на map, а мок оставляю для внешнего API. У репозитория есть состояние, и тест «записали — прочитали» устойчив к рефакторингу, а мок фиксирует протокол вызовов и рассыпается при любой перекладке кода. И сразу добавить границу: настоящий SQL, маппинг NULL, уникальные индексы и транзакции моками не проверяются в принципе, под них пишут отдельный интеграционный тест с testcontainers под тегом integration.

На чём валят

Подвопрос: «а если сервис сам делает sql.Open в конструкторе и берёт http.DefaultClient?» Правильный ответ: тогда точки подмены физически нет, и любые трюки (подмена пакетной переменной, build-теги, monkey patching через unsafe) лечат симптом. Чинить надо конструктор. Второй подвопрос: «замокай *sql.DB». Но это структура, а не интерфейс, и mockgen её не возьмёт; из рабочих решений ближе всего sqlmock, который матчит SQL-строки, но он привязывает тест к тексту запроса и потому хуже интеграционного теста.

Суть: стаб подставляет данные, мок проверяет взаимодействие, фейк — это упрощённая рабочая реализация. Стаб не может провалить тест, мок может — он сам верифицирует, что его позвали как обещано.

Пять терминов Месароша

  • Dummy просто занимает место в сигнатуре и никогда не вызывается: например, nil на месте зависимости, до которой сценарий не доходит. Логгер с пустыми методами (type nopLogger struct{}) уже стаб: его вызывают, он просто ничего не делает.
  • Stub отдаёт заранее заданные ответы и так ставит систему в нужное состояние. Ничего не проверяет: func (s stubRepo) Get(...) (Order, error) { return s.order, s.err }.
  • Spy — стаб, который дополнительно записывает вызовы; проверяют их потом, в теле теста: require.Equal(t, []string{"paid"}, repo.updateCalls).
  • Mock получает ожидания заранее и сам падает, если его вызвали не с теми аргументами, не столько раз или не в том порядке: m.EXPECT().Charge(gomock.Any(), 7, int64(500)).Return("tx", nil).Times(1).
  • Fake уже не заглушка, а работающая упрощённая реализация с настоящим поведением и состоянием: репозиторий на map[int]Order с мьютексом, in-memory очередь, локальная файловая «S3».

Ключевое различие — что именно верифицируется

Со стабом и фейком получается state-based testing: тест проверяет наблюдаемое состояние после операции («заказ стал оплаченным»). С моком получается interaction-based testing: тест проверяет протокол («был вызван Charge с суммой 500 ровно один раз»). Отсюда практическое следствие, ради которого вопрос и задают: мок связывает тест с внутренним устройством метода. Перепишешь два запроса в один — поведение снаружи то же, а тесты красные.

ДублёрМожет провалить тест сам?Что проверяет тестЛомается при рефакторинге
dummyнетничегонет
stubнетрезультатредко
spyнет (проверка в тесте)результат + факт вызоваиногда
mockдапротокол взаимодействиячасто
fakeнетсостояниепочти нет

Когда что брать

Фейк бери для всего, где есть состояние (репозитории, кеши, хранилища). Стаб годится для чтения без состояния и для моделирования ошибок. Мок нужен, только когда сам факт вызова и есть требование: отправили письмо, списали деньги, опубликовали событие в Kafka, инвалидировали кеш, записали аудит. Такой эффект снаружи не наблюдается никак иначе, и проверять его законно. Логгерам, метрикам и трейсерам хватит пустой реализации.

Чем добить

На возражение «фейк врёт: настоящий PostgreSQL вернёт unique violation, а твой map перезапишет» отвечают контрактными тестами. Пишется общий набор проверок на интерфейс func testRepoContract(t *testing.T, newRepo func(*testing.T) order.Repo), и его гоняют дважды: на фейке в быстром прогоне и на реальной БД под тегом integration. Разъехались — узнали в тот же день. В стандартной библиотеке есть тот же приём: testing/fstest.TestFS проверяет любую реализацию fs.FS.

Суть: два разных инструмента для двух сторон. Хендлерhttptest.NewRequest + httptest.NewRecorder, сети нет вообще, хендлер вызывается функцией. Клиентhttptest.NewServer, поднимается настоящий сервер на 127.0.0.1 со случайным свободным портом.

Сторона сервера

h := NewPayHandler(stubPayer{txID: "tx-42"})

req := httptest.NewRequest(http.MethodPost, "/orders/pay", strings.NewReader(`{"order_id":1}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()

h.ServeHTTP(rec, req)          // прямой вызов: ни сокета, ни таймаутов, ни портов

res := rec.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
body, _ := io.ReadAll(res.Body)
require.JSONEq(t, `{"tx_id":"tx-42"}`, string(body))

ResponseRecorder реализует http.ResponseWriter и копит статус, заголовки и тело в памяти. Проверяют обычно табличным тестом: успех, битый JSON → 400, не найдено → 404, ошибка зависимости → 502; отдельно смотрят заголовки, коды и формат ошибки. Из тонкостей: httptest.NewRequest делает серверный запрос (проставлены RemoteAddr, Host и RequestURI) в отличие от http.NewRequest; если хендлер читает path-параметры, прогонять надо через роутер, иначе с Go 1.22 r.PathValue("id") вернёт пустую строку. Мидлварь тестируют так же, обернув заглушечный хендлер.

Сторона клиента

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    gotAuth = r.Header.Get("Authorization")
    gotBody, _ = io.ReadAll(r.Body)
    w.WriteHeader(200)
    _, _ = w.Write([]byte(`{"id":"tx-42"}`))
}))
defer srv.Close()                     // Close дожидается активных запросов

c := paygate.New(srv.URL, "secret", srv.Client())
tx, err := c.Charge(ctx, 7, 500)

require.NoError(t, err)
require.Equal(t, "tx-42", tx)
require.Equal(t, "Bearer secret", gotAuth)      // проверяем в тесте, а не в хендлере

Ради этого клиент и параметризуют базовым URL и *http.Client. Что ещё стоит назвать: httptest.NewTLSServer с ходом через srv.Client() (там уже подложен нужный корневой сертификат — InsecureSkipVerify не нужен); подмена http.RoundTripper, если клиент не даёт передать URL: это точка расширения ниже уровня адреса, и через неё удобно проверять ретраи и обработку 503 вообще без сервера; и моделирование сбоев — 500, 429 с Retry-After, обрезанное тело, time.Sleep в хендлере для проверки клиентского таймаута.

Классическая ошибка

require.Equal внутри хендлера тестового сервера. Хендлер работает в горутине сервера, а require вызывает t.FailNow()runtime.Goexit(): горутина умрёт посреди обработки, клиент получит EOF, и под настоящей причиной в логе окажется ещё и сбивающая с толку ошибка клиента. Складывай наблюдения в переменные и проверяй их после того, как клиент вернул управление. Вторая ошибка — забыть srv.Close(): сервер и его горутины останутся жить, а goleak потом покажет утечку. А вот забытый defer res.Body.Close() у rec.Result() ничего не сломает: тело лежит в памяти, и линтер bodyclose на рекордер не ругается с октября 2024 года (в golangci-lint — с 1.63).

Суть: тест сам поднимает настоящий PostgreSQL контейнером на случайном порту, ждёт готовности по условию (не sleep), накатывает миграции один раз на пакет, изолирует кейсы транзакцией или TRUNCATE и убирает за собой в t.Cleanup. Файлы помечаются //go:build integration, чтобы обычный go test ./... оставался быстрым.

Зачем вообще, если есть моки

Потому что мок не проверяет ровно то, ради чего пишется слой БД: корректность самого SQL, маппинг NULL, часовых поясов, numeric, массивов и jsonb, превращение кодов ошибок PostgreSQL в доменные (23505ErrDuplicate), работу уникальных индексов и каскадов, транзакции и уровни изоляции, миграции «вверх и вниз», SELECT ... FOR UPDATE. Всё это либо работает с настоящей базой, либо не проверено.

testcontainers-go

func TestMain(m *testing.M) {
    ctx := context.Background()
    pg, err := postgres.Run(ctx, "postgres:16-alpine",
        postgres.WithDatabase("app_test"),
        postgres.WithUsername("test"), postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2).WithStartupTimeout(60*time.Second)),
    )
    if err != nil { os.Exit(1) }

    dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
    testDB, _ = sql.Open("pgx", dsn)
    _ = applyMigrations(testDB)          // один раз на контейнер

    code := m.Run()

    _ = testDB.Close()                   // os.Exit не выполняет defer, убираем руками
    _ = testcontainers.TerminateContainer(pg)
    os.Exit(code)
}

Почему это лучше «базы, поднятой заранее на 5432»: случайный свободный порт (параллельные прогоны не дерутся), одинаковое поведение на ноутбуке и в CI, версия зафиксирована тегом образа, и вспомогательный контейнер Ryuk убьёт всё поднятое, даже если тест упал паникой или его прервали по Ctrl+C. Старт стоит 1–3 секунды, поэтому контейнер поднимается один раз на пакет (TestMain или sync.Once), а не на тест.

Изоляция между кейсами

Транзакция с откатом

tx, _ := db.BeginTx(ctx, nil)
t.Cleanup(func(){ _ = tx.Rollback() })
repo := postgres.NewOrderRepo(tx)

Быстро (миллисекунды), чужих незакоммиченных строк тест не видит, параллелить можно, если тесты не пишут одни и те же ключи: иначе они ждут друг друга. Не подходит, если сам тестируемый код открывает транзакции или проверяется реальный коммит.

Очистка таблиц

TRUNCATE orders, payments
  RESTART IDENTITY CASCADE;

Работает с любым кодом и видит настоящие коммиты, но медленнее и требует последовательного прогона (или отдельной схемы/базы на тест). Последовательного и между пакетами: go test ./... по умолчанию гоняет пакеты параллельно, и при общей базе очистка одного пакета стирает данные другого, так что нужен -p 1.

Билд-теги и CI

//go:build integration
                                  // пустая строка перед package нужна старому // +build
package postgres_test
$ go test ./...                       # быстрый прогон, докер не нужен
$ go test -tags=integration -race -timeout=15m ./...

В CI это два независимых job'а: unit гоняется на каждый push и занимает секунды, integration — отдельно и с большим таймаутом. Теги можно заменить рантайм-скипом (if testing.Short() { t.Skip(...) } и go test -short ./...): так проще, но файл всё равно компилируется. Когда сервисов много и им надо видеть друг друга (база + Kafka + Redis), берут docker-compose с обязательными healthcheck и depends_on: {condition: service_healthy} либо блок services: в GitHub Actions.

На чём ловят
  • Ожидание порта вместо ожидания сервиса: порт на хосте открыт сразу, а PostgreSQL сначала поднимается для инициализации только на unix-сокете и потом перезапускается. Отсюда WithOccurrence(2) или wait.ForSQL с реальным SELECT 1.
  • Забытый -count=1: Go кеширует успешные тесты, и второй прогон вернёт (cached), ничего не выполнив.
  • Тесты, зависящие от порядка: каждый готовит свои данные сам, иначе -run одного теста краснеет.
  • defer в TestMain после os.Exit не выполняется никогда.
Чем добить: ускорение

CREATE DATABASE x TEMPLATE app_template вместо прогона миграций на каждый пакет; fsync=off, synchronous_commit=off, PGDATA на tmpfs — для тестов надёжность не нужна, а ускорение кратное; Reuse: true для контейнера локально; Snapshot()/Restore() модуля postgres для отката к чистому состоянию без TRUNCATE.

Суть: распределение по уровням определяется ценой обратной связи: внизу много быстрых и точных тестов, вверху мало медленных и хрупких. Мой критерий разделения: юнит-тест не выходит за границы процесса — ни сети, ни диска, ни настоящего времени; всё, что выходит, — уже интеграционный.

Что где тестировать

  • Unit (~70–80 %) проверяет логику одной единицы: расчёты, валидацию, ветвления, обработку ошибок, граничные значения, идемпотентность. Все зависимости заменены фейками и стабами. Миллисекунды, падение сразу указывает на функцию.
  • Integration (~15–25 %) отвечает за границы: SQL и маппинг типов, миграции, транзакции, коды ошибок БД, HTTP-контракт хендлера, сериализация в брокер, чтение конфига. Реальная зависимость поднимается в контейнере, чужие сервисы — заглушками.
  • Contract следит, чтобы провайдер и потребитель одинаково понимали формат (Pact, схемы protobuf/Avro). Отдельный дешёвый слой, который часто забывают назвать.
  • E2E (~1–5 %) — несколько сервисов вместе, только критичные пользовательские сценарии и smoke после деплоя. Минуты, самые хрупкие.

Антипаттерн «рожок мороженого»

Перевёрнутая пирамида: гора ручного тестирования сверху, много e2e через UI, немного интеграционных и почти нет юнитов. Симптомы узнаваемые: прогон CI занимает часы; красный билд не говорит, что сломалось, а сообщает только «сценарий оформления заказа упал»; флаки лечат перезапуском пайплайна, и через полгода команда перестаёт читать красные билды вообще. Причина обычно не в лени, а в архитектуре: код с зависимостями внутри, без точек подмены, просто невозможно покрыть юнит-тестами, и остаётся тестировать снаружи.

Как звучать сильно

Назвать не проценты, а критерий, и сразу оговорить исключение: для сервиса с тонким доменом и толстым слоем БД буквальная пирамида вредна. Если 80 % ценности кода сидит в SQL, транзакциях и внешних вызовах, честные 40 % интеграционных тестов полезнее, чем 80 % юнит-тестов на маппинг структур. Такую форму называют «тестовым трофеем»: широкий слой интеграционных, узкие юнит и e2e. И добавить про флаки: тест, падающий раз в двадцать прогонов, хуже отсутствующего. Он приучает команду перезапускать CI не глядя, и однажды так же не глядя проигнорируют настоящую поломку.

Суть: тест не должен зависеть от скорости планировщика. Синхронизация — каналом, WaitGroup или errgroup, а не time.Sleep; на каждое ожидание — таймаут как страховка; ассерты только из горутины теста; в CI обязательны -race и goleak.

Почему не time.Sleep

Планировщик Go не даёт гарантий на момент запуска горутины. На ноутбуке с 10 ядрами и на CI-раннере с двумя, да ещё под -race (замедление в 2–20 раз), тайминги отличаются на порядок. Итог: либо тест иногда падает на ровном месте, либо (в зелёном случае) ты выбрасываешь по 100 мс на каждый такой тест; на пакете из ста тестов набегает десять секунд впустую на каждом прогоне.

// Правильный каркас: сигнал + таймаут + проверка завершения
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)

done := make(chan struct{})
go func() { defer close(done); w.Run(ctx) }()

processed := make(chan int, 1)
w.OnDone = func(id int) { processed <- id }
w.Submit(Job{ID: 1})

select {
case id := <-processed:
    require.Equal(t, 1, id)
case <-time.After(2 * time.Second):
    t.Fatal("задача не обработана за 2 с")    // таймаут не для ожидания, а для внятного сообщения
}

cancel()
select {
case <-done:                                   // проверяем, что горутина вышла по отмене
case <-time.After(time.Second):
    t.Fatal("Run не завершился по отмене контекста")
}

Правила

  • Никаких t.Fatal и require из побочных горутин. Внутри у них runtime.Goexit(): умрёт только эта горутина, тест пойдёт дальше, а если Done() вызывается не через defer, повиснет. Результаты передавай каналом.
  • Таймаут на каждое ожидание. Без него повисший тест держит весь бинарь до -timeout и печатает дамп всех горутин вместо внятной причины.
  • Отмена входит в проверку, а не в уборку. Тест обязан убедиться, что горутина вышла: именно этот баг в проде выглядит как медленно растущая память.
  • go test -race -count=10 -cpu=1,4 ./... для конкурентных пакетов: так ловится гонка, которая проявляется в одном прогоне из пяти.
  • assert.Eventually / require.EventuallyWithT легально заменяют sleep там, где сигнала физически нет (внешняя система, файл на диске): опрос с интервалом и общим дедлайном.
  • Тикеры и таймауты внутри кода заводи через инжектированные часы (см. 5.1), иначе тест на «ретрай через 30 секунд» будет идти 30 секунд.

goleak

Утекает горутина, которая навсегда заблокировалась на чтении из канала, куда больше никто не пишет, на отправке без читателя, на забытом тикере. GC её не соберёт: горутина сама считается корнем, так что утекает весь её стек и всё, на что она ссылается. go.uber.org/goleak снимает дамп через runtime.Stack, отфильтровывает системные горутины и сравнивает список с ожидаемым.

func TestMain(m *testing.M) { goleak.VerifyTestMain(m) }   // на весь пакет
func TestPipeline(t *testing.T) { defer goleak.VerifyNone(t); ... }   // точечно

Ложные срабатывания бывают двух видов: горутина в процессе корректного завершения (внутри есть ретраи с паузами, в сумме около 0,4 с, но правильнее дождаться завершения явно) и фоновые горутины библиотек — пул database/sql, keep-alive HTTP-транспорта, клиенты метрик. Их закрывают явно (db.Close(), CloseIdleConnections()) или добавляют в goleak.IgnoreTopFunction(...), но каждый такой Ignore надо уметь объяснить, иначе он однажды спрячет настоящую утечку.

Чем добить

Упомянуть testing/synctest: экспериментальный в Go 1.24, стабилизирован в 1.25. Он выполняет тест в изолированном «пузыре» с фейковым временем, где time.Sleep мгновенен, а synctest.Wait() ждёт, пока все горутины пузыря заблокируются. С Go 1.27 есть ещё synctest.Sleep(d), который делает time.Sleep(d) и Wait() одним вызовом: без него тест и код, проснувшиеся от одного и того же тика фейковых часов, бегут в неопределённом порядке, и это тот же флак, только внутри пузыря. Это прямой ответ на «как детерминированно тестировать таймауты, ретраи и тикеры». И назвать границу встроенного детектора дедлоков: рантайм ловит только полную остановку («all goroutines are asleep»); если жива хоть одна горутина или взведён таймер — а в go test взведён таймер самого -timeout, — дедлок остальных не заметят, и тест просто повиснет до -timeout.