В Go тестирование через интерфейсы -- фундаментальный подход. Вместо magicmock или monkey-patching вы определяете интерфейс, реализуете мок-структуру и подставляете её через dependency injection. Это делает код тестируемым по дизайну.
Интерфейсы для тестируемости
Ключевой принцип: Accept interfaces, return structs. Зависимости определяются через интерфейсы, что позволяет легко подменять их в тестах.
// repository.go
// UserRepository defines the contract for user data access.
type UserRepository interface {
GetByID(ctx context.Context, id string) (*User, error)
Save(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
}
// EmailSender defines the contract for sending emails.
type EmailSender interface {
Send(ctx context.Context, to, subject, body string) error
}
// UserService depends on interfaces, not implementations.
type UserService struct {
repo UserRepository
mailer EmailSender
}
func NewUserService(repo UserRepository, mailer EmailSender) *UserService {
return &UserService{repo: repo, mailer: mailer}
}
func (s *UserService) Register(ctx context.Context, name, email string) (*User, error) {
user := &User{
ID: generateID(),
Name: name,
Email: email,
}
if err := s.repo.Save(ctx, user); err != nil {
return nil, fmt.Errorf("saving user: %w", err)
}
if err := s.mailer.Send(ctx, email, "Welcome!", "Welcome to our platform!"); err != nil {
// Log but don't fail -- email is not critical
log.Printf("failed to send welcome email to %s: %v", email, err)
}
return user, nil
}
Ручные моки (Manual Mocks)
Самый простой и часто достаточный способ -- написать мок вручную:
// mock_test.go
package user
import (
"context"
"testing"
)
// mockRepo implements UserRepository for testing.
type mockRepo struct {
users map[string]*User
saveErr error
getErr error
saveCalls int
}
func newMockRepo() *mockRepo {
return &mockRepo{users: make(map[string]*User)}
}
func (m *mockRepo) GetByID(_ context.Context, id string) (*User, error) {
if m.getErr != nil {
return nil, m.getErr
}
u, ok := m.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}
func (m *mockRepo) Save(_ context.Context, user *User) error {
m.saveCalls++
if m.saveErr != nil {
return m.saveErr
}
m.users[user.ID] = user
return nil
}
func (m *mockRepo) Delete(_ context.Context, id string) error {
delete(m.users, id)
return nil
}
// mockMailer implements EmailSender for testing.
type mockMailer struct {
sent []sentEmail
sendErr error
}
type sentEmail struct {
to, subject, body string
}
func (m *mockMailer) Send(_ context.Context, to, subject, body string) error {
if m.sendErr != nil {
return m.sendErr
}
m.sent = append(m.sent, sentEmail{to, subject, body})
return nil
}
func TestUserService_Register(t *testing.T) {
repo := newMockRepo()
mailer := &mockMailer{}
svc := NewUserService(repo, mailer)
user, err := svc.Register(context.Background(), "Alice", "[email protected]")
if err != nil {
t.Fatalf("Register failed: %v", err)
}
// Verify user was saved
if repo.saveCalls != 1 {
t.Errorf("Save called %d times, want 1", repo.saveCalls)
}
// Verify user data
if user.Name != "Alice" {
t.Errorf("Name = %q, want %q", user.Name, "Alice")
}
// Verify welcome email was sent
if len(mailer.sent) != 1 {
t.Fatalf("sent %d emails, want 1", len(mailer.sent))
}
if mailer.sent[0].to != "[email protected]" {
t.Errorf("email sent to %q, want %q", mailer.sent[0].to, "[email protected]")
}
}
func TestUserService_Register_RepoError(t *testing.T) {
repo := newMockRepo()
repo.saveErr = errors.New("database is down")
mailer := &mockMailer{}
svc := NewUserService(repo, mailer)
_, err := svc.Register(context.Background(), "Alice", "[email protected]")
if err == nil {
t.Fatal("expected error, got nil")
}
// Email should NOT be sent if save failed
if len(mailer.sent) != 0 {
t.Errorf("sent %d emails, want 0", len(mailer.sent))
}
}
testify/assert и testify/require
testify -- самая популярная библиотека для утверждений в Go-тестах.
go get github.com/stretchr/testify
assert -- продолжает тест после провала
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestUser(t *testing.T) {
user := NewUser("Alice", 30)
// Basic assertions
assert.Equal(t, "Alice", user.Name)
assert.Equal(t, 30, user.Age)
assert.NotEmpty(t, user.ID)
assert.NotNil(t, user)
// Error assertions
err := user.Validate()
assert.NoError(t, err)
// String assertions
assert.Contains(t, user.String(), "Alice")
assert.Regexp(t, `^[a-f0-9-]+$`, user.ID)
// Numeric assertions
assert.Greater(t, user.Age, 0)
assert.LessOrEqual(t, user.Age, 150)
// Collection assertions
roles := user.Roles()
assert.Len(t, roles, 1)
assert.Contains(t, roles, "viewer")
assert.ElementsMatch(t, []string{"viewer"}, roles)
// Boolean
assert.True(t, user.IsActive())
assert.False(t, user.IsAdmin())
// Type assertions
assert.IsType(t, &User{}, user)
// Error type assertions
_, err = FindUser("nonexistent")
assert.ErrorIs(t, err, ErrNotFound)
assert.ErrorContains(t, err, "not found")
}
require -- останавливает тест после провала
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestFetchUser(t *testing.T) {
// Use require for preconditions -- no point continuing if this fails
user, err := FetchUser(context.Background(), "user-123")
require.NoError(t, err) // Stops test here if error
require.NotNil(t, user) // Stops test here if nil
// Use assert for checks -- continue to see all failures
assert.Equal(t, "Alice", user.Name)
assert.Equal(t, "[email protected]", user.Email)
assert.True(t, user.Active)
}
Правило: require для setup/предусловий (if it fails, nothing else makes sense), assert для проверок (want to see all failures).
testify/suite -- тестовые наборы
Suites позволяют группировать тесты с общим setup/teardown:
import (
"testing"
"github.com/stretchr/testify/suite"
)
type UserServiceSuite struct {
suite.Suite
svc *UserService
repo *mockRepo
mailer *mockMailer
}
// SetupTest runs before EACH test method
func (s *UserServiceSuite) SetupTest() {
s.repo = newMockRepo()
s.mailer = &mockMailer{}
s.svc = NewUserService(s.repo, s.mailer)
}
// TearDownTest runs after EACH test method
func (s *UserServiceSuite) TearDownTest() {
// Cleanup if needed
}
// SetupSuite runs ONCE before all tests
func (s *UserServiceSuite) SetupSuite() {
// One-time setup
}
func (s *UserServiceSuite) TestRegister() {
user, err := s.svc.Register(context.Background(), "Alice", "[email protected]")
s.Require().NoError(err)
s.Assert().Equal("Alice", user.Name)
s.Assert().Equal(1, s.repo.saveCalls)
}
func (s *UserServiceSuite) TestRegisterSendsEmail() {
_, err := s.svc.Register(context.Background(), "Bob", "[email protected]")
s.Require().NoError(err)
s.Assert().Len(s.mailer.sent, 1)
s.Assert().Equal("[email protected]", s.mailer.sent[0].to)
}
func (s *UserServiceSuite) TestRegisterRepoError() {
s.repo.saveErr = errors.New("db error")
_, err := s.svc.Register(context.Background(), "Alice", "[email protected]")
s.Assert().Error(err)
}
// Entry point -- runs the suite
func TestUserServiceSuite(t *testing.T) {
suite.Run(t, new(UserServiceSuite))
}
testify/mock -- автоматические моки
import (
"context"
"testing"
"github.com/stretchr/testify/mock"
)
// MockUserRepo is a testify mock implementing UserRepository.
type MockUserRepo struct {
mock.Mock
}
func (m *MockUserRepo) GetByID(ctx context.Context, id string) (*User, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func (m *MockUserRepo) Save(ctx context.Context, user *User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
func (m *MockUserRepo) Delete(ctx context.Context, id string) error {
args := m.Called(ctx, id)
return args.Error(0)
}
func TestWithTestifyMock(t *testing.T) {
repo := new(MockUserRepo)
mailer := &mockMailer{}
// Setup expectations
repo.On("Save", mock.Anything, mock.MatchedBy(func(u *User) bool {
return u.Name == "Alice" && u.Email == "[email protected]"
})).Return(nil)
svc := NewUserService(repo, mailer)
_, err := svc.Register(context.Background(), "Alice", "[email protected]")
assert.NoError(t, err)
// Verify all expectations were met
repo.AssertExpectations(t)
// Verify specific call count
repo.AssertNumberOfCalls(t, "Save", 1)
}
Гибкие матчеры
// Match any argument
repo.On("GetByID", mock.Anything, mock.Anything).Return(user, nil)
// Match specific value
repo.On("GetByID", mock.Anything, "user-123").Return(user, nil)
// Custom matcher
repo.On("Save", mock.Anything, mock.MatchedBy(func(u *User) bool {
return u.Age >= 18
})).Return(nil)
// Return different values on sequential calls
repo.On("GetByID", mock.Anything, "user-123").
Return(nil, ErrNotFound).Once() // First call
repo.On("GetByID", mock.Anything, "user-123").
Return(user, nil).Once() // Second call
// Run function on call (side effects)
repo.On("Save", mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
user := args.Get(1).(*User)
user.ID = "generated-id"
}).Return(nil)
gomock -- code-generated моки
go install go.uber.org/mock/mockgen@latest
// Generate mocks from interface
//go:generate mockgen -source=repository.go -destination=mock_repository_test.go -package=user
func TestWithGoMock(t *testing.T) {
ctrl := gomock.NewController(t)
repo := NewMockUserRepository(ctrl)
mailer := NewMockEmailSender(ctrl)
// Setup expectations (order matters with InOrder)
repo.EXPECT().
Save(gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, u *User) error {
if u.Name == "" {
return errors.New("name required")
}
return nil
})
mailer.EXPECT().
Send(gomock.Any(), "[email protected]", gomock.Any(), gomock.Any()).
Return(nil)
svc := NewUserService(repo, mailer)
_, err := svc.Register(context.Background(), "Alice", "[email protected]")
assert.NoError(t, err)
}
gomock-матчеры
// Any value
gomock.Any()
// Specific value
gomock.Eq("user-123")
// Nil
gomock.Nil()
// Not nil
gomock.Not(gomock.Nil())
// Custom matcher
gomock.AssignableToTypeOf(&User{})
// Combine matchers
gomock.All(gomock.Not(gomock.Nil()), gomock.AssignableToTypeOf(&User{}))
httptest -- тестирование HTTP
httptest.NewRecorder -- тестирование хендлеров
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthHandler(t *testing.T) {
// Create request
req := httptest.NewRequest(http.MethodGet, "/health", nil)
// Create response recorder
w := httptest.NewRecorder()
// Call handler directly
HealthHandler(w, req)
// Check response
resp := w.Result()
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.JSONEq(t, `{"status": "ok"}`, string(body))
}
func TestCreateUserHandler(t *testing.T) {
repo := newMockRepo()
handler := NewCreateUserHandler(repo)
body := strings.NewReader(`{"name": "Alice", "email": "[email protected]"}`)
req := httptest.NewRequest(http.MethodPost, "/users", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var user User
err := json.NewDecoder(w.Body).Decode(&user)
require.NoError(t, err)
assert.Equal(t, "Alice", user.Name)
}
httptest.NewServer -- тестирование клиентов
func TestAPIClient(t *testing.T) {
// Create a test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/users/123", r.URL.Path)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
// Return mock response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(User{
ID: "123",
Name: "Alice",
})
}))
defer server.Close()
// Create client pointing to test server
client := NewAPIClient(server.URL, "test-token")
user, err := client.GetUser(context.Background(), "123")
require.NoError(t, err)
assert.Equal(t, "Alice", user.Name)
}
Тестирование middleware
func TestAuthMiddleware(t *testing.T) {
tests := []struct {
name string
token string
wantStatus int
}{
{
name: "valid token",
token: "Bearer valid-token",
wantStatus: http.StatusOK,
},
{
name: "missing token",
token: "",
wantStatus: http.StatusUnauthorized,
},
{
name: "invalid token",
token: "Bearer invalid",
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Inner handler -- always returns 200
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Wrap with auth middleware
handler := AuthMiddleware(inner)
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
if tt.token != "" {
req.Header.Set("Authorization", tt.token)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, tt.wantStatus, w.Code)
})
}
}
testcontainers-go -- реальные зависимости
Для интеграционных тестов с реальной БД, Redis, Kafka:
import (
"context"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestWithPostgres(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
// Start PostgreSQL container
pgContainer, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2)),
)
require.NoError(t, err)
defer pgContainer.Terminate(ctx)
// Get connection string
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
// Connect and test
db, err := sql.Open("postgres", connStr)
require.NoError(t, err)
defer db.Close()
// Run migrations
err = runMigrations(db)
require.NoError(t, err)
// Create repository with real DB
repo := NewPostgresUserRepo(db)
// Test CRUD operations
user := &User{ID: "1", Name: "Alice", Email: "[email protected]"}
err = repo.Save(ctx, user)
assert.NoError(t, err)
got, err := repo.GetByID(ctx, "1")
assert.NoError(t, err)
assert.Equal(t, "Alice", got.Name)
}
Паттерны интеграционного тестирования
Build tags для разделения тестов
//go:build integration
package user
import "testing"
func TestIntegration_UserWorkflow(t *testing.T) {
// This test only runs with: go test -tags=integration
}
# Run only unit tests (fast)
go test ./...
# Run integration tests too
go test -tags=integration ./...
Test fixtures с t.Cleanup
func setupTestServer(t *testing.T) (*httptest.Server, *UserService) {
t.Helper()
repo := newMockRepo()
mailer := &mockMailer{}
svc := NewUserService(repo, mailer)
mux := http.NewServeMux()
mux.HandleFunc("POST /users", svc.HandleCreateUser)
mux.HandleFunc("GET /users/{id}", svc.HandleGetUser)
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return server, svc
}
func TestAPI_CreateAndGetUser(t *testing.T) {
server, _ := setupTestServer(t)
// Create user
body := `{"name": "Alice", "email": "[email protected]"}`
resp, err := http.Post(server.URL+"/users", "application/json", strings.NewReader(body))
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var created User
json.NewDecoder(resp.Body).Decode(&created)
resp.Body.Close()
// Get user
resp, err = http.Get(server.URL + "/users/" + created.ID)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var got User
json.NewDecoder(resp.Body).Decode(&got)
resp.Body.Close()
assert.Equal(t, "Alice", got.Name)
}
Когда что использовать
| Подход | Когда использовать |
|---|---|
| Ручные моки | Простые интерфейсы (1-3 метода), полный контроль |
| testify/mock | Сложные интерфейсы, нужна проверка вызовов |
| gomock | Много интерфейсов, хотите генерацию кода |
| httptest.NewRecorder | Тестирование HTTP-хендлеров |
| httptest.NewServer | Тестирование HTTP-клиентов |
| testcontainers | Интеграционные тесты с реальными сервисами |
Совет: Начинайте с ручных моков. Переходите на testify/mock или gomock только когда ручные моки становятся слишком объёмными.