Тестирование в Go: от табличных тестов до интеграционных сценариев

В 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/mocktestifyНетAssertExpectationsСредние интерфейсы, нужна проверка вызовов
gomockgomock + 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
Отсутствие -racego test ./...Всегда go test -race ./... в CI
time.Sleep вместо синхронизацииtime.Sleep(time.Second) в тесте на горутинуКаналы, sync.WaitGroup, require.Eventually из testify
Общее состояние между тестамиГлобальная переменная, изменяемая в разных тестахСоздавать состояние внутри каждого теста или в setup-функции

Заключение

  1. Табличные тесты — основной паттерн. Слайс структур + t.Run покрывают большинство случаев.
  2. Example-функции — документация и тест в одном месте. Полезны для библиотечного кода.
  3. t.Helper(), t.Cleanup(), t.TempDir() — стандартная библиотека даёт всё для организации тестов.
  4. Фаззинг — встроен с Go 1.18. Находит крайние случаи, которые вы не придумаете.
  5. Моки через интерфейсы — начинайте с ручных моков, переходите к testify/mock или gomock, когда интерфейсы разрастаются.
  6. httptest покрывает оба направления: тестирование своих обработчиков и подмена внешних API.
  7. -race в CI — обязательно. Гонки данных не воспроизводятся стабильно, но детектор их находит.
  8. Покрытие 70–85% — разумный ориентир. Не гонитесь за цифрой, тестируйте критичный код.
  9. -short и build tags — два способа разделить быстрые и медленные тесты. -short проще, build tags надёжнее.

Теги: