Golang для бэкенда. От юнит-тестов до end-to-end и BDD с godog

Подпишитесь на наш ТЕЛЕГРАМ КАНАЛ ПО АВТОМАТИЗАЦИИ ТЕСТИРОВАНИЯ

В этом материале вы узнаете:

  • как работать с go test, табличные тесты, подтесты, а также бенчмарки;
  • как мокировать зависимости с помощью testify/mock и интерфейсов;
  • как запускать интеграционные тесты в контейнерах с testcontainers-go (на примере PostgreSQL);
  • как писать end-to-end HTTP тесты с httptest и реальной контейнерной инфраструктурой;
  • как применять BDD в Go с помощью godog: от feature-файлов до шагов и хуков;
  • как внедрить лучшие практики — CI на GitHub Actions, а также как работать с coverage, race detector, fuzzing и профилированием.

Пример проекта (простой сервис)

Структура проекта:

go-backend/
├── cmd/
│   └── server/main.go
├── internal/
│   ├── user/
│   │   ├── service.go
│   │   └── repository.go
│   └── http/
│       └── handler.go
├── test/                     # optional: BDD feature files here
│   └── features/
│       └── auth.feature
├── go.mod

Минимальный пример кода для тестирования (упрощенный для демонстрации):

internal/user/service.go

package user
import "context"// User represents a user.
type User struct {
    ID    int64
    Email string
    Name  string
}// Repo defines methods the service depends on (easy to mock).
type Repo interface {
    Create(ctx context.Context, u *User) (int64, error)
    GetByEmail(ctx context.Context, email string) (*User, error)
}// Service provides user operations.
type Service struct {
    repo Repo
}func NewService(r Repo) *Service { return &Service{repo: r} }func (s *Service) CreateUser(ctx context.Context, email, name string) (*User, error) {
    if email == "" {
        return nil, ErrInvalidEmail
    }
    u := &User{Email: email, Name: name}
    id, err := s.repo.Create(ctx, u)
    if err != nil {
        return nil, err
    }
    u.ID = id
    return u, nil
}var ErrInvalidEmail = &ErrString{"invalid email"}type ErrString struct{ s string }
func (e *ErrString) Error() string { return e.s }

internal/http/handler.go

package http
import (
    "encoding/json"
    "net/http"    usersvc "your/module/internal/user"
)type Handler struct {
    userSvc *usersvc.Service
}func NewHandler(us *usersvc.Service) *Handler { return &Handler{userSvc: us} }func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req struct { Email, Name string }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }
    u, err := h.userSvc.CreateUser(r.Context(), req.Email, req.Name)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    w.WriteHeader(http.StatusCreated)
    _ = json.NewEncoder(w).Encode(u)
}

1) Юнит-тесты: go test + табличные тесты + подтесты

internal/user/service_test.go

package user
import (
    "context"
    "errors"
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)
// MockRepo using testify/mock
type MockRepo struct{ mock.Mock }
func (m *MockRepo) Create(ctx context.Context, u *User) (int64, error) {
    args := m.Called(ctx, u)
    return args.Get(0).(int64), args.Error(1)
}
func (m *MockRepo) GetByEmail(ctx context.Context, email string) (*User, error) {
    args := m.Called(ctx, email)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*User), args.Error(1)
}
func TestCreateUser(t *testing.T) {
    ctx := context.Background()
    t.Run("invalid email", func(t *testing.T) {
        s := NewService(&MockRepo{})
        _, err := s.CreateUser(ctx, "", "Alice")
        assert.Equal(t, ErrInvalidEmail, err)
    })
    t.Run("repo create error", func(t *testing.T) {
        m := &MockRepo{}
        // expect Create called and return error
        m.On("Create", mock.Anything, mock.Anything).Return(int64(0), errors.New("db error"))
        s := NewService(m)
        _, err := s.CreateUser(ctx, "a@b.com", "Alice")
        assert.Error(t, err)
        m.AssertExpectations(t)
    })
    t.Run("success", func(t *testing.T) {
        m := &MockRepo{}
        m.On("Create", mock.Anything, mock.Anything).Return(int64(42), nil)
        s := NewService(m)
        u, err := s.CreateUser(ctx, "a@b.com", "Alice")
        assert.NoError(t, err)
        assert.Equal(t, int64(42), u.ID)
        m.AssertExpectations(t)
    })
}

