Перейти к основному содержимому

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

Это руководство описывает, как писать тесты для приложений, построенных с использованием фреймворка Tonica, включая модульные тесты, интеграционные тесты и end-to-end тесты.

Философия тестирования

Tonica спроектирована для удобного тестирования:

  • Внедрение зависимостей: Легко создавать моки для зависимостей
  • Интерфейсный подход: Сервисы реализуют интерфейсы
  • Модульная архитектура: Тестирование компонентов в изоляции
  • Стандартная библиотека: Использует стандартный пакет testing из Go

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

Рекомендуемая организация тестов:

myservice/
├── internal/
│ ├── service/
│ │ ├── user_service.go
│ │ └── user_service_test.go # Модульные тесты
│ └── repository/
│ ├── user_repository.go
│ └── user_repository_test.go
├── tests/
│ ├── integration/ # Интеграционные тесты
│ │ └── user_api_test.go
│ └── e2e/ # End-to-end тесты
│ └── complete_flow_test.go
└── main_test.go # Тесты основного пакета

Модульное тестирование

Тестирование реализации сервиса

package service

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
hellov1 "github.com/yourusername/myservice/proto/hello/v1"
)

// Мок репозитория
type MockUserRepo struct {
mock.Mock
}

func (m *MockUserRepo) GetUser(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 TestHelloService_SayHello(t *testing.T) {
t.Run("should return greeting with name", func(t *testing.T) {
svc := &HelloServiceImpl{}

resp, err := svc.SayHello(context.Background(), &hellov1.SayHelloRequest{
Name: "Alice",
})

assert.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, "Hello, Alice!", resp.Message)
})

t.Run("should return error for empty name", func(t *testing.T) {
svc := &HelloServiceImpl{}

resp, err := svc.SayHello(context.Background(), &hellov1.SayHelloRequest{
Name: "",
})

assert.Error(t, err)
assert.Nil(t, resp)
})
}

func TestUserService_GetUser(t *testing.T) {
mockRepo := new(MockUserRepo)
svc := &UserService{
repo: mockRepo,
}

t.Run("should return user when found", func(t *testing.T) {
expectedUser := &User{ID: "123", Name: "Alice"}
mockRepo.On("GetUser", mock.Anything, "123").Return(expectedUser, nil)

user, err := svc.GetUser(context.Background(), "123")

assert.NoError(t, err)
assert.Equal(t, expectedUser, user)
mockRepo.AssertExpectations(t)
})

t.Run("should return error when user not found", func(t *testing.T) {
mockRepo.On("GetUser", mock.Anything, "999").Return(nil, errors.New("not found"))

user, err := svc.GetUser(context.Background(), "999")

assert.Error(t, err)
assert.Nil(t, user)
mockRepo.AssertExpectations(t)
})
}

Тестирование пользовательских маршрутов

package main

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/tonica-go/tonica/pkg/tonica"
)

func TestHealthEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
app := tonica.NewApp()

// Регистрируем маршрут
tonica.NewRoute(app).
GET("/health").
Handle(func(c *gin.Context) {
c.JSON(200, gin.H{"status": "healthy"})
})

// Тестируем его
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/health", nil)
app.GetRouter().ServeHTTP(w, req)

assert.Equal(t, 200, w.Code)
assert.JSONEq(t, `{"status":"healthy"}`, w.Body.String())
}

func TestUserEndpoints(t *testing.T) {
gin.SetMode(gin.TestMode)
app := tonica.NewApp()

// Регистрируем маршруты
setupUserRoutes(app)

t.Run("GET /users/:id", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/users/123", nil)
app.GetRouter().ServeHTTP(w, req)

assert.Equal(t, 200, w.Code)
assert.Contains(t, w.Body.String(), "123")
})

