Files
smart-search-back/TESTING.md
vallyenfail 80e5f318a9
All checks were successful
Deploy Smart Search Backend Test / deploy (push) Successful in 1m24s
add service
2026-01-18 01:48:46 +03:00

11 KiB
Raw Blame History

Руководство по тестированию

Архитектура для тестирования

Проект спроектирован с учетом тестируемости:

Интерфейсы для всех слоев

Repository интерфейсы (internal/repository/interfaces.go):

type UserRepository interface {
    FindByEmailHash(ctx context.Context, emailHash string) (*model.User, error)
    FindByID(ctx context.Context, userID int) (*model.User, error)
    Create(ctx context.Context, user *model.User) error
    // ...
}

Service интерфейсы (internal/service/interfaces.go):

type AuthService interface {
    Login(ctx context.Context, email, password, ip, userAgent string) (string, string, error)
    Refresh(ctx context.Context, refreshToken string) (string, error)
    // ...
}

Context пробрасывается через все слои

main.go (ctx) → Workers (ctx) → gRPC Handler (ctx) → Service (ctx) → Repository (ctx) → pgx

Правило: context.Background() создается только один раз в main.go и прокидывается через все компоненты.

Это позволяет:

  • Graceful shutdown: при отмене context все workers и операции останавливаются
  • Отменять долгие операции
  • Передавать метаданные (trace ID, user ID)
  • Контролировать таймауты
  • Избежать потерянных goroutines

Автоматическая генерация моков

Проект использует minimock для автоматической генерации типизированных моков из интерфейсов.

Генерация моков

Все моки генерируются в internal/mocks/ одной командой:

make generate-mock

Эта команда генерирует моки для всех интерфейсов:

  • Repository интерфейсы: UserRepository, SessionRepository, InviteRepository, RequestRepository, SupplierRepository, TokenUsageRepository
  • Service интерфейсы: AuthService, UserService, InviteService, RequestService, SupplierService

Использование сгенерированных моков

import (
    "testing"
    "context"
    "smart-search-back/internal/mocks"
    "smart-search-back/internal/service"
    "smart-search-back/internal/model"
    "github.com/stretchr/testify/assert"
)

func TestAuthService_Login_Success(t *testing.T) {
    // Создаем моки
    mockUserRepo := mocks.NewUserRepositoryMock(t)
    mockSessionRepo := mocks.NewSessionRepositoryMock(t)
    
    // Настраиваем поведение мока
    mockUserRepo.FindByEmailHashMock.Expect(context.Background(), "email_hash").Return(&model.User{
        ID: 1,
        PasswordHash: "b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df5f1326af5a2ea6d103fd07c95385ffab0cacbc86",
    }, nil)
    
    mockSessionRepo.CreateMock.Expect(context.Background(), &model.Session{}).Return(nil)
    
    // Создаем сервис с моками
    authService := service.NewAuthService(mockUserRepo, mockSessionRepo)
    
    // Выполняем тест
    accessToken, refreshToken, err := authService.Login(
        context.Background(),
        "test@example.com",
        "password",
        "127.0.0.1",
        "test-agent",
    )
    
    // Проверяем результат
    assert.NoError(t, err)
    assert.NotEmpty(t, accessToken)
    assert.NotEmpty(t, refreshToken)
    
    // Minimock автоматически проверит что все ожидания выполнены
}

func TestAuthService_Register_Success(t *testing.T) {
    mockUserRepo := mocks.NewUserRepositoryMock(t)
    mockSessionRepo := mocks.NewSessionRepositoryMock(t)
    mockInviteRepo := mocks.NewInviteRepositoryMock(t)
    
    mockInviteRepo.FindActiveByCodeMock.Expect(context.Background(), int64(123456)).Return(&model.InviteCode{
        Code: 123456,
        CanBeUsedCount: 5,
        UsedCount: 0,
    }, nil)
    
    mockUserRepo.FindByEmailHashMock.Expect(context.Background(), "email_hash").Return(nil, 
        errors.NewBusinessError(errors.UserNotFound, "user not found"))
    
    mockUserRepo.CreateTxMock.Return(nil)
    mockInviteRepo.DecrementCanBeUsedCountTxMock.Return(nil)
    mockSessionRepo.CreateMock.Return(nil)
    
    authService := service.NewAuthService(mockUserRepo, mockSessionRepo, mockInviteRepo, txManager, "secret", "cryptosecret")
    
    accessToken, refreshToken, err := authService.Register(
        context.Background(),
        "newuser@example.com",
        "password123",
        "New User",
        "+1234567890",
        123456,
        "127.0.0.1",
        "test-agent",
    )
    
    assert.NoError(t, err)
    assert.NotEmpty(t, accessToken)
    assert.NotEmpty(t, refreshToken)
}

Преимущества minimock

