Подпишитесь на наш ТЕЛЕГРАМ КАНАЛ ПО АВТОМАТИЗАЦИИ ТЕСТИРОВАНИЯ
В этом материале вы узнаете:
- как работать с
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 detector:
go test -race ./... - Покрытие (coverage):
go test -coverprofile=coverage.out ./...thengo tool cover -html=coverage.out - CPU профилирование:
go test -cpuprofile=cpu.out ./...and usego 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».