Раздел 22 · Go

Тесты, бенчмарки, моки и testcontainers

middle~50 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Тесты, бенчмарки, моки и testcontainers

Урок-конспект про то, как тестируют Go. Главный тезис: stdlib testing закрывает 80% задач, поверх обычно нужны testify для читаемых проверок и mockery для интерфейсов. Остальное это httptest, testcontainers-go и pprof точечно. Считаем, что Tour of Go и 00-tour-and-setup уже прошли.

1. Где живут тесты

Файлы вида *_test.go лежат рядом с кодом и компилируются только при go test. Пакет совпадает с тестируемым (package greet), и тогда тесту видно приватные имена. Если пакет назвать greet_test, тесты становятся внешними: видят только публичный API. Полезно для “чёрного ящика” и интеграционных тестов.

// greet/greet_test.go
package greet

func TestHello(t *testing.T) { /* ... */ }

Всё, что ниже, это контракт пакета testing:

ИмяЧто делает
func TestXxx(t *testing.T)обычный тест
func BenchmarkXxx(b *testing.B)бенчмарк
func ExampleXxx()пример, который дополнительно проверяется по // Output:
func FuzzXxx(f *testing.F)fuzz-тест (Go 1.18+)
func TestMain(m *testing.M)главная функция тестов: setup/teardown на весь пакет

Запуск:

go test ./...                  # тесты во всех пакетах модуля
go test -v ./greet             # подробный вывод одного пакета
go test -run TestHello/empty   # один под-тест
go test -race ./...            # с race detector
go test -short ./...           # пропустить тесты, которые сами зовут t.Skip(...)
go test -count=1 ./...         # без кеша (count=1, идиома для "перезапусти честно")

go test кеширует прошедшие тесты по содержимому: если ни код, ни зависимости не менялись, второй раз тест не побежит. Это полезно, но иногда сбивает с толку. -count=1 идиома для форсированного перезапуска.

2. Таблично-управляемые тесты как идиома

В Go таблично-управляемый тест, это дефолт. Не “один из стилей”, а первый, к которому тянемся. Один тест, N кейсов, общий arrange, свой assert на каждый кейс.

func TestParseDate(t *testing.T) {
    cases := []struct {
        name    string
        in      string
        want    time.Time
        wantErr bool
    }{
        {name: "iso",     in: "2026-05-05",          want: must(t, "2026-05-05")},
        {name: "with_tz", in: "2026-05-05T12:00+03", want: must(t, "2026-05-05T09:00Z")},
        {name: "empty",   in: "",                    wantErr: true},
        {name: "garbage", in: "yesterday",           wantErr: true},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            got, err := ParseDate(tc.in)
            if (err != nil) != tc.wantErr {
                t.Fatalf("err: got %v, wantErr %v", err, tc.wantErr)
            }
            if !got.Equal(tc.want) {
                t.Fatalf("ParseDate(%q) = %v, want %v", tc.in, got, tc.want)
            }
        })
    }
}

t.Run(tc.name, ...) важен по двум причинам:

  1. В выводе go test -v каждый кейс отдельной строкой: --- PASS: TestParseDate/iso (0.00s). Сходу видно, какой кейс упал.
  2. Можно запускать конкретный кейс: go test -run TestParseDate/empty.

С Go 1.22 захват переменной цикла починили: tc := tc внутри t.Run больше не нужен. До 1.22 без этой строки все горутины ловили одну и ту же копию.

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

Не могу найти задачу 22-golang/24-testing-and-bench/leap-cases. Проверь путь в src/content/tasks/.

3. t.Helper, t.Cleanup, t.Parallel

Три функции, без которых табличные тесты выглядят неряшливо.

t.Helper() говорит, что эта функция, helper. Когда из неё вызывают t.Fatalf, сообщение об ошибке указывает на caller, не на саму helper. Без этого все ошибки в выводе указывают в одну строку, бесполезно.