Типобезопасность - моки генерируются из интерфейсов, ошибки компиляции при изменении сигнатур
Автоматическая проверка - проверяет что все ожидания выполнены
Счетчики вызовов - можно проверить сколько раз был вызван метод
Inspection - можно проверить аргументы вызовов
Minimal boilerplate - не нужно писать моки вручную

Пример с проверкой вызовов

func TestUserService_GetBalance(t *testing.T) {
    mockUserRepo := mocks.NewUserRepositoryMock(t)
    
    // Ожидаем вызов GetBalance с userID=123
    mockUserRepo.GetBalanceMock.Expect(context.Background(), 123).Return(100.50, nil)
    
    userService := service.NewUserService(mockUserRepo, nil)
    balance, err := userService.GetBalance(context.Background(), 123)
    
    assert.NoError(t, err)
    assert.Equal(t, 100.50, balance)
    
    // Minimock автоматически проверит:
    // - что GetBalance был вызван ровно 1 раз
    // - с правильными аргументами
    // - и вернул правильное значение
}

Ручные моки (legacy пример)

Для сравнения, старый пример с ручными моками (все еще работает, но не рекомендуется):

// Mock репозитория (legacy - используйте minimock!)
type mockUserRepo struct {
    findByEmailHashFunc func(ctx context.Context, emailHash string) (*model.User, error)
}

func (m *mockUserRepo) FindByEmailHash(ctx context.Context, emailHash string) (*model.User, error) {
    if m.findByEmailHashFunc != nil {
        return m.findByEmailHashFunc(ctx, emailHash)
    }
    return nil, nil
}

Запуск тестов

# Все тесты
go test ./...

# С покрытием
go test ./... -cover

# Verbose режим
go test ./... -v

# Конкретный пакет
go test ./internal/service/tests/...

# С race detector
go test ./... -race

Использование minimock

Проект использует minimock для автоматической генерации типизированных моков.

Генерация моков

Все моки генерируются в internal/mocks/:

make generate-mock

Генерируются моки для всех интерфейсов:

  • Repository: UserRepository, SessionRepository, InviteRepository, RequestRepository, SupplierRepository, TokenUsageRepository
  • Service: AuthService, UserService, InviteService, RequestService, SupplierService

Использование сгенерированных моков

import "smart-search-back/internal/mocks"

func TestAuthService_Login(t *testing.T) {
    mockUserRepo := mocks.NewUserRepositoryMock(t)
    mockSessionRepo := mocks.NewSessionRepositoryMock(t)
    
    mockUserRepo.FindByEmailHashMock.Expect(ctx, "hash").Return(&model.User{...}, nil)
    
    authService := service.NewAuthService(mockUserRepo, mockSessionRepo)
    // ... тест
}

Подробнее см. раздел "Автоматическая генерация моков" выше.

Структура тестов

internal/
├── service/
│   ├── auth.go
│   ├── interfaces.go       # Интерфейсы сервисов
│   └── tests/
│       └── auth_test.go    # Тесты с моками
├── repository/
│   ├── user.go
│   ├── interfaces.go       # Интерфейсы репозиториев
│   └── tests/
│       └── user_test.go

Best Practices

1. Используйте context.Background() в тестах

ctx := context.Background()
result, err := service.SomeMethod(ctx, params)

2. Мокайте только то, что нужно

mockRepo := &mockUserRepo{
    findByIDFunc: func(ctx context.Context, id int) (*model.User, error) {
        return &model.User{ID: id}, nil
    },
    // Остальные методы можно не реализовывать если не используются
}

3. Проверяйте вызовы

var called bool
mockRepo := &mockUserRepo{
    createFunc: func(ctx context.Context, user *model.User) error {
        called = true
        assert.Equal(t, "expected@email.com", user.Email)
        return nil
    },
}

// ... вызов сервиса

assert.True(t, called, "Create should have been called")

4. Тестируйте ошибки

mockRepo := &mockUserRepo{
    findByIDFunc: func(ctx context.Context, id int) (*model.User, error) {
        return nil, errors.NewBusinessError(errors.UserNotFound, "user not found")
    },
}

result, err := service.GetUser(ctx, 123)
assert.Error(t, err)
assert.Nil(t, result)

Integration тесты

Для integration тестов с реальной БД можно использовать testcontainers:

func TestUserRepository_Integration(t *testing.T) {
    // Создать testcontainer с PostgreSQL
    // Применить миграции
    // Запустить тесты
}

CI/CD

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-go@v2
        with:
          go-version: 1.21
      - run: go test ./... -race -cover

Хэши для тестов

При тестировании аутентификации используйте правильные хэши:

  • Пароль: "password"
  • SHA512 хэш: "b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df5f1326af5a2ea6d103fd07c95385ffab0cacbc86"

Или используйте crypto.PasswordHash("password") прямо в тестах.