decision-test-strategy.md 6.8 KB

Decision Record: Test Strategy

Дата: 2026-06-11 Статус: Accepted (требует реализации)

Контекст

В проекте 0% тестов. Ни unit, ни integration, ни e2e. Это блокер для безопасного рефакторинга и CI/CD.

Решение

Трёхслойная стратегия тестирования с целевым покрытием:

Слой Цель покрытия Инструменты Что тестируем
Unit 70%+ Go: testing, testify, gomock / go.uber.org/mock
TS: vitest, react-testing-library
Бизнес-логика (services), утилиты, валидаторы, мапперы
Integration Критичные пути Go: testcontainers-go (PostgreSQL, Redis, MinIO)
TS: msw (Mock Service Worker)
Repository слой, HTTP handlers, API контракты, auth flow
E2E Happy paths Playwright (TypeScript) Critical user journeys: регистрация → создание места → бронирование → оплата

Backend: Unit тесты (services)

// internal/services/auth_service_test.go
func TestAuthService_Register(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    
    mockRepo := repository.NewMockUserRepo(ctrl)
    svc := services.NewAuthService(mockRepo, "secret", "refresh-secret")
    
    mockRepo.EXPECT().GetByEmail(gomock.Any(), "test@example.com").Return(nil, nil)
    mockRepo.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(
        func(ctx context.Context, u *models.User) error {
            u.ID = "uuid-123"
            return nil
        }).Times(1)
    
    result, err := svc.Register(context.Background(), services.RegisterInput{
        Email: "test@example.com", Password: "pass123", Role: "customer", Name: "Test",
    })
    
    assert.NoError(t, err)
    assert.NotEmpty(t, result.AccessToken)
    assert.NotEmpty(t, result.RefreshToken)
    assert.Equal(t, "customer", result.User.Role)
}

Backend: Integration тесты (handlers + DB)

// internal/handlers/places_integration_test.go
func TestPlaceHandler_Create(t *testing.T) {
    // testcontainers: запустить postgres
    ctx := context.Background()
    pgContainer, _ := postgres.RunContainer(ctx, testcontainers.WithImage("postgis/postgis:16-3.4-alpine"))
    defer pgContainer.Terminate(ctx)
    
    // Накатить миграции
    dbURL := pgContainer.ConnectionString(ctx)
    migrate.Up(dbURL)
    
    // Создать реальный pool, repo, service, handler
    pool := repository.NewPool(ctx, dbURL)
    placeRepo := repository.NewPlaceRepo(pool)
    placeSvc := services.NewPlaceService(placeRepo)
    handler := handlers.NewPlaceHandler(placeSvc)
    
    // Сделать HTTP запрос через httptest
    req := httptest.NewRequest("POST", "/api/v1/places", strings.NewReader(`{"title":"Test","type":"place","lat":55.75,"lng":37.61}`))
    req.Header.Set("Authorization", "Bearer "+generateTestToken("user-1", "customer"))
    w := httptest.NewRecorder()
    handler.Create(w, req)
    
    assert.Equal(t, http.StatusCreated, w.Code)
}

Frontend: Unit тесты (components, hooks, utils)

// src/hooks/__tests__/useAuth.test.tsx
import { renderHook, act } from '@testing-library/react'
import { AuthProvider, useAuth } from '@/hooks/useAuth'
import { api } from '@/lib/api'

vi.mock('@/lib/api')

test('login sets user and token', async () => {
  const mockUser = { id: '1', email: 'test@test.com', role: 'customer' }
  vi.mocked(api.post).mockResolvedValue({ user: mockUser, access_token: 'token123' })
  
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <AuthProvider>{children}</AuthProvider>
  )
  
  const { result } = renderHook(() => useAuth(), { wrapper })
  
  await act(async () => {
    await result.current.login('test@test.com', 'password')
  })
  
  expect(result.current.user).toEqual(mockUser)
  expect(localStorage.getItem('access_token')).toBe('token123')
})

E2E: Playwright critical paths

// e2e/booking.spec.ts
test('user can book a studio', async ({ page }) => {
  await page.goto('/login')
  await page.fill('[name=email]', 'user@test.com')
  await page.fill('[name=password]', 'password')
  await page.click('button[type=submit]')
  
  await page.goto('/studios/studio-123')
  await page.click('[data-testid=book-button]')
  await page.fill('[name=start_time]', '2024-02-01T10:00')
  await page.fill('[name=end_time]', '2024-02-01T14:00')
  await page.click('button[type=submit]')
  
  await expect(page.locator('[data-testid=booking-confirmation]')).toBeVisible()
})

CI Pipeline

# .github/workflows/ci.yml
jobs:
  backend-test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgis/postgis:16-3.4-alpine
        env: { POSTGRES_DB: test, POSTGRES_USER: test, POSTGRES_PASSWORD: test }
        ports: [5432:5432]
        options: >-
          --health-cmd "pg_isready -U test"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7-alpine
        ports: [6379:6379]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.22' }
      - run: go test ./backend/... -race -coverprofile=coverage.out
      - uses: codecov/codecov-action@v4
  
  frontend-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm', cache-dependency-path: frontend/package-lock.json }
      - run: cd frontend && npm ci && npm run test:ci
  
  e2e-test:
    runs-on: ubuntu-latest
    needs: [backend-test, frontend-test]
    steps:
      - uses: actions/checkout@v4
      - run: docker compose -f deploy/docker-compose.prod.yml up -d
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test

Последствия

Положительные:

  • Безопасный рефакторинг
  • Документация поведения через тесты
  • CI как quality gate
  • Регрессии ловятся автоматически

Отрицательные:

  • Время на написание (инвестиция)
  • Поддержка тестов при изменениях
  • testcontainers медленные в CI (нужен кэш)

Связанные заметки

  • [[backend-auth-security]] — тесты для refresh token rotation
  • [[backend-validation]] — тесты для кастомных валидаторов
  • [[architecture-overview]]

Теги

#decision-record #testing #ci-cd #unit #integration #e2e #best-practice