func mustOpen(t *testing.T, path string) *os.File {
    t.Helper()
    f, err := os.Open(path)
    if err != nil {
        t.Fatalf("open %q: %v", path, err)
    }
    return f
}

t.Cleanup(fn) регистрирует teardown, который запускается в конце теста, в порядке LIFO. Заменяет нагромождение defer плюс безопаснее: если у тебя несколько Cleanup-ов, они отработают даже после t.Fatalf.

func setupDB(t *testing.T) *sql.DB {
    t.Helper()
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        t.Fatalf("open: %v", err)
    }
    t.Cleanup(func() { db.Close() })
    return db
}

t.Parallel() помечает тест как параллельный. Все t.Parallel()-тесты в пакете запускаются одновременно (по -parallel=N, дефолт GOMAXPROCS). Полезно для I/O-тяжёлых тестов, вреден для тестов с общим состоянием. По возможности, помечай каждый табличный кейс как parallel:

for _, tc := range cases {
    t.Run(tc.name, func(t *testing.T) {
        t.Parallel()
        // ...
    })
}

4. testify: assert и require

testify даёт три модуля: assert, require, suite. Минимально нужен только require.

  • assert.X(t, ...), тест продолжается даже если упало.
  • require.X(t, ...), тест прерывается при первом падении.
  • suite.Suite, общий setup/teardown через структуру с методами. В современном Go обычно не нужен (заменяется TestMain плюс t.Cleanup).

Когда assert, когда require: если после падения остальные проверки теряют смысл (объект nil, не на чем дальше проверять), require. Если падает одна проверка, но остальные независимы и стоит увидеть все проблемы сразу, assert.

import (
    "testing"
    "github.com/stretchr/testify/require"
)

func TestUser_Save(t *testing.T) {
    repo := newTestRepo(t)
    u := User{Email: "v@bat.school"}

    err := repo.Save(t.Context(), &u)
    require.NoError(t, err)
    require.NotZero(t, u.ID)

    got, err := repo.GetByID(t.Context(), u.ID)
    require.NoError(t, err)
    require.Equal(t, u.Email, got.Email)
}

Антипаттерн, использовать assert.Equal на больших структурах. Если структура глубже двух уровней, читать expected: ... actual: ... становится больно. Замени на go-cmp:

import "github.com/google/go-cmp/cmp"

if diff := cmp.Diff(want, got); diff != "" {
    t.Fatalf("user mismatch (-want +got):\n%s", diff)
}

Вывод даст красивый diff с зелёным/красным, как в git, видно ровно то поле, которое разъехалось.

5. Mock-генерация: mockery и gomock

Моки нужны, когда тестируешь верхний слой и не хочешь за ним тащить БД, HTTP-клиент, очередь. Правило, мокать интерфейс, не конкретный тип.

В Go два мейнстримных генератора:

  • mockery, читает интерфейсы из .go файлов, генерит файлы вида mocks/UserRepo.go с нужными методами. Дружит с testify/mock под капотом.
  • gomock (uber-go), форк старого golang/mock. Чуть строже типизация, ожидания через EXPECT()....

Mockery конфигурится в .mockery.yaml:

with-expecter: true
dir: '{{.InterfaceDir}}'
mockname: 'Mock{{.InterfaceName}}'
outpkg: '{{.PackageName}}'
filename: 'mock_{{.InterfaceNameSnake}}.go'
packages:
  github.com/USERNAME/hello/internal/core:
    interfaces:
      UserRepo:

Запуск, mockery (без аргументов) собирает всё по конфигу. Дальше в тесте:

func TestRegister_existingEmail(t *testing.T) {
    repo := core.NewMockUserRepo(t)
    repo.EXPECT().
        FindByEmail(mock.Anything, "v@bat.school").
        Return(&core.User{ID: 7}, nil)

    svc := core.NewRegisterService(repo)
    _, err := svc.Register(t.Context(), "v@bat.school", "secret")

    require.ErrorIs(t, err, core.ErrEmailTaken)
}

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

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.