Go обладает встроенной, мощной системой тестирования прямо в стандартной библиотеке. Никаких внешних фреймворков не нужно -- пакет testing покрывает unit-тесты, бенчмарки, фаззинг и примеры. Философия Go: тесты -- это обычный Go-код, без магии и DSL.
Пакет testing: обзор
Пакет testing -- центральный элемент экосистемы тестирования Go. Тестовые файлы имеют суффикс _test.go и не компилируются в финальный бинарник.
Ключевые типы:
*testing.T-- для unit-тестов*testing.B-- для бенчмарков*testing.F-- для фаззинга (Go 1.18+)*testing.M-- для TestMain (глобальный setup/teardown)
// math.go
package math
// Add returns the sum of two integers.
func Add(a, b int) int {
return a + b
}
// math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d, want %d", got, want)
}
}
Запуск: go test в директории пакета.
Тестовые функции: сигнатура и соглашения
Каждая тестовая функция должна:
- Начинаться с
Testи следующей заглавной буквы - Принимать единственный параметр
*testing.T - Находиться в файле
*_test.go
func TestCalculateTotal(t *testing.T) {
// Test logic here
}
func TestUserService_Create(t *testing.T) {
// Convention: TestType_Method for method tests
}
Важно: Имя после
Testдолжно начинаться с заглавной буквы или подчёркивания.Testcalculate-- невалидно,Test_calculateилиTestCalculate-- валидно.
t.Run() -- подтесты для группировки
Подтесты позволяют группировать связанные проверки и запускать их выборочно:
func TestMathOperations(t *testing.T) {
t.Run("Addition", func(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("1 + 2 should equal 3")
}
})
t.Run("Subtraction", func(t *testing.T) {
if Subtract(5, 3) != 2 {
t.Error("5 - 3 should equal 2")
}
})
t.Run("Negative numbers", func(t *testing.T) {
if Add(-1, -2) != -3 {
t.Error("-1 + -2 should equal -3")
}
})
}
Запуск конкретного подтеста:
go test -run TestMathOperations/Addition
go test -run TestMathOperations/Negative
Таблично-управляемые тесты (Table-Driven Tests)
Это THE паттерн тестирования в Go. Используется повсеместно в стандартной библиотеке и всех серьёзных проектах.
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{name: "positive numbers", a: 2, b: 3, want: 5},
{name: "negative numbers", a: -1, b: -2, want: -3},
{name: "zero", a: 0, b: 0, want: 0},
{name: "mixed signs", a: -5, b: 10, want: 5},
{name: "large numbers", a: 1000000, b: 2000000, want: 3000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
Преимущества:
- Добавление нового кейса -- одна строка
- Все тест-кейсы видны в одном месте
- Каждый кейс имеет имя для идентификации
- Подтесты запускаются через
t.Run-- можно фильтровать
Расширенный пример с проверкой ошибок
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
input string
want *Config
wantErr bool
}{
{
name: "valid config",
input: `{"host": "localhost", "port": 8080}`,
want: &Config{Host: "localhost", Port: 8080},
},
{
name: "empty input",
input: "",
wantErr: true,
},
{
name: "invalid json",
input: "{invalid}",
wantErr: true,
},
{
name: "missing port uses default",
input: `{"host": "localhost"}`,
want: &Config{Host: "localhost", Port: 3000},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseConfig([]byte(tt.input))
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Host != tt.want.Host || got.Port != tt.want.Port {
t.Errorf("ParseConfig() = %+v, want %+v", got, tt.want)
}
})
}
}
t.Parallel() -- параллельные тесты
t.Parallel() позволяет тестам выполняться параллельно, ускоряя тестирование:
func TestSlowOperations(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "case1", input: "hello", want: "HELLO"},
{name: "case2", input: "world", want: "WORLD"},
{name: "case3", input: "go", want: "GO"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // This subtest runs in parallel
// Note: since Go 1.22, loop variable capture is fixed
// No need for `tt := tt` anymore
got := strings.ToUpper(tt.input)
if got != tt.want {
t.Errorf("ToUpper(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
Go 1.22+: Переменная цикла теперь создаётся заново на каждой итерации. Старый трюк
tt := ttбольше не нужен!
t.Helper() -- чистые стек-трейсы
t.Helper() помечает функцию как вспомогательную. При ошибке Go покажет строку вызывающего кода, а не хелпера:
func assertEqual(t *testing.T, got, want int) {
t.Helper() // Without this, error shows THIS line
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWithHelpers(t *testing.T) {
result := Add(2, 3)
assertEqual(t, result, 5) // Error will point HERE, not inside assertEqual
}
t.Cleanup() -- гарантированная очистка
t.Cleanup() регистрирует функцию, которая вызовется по завершении теста (или подтеста). Работает как defer, но привязан к тесту:
func TestDatabaseOperation(t *testing.T) {
db := setupTestDB(t)
// No need for defer db.Close() -- cleanup handles it
// Test logic
err := db.Insert("users", User{Name: "Alice"})
if err != nil {
t.Fatalf("insert failed: %v", err)
}
}
func setupTestDB(t *testing.T) *TestDB {
t.Helper()
db, err := NewTestDB()
if err != nil {
t.Fatalf("failed to create test DB: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("failed to close DB: %v", err)
}
})
return db
}
Преимущество перед defer: cleanup вызывается даже если тест паникует, и можно регистрировать несколько cleanup-функций (вызываются в обратном порядке LIFO).
Тестовые фикстуры: директория testdata
Go-тулчейн игнорирует директории с именем testdata при сборке. Это стандартное место для тестовых данных:
mypackage/
parser.go
parser_test.go
testdata/
valid_input.json
invalid_input.json
expected_output.json
func TestParseFile(t *testing.T) {
// testdata is relative to the package directory
input, err := os.ReadFile("testdata/valid_input.json")
if err != nil {
t.Fatalf("failed to read test data: %v", err)
}
got, err := Parse(input)
if err != nil {
t.Fatalf("Parse() error: %v", err)
}
// Compare with expected output
want, err := os.ReadFile("testdata/expected_output.json")
if err != nil {
t.Fatalf("failed to read expected data: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("Parse() output mismatch\ngot: %s\nwant: %s", got, want)
}
}
Паттерн Golden Files
Golden files -- это эталонные файлы с ожидаемым выводом. Флаг -update обновляет их:
var update = flag.Bool("update", false, "update golden files")
func TestRender(t *testing.T) {
got := Render(templateData)
golden := filepath.Join("testdata", t.Name()+".golden")
if *update {
// Update the golden file
os.WriteFile(golden, got, 0644)
return
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("failed to read golden file: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("Render() mismatch:\n%s", diff(want, got))
}
}
Обновление golden files: go test -run TestRender -update
testing.Short() и testing.Verbose()
Флаги для управления поведением тестов:
func TestIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// Long-running integration test...
db := connectToRealDB()
defer db.Close()
// ...
}
func TestWithLogging(t *testing.T) {
if testing.Verbose() {
t.Logf("running with verbose output")
}
result := Process(input)
if testing.Verbose() {
t.Logf("result: %+v", result)
}
}
Запуск в коротком режиме: go test -short ./...
Флаги go test
# Run all tests in current package
go test
# Run all tests recursively
go test ./...
# Verbose output (show t.Log output)
go test -v
# Run specific test by regex
go test -run TestAdd
go test -run TestAdd/positive
# Disable test caching
go test -count=1 ./...
# Skip long tests
go test -short
# Set timeout (default 10m)
go test -timeout 30s
# Race detector (ESSENTIAL in CI!)
go test -race ./...
# Coverage
go test -cover
go test -coverprofile=coverage.out
go tool cover -html=coverage.out # HTML report
go tool cover -func=coverage.out # Function-level report
# Parallel test count
go test -parallel 4
# Fail fast
go test -failfast
t.Fatal vs t.Error
t.Error/t.Errorf-- записывает ошибку, тест продолжаетсяt.Fatal/t.Fatalf-- записывает ошибку, тест останавливается (вызываетruntime.Goexit())
func TestUserCreation(t *testing.T) {
// Use Fatal for setup failures -- no point continuing
user, err := CreateUser("alice", "[email protected]")
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
// Use Error for assertion failures -- check more things
if user.Name != "alice" {
t.Errorf("Name = %q, want %q", user.Name, "alice")
}
if user.Email != "[email protected]" {
t.Errorf("Email = %q, want %q", user.Email, "[email protected]")
}
if user.ID == "" {
t.Error("ID should not be empty")
}
}
Правило: t.Fatal для предусловий (setup, DB connection), t.Error для проверок (assertions).
TestMain -- глобальный setup/teardown
TestMain позволяет выполнить код до и после всех тестов в пакете:
var testDB *sql.DB
func TestMain(m *testing.M) {
// Global setup
var err error
testDB, err = sql.Open("postgres", os.Getenv("TEST_DATABASE_URL"))
if err != nil {
log.Fatalf("failed to connect to DB: %v", err)
}
// Run migrations
if err := runMigrations(testDB); err != nil {
log.Fatalf("failed to run migrations: %v", err)
}
// Run all tests
code := m.Run()
// Global teardown
testDB.Close()
// Exit with test result code
os.Exit(code)
}
func TestQueryUsers(t *testing.T) {
// testDB is available here
rows, err := testDB.Query("SELECT id, name FROM users LIMIT 1")
// ...
}
Важно:
TestMainпринимает*testing.M(не*testing.T). ЕслиTestMainопределён, именно он вызываетm.Run()для запуска тестов. Не забудьтеos.Exit(code)-- иначе код возврата потеряется.
Фаззинг (Go 1.18+)
Фаззинг автоматически генерирует входные данные для поиска edge cases:
func FuzzReverse(f *testing.F) {
// Seed corpus: starting inputs
f.Add("hello")
f.Add("world")
f.Add("")
f.Add("a")
f.Add("ab")
f.Fuzz(func(t *testing.T, s string) {
// Property: reversing twice returns original
rev := Reverse(s)
doubleRev := Reverse(rev)
if s != doubleRev {
t.Errorf("Reverse(Reverse(%q)) = %q, want %q", s, doubleRev, s)
}
// Property: length is preserved
if len(rev) != len(s) {
t.Errorf("len(Reverse(%q)) = %d, want %d", s, len(rev), len(s))
}
})
}
# Run fuzzing for 30 seconds
go test -fuzz FuzzReverse -fuzztime 30s
# Run with specific seed corpus
go test -fuzz FuzzReverse -fuzztime 100x # Run 100 iterations
# Failing inputs are saved in testdata/fuzz/FuzzReverse/
# They become permanent test cases!
Пример: фаззинг парсера
func FuzzParseJSON(f *testing.F) {
f.Add([]byte(`{"key": "value"}`))
f.Add([]byte(`{"num": 42}`))
f.Add([]byte(`[]`))
f.Add([]byte(`null`))
f.Fuzz(func(t *testing.T, data []byte) {
var v interface{}
err := json.Unmarshal(data, &v)
if err != nil {
return // Invalid JSON is expected, skip
}
// If we parsed it, we should be able to marshal it back
out, err := json.Marshal(v)
if err != nil {
t.Errorf("Marshal(Unmarshal(%q)) failed: %v", data, err)
}
// And parse the result again
var v2 interface{}
if err := json.Unmarshal(out, &v2); err != nil {
t.Errorf("second Unmarshal failed: %v", err)
}
})
}
Полный пример: тестирование UserService
// user.go
package user
import (
"errors"
"fmt"
"regexp"
"strings"
)
var (
ErrEmptyName = errors.New("name cannot be empty")
ErrInvalidEmail = errors.New("invalid email format")
ErrDuplicate = errors.New("user already exists")
)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
type User struct {
ID string
Name string
Email string
}
type Store interface {
Save(u *User) error
FindByEmail(email string) (*User, error)
}
type Service struct {
store Store
}
func NewService(store Store) *Service {
return &Service{store: store}
}
func (s *Service) Create(name, email string) (*User, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, ErrEmptyName
}
email = strings.ToLower(strings.TrimSpace(email))
if !emailRegex.MatchString(email) {
return nil, fmt.Errorf("%w: %s", ErrInvalidEmail, email)
}
existing, _ := s.store.FindByEmail(email)
if existing != nil {
return nil, fmt.Errorf("%w: %s", ErrDuplicate, email)
}
u := &User{
ID: generateID(),
Name: name,
Email: email,
}
if err := s.store.Save(u); err != nil {
return nil, fmt.Errorf("saving user: %w", err)
}
return u, nil
}
// user_test.go
package user
import (
"errors"
"testing"
)
// Manual mock implementing Store interface
type mockStore struct {
users map[string]*User
err error
}
func newMockStore() *mockStore {
return &mockStore{users: make(map[string]*User)}
}
func (m *mockStore) Save(u *User) error {
if m.err != nil {
return m.err
}
m.users[u.Email] = u
return nil
}
func (m *mockStore) FindByEmail(email string) (*User, error) {
if m.err != nil {
return nil, m.err
}
u, ok := m.users[email]
if !ok {
return nil, nil
}
return u, nil
}
func TestService_Create(t *testing.T) {
tests := []struct {
name string
inputName string
email string
setup func(*mockStore) // Pre-populate store
storeErr error
wantErr error
}{
{
name: "valid user",
inputName: "Alice",
email: "[email protected]",
},
{
name: "trims whitespace",
inputName: " Bob ",
email: " [email protected] ",
},
{
name: "empty name",
inputName: "",
email: "[email protected]",
wantErr: ErrEmptyName,
},
{
name: "whitespace-only name",
inputName: " ",
email: "[email protected]",
wantErr: ErrEmptyName,
},
{
name: "invalid email",
inputName: "Alice",
email: "not-an-email",
wantErr: ErrInvalidEmail,
},
{
name: "duplicate email",
inputName: "Alice",
email: "[email protected]",
setup: func(store *mockStore) {
store.users["[email protected]"] = &User{Name: "Existing"}
},
wantErr: ErrDuplicate,
},
{
name: "store error",
inputName: "Alice",
email: "[email protected]",
storeErr: errors.New("database connection failed"),
wantErr: errors.New("saving user"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := newMockStore()
if tt.setup != nil {
tt.setup(store)
}
if tt.storeErr != nil {
store.err = tt.storeErr
}
svc := NewService(store)
got, err := svc.Create(tt.inputName, tt.email)
if tt.wantErr != nil {
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, tt.wantErr) {
// Check if error message contains expected text
if tt.wantErr.Error() != "" {
return // Accept any error when storeErr is set
}
t.Errorf("error = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.ID == "" {
t.Error("expected non-empty ID")
}
if got.Name == "" {
t.Error("expected non-empty Name")
}
})
}
}
Покрытие кода (Coverage)
# Quick coverage percentage
go test -cover ./...
# Generate coverage profile
go test -coverprofile=coverage.out ./...
# View in browser (interactive HTML report)
go tool cover -html=coverage.out
# Function-level coverage
go tool cover -func=coverage.out
# Coverage by package
go test -coverpkg=./... ./...
# Only count as covered when ALL conditions in a line are tested
go test -covermode=atomic ./...
Coverage modes:
set-- statement was executed (yes/no) -- defaultcount-- how many times statement was executedatomic-- like count, but safe for parallel tests (use with-race)