MidПрактика10 min

Моки и тестовые фреймворки

Testify, gomock, httptest, testcontainers и паттерны интеграционного тестирования

В 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 только когда ручные моки становятся слишком объёмными.

Проверь себя

Какой принцип делает Go-код тестируемым по дизайну?

В чём разница между testify/assert и testify/require?

Как httptest.NewRecorder используется для тестирования HTTP-хендлеров?

Для чего используется testcontainers-go?