Основные идеи:

  • Используйте интерфейсы для зависимостей, чтобы упростить мокирование.
  • Табличные тесты отлично подходит для проверки входных кейсов (показано далее).

Пример табличного теста:

func TestCreateUser_Table(t *testing.T) {
    cases := []struct{
        name, email, nameVal string
        repoErr error
        wantErr bool
    }{
        {"empty email", "", "A", nil, true},
        {"repo fail", "x@y.z", "A", errors.New("fail"), true},
        {"ok", "x@y.z", "A", nil, false},
    }
    for _, tc := range cases {
        tc := tc // capture
        t.Run(tc.name, func(t *testing.T) {
            m := &MockRepo{}
            if tc.repoErr != nil {
                m.On("Create", mock.Anything, mock.Anything).Return(int64(0), tc.repoErr)
            } else if tc.name != "empty email" {
                m.On("Create", mock.Anything, mock.Anything).Return(int64(1), nil)
            }
            s := NewService(m)
            _, err := s.CreateUser(context.Background(), tc.email, tc.nameVal)
            if tc.wantErr {
                assert.Error(t, err)
            } else {
                assert.NoError(t, err)
            }
            m.AssertExpectations(t)
        })
    }
}

2) Бенчмарки

Создадим функцию, имя которой начинается с Benchmark, в файле с тестами (_test.go):

func BenchmarkCreateUser(b *testing.B) {
    m := &MockRepo{}
    m.On("Create", mock.Anything, mock.Anything).Return(int64(1), nil)
    s := NewService(m)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _, _ = s.CreateUser(context.Background(), "a@b.com", "name")
    }
}

Запуск:

go test -bench=. -benchmem ./...

3) Race detector, покрытие и профилирование

Команды:

  • Race detectorgo test -race ./...
  • Покрытие (coverage)go test -coverprofile=coverage.out ./... then go tool cover -html=coverage.out
  • CPU профилированиеgo test -cpuprofile=cpu.out ./... and use go tool pprof cpu.out

Все эти проверки стоит добавить в CI, чтобы автоматически отслеживать регрессии, утечки производительности и снижение покрытия.

4) Интеграционные тесты с Testcontainers (пример PostgreSQL)

Установите пакеты github.com/testcontainers/testcontainers-go и github.com/jackc/pgx/v5 (или используйте database/sql). В примере показан запуск контейнера PostgreSQL, выполнение миграций (логика оставлена как концепт) и запуск реального репозитория в контейнере.

internal/user/integration_test.go

