В Go инфраструктура тестирования является частью стандартной библиотеки. Без внешних фреймворков, без сложной конфигурации. Но за простотой go test стоит набор паттернов, которые отличают production-качество тестов от хрупких и поддерживаемых с трудом. В этой статье разберём основные приёмы: от табличных тестов и подтестов до моков через интерфейсы, тестирования HTTP и интеграционных сценариев.
Пакет testing: что есть в стандартной библиотеке
Тесты в Go живут в файлах с суффиксом _test.go. Компилятор исключает их из финальной сборки — в бинарнике их не будет. Каждая тестовая функция начинается с Test и принимает единственный параметр *testing.T:
package math
import "testing"
func Abs(n int) int {
if n < 0 {
return -n
}
return n
}
func TestAbs(t *testing.T) {
result := Abs(-5)
if result != 5 {
t.Errorf("Abs(-5) = %d, ожидали 5", result)
}
}
Запуск:
$ go test ./...
ok mypackage 0.003s
Флаг -v покажет имена тестов и их результаты:
$ go test -v ./...
=== RUN TestAbs
--- PASS: TestAbs (0.00s)
PASS
ok mypackage 0.003s
Флаг -run фильтрует тесты по имени (поддерживает регулярные выражения):
$ go test -v -run TestAbs ./...
t.Error и t.Fatal
t.Error и t.Errorf отмечают тест как проваленный, но продолжают выполнение. t.Fatal и t.Fatalf останавливают тест немедленно.
func TestExample(t *testing.T) {
conn, err := connect()
if err != nil {
t.Fatalf("не удалось подключиться: %v", err) // Дальше проверять нечего
}
result := conn.Query("SELECT 1")
if result != 1 {
t.Errorf("Query() = %d, ожидали 1", result) // Можно продолжать
}
result2 := conn.Query("SELECT 2")
if result2 != 2 {
t.Errorf("Query() = %d, ожидали 2", result2) // Увидим оба провала
}
}
Правило простое: t.Fatal для setup-кода, без которого остальные проверки не имеют смысла. t.Error для самих проверок — так вы увидите все проблемы за один запуск, а не по одной.
Example-функции
Помимо Test*-функций, Go поддерживает Example*-функции. Это тесты, которые одновременно работают как документация: go doc и pkg.go.dev показывают их как примеры использования, а go test проверяет, что вывод совпадает с ожидаемым:
func ExampleAbs() {
fmt.Println(Abs(-5))
fmt.Println(Abs(0))
fmt.Println(Abs(3))
// Output:
// 5
// 0
// 3
}
Комментарий // Output: в конце — обязательная часть. Без него функция компилируется, но go test не будет проверять вывод. Если реальный вывод не совпадает с ожидаемым, тест падает:
$ go test -v -run ExampleAbs
=== RUN ExampleAbs
--- PASS: ExampleAbs (0.00s)
PASS
Для функций, у которых порядок строк в выводе недетерминирован, есть // Unordered output: — Go отсортирует строки перед сравнением.
Именование привязано к коду:
| Функция | Что документирует |
|---|---|
ExampleAbs | Функцию Abs |
ExampleUser_Name | Метод Name типа User |
ExampleNewUser | Функцию NewUser |
Example | Пакет целиком |
Главное применение — библиотечный код. Пользователь видит работающий пример прямо в документации, а пример не устаревает, потому что go test его проверяет при каждом прогоне.
Табличные тесты
Табличные тесты (table-driven tests) — основной паттерн тестирования в Go. Вместо того чтобы писать отдельную функцию на каждый кейс, вы описываете все случаи в слайсе структур и прогоняете их в цикле через t.Run:
package validation
import (
"regexp"
"testing"
)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
func ValidateEmail(email string) bool {
if len(email) > 254 {
return false
}
return emailRegex.MatchString(email)
}
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
want bool
}{
{name: "валидный email", email: "user@example.com", want: true},
{name: "с точкой в имени", email: "user.name@example.com", want: true},
{name: "с плюсом", email: "user+tag@example.com", want: true},
{name: "без @", email: "userexample.com", want: false},
{name: "без домена", email: "user@", want: false},
{name: "пустая строка", email: "", want: false},
{name: "пробелы", email: "user @example.com", want: false},
{name: "слишком длинный", email: string(make([]byte, 255)) + "@example.com", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ValidateEmail(tt.email)
if got != tt.want {
t.Errorf("ValidateEmail(%q) = %v, ожидали %v", tt.email, got, tt.want)
}
})
}
}
Вывод с -v:
$ go test -v -run TestValidateEmail
=== RUN TestValidateEmail
=== RUN TestValidateEmail/валидный_email
=== RUN TestValidateEmail/с_точкой_в_имени
=== RUN TestValidateEmail/с_плюсом
=== RUN TestValidateEmail/без_@
=== RUN TestValidateEmail/без_домена
=== RUN TestValidateEmail/пустая_строка
=== RUN TestValidateEmail/пробелы
=== RUN TestValidateEmail/слишком_длинный
--- PASS: TestValidateEmail (0.00s)
--- PASS: TestValidateEmail/валидный_email (0.00s)
--- PASS: TestValidateEmail/с_точкой_в_имени (0.00s)
--- PASS: TestValidateEmail/с_плюсом (0.00s)
--- PASS: TestValidateEmail/без_@ (0.00s)
--- PASS: TestValidateEmail/без_домена (0.00s)
--- PASS: TestValidateEmail/пустая_строка (0.00s)
--- PASS: TestValidateEmail/пробелы (0.00s)
--- PASS: TestValidateEmail/слишком_длинный (0.00s)
PASS
Каждый кейс — отдельный подтест с именем. Можно запустить конкретный:
$ go test -v -run "TestValidateEmail/без_@"
Добавить новый кейс — одна строка в слайсе. Не нужно дублировать логику вызова и проверки.
Подтесты с t.Run()
t.Run используется не только в табличных тестах. Подтесты удобны для логической группировки, когда одна тестовая функция проверяет несколько аспектов:
func TestUserService(t *testing.T) {
svc := NewUserService(newMockRepo())
t.Run("создание", func(t *testing.T) {
user, err := svc.Create("alice", "alice@example.com")
if err != nil {
t.Fatalf("Create: %v", err)
}
if user.Name != "alice" {
t.Errorf("Name = %q, ожидали %q", user.Name, "alice")
}
})
t.Run("дубликат email", func(t *testing.T) {
_, err := svc.Create("bob", "alice@example.com")
if err == nil {
t.Error("ожидали ошибку при дублировании email")
}
})
t.Run("поиск по ID", func(t *testing.T) {
user, err := svc.FindByID(1)
if err != nil {
t.Fatalf("FindByID: %v", err)
}
if user.Email != "alice@example.com" {
t.Errorf("Email = %q, ожидали %q", user.Email, "alice@example.com")
}
})
}
Фильтрация через -run работает по иерархии: -run TestUserService/создание запустит только подтест «создание».
Параллельные тесты с t.Parallel()
Вызов t.Parallel() внутри подтеста сигнализирует Go, что этот подтест можно запускать параллельно с другими параллельными подтестами:
func TestValidateEmail_Parallel(t *testing.T) {
tests := []struct {
name string
email string
want bool
}{
{name: "валидный", email: "user@example.com", want: true},
{name: "без @", email: "invalid", want: false},
{name: "пустой", email: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := ValidateEmail(tt.email)
if got != tt.want {
t.Errorf("ValidateEmail(%q) = %v, ожидали %v", tt.email, got, tt.want)
}
})
}
}
До Go 1.22 здесь была ловушка: переменная цикла tt захватывалась по ссылке, и к моменту выполнения горутины содержала значение последней итерации. Приходилось писать tt := tt внутри цикла. Начиная с Go 1.22, переменные цикла создаются заново на каждую итерацию, и эта проблема исчезла.
Флаг -race хорошо сочетается с t.Parallel() — параллельное выполнение повышает шансы обнаружить гонку данных:
$ go test -race -v ./...
Когда применять t.Parallel():
| Ситуация | Использовать? | Причина |
|---|---|---|
| Чистые функции без побочных эффектов | Да | Нет общего состояния |
| Тесты с общим состоянием (БД, файлы) | Нет | Гонки и непредсказуемые результаты |
| Медленные тесты с сетевыми вызовами | Да | Ускоряет прогон |
Тесты с t.Setenv() | Нет | Переменные окружения — глобальное состояние |
| Тесты, зависящие от порядка выполнения | Нет | Порядок не гарантирован |
Хелперы: t.Helper(), t.Cleanup() и t.TempDir()
t.Helper()
Если вы выносите проверки в отдельную функцию, при провале теста Go покажет строку внутри хелпера, а не строку вызова. t.Helper() исправляет это — ошибка будет указывать на вызывающий код:
func assertEqual(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("получили %d, ожидали %d", got, want)
}
}
func TestCalculation(t *testing.T) {
assertEqual(t, Abs(-5), 5) // При ошибке укажет на эту строку
assertEqual(t, Abs(3), 3)
}
t.Cleanup()
t.Cleanup() регистрирует функцию, которая будет вызвана после завершения теста (и всех его подтестов). В отличие от defer, cleanup-функции выполняются даже после t.Fatal:
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("postgres", "postgres://localhost/testdb?sslmode=disable")
if err != nil {
t.Fatalf("не удалось подключиться к БД: %v", err)
}
// Будет вызвано после завершения теста
t.Cleanup(func() {
db.Exec("DELETE FROM users WHERE email LIKE '%@test.example.com'")
db.Close()
})
return db
}
func TestCreateUser(t *testing.T) {
db := setupTestDB(t)
// Работаем с БД, cleanup вызовется автоматически
_, err := db.Exec("INSERT INTO users (email) VALUES ($1)", "alice@test.example.com")
if err != nil {
t.Fatalf("INSERT: %v", err)
}
}
t.TempDir()
t.TempDir() создаёт временную директорию, которая автоматически удаляется после теста:
func TestWriteConfig(t *testing.T) {
dir := t.TempDir() // Удалится после теста
path := filepath.Join(dir, "config.json")
err := WriteConfig(path, Config{Port: 8080})
if err != nil {
t.Fatalf("WriteConfig: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !strings.Contains(string(data), "8080") {
t.Error("конфиг не содержит порт 8080")
}
}
Папка testdata
Go-тулчейн специально обрабатывает директорию testdata. go build игнорирует её при сборке, а go test делает доступной для чтения. Это стандартное место для тестовых данных: входных файлов, эталонных ответов (golden files), конфигов, SQL-дампов.
mypackage/
├── parser.go
├── parser_test.go
└── testdata/
├── valid_input.json
├── malformed.json
└── golden/
└── expected_output.json
Доступ к файлам возможен через относительный путь от пакета:
func TestParseConfig(t *testing.T) {
data, err := os.ReadFile("testdata/valid_input.json")
if err != nil {
t.Fatalf("не удалось прочитать фикстуру: %v", err)
}
got, err := ParseConfig(data)
if err != nil {
t.Fatalf("ParseConfig: %v", err)
}
if got.Port != 8080 {
t.Errorf("Port = %d, ожидали 8080", got.Port)
}
}
Golden files
Существует паттерн Golden files, при котором эталонный результат хранится в файле. Тест сравнивает вывод функции с содержимым файла. Если формат вывода меняется, golden-файл обновляют через флаг:
var update = flag.Bool("update", false, "обновить golden files")
func TestRender(t *testing.T) {
got := Render(inputData)
goldenPath := "testdata/golden/expected_output.json"
if *update {
os.WriteFile(goldenPath, got, 0644)
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("не удалось прочитать golden file: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("результат не совпадает с golden file:\nполучили:\n%s\nожидали:\n%s", got, want)
}
}
Обновление эталонов:
$ go test -run TestRender -update
После обновления golden-файл коммитится в репозиторий. То есть эталонные файлы видны в git diff. На ревью проще понять, как изменился вывод для тех или иных данных (текстовых и бинарных).
Стоит выбирать testdata вместо t.TempDir(), когда тестовые данные фиксированы и переиспользуются между запусками (фикстуры, эталоны, примеры невалидных входов). Метод t.TempDir() нужен для файлов, создаваемых временно и не нужных после завершения.
Fuzz-тестирование
С Go 1.18 фаззинг встроен в стандартную библиотеку. Фаззер генерирует случайные входные данные и скармливает их вашей функции, пытаясь вызвать панику или нарушение инвариантов. Табличные тесты проверяют случаи, которые вы придумали. Фаззер ищет те, которые вы не придумали.
Fuzz-функция начинается с Fuzz и принимает *testing.F:
func FuzzValidateEmail(f *testing.F) {
// Seed-корпус: начальные примеры, от которых фаззер отталкивается
f.Add("user@example.com")
f.Add("")
f.Add("@")
f.Add("a@b.c")
f.Add(string(make([]byte, 300)))
f.Fuzz(func(t *testing.T, email string) {
// Функция не должна паниковать ни на каком входе
ValidateEmail(email)
})
}
Запуск:
$ go test -fuzz FuzzValidateEmail
fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed
fuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 352781 (117573/sec), new interesting: 12 (total: 17)
^C
Фаззер работает, пока не найдёт ошибку или пока вы не остановите его через Ctrl+C. Флаг -fuzztime ограничивает время:
$ go test -fuzz FuzzValidateEmail -fuzztime=30s
Если фаззер нашёл входные данные, вызывающие ошибку, он сохраняет их в testdata/fuzz/<FuzzTestName>/. При следующем запуске go test (уже без -fuzz) этот кейс прогонится как обычный регрессионный тест. Добавлять крашащие входы вручную не нужно.
Проверять можно не только отсутствие паник. Типичный приём — round-trip: сериализуем, десериализуем и сравниваем с оригиналом:
func FuzzJSONRoundTrip(f *testing.F) {
f.Add("Alice", "alice@example.com", 30)
f.Fuzz(func(t *testing.T, name, email string, age int) {
original := User{Name: name, Email: email, Age: age}
data, err := json.Marshal(original)
if err != nil {
t.Skip("невалидный вход для JSON")
}
var decoded User
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal не смог прочитать то, что Marshal записал: %v", err)
}
if original != decoded {
t.Errorf("round-trip изменил данные:\nдо: %+v\nпосле: %+v", original, decoded)
}
})
}
t.Skip внутри fuzz-функции пропускает конкретный сгенерированный вход, не останавливая фаззинг целиком. Используйте его для входов, которые заведомо невалидны для тестируемой функции.
Фаззинг полезнее всего там, где код принимает произвольный внешний ввод: парсеры, валидаторы, кодеки.
Моки через интерфейсы
В Go моки строятся на интерфейсах. Принцип «accept interfaces, return structs» — принимайте интерфейс как зависимость, а реализацию подставляйте при тестировании.
Ручные моки
Самый простой подход — написать структуру, которая реализует нужный интерфейс:
// Интерфейс репозитория
type UserRepository interface {
FindByID(id int) (*User, error)
Save(user *User) error
}
// Сервис принимает интерфейс
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(id int) (*User, error) {
user, err := s.repo.FindByID(id)
if err != nil {
return nil, fmt.Errorf("поиск пользователя: %w", err)
}
return user, nil
}
Мок — обычная структура с заранее заданным поведением:
type mockUserRepo struct {
users map[int]*User
err error
}
func (m *mockUserRepo) FindByID(id int) (*User, error) {
if m.err != nil {
return nil, m.err
}
user, ok := m.users[id]
if !ok {
return nil, fmt.Errorf("пользователь %d не найден", id)
}
return user, nil
}
func (m *mockUserRepo) Save(user *User) error {
if m.err != nil {
return m.err
}
m.users[user.ID] = user
return nil
}
func TestGetUser(t *testing.T) {
repo := &mockUserRepo{
users: map[int]*User{
1: {ID: 1, Name: "Alice", Email: "alice@example.com"},
},
}
svc := NewUserService(repo)
t.Run("существующий пользователь", func(t *testing.T) {
user, err := svc.GetUser(1)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if user.Name != "Alice" {
t.Errorf("Name = %q, ожидали %q", user.Name, "Alice")
}
})
t.Run("несуществующий пользователь", func(t *testing.T) {
_, err := svc.GetUser(999)
if err == nil {
t.Error("ожидали ошибку для несуществующего пользователя")
}
})
t.Run("ошибка репозитория", func(t *testing.T) {
repo := &mockUserRepo{err: fmt.Errorf("connection refused")}
svc := NewUserService(repo)
_, err := svc.GetUser(1)
if err == nil {
t.Error("ожидали ошибку при сбое репозитория")
}
})
}
Ручные моки подходят, когда интерфейс маленький (1-3 метода) и поведение простое.
testify/mock
Когда интерфейс разрастается или нужно проверять, какие методы вызывались и с какими аргументами, удобнее testify/mock:
import (
"testing"
"github.com/stretchr/testify/mock"
)
type MockUserRepo struct {
mock.Mock
}
func (m *MockUserRepo) FindByID(id int) (*User, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func (m *MockUserRepo) Save(user *User) error {
args := m.Called(user)
return args.Error(0)
}
func TestGetUser_Testify(t *testing.T) {
repo := new(MockUserRepo)
// Настраиваем ожидаемое поведение
repo.On("FindByID", 1).Return(&User{ID: 1, Name: "Alice"}, nil)
repo.On("FindByID", 999).Return(nil, fmt.Errorf("не найден"))
svc := NewUserService(repo)
user, err := svc.GetUser(1)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if user.Name != "Alice" {
t.Errorf("Name = %q, ожидали %q", user.Name, "Alice")
}
// Проверяем, что все ожидаемые вызовы произошли
repo.AssertExpectations(t)
}
Сравнение подходов к мокам
| Подход | Зависимости | Автогенерация | Проверка вызовов | Когда использовать |
|---|---|---|---|---|
| Ручные моки | Нет | Нет | Вручную | Маленькие интерфейсы, простое поведение |
testify/mock | testify | Нет | AssertExpectations | Средние интерфейсы, нужна проверка вызовов |
gomock | gomock + mockgen | Да (mockgen) | EXPECT() + верификация | Большие интерфейсы, строгий контроль порядка вызовов |
gomock с генератором mockgen удобен для больших интерфейсов, где писать моки руками утомительно. Генератор создаёт мок автоматически из определения интерфейса.
testify: assert и require
Пакет testify — самая популярная библиотека для удобных проверок в Go-тестах. Два основных пакета: assert и require.
assert при провале продолжает тест. require при провале останавливает тест (аналогично t.Fatal):
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOrderCreation(t *testing.T) {
svc := NewOrderService(newMockDB())
// require для setup — если заказ не создался, проверять нечего
order, err := svc.CreateOrder(CreateOrderRequest{
UserID: 1,
Items: []Item{{ProductID: 42, Quantity: 2}},
})
require.NoError(t, err)
require.NotNil(t, order)
// assert для проверок — увидим все ошибки за один прогон
assert.Equal(t, 1, order.UserID)
assert.Equal(t, StatusPending, order.Status)
assert.Len(t, order.Items, 1)
assert.False(t, order.CreatedAt.IsZero())
}
require для setup-кода (подключение, создание объектов). assert для самих проверок — при провале увидите все несовпадения, а не только первое.
Тестирование HTTP с httptest
Стандартный пакет net/http/httptest покрывает два сценария: тестирование ваших обработчиков и тестирование кода, который вызывает внешние HTTP API.
Тестирование обработчиков
httptest.NewRecorder создаёт фейковый http.ResponseWriter, который записывает ответ в память:
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("статус %d, ожидали %d", rec.Code, http.StatusOK)
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("не удалось распарсить ответ: %v", err)
}
if body["status"] != "ok" {
t.Errorf("status = %q, ожидали %q", body["status"], "ok")
}
}
Здесь нет сетевых вызовов — обработчик вызывается напрямую как обычная функция. Тест работает за микросекунды.
Тестирование HTTP-клиентов
httptest.NewServer поднимает реальный HTTP-сервер на случайном порте. Используется для тестирования кода, который обращается к внешним API:
func TestGitHubClient(t *testing.T) {
// Поднимаем тестовый сервер, который имитирует GitHub API
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/octocat" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"login": "octocat",
"name": "The Octocat",
"public_repos": 8,
})
}))
defer server.Close()
// Подставляем URL тестового сервера вместо реального
client := NewGitHubClient(server.URL)
user, err := client.GetUser("octocat")
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if user.Login != "octocat" {
t.Errorf("Login = %q, ожидали %q", user.Login, "octocat")
}
if user.PublicRepos != 8 {
t.Errorf("PublicRepos = %d, ожидали %d", user.PublicRepos, 8)
}
}
Ключевой момент: ваш HTTP-клиент должен принимать базовый URL как параметр. В продакшене это https://api.github.com, в тестах — server.URL.
Покрытие тестами
Go имеет встроенную поддержку покрытия кода тестами:
$ go test -cover ./...
ok mypackage 0.003s coverage: 78.4% of statements
Для детального анализа — профиль покрытия и визуализация в браузере:
$ go test -coverprofile=coverage.out ./...
$ go tool cover -html=coverage.out
В открывшейся HTML-странице зелёным отмечены покрытые строки, красным — непокрытые.
Практические рекомендации по уровням покрытия:
| Покрытие | Оценка | Рекомендация |
|---|---|---|
| < 40% | Низкое | Начните с тестов для критичной бизнес-логики |
| 40–70% | Приемлемое | Достаточно для большинства проектов на старте |
| 70–85% | Хорошее | Оптимальный уровень для production-кода |
| > 85% | Высокое | Имеет смысл для библиотек и критичных модулей |
Не гонитесь за 100%. Стопроцентное покрытие формально достижимо, но требует тестирования тривиальных вещей (геттеры, форматирование ошибок) и приводит к хрупким тестам, которые ломаются при любом рефакторинге. Лучше 75% осмысленных тестов, чем 100% с моками на каждую строку.
Команда для CI:
$ go test -race -cover -count=1 ./...
Флаг -race включает детектор гонок данных. -count=1 отключает кэширование — тесты будут запускаться при каждом вызове.
Пропуск тестов: t.Skip() и testing.Short()
Не все тесты нужно запускать каждый раз. Тесты с сетевыми вызовами или контейнерами замедляют цикл разработки. t.Skip() пропускает тест по любому условию:
func TestWithExternalAPI(t *testing.T) {
apiKey := os.Getenv("API_KEY")
if apiKey == "" {
t.Skip("API_KEY не задан, пропускаем")
}
// ... тест с реальным API
}
Для типичного сценария «быстрый прогон без медленных тестов» есть флаг -short и функция testing.Short():
func TestSlowIntegration(t *testing.T) {
if testing.Short() {
t.Skip("пропускаем в short-режиме")
}
// ... тест, который работает 10 секунд
}
# Быстрый прогон при разработке
$ go test -short ./...
# Полный прогон в CI
$ go test ./...
-short работает по соглашению: Go не пропускает тесты автоматически, это ваш код проверяет testing.Short() и решает, что делать. Тесты, которые не проверяют этот флаг, выполнятся в любом случае.
Когда -short недостаточно и нужно жёстко отделить интеграционные тесты от юнит-тестов, используют build tags.
Интеграционные тесты
Интеграционные тесты медленнее юнит-тестов и требуют внешних зависимостей (базу данных, очередь, сеть). В Go их принято отделять с помощью build tags:
//go:build integration
package storage
import (
"database/sql"
"os"
"testing"
_ "github.com/lib/pq"
)
var testDB *sql.DB
func TestMain(m *testing.M) {
// Setup: подключаемся к тестовой БД
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = "postgres://localhost/testdb?sslmode=disable"
}
var err error
testDB, err = sql.Open("postgres", dsn)
if err != nil {
panic("не удалось подключиться к БД: " + err.Error())
}
// Запускаем тесты
code := m.Run()
// Teardown: очищаем и закрываем
testDB.Exec("DELETE FROM users WHERE email LIKE '%@test.example.com'")
testDB.Close()
os.Exit(code)
}
func TestUserRepository_Create(t *testing.T) {
repo := NewUserRepository(testDB)
user, err := repo.Create("test@test.example.com", "Test User")
if err != nil {
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() {
testDB.Exec("DELETE FROM users WHERE id = $1", user.ID)
})
found, err := repo.FindByID(user.ID)
if err != nil {
t.Fatalf("FindByID: %v", err)
}
if found.Email != "test@test.example.com" {
t.Errorf("Email = %q, ожидали %q", found.Email, "test@test.example.com")
}
}
Директива //go:build integration в начале файла означает, что эти тесты не запустятся при обычном go test ./.... Для запуска нужен явный тег:
$ go test -tags=integration ./...
TestMain — точка входа для всего пакета. Вызывается один раз перед всеми тестами. Используется для инициализации ресурсов (подключение к БД, запуск контейнеров) и очистки после.
Для полноценных интеграционных тестов с контейнерами есть testcontainers-go — библиотека, которая поднимает Docker-контейнеры прямо из тестов. Это отдельная тема, которая заслуживает своей статьи.
Типичные ошибки
| Ошибка | Пример | Как исправить |
|---|---|---|
| Захват переменной цикла (до Go 1.22) | for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel(); ... }) } | Добавить tt := tt перед t.Run или обновиться до Go 1.22+ |
| Тестирование реализации вместо поведения | Проверка количества вызовов internal-метода | Тестируйте публичный API: вход → выход |
| Слишком много проверок в одном тесте | 20 assert-ов в одной функции | Разбить на подтесты с t.Run |
Отсутствие -race | go test ./... | Всегда go test -race ./... в CI |
time.Sleep вместо синхронизации | time.Sleep(time.Second) в тесте на горутину | Каналы, sync.WaitGroup, require.Eventually из testify |
| Общее состояние между тестами | Глобальная переменная, изменяемая в разных тестах | Создавать состояние внутри каждого теста или в setup-функции |
Заключение
- Табличные тесты — основной паттерн. Слайс структур +
t.Runпокрывают большинство случаев. - Example-функции — документация и тест в одном месте. Полезны для библиотечного кода.
t.Helper(),t.Cleanup(),t.TempDir()— стандартная библиотека даёт всё для организации тестов.- Фаззинг — встроен с Go 1.18. Находит крайние случаи, которые вы не придумаете.
- Моки через интерфейсы — начинайте с ручных моков, переходите к
testify/mockилиgomock, когда интерфейсы разрастаются. httptestпокрывает оба направления: тестирование своих обработчиков и подмена внешних API.-raceв CI — обязательно. Гонки данных не воспроизводятся стабильно, но детектор их находит.- Покрытие 70–85% — разумный ориентир. Не гонитесь за цифрой, тестируйте критичный код.
-shortи build tags — два способа разделить быстрые и медленные тесты.-shortпроще, build tags надёжнее.
Теги: