Тесты, бенчмарки, моки и 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, ...) важен по двум причинам:
- В выводе
go test -vкаждый кейс отдельной строкой:--- PASS: TestParseDate/iso (0.00s). Сходу видно, какой кейс упал. - Можно запускать конкретный кейс:
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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.