package user_test
import (
    "context"
    "database/sql"
    "fmt"
    "os"
    "testing"
    "time"    _ "github.com/lib/pq"
    tc "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
    usersvc "your/module/internal/user"
)func TestRepoCreateAndGet(t *testing.T) {
    ctx := context.Background()
    req := tc.ContainerRequest{
        Image:        "postgres:16",
        ExposedPorts: []string{"5432/tcp"},
        Env: map[string]string{
            "POSTGRES_PASSWORD": "secret",
            "POSTGRES_DB":       "testdb",
            "POSTGRES_USER":     "test",
        },
        WaitingFor: wait.ForListeningPort("5432/tcp").WithStartupTimeout(60 * time.Second),
    }
    pgC, err := tc.GenericContainer(ctx, tc.GenericContainerRequest{
        ContainerRequest: req,
        Started:          true,
    })
    if err != nil { t.Fatal(err) }
    defer pgC.Terminate(ctx)    host, _ := pgC.Host(ctx)
    port, _ := pgC.MappedPort(ctx, "5432")
    dsn := fmt.Sprintf("postgres://test:secret@%s:%s/testdb?sslmode=disable", host, port.Port())    // wait a bit for readiness (already waited by wait strategy, but safe)
    db, err := sql.Open("postgres", dsn)
    if err != nil { t.Fatal(err) }
    defer db.Close()    // run migrations here (omitted) or create table quickly:
    _, err = db.Exec(`CREATE TABLE users (id serial primary key, email text, name text)`)
    if err != nil { t.Fatal(err) }    repo := usersvc.NewSQLRepo(db) // implement repo backed by DB
    id, err := repo.Create(context.Background(), &usersvc.User{Email: "a@b.com", Name: "Alice"})
    if err != nil { t.Fatal(err) }
    if id == 0 { t.Fatalf("expected id > 0") }    got, err := repo.GetByEmail(context.Background(), "a@b.com")
    if err != nil { t.Fatal(err) }
    if got.Email != "a@b.com" { t.Fatalf("expected email a@b.com, got %s", got.Email) }
}

Примечания:

  • Используйте Testcontainers только для интеграционных тестов — чтобы ускорить выполнение юнит-тестов.
  • Выполняйте интеграционные тесты выборочно (через тег, отдельный запуск ./... -run Integration или build tags).

5) End-to-End HTTP тесты — быстрые и полные сценарии

5-A) Быстрый E2E с httptest.NewServer (in-memory)

internal/http/handler_test.go

package http_test
import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"    usersvc "your/module/internal/user"
    httpx "your/module/internal/http"
)type fakeRepo struct{}
func (f *fakeRepo) Create(ctx context.Context, u *usersvc.User) (int64, error) {
    return 101, nil
}
func (f *fakeRepo) GetByEmail(ctx context.Context, email string) (*usersvc.User, error) {
    return &usersvc.User{ID: 101, Email: email, Name: "A"}, nil
}func TestCreateUserHandler(t *testing.T) {
    svc := usersvc.NewService(&fakeRepo{})
    h := httpx.NewHandler(svc)
    mux := http.NewServeMux()
    mux.HandleFunc("/users", h.CreateUser)    ts := httptest.NewServer(mux)
    defer ts.Close()    reqBody := map[string]string{"email":"a@b.com","name":"A"}
    b, _ := json.Marshal(reqBody)
    resp, err := http.Post(ts.URL+"/users", "application/json", bytes.NewReader(b))
    if err != nil { t.Fatal(err) }
    if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201 got %d", resp.StatusCode) }
}

5-B) Полный E2E (с контейнерами)

  • Запустите Postgres + Redis + сервис (через Docker Compose или Kubernetes в CI).
  • Запустите HTTP-запросы с помощью тестового запуска (Go test) на развернутом стеке.
  • Завершите и удалите стек после тестов.

Это медленнее, но такой подход проверяет всю систему целиком.

6) Behavior-Driven Development (BDD) с godog

godog (Cucumber для Go) позволяет писать .feature-файлы и реализовывать шаги на Go. Отлично подходит для acceptance-тестов и сценариев, понятных для бизнеса.

Установка: go get github.com/cucumber/godog/cmd/godog (or use module github.com/cucumber/godog)

6-A) Feature-файл test/features/auth.feature

Feature: Authentication
  Scenario: Successful login
    Given the service is running
    When I login with "user@example.com" and "password123"
    Then I receive a token

6-B) Определение шагов test/steps/auth_steps.go