t.Run("POST /users", func(t *testing.T) {
body := strings.NewReader(`{"name":"Alice","email":"alice@example.com"}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/users", body)
req.Header.Set("Content-Type", "application/json")
app.GetRouter().ServeHTTP(w, req)

assert.Equal(t, 201, w.Code)
})
}

Тестирование воркеров

package worker

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.temporal.io/sdk/testsuite"
)

type EmailActivitySuite struct {
testsuite.WorkflowTestSuite
}

func TestEmailActivity(t *testing.T) {
suite.Run(t, new(EmailActivitySuite))
}

func (s *EmailActivitySuite) TestSendEmailActivity() {
env := s.NewTestActivityEnvironment()

// Регистрируем активность
env.RegisterActivity(SendEmailActivity)

// Выполняем активность
val, err := env.ExecuteActivity(SendEmailActivity, "test@example.com", "Hello")

s.NoError(err)
s.NotNil(val)

var result string
s.NoError(val.Get(&result))
s.Equal("Email sent to test@example.com", result)
}

func (s *EmailActivitySuite) TestEmailWorkflow() {
env := s.NewTestWorkflowEnvironment()

// Мокируем активность
env.OnActivity(SendEmailActivity, mock.Anything, "test@example.com", "Hello").
Return("sent", nil)

// Выполняем workflow
env.ExecuteWorkflow(EmailWorkflow, "test@example.com", "Hello")

s.True(env.IsWorkflowCompleted())
s.NoError(env.GetWorkflowError())
}

Тестирование консьюмеров

package consumer

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/tonica-go/tonica/pkg/tonica/consumer"
)

type MockPubSubClient struct {
messages chan *pubsub.Message
errors chan error
}

func (m *MockPubSubClient) Subscribe(ctx context.Context, topic string) (*pubsub.Message, error) {
select {
case msg := <-m.messages:
return msg, nil
case err := <-m.errors:
return nil, err
case <-ctx.Done():
return nil, ctx.Err()
}
}

func TestConsumer_ProcessMessage(t *testing.T) {
t.Run("should process message successfully", func(t *testing.T) {
mockClient := &MockPubSubClient{
messages: make(chan *pubsub.Message, 1),
errors: make(chan error),
}

processed := false
handler := func(ctx context.Context, msg *pubsub.Message) error {
processed = true
return nil
}

cons := consumer.NewConsumer(
consumer.WithName("test-consumer"),
consumer.WithTopic("test-topic"),
consumer.WithClient(mockClient),
consumer.WithHandler(handler),
)

// Отправляем тестовое сообщение
mockClient.messages <- &pubsub.Message{Value: []byte("test")}

// Запускаем консьюмер с таймаутом
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

go cons.Start(ctx)

// Ждем обработки
time.Sleep(100 * time.Millisecond)
assert.True(t, processed)
})
}

Интеграционное тестирование

Тестирование с реальной базой данных

package integration

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tonica-go/tonica/pkg/tonica/service"
)

func TestUserRepository_Integration(t *testing.T) {
// Пропускаем, если не запускаются интеграционные тесты
if testing.Short() {
t.Skip("Skipping integration test")
}

// Настраиваем тестовую базу данных
db := service.NewDB(
service.WithDriver(service.Sqlite),
service.WithDSN("file::memory:?cache=shared"),
)
client := db.GetClient()

// Запускаем миграции
err := runMigrations(client)
require.NoError(t, err)

// Создаем репозиторий
repo := NewUserRepository(client)

t.Run("should create and retrieve user", func(t *testing.T) {
user := &User{
Name: "Alice",
Email: "alice@example.com",
}

// Создаем
err := repo.Create(context.Background(), user)
require.NoError(t, err)
assert.NotEmpty(t, user.ID)

// Получаем
retrieved, err := repo.GetByID(context.Background(), user.ID)
require.NoError(t, err)
assert.Equal(t, user.Name, retrieved.Name)
assert.Equal(t, user.Email, retrieved.Email)
})

// Очистка
defer client.Close()
}

Тестирование HTTP сервера

package integration

import (
"context"
"net/http"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/tonica-go/tonica/pkg/tonica"
)

func TestHTTPServer_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}

// Создаем приложение
app := tonica.NewApp(
tonica.WithName("test-service"),
)

// Регистрируем маршруты
setupRoutes(app)

// Запускаем сервер в фоне
go func() {
app.Run()
}()

// Ждем запуска сервера
time.Sleep(500 * time.Millisecond)

// Тестовые запросы
t.Run("GET /health", func(t *testing.T) {
resp, err := http.Get("http://localhost:8080/health")
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
defer resp.Body.Close()
})

t.Run("GET /users", func(t *testing.T) {
resp, err := http.Get("http://localhost:8080/users")
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
defer resp.Body.Close()
})

// Очистка: отменяем контекст для остановки сервера
cancel()
time.Sleep(100 * time.Millisecond)
}

Тестирование с Docker Compose

package integration

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/require"
"github.com/tonica-go/tonica/pkg/tonica/service"
)

func TestWithRealPostgres(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}

// Предполагается, что postgres запущен через docker-compose
db := service.NewDB(
service.WithDriver(service.Postgres),
service.WithDSN("postgres://test:test@localhost:5432/testdb?sslmode=disable"),
)

ctx := context.Background()
client := db.GetClient()

// Тестируем подключение
err := client.PingContext(ctx)
require.NoError(t, err)

// Запускаем ваши тесты...
t.Run("should perform database operations", func(t *testing.T) {
// Реализация теста
})

// Очистка
defer client.Close()
}

docker-compose.test.yml:

version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- "5432:5432"

redis:
image: redis:7-alpine
ports:
- "6379:6379"

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

# Запускаем зависимости
docker-compose -f docker-compose.test.yml up -d

# Запускаем тесты
go test ./tests/integration/... -v

# Очистка
docker-compose -f docker-compose.test.yml down

End-to-End тестирование

Полный пользовательский сценарий

package e2e

import (
"bytes"
"encoding/json"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestUserFlow_E2E(t *testing.T) {
if testing.Short() {
t.Skip("Skipping e2e test")
}

baseURL := "http://localhost:8080"
var userID string

t.Run("1. Create user", func(t *testing.T) {
payload := map[string]string{
"name": "Alice",
"email": "alice@example.com",
}
body, _ := json.Marshal(payload)

resp, err := http.Post(baseURL+"/users", "application/json", bytes.NewReader(body))
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, 201, resp.StatusCode)

var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
userID = result["id"].(string)
assert.NotEmpty(t, userID)
})

t.Run("2. Get user", func(t *testing.T) {
resp, err := http.Get(baseURL + "/users/" + userID)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, 200, resp.StatusCode)

var user map[string]interface{}
json.NewDecoder(resp.Body).Decode(&user)
assert.Equal(t, "Alice", user["name"])
assert.Equal(t, "alice@example.com", user["email"])
})

t.Run("3. Update user", func(t *testing.T) {
payload := map[string]string{
"name": "Alice Smith",
}
body, _ := json.Marshal(payload)

req, _ := http.NewRequest("PUT", baseURL+"/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, 200, resp.StatusCode)
})

t.Run("4. Delete user", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", baseURL+"/users/"+userID, nil)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, 204, resp.StatusCode)
})

t.Run("5. Verify deletion", func(t *testing.T) {
resp, err := http.Get(baseURL + "/users/" + userID)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, 404, resp.StatusCode)
})
}

Вспомогательные функции для тестов

Фабрика тестового приложения

package testutil

import (
"github.com/tonica-go/tonica/pkg/tonica"
"github.com/tonica-go/tonica/pkg/tonica/service"
)

func NewTestApp() *tonica.App {
app := tonica.NewApp(
tonica.WithName("test-app"),
)

// Используем базу данных в памяти
db := service.NewDB(
service.WithDriver(service.Sqlite),
service.WithDSN("file::memory:?cache=shared"),
)

// Добавляем в приложение (при необходимости)
// ...

return app
}

Тестовые фикстуры

package testutil

type UserFixture struct {
ID string
Name string
Email string
}

func CreateUserFixture(t *testing.T, repo UserRepository) *UserFixture {
user := &User{
Name: "Test User",
Email: "test@example.com",
}

err := repo.Create(context.Background(), user)
if err != nil {
t.Fatalf("Failed to create user fixture: %v", err)
}

return &UserFixture{
ID: user.ID,
Name: user.Name,
Email: user.Email,
}
}

Моки компонентов Tonica

package testutil

import "github.com/tonica-go/tonica/pkg/tonica/storage"

type MockStorage struct {
data map[string][]byte
}

func NewMockStorage() *MockStorage {
return &MockStorage{
data: make(map[string][]byte),
}
}

func (m *MockStorage) Set(ctx context.Context, key string, value []byte) error {
m.data[key] = value
return nil
}

func (m *MockStorage) Get(ctx context.Context, key string) ([]byte, error) {
if val, ok := m.data[key]; ok {
return val, nil
}
return nil, storage.ErrNotFound
}

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

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

go test ./...

Запуск конкретного пакета

go test ./internal/service

Запуск с покрытием

go test ./... -cover

# Детальное покрытие
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out

Запуск только модульных тестов (пропуск интеграционных)

go test ./... -short

Запуск только интеграционных тестов

go test ./tests/integration/... -v

Запуск с детектором гонок

go test ./... -race

Параллельное выполнение

# Запуск тестов параллельно
go test ./... -parallel 4

# В коде теста
func TestSomething(t *testing.T) {
t.Parallel() // Включаем параллельное выполнение
// ...
}

Подробный вывод

go test ./... -v

Непрерывная интеграция

GitHub Actions

name: Tests

on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432

redis:
image: redis:7-alpine
ports:
- 6379:6379

steps:
- uses: actions/checkout@v3

- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'

- name: Install dependencies
run: go mod download

- name: Run unit tests
run: go test ./... -short -cover

- name: Run integration tests
run: go test ./tests/integration/... -v
env:
DB_DSN: postgres://test:test@localhost:5432/testdb?sslmode=disable
REDIS_ADDR: localhost:6379

- name: Run with race detector
run: go test ./... -race -short

Лучшие практики тестирования

1. Используйте табличные тесты

func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
want bool
wantErr bool
}{
{"valid email", "user@example.com", true, false},
{"invalid email", "invalid", false, true},
{"empty email", "", false, true},
{"email without domain", "user@", false, true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ValidateEmail(tt.email)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateEmail() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ValidateEmail() = %v, want %v", got, tt.want)
}
})
}
}

2. Настройка и очистка

func TestMain(m *testing.M) {
// Настройка
setup()

// Запуск тестов
code := m.Run()

// Очистка
teardown()

os.Exit(code)
}

func TestWithSetup(t *testing.T) {
// Настройка для отдельного теста
db := setupTestDB(t)
defer db.Close()

// Код теста...
}

3. Используйте подтесты для организации

func TestUserService(t *testing.T) {
svc := setupService(t)

t.Run("Create", func(t *testing.T) {
// Тест создания пользователя
})

t.Run("Get", func(t *testing.T) {
// Тест получения пользователя
})

t.Run("Update", func(t *testing.T) {
// Тест обновления пользователя
})
}

4. Мокируйте внешние зависимости

// Хорошо: Мокируем внешний API
type MockPaymentAPI struct {
mock.Mock
}

func (m *MockPaymentAPI) Charge(amount int) error {
args := m.Called(amount)
return args.Error(0)
}

// Плохо: Вызываем реальный внешний API в тестах
func TestPayment(t *testing.T) {
api := NewRealPaymentAPI() // ❌ Не делайте так
// ...
}

5. Тестируйте случаи ошибок

func TestGetUser(t *testing.T) {
t.Run("success case", func(t *testing.T) {
// Тест успешного сценария
})

t.Run("user not found", func(t *testing.T) {
// Тест случая ошибки
})

t.Run("database error", func(t *testing.T) {
// Тест случая ошибки
})

t.Run("invalid input", func(t *testing.T) {
// Тест валидации
})
}

Распространенные паттерны тестирования

Тестирование с контекстом

func TestWithContext(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

result, err := svc.DoSomething(ctx)
assert.NoError(t, err)
assert.NotNil(t, result)
}

Тестирование конкурентного кода

func TestConcurrent(t *testing.T) {
svc := NewService()

var wg sync.WaitGroup
errors := make(chan error, 10)

for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
if err := svc.Process(id); err != nil {
errors <- err
}
}(i)
}

wg.Wait()
close(errors)

for err := range errors {
t.Errorf("Error occurred: %v", err)
}
}

Тестирование с таймаутами

func TestWithTimeout(t *testing.T) {
done := make(chan bool, 1)

go func() {
// Долгая операция
svc.LongOperation()
done <- true
}()

select {
case <-done:
// Успех
case <-time.After(5 * time.Second):
t.Fatal("Test timed out")
}
}

Следующие шаги