MidТеория10 min

Основы тестирования в Go

Пакет testing, таблично-управляемые тесты, подтесты, фаззинг, coverage и лучшие практики

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 в директории пакета.

Тестовые функции: сигнатура и соглашения

Каждая тестовая функция должна:

  1. Начинаться с Test и следующей заглавной буквы
  2. Принимать единственный параметр *testing.T
  3. Находиться в файле *_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) -- default
  • count -- how many times statement was executed
  • atomic -- like count, but safe for parallel tests (use with -race)

Проверь себя

Нужен ли 'tt := tt' внутри цикла table-driven тестов с t.Parallel() начиная с Go 1.22?

Что делает t.Helper() в тестовой вспомогательной функции?

Для чего используется TestMain(m *testing.M)?

В чём разница между t.Error() и t.Fatal()?

Какой суффикс должны иметь файлы с тестами в Go?