package steps
import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "strings"
    "testing"
    "time"    "github.com/cucumber/godog"
    usersvc "your/module/internal/user"
    httpx "your/module/internal/http"
)// test harness
var srvURL string
var lastResp *http.Responsefunc startService() (func(), error) {
    // start your service in a goroutine (or start docker container)
    // for example use httptest.NewServer if runnable in-process
    svc := usersvc.NewService(&inMemoryRepo{})
    h := httpx.NewHandler(svc)
    mux := http.NewServeMux()
    mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
        // implement simple login for BDD example
        w.WriteHeader(http.StatusOK); _, _ = w.Write([]byte(`{"token":"abc"}`))
    })
    ts := &http.Server{Addr: ":8085", Handler: mux}
    go ts.ListenAndServe()
    // give it a moment
    time.Sleep(100 * time.Millisecond)
    return func() { _ = ts.Close() }, nil
}func theServiceIsRunning() error {
    if srvURL == "" {
        srvURL = "http://localhost:8085"
    }
    // optionally check health endpoint
    return nil
}func iLoginWithAnd(email, password string) error {
    payload := fmt.Sprintf(`{"email":"%s","password":"%s"}`, email, password)
    resp, err := http.Post(srvURL+"/login", "application/json", strings.NewReader(payload))
    if err != nil { return err }
    lastResp = resp
    return nil
}func iReceiveAToken() error {
    if lastResp == nil { return fmt.Errorf("no response") }
    body, _ := io.ReadAll(lastResp.Body)
    var res map[string]string
    _ = json.Unmarshal(body, &res)
    if res["token"] == "" { return fmt.Errorf("no token") }
    return nil
}func FeatureContext(s *godog.Suite) {
    s.BeforeSuite(func() {
        // start app or containers
        if _, err := startService(); err != nil {
            panic(err)
        }
    })
    s.Step(`^the service is running$`, theServiceIsRunning)
    s.Step(`^I login with "([^"]*)" and "([^"]*)"$`, iLoginWithAnd)
    s.Step(`^I receive a token$`, iReceiveAToken)
}

6-C) Запуск godog

  • CLI: godog test/features (из корневого каталога репозитория)
  • Или интегрировать в TestMain, чтобы запускать вместе с go test (удобно для CI):
func TestMain(m *testing.M) {
    status := godog.RunWithOptions("godog", func(s *godog.Suite) { steps.FeatureContext(s) },
        godog.Options{Format: "pretty", Paths: []string{"test/features"}})
    if st := m.Run(); st > status { status = st }
    os.Exit(status)
}

Примечания:

  • Feature-файлы должны быть минимальными и читабельными для бизнеса.
  • Step definitions должны управлять реальным сервисом (в процессе или через контейнер).
  • Используйте теги godog для запуска подмножеств сценариев.

7) Организация тестов и соглашений

  • Файлы *_test.go могут находиться в том же пакете (package user) или во внешнем пакете (package user_test). Второй вариант заставляет использовать тестируемый код через публичный API.
  • Сохраняйте высокую скорость выполнения юнит-тестов (в идеале <50ms).
  • Интеграционные тесты лучше запускать через build tags или с помощью команды go test ./... -tags=integration.
  • Пример использования build tag: // +build integration package user_test
  • BDD feature-файлы храните в /test/features или /features.

8) CI: пример GitHub Actions

.github/workflows/ci.yml

name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: secret
          POSTGRES_DB: testdb
        ports: ['5432:5432']
        options: >-
          --health-cmd "pg_isready -U test" --health-interval 10s --health-timeout 5s --health-retries 5
    steps:
    - uses: actions/checkout@v4
    - name: Set up Go
      uses: actions/setup-go@v4
      with:
        go-version: 1.22
    - name: Install dependencies
      run: go mod download
    - name: Run unit tests (race, cover)
      run: go test -race -coverprofile=coverage.out ./...
    - name: Upload coverage
      uses: codecov/codecov-action@v4
      with:
        files: coverage.out
    - name: Run godog features
      run: |
        go install github.com/cucumber/godog/cmd/godog@latest
        godog test/features

Советы:

  • Запускайте юнит-тесты параллельно через несколько jobs для ускорения.
  • Интеграционные и E2E-тесты лучше запускать на merge в main или по ночным сборкам.

9) Продвинутый уровень: Fuzz-тестирование в Go 1.18+

Пример fuzz-теста:

func FuzzEmailCheck(f *testing.F) {
    f.Add("a@b.com")
    f.Fuzz(func(t *testing.T, s string) {
        _ = IsValidEmail(s) // your validation function
    })
}

Запуск: go test ./... -fuzz=Fuzz -run=Fuzz

10) Тестовый пайплайн (наглядно)

Тестовый пайплайн

Лучшие практики и чек-лист

  • Небольшие детерминированные юнит-тесты.
  • Используйте интерфейсы для внешних зависимостей; внедряйте мок-объекты.
  • Быстрая обратная связь: юнит-тесты запускаются локально и в pull request.
  • Разделяйте интеграционные и E2E-тесты; запускайте их по ночам или с gated-процессом.
  • Используйте -race в CI для обнаружения конфликтов данных.
  • Соберите покрытие кода и при необходимости проверяйте пороги (coverage thresholds).
  • Используйте godog для тестов, понятных бизнесу, и acceptance-тестов.
  • Используйте Testcontainers для надежных интеграционных тестов.
  • Используйте httptest.NewServer для быстрых тестов HTTP-хендлеров.
  • Применяйте таймауты через context при вызове внешних сервисов в тестах.

Быстрое устранение неполадок

  • Медленные тесты? 
    Найдите проблемные с помощью go test -run TestName -v и измерьте время. Переместите нестабильные и медленные части на этап интеграционного тестирования.
  • Нестабильные тесты (flaky)?
    Добавляйте повторные попытки (retries) только при необходимости; устраняйте причину непредсказуемости (проблемы с таймингом, случайностью или внешними сервисами).
  • Моки слишком хрупкие?
    Для сложного поведения используйте фейки (fakes) или тестовые двойники, основанные на интерфейсах.
  • godog не работает в CI?
    Убедитесь, что служба запущена до выполнения шагов, и проверки состояния (health checks) проходят успешно.

Финальная версия: минимальный Makefile, готовый к копированию

.PHONY: test unit integration godog coverage
unit:
    go test ./... -run Test -vtest:
    go test ./... -vintegration:
    go test -tags=integration ./...coverage:
    go test -coverprofile=coverage.out ./...
    go tool cover -html=coverage.outgodog:
    godog test/features

🏁 Вывод — тесты как фундамент для уверенности

Тестирование в Go — это не просто способ убедиться, что все работает.
Это инструмент для создания уверенности — в коде, архитектуре и способности развиваться без страха.

От юнит-тестов с go test до интеграционных наборов, моков и полноценного BDD с Godog — мы увидели, как Go помогает разработчикам:

  • проектировать поведение еще до написания кода,
  • документировать намерения через тесты,
  • и гарантировать стабильность после каждого деплоя.

BDD — это не просто инструмент, а изменение мышления.
Она помогает разработчикам и продуктовым командам говорить на одном языке — не о том, как система работает, а о том, что она должна делать.

Когда сочетаются конкурентность, производительность и типобезопасность Go с выразительностью Godog, заканчивается просто тестирование функции — начинается проектирование доверия к своему приложению.

Таким образом, в следующий раз, когда вы будете выпускать продукт, вы не будете надеяться, что система работает. Вы будете точно знать, что она работает, потому что тесты уже доказали это.

«Тесты не тормозят вас.
Они позволяют двигаться быстро, 
без сбоев.»

Перевод статьи «Go Testing Masterclass — Unit, Integration, E2E & BDD (godog) — End-to-End».

🔥 Какой была ваша первая зарплата в QA и как вы искали первую работу? 

Мега обсуждение в нашем телеграм-канале о поиске первой работы. Обмен опытом и мнения.

Читать в телеграм

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *