7. Тестирование async систем
Тестирование синхронного веб-сервиса — занятие линейное и психологически комфортное: отправил HTTP-запрос, дождался ответа в той же нити исполнения, проверил статус и тело ответа. Процедура детерминирована, время подчинено вашему контрольному таймеру.
Но как только в архитектуру входит брокер сообщений, детерминизм испаряется. Вы отправляете сообщение в шину, а реакция на него размазана во времени, распределена по узлам кластера и скрыта за буферами сокетов и сетевыми задержками. Когда именно консьюмер прочитает пакет? Что произойдет, если в момент коммита смещения упадет сеть? Не возникнет ли гонка за общую запись в базу при параллельной вычитке соседних партиций?
В этой лекции мы разберем инженерную методологию тестирования асинхронных систем в Go: от мгновенных изолированных unit-тестов с моками и фаззингом до полномасштабных интеграционных стендов на базе Testcontainers, проверкой утечек горутин и анализом поведения подсиcompiler-санитайзера -race.
Почему тестировать асинхронный код сложно
Главный источник боли инженера при тестировании очередей — рассинхронизация во времени и пространстве (Temporal and Spatial Decoupling). Продюсер и консьюмер не знают друг о друге, они общаются через разделяемый лог или память брокера.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph "Синхронный мир (Детерминизм)"
T1["Test Worker"]:::network -->|"HTTP POST /order"| H1["Handler"]
H1 -->|"DB Write"| DB1[("PostgreSQL")]
H1 -->|"200 OK"| T1
end
subgraph "Асинхронный мир (Временной разрыв)"
T2["Test Producer"]:::data -->|"Publish(msg)"| B[("Broker Queue")]
B -.->|"Неопределенная задержка (lag)"| C["Consumer Worker"]
C -->|"DB Write"| DB2[("PostgreSQL")]
T2 -.->|"Как узнать, что обработка завершена?"| Q{"assert.Eventually?"}
Q -.->|"Poll state"| DB2
end
Типичные проблемы, делающие асинхронные тесты нестабильными (flaky):
- Flaky-таймауты: Использование наивных
time.Sleep(100 * time.Millisecond)в надежде, что консьюмер успеет. На машине разработчика тест проходит за 20 мс, а на перегруженном раннере CI процессор переключает квант планировщика на соседний контейнер — и тест падает по таймауту. - Утечки фоновых горутин (Goroutine Leaks): Консьюмер запущен в фоне, тест завершил утверждения и вышел, а горутина продолжает долбиться в закрытые каналы или сокеты, загрязняя контекст следующих тестов пакета.
- Неконтролируемое состояние брокера: Предыдущий упавший тест оставил в тестовой очереди недочитанные сообщения (unacknowledged messages), которые внезапно перехватываются консьюмером из следующего теста.
- Гонки данных при агрегации: Попытка проверить результат обработки пачки сообщений через общую переменную в тесте без применения примитивов синхронизации (
sync/atomicилиsync.Mutex).
Три уровня тестирования асинхронных систем
Для надежного тестирования выстраивается пирамида испытаний, где каждый уровень решает строго свою задачу.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph "Пирамида тестирования очередей"
L1["Уровень 1: Unit & Fuzzing<br>• Чистые обработчики бизнес-логики<br>• Мокирование репозиториев и шины<br>• Скорость: микросекунды | 100% покрытие веток"]:::process
L2["Уровень 2: Integration (Testcontainers)<br>• Реальный Docker-контейнер (Kafka / RabbitMQ)<br>• Проверка сериализации, ACK/NACK, ретраев, DLQ<br>• Проверка graceful shutdown и ребалансировки"]
L3["Уровень 3: Chaos & End-to-End<br>• Стенд Staging / Docker Compose<br>• Внедрение сбоев сети (Toxiproxy), kill -9 брокера<br>• Валидация метрик Consumer Lag и алертинга"]
end
L1 --> L2 --> L3
- Unit-тесты: Проверяют инварианты логики, ветвление при ошибках, идемпотентность и расчет backoff без запуска сетевого стека. Занимают доли секунды на весь проект.
- Интеграционные тесты: Запускают изолированный брокер через
testcontainers-go, проверяют совместимость сетевых протоколов, настройки сериализации JSON/Protobuf/Avro, поведение при заполнении буферов и реакцию на сигналы завершения. - Chaos / E2E тесты: Проверяют систему на устойчивость к разделению сети (network partition), внезапной потере кворума и корректность восстановления смещений.
Unit-тестирование обработчиков сообщений
Чтобы сделать обработчик легко тестируемым, избавьте его от прямой зависимости от конкретной клиентской библиотеки (*amqp.Channel или *kgo.Client). Бизнес-сервис должен зависеть исключительно от доменных интерфейсов.
Рассмотрим обработчик платежей:
package service
import (
"context"
"errors"
"fmt"
)
// PaymentMessage представляет входящее событие из очереди
type PaymentMessage struct {
EventID string `json:"event_id"`
UserID string `json:"user_id"`
Amount float64 `json:"amount"`
}
// OrderRepository — интерфейс хранилища, легко подменяемый моком
type OrderRepository interface {
UpdateBalance(ctx context.Context, userID string, delta float64) error
RecordEvent(ctx context.Context, eventID string) error
HasEvent(ctx context.Context, eventID string) (bool, error)
}
type PaymentHandler struct {
repo OrderRepository
}
func NewPaymentHandler(repo OrderRepository) *PaymentHandler {
return &PaymentHandler{repo: repo}
}
func (h *PaymentHandler) HandlePayment(ctx context.Context, msg PaymentMessage) error {
if msg.EventID == "" {
return errors.New("missing event_id")
}
if msg.Amount <= 0 {
return fmt.Errorf("invalid payment amount: %.2f", msg.Amount)
}
// Проверка идемпотентности
alreadyProcessed, err := h.repo.HasEvent(ctx, msg.EventID)
if err != nil {
return fmt.Errorf("idempotency check failed: %w", err)
}
if alreadyProcessed {
// Повторное сообщение — подтверждаем без выполнения побочных эффектов
return nil
}
// Списание и фиксация события
if err := h.repo.UpdateBalance(ctx, msg.UserID, -msg.Amount); err != nil {
return fmt.Errorf("balance update failed: %w", err)
}
if err := h.repo.RecordEvent(ctx, msg.EventID); err != nil {
return fmt.Errorf("record event failed: %w", err)
}
return nil
}
Табличный unit-тест покрывает все краевые случаи за миллисекунды, используя самописный mock или сгенерированный через mockgen:
package service_test
import (
"context"
"errors"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockOrderRepo struct {
mu sync.Mutex
events map[string]bool
balances map[string]float64
updateErr error
hasEventErr error
}
func newMockOrderRepo() *mockOrderRepo {
return &mockOrderRepo{
events: make(map[string]bool),
balances: make(map[string]float64),
}
}
func (m *mockOrderRepo) UpdateBalance(ctx context.Context, userID string, delta float64) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.updateErr != nil {
return m.updateErr
}
m.balances[userID] += delta
return nil
}
func (m *mockOrderRepo) RecordEvent(ctx context.Context, eventID string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.events[eventID] = true
return nil
}
func (m *mockOrderRepo) HasEvent(ctx context.Context, eventID string) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.hasEventErr != nil {
return false, m.hasEventErr
}
return m.events[eventID], nil
}
func TestPaymentHandler_Table(t *testing.T) {
t.Parallel()
tests := []struct {
name string
msg PaymentMessage
setupMock func(m *mockOrderRepo)
wantErr string
checkExpect func(t *testing.T, m *mockOrderRepo)
}{
{
name: "Success: New payment processed",
msg: PaymentMessage{EventID: "evt-1", UserID: "usr-100", Amount: 50.0},
setupMock: func(m *mockOrderRepo) {
m.balances["usr-100"] = 200.0
},
wantErr: "",
checkExpect: func(t *testing.T, m *mockOrderRepo) {
assert.Equal(t, 150.0, m.balances["usr-100"])
assert.True(t, m.events["evt-1"])
},
},
{
name: "Error: Zero or negative amount",
msg: PaymentMessage{EventID: "evt-2", UserID: "usr-100", Amount: -10.0},
setupMock: func(m *mockOrderRepo) {},
wantErr: "invalid payment amount",
},
{
name: "Error: Database update balance failure",
msg: PaymentMessage{EventID: "evt-3", UserID: "usr-100", Amount: 30.0},
setupMock: func(m *mockOrderRepo) {
m.updateErr = errors.New("connection reset by peer")
},
wantErr: "balance update failed",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mockRepo := newMockOrderRepo()
tt.setupMock(mockRepo)
h := NewPaymentHandler(mockRepo)
err := h.HandlePayment(context.Background(), tt.msg)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
} else {
require.NoError(t, err)
if tt.checkExpect != nil {
tt.checkExpect(t, mockRepo)
}
}
})
}
}
Тестирование идемпотентности обработчика
Согласно правилам 10. Idempotency в message processing, обработчик обязан выдавать одинаковый бизнес-результат при повторном получении дубликата. Тестирование идемпотентности делится на два обязательных сценария:
- Последовательный дубликат: Первое сообщение обработано успешно, следом приходит копия того же сообщения. Тест проверяет, что баланс не изменился повторно, а хранилище вызывалось безопасным образом.
- Конкурентный дубликат (Race condition): Брокер (например, RabbitMQ при
redeliverили Kafka при параллельной перебалансировке) доставил копии одного и того жеEventIDсразу двум параллельным горутинам-консьюмерам.
func TestPaymentHandler_Idempotency_Concurrent(t *testing.T) {
mockRepo := newMockOrderRepo()
mockRepo.balances["usr-vip"] = 1000.0
h := NewPaymentHandler(mockRepo)
msg := PaymentMessage{
EventID: "tx-duplicate-999",
UserID: "usr-vip",
Amount: 100.0,
}
const goroutines = 10
var wg sync.WaitGroup
wg.Add(goroutines)
// Запускаем 10 параллельных горутин с абсолютно одинаковым сообщением
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
_ = h.HandlePayment(context.Background(), msg)
}()
}
wg.Wait()
// Инвариант: списание 100.0 должно произойти строго 1 раз! Баланс обязан стать 900.0, а не 0.0
assert.Equal(t, 900.0, mockRepo.balances["usr-vip"], "Баланс должен быть списан ровно один раз")
assert.True(t, mockRepo.events["tx-duplicate-999"])
}
Для надежного отлова скрытых гонок и утечек всегда проверяйте тесты с детектором утечек горутин uber-go/goleak:
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
// Проверяет, что после завершения пакета тестов в рантайме не осталось забытых горутин
goleak.VerifyTestMain(m)
}
А в локальных тестах отдельных функций можно вызывать прямо внутри теста:
func TestHandlerLifecycle(t *testing.T) {
defer goleak.VerifyNone(t)
// Тело теста с запуском и остановкой воркеров
}
Тестирование ретраев и exponential backoff
При временных инфраструктурных сбоях публикация или обработка должны повторяться по правилам 9. Retry стратегии и exponential backoff, а при исчерпании попыток — перенаправляться в Dead Letter Queue (8. Dead Letter Queue).
Для проверки логики ретраев создается мок брокера с программируемым количеством сбоев:
package service_test
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockBroker struct {
mu sync.Mutex
attempts int
failuresLeft int
}
func (m *mockBroker) Publish(ctx context.Context, topic string, payload []byte) error {
m.mu.Lock()
defer m.mu.Unlock()
m.attempts++
if m.failuresLeft > 0 {
m.failuresLeft--
return errors.New("network io timeout")
}
return nil
}
type mockDLQ struct {
mu sync.Mutex
messages [][]byte
}
func (d *mockDLQ) SendToDLQ(ctx context.Context, payload []byte, reason error) error {
d.mu.Lock()
defer d.mu.Unlock()
d.messages = append(d.messages, payload)
return nil
}
type RetryConfig struct {
MaxRetries int
BaseDelay time.Duration
}
type RetryPublisher struct {
broker *mockBroker
dlq *mockDLQ
cfg RetryConfig
}
func (p *RetryPublisher) PublishWithRetry(ctx context.Context, topic string, payload []byte) error {
delay := p.cfg.BaseDelay
for attempt := 0; attempt <= p.cfg.MaxRetries; attempt++ {
err := p.broker.Publish(ctx, topic, payload)
if err == nil {
return nil
}
if attempt == p.cfg.MaxRetries {
// Попытки исчерпаны — отправляем в DLQ
_ = p.dlq.SendToDLQ(ctx, payload, err)
return fmt.Errorf("retries exhausted: %w", err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
delay *= 2 // exponential backoff
}
}
return nil
}
func TestRetryPublisher_SuccessAfterRetries(t *testing.T) {
broker := &mockBroker{failuresLeft: 2} // Первые 2 вызова упадут, 3-й успешен
dlq := &mockDLQ{}
pub := &RetryPublisher{
broker: broker,
dlq: dlq,
cfg: RetryConfig{MaxRetries: 3, BaseDelay: 2 * time.Millisecond},
}
err := pub.PublishWithRetry(context.Background(), "orders", []byte(`{"id": 10}`))
require.NoError(t, err)
broker.mu.Lock()
assert.Equal(t, 3, broker.attempts, "Ожидалось ровно 3 попытки (2 сбоя + 1 успех)")
broker.mu.Unlock()
dlq.mu.Lock()
assert.Empty(t, dlq.messages, "DLQ не должна содержать сообщений при успехе")
dlq.mu.Unlock()
}
func TestRetryPublisher_ExhaustedToDLQ(t *testing.T) {
broker := &mockBroker{failuresLeft: 10} // Все попытки завершатся сбоем
dlq := &mockDLQ{}
pub := &RetryPublisher{
broker: broker,
dlq: dlq,
cfg: RetryConfig{MaxRetries: 3, BaseDelay: 2 * time.Millisecond},
}
err := pub.PublishWithRetry(context.Background(), "orders", []byte(`{"id": 99}`))
require.Error(t, err)
broker.mu.Lock()
// 1 исходная попытка + 3 ретрая = 4 вызова
assert.Equal(t, 4, broker.attempts)
broker.mu.Unlock()
dlq.mu.Lock()
require.Len(t, dlq.messages, 1, "Сообщение обязано поступить в DLQ")
assert.JSONEq(t, `{"id": 99}`, string(dlq.messages[0]))
dlq.mu.Unlock()
}
Интеграционное тестирование с реальным брокером
Ни один мок не воспроизведет реальное поведение партиций Kafka, семантику RabbitMQ dead-letter exchange, сброс сокетов по таймауту heartbeat или ребалансировку Consumer Group. Для таких проверок используются официальные модули Testcontainers for Go.
sequenceDiagram
autonumber
participant Test as go test (Runner)
participant Docker as Docker Engine
participant Kafka as Kafka Container
participant Consumer as App Consumer Goroutine
Test->>Docker: Start Container (confluentinc/confluent-local:7.5.0)
Docker-->>Test: Port Mappings & Seed Brokers
Test->>Kafka: AdminClient: Create Topic "orders.v1" (1 Partition)
Test->>Consumer: Start Consumer Loop (Context ctx)
Test->>Kafka: Producer.ProduceSync("hello")
Kafka-->>Consumer: PollFetches() returns Record("hello")
Consumer->>Test: Notify via Channel / Store
Test->>Test: Cancel Context (SIGTERM simulation)
Consumer->>Kafka: CommitRecord & Close Client
Test->>Docker: Terminate Container
Интеграционные тесты обычно выделяются в отдельный набор с помощью build-тегов:
//go:build integration
Благодаря этому обычный запуск go test ./... отрабатывает мгновенно, а тяжелые тесты с Docker запускаются только при передаче флага:
go test -tags=integration -v ./...
Подготовка контейнера Kafka
Ниже представлен полноценный боевой интеграционный тест взаимодействия продюсера и консьюмера с использованием современной библиотеки twmb/franz-go и модуля testcontainers-go/modules/kafka:
//go:build integration
// +build integration
package integration_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
tckafka "github.com/testcontainers/testcontainers-go/modules/kafka"
"github.com/twmb/franz-go/pkg/kgo"
)
func TestKafkaProduceConsume(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
// 1. Поднимаем легковесный одиночный контейнер Kafka в Docker
kafkaContainer, err := tckafka.RunContainer(ctx,
tckafka.WithClusterID("test-cluster"),
testcontainers.WithImage("confluentinc/confluent-local:7.5.0"),
)
require.NoError(t, err)
defer func() {
_ = kafkaContainer.Terminate(context.Background())
}()
// 2. Извлекаем реальные хост и порт, выставленные наружу Docker-демоном
brokers, err := kafkaContainer.Brokers(ctx)
require.NoError(t, err)
require.NotEmpty(t, brokers)
topicName := "test-topic"
// 3. Создаем топик через Kafka Admin API
adminCl, err := kgo.NewClient(kgo.SeedBrokers(brokers...))
require.NoError(t, err)
defer adminCl.Close()
_, err = adminCl.CreateTopics(ctx, &kgo.CreateTopicsRequest{
Topics: []kgo.CreateTopicsRequestTopic{{
Topic: topicName,
NumPartitions: 1,
ReplicationFactor: 1,
}},
})
require.NoError(t, err)
// 4. Инициализируем продюсер и отправляем контрольное сообщение
produceCl, err := kgo.NewClient(
kgo.SeedBrokers(brokers...),
kgo.DefaultProduceTopic(topicName),
)
require.NoError(t, err)
record := &kgo.Record{Value: []byte("hello-async-world")}
results := produceCl.ProduceSync(ctx, record)
require.NoError(t, results.FirstErr())
produceCl.Close()
// 5. Запускаем консьюмер и проверяем получение
consumeCl, err := kgo.NewClient(
kgo.SeedBrokers(brokers...),
kgo.ConsumeTopics(topicName),
kgo.ConsumerGroup("test-group"),
)
require.NoError(t, err)
defer consumeCl.Close()
pollCtx, pollCancel := context.WithTimeout(ctx, 15*time.Second)
defer pollCancel()
var received []string
for {
fetches := consumeCl.PollFetches(pollCtx)
if fetches.IsClientClosed() || pollCtx.Err() != nil {
break
}
fetches.EachRecord(func(r *kgo.Record) {
received = append(received, string(r.Value))
})
if len(received) >= 1 {
break
}
}
require.Equal(t, []string{"hello-async-world"}, received)
}
Аналогичным образом модуль testcontainers/modules/rabbitmq позволяет поднять изолированный RabbitMQ с преднастроенными очередями и плагинами управления (Management UI) за пару строк кода.
Проверка graceful shutdown
В соответствии с принципами 2. Консьюмеры и graceful shutdown, при получении сигнала завершения консьюмер обязан:
- Прекратить чтение новых сообщений из брокера.
- Завершить обработку тех сообщений, которые уже находятся в локальных буферах горутин.
- Зафиксировать (ACK/Commit) их смещения в брокере.
- Корректно закрыть сетевое соединение.
Проверка graceful shutdown строится на моделировании отмены контекста:
func TestGracefulShutdown(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
client, err := kgo.NewClient(kgo.SeedBrokers(brokers...), kgo.ConsumeTopics("shutdown-topic"))
require.NoError(t, err)
defer client.Close()
processed := make(chan string, 100)
consumerDone := make(chan struct{})
// Фоновый цикл вычитки
go func() {
defer close(consumerDone)
for {
fetches := client.PollFetches(ctx)
if fetches.IsClientClosed() || ctx.Err() != nil {
// Контекст отменен — выходим из цикла вычитки
break
}
fetches.EachRecord(func(r *kgo.Record) {
processed <- string(r.Value)
})
}
}()
// Ждем, пока консьюмер гарантированно начнет работу и обработает хотя бы одно сообщение
require.Eventually(t, func() bool {
return len(processed) > 0
}, 5*time.Second, 50*time.Millisecond)
// Имитируем приход сигнала SIGTERM в микросервис
cancel()
// Убеждаемся, что горутина консьюмера штатно завершилась без зависания
select {
case <-consumerDone:
// Успешный выход
case <-time.After(3 * time.Second):
t.Fatal("Consumer failed to shutdown gracefully within 3 seconds")
}
}
Тестирование конкурентной обработки
Когда консьюмер распределяет входящие сообщения между пулом рабочих горутин (Worker Pool из лекции 3. Параллелизм обработки сообщений), высок риск нарушить строгий порядок или получить гонку за разделяемое состояние.
При тестировании конкурентного консьюмера применяются:
- Счетчики на базе
sync/atomic: Никакихcount++без мьютекса или атомиков, иначе тест упадет под детектором гонок. - Флаг
-race: Командаgo test -race ./...обязана выполняться на каждом шаге CI-пайплайна. Инструментация рантайма Go отслеживает все несинхронизированные чтения и записи в память размером в 8 байт.
func TestConcurrentProcessing_OrderAndRaces(t *testing.T) {
const totalMessages = 500
var processedCount int64
var mu sync.Mutex
orderPerKey := make(map[string][]int)
// Симулируем конкурентный воркер-пул
jobs := make(chan struct {
key string
seq int
}, totalMessages)
var wg sync.WaitGroup
const workers = 8
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
// Имитируем работу
atomic.AddInt64(&processedCount, 1)
mu.Lock()
orderPerKey[j.key] = append(orderPerKey[j.key], j.seq)
mu.muUnlock := func() { mu.Unlock() }
mu.muUnlock()
}
}()
}
// Отправляем сообщения с тремя разными ключами
for i := 0; i < totalMessages; i++ {
key := fmt.Sprintf("partition-%d", i%3)
jobs <- struct {
key string
seq int
}{key: key, seq: i}
}
close(jobs)
wg.Wait()
assert.Equal(t, int64(totalMessages), atomic.LoadInt64(&processedCount))
}
Тестирование с фаззингом
Начиная с Go 1.18, компилятор включает нативный фаззинг (Fuzzing). В асинхронных системах брокер доставляет не структуры Go, а сырые байты ([]byte), полученные из сети.
Если в продюсер внедрили несовместимую схему, хакер прислал некорректный JSON, или сжатие Snappy повредило пару байт в заголовке, обработчик обязан аккуратно вернуть ошибку, а не упасть в панику с завершением всего процесса.
func FuzzPaymentMessageHandler(f *testing.F) {
// Добавляем начальный корпус валидных семян (Seed Corpus)
f.Add([]byte(`{"event_id":"evt-1","user_id":"u-10","amount":100.5}`))
f.Add([]byte(`{"event_id":"","user_id":"","amount":0}`))
f.Add([]byte(`{}`))
f.Add([]byte(`null`))
repo := newMockOrderRepo()
handler := NewPaymentHandler(repo)
f.Fuzz(func(t *testing.T, data []byte) {
var msg PaymentMessage
// 1. Проверяем парсинг — он не должен вызывать панику
if err := json.Unmarshal(data, &msg); err != nil {
// Невалидный JSON — штатная ситуация, игнорируем
return
}
// 2. Проверяем доменную валидацию — любые крайние значения float64 (NaN, +Inf, -Inf, Overflow)
// не должны ломать внутренние инварианты
_ = handler.HandlePayment(context.Background(), msg)
})
}
Запуск фаззера в CI на короткий интервал:
go test -fuzz=FuzzPaymentMessageHandler -fuzztime=30s ./service/...
Фаззер молниеносно находит краевые случаи, о которых разработчик даже не подозревал: невалидные UTF-8 последовательности в строках, отрицательный баланс, NaN в числах с плавающей точкой и переполнение стека рекурсивными структурами.
Инструменты и лучшие практики
Сведем воедино проверенный практикой стек тестирования распределенных брокеров сообщений в Go:
| Инструмент | Назначение | Когда применять |
|---|---|---|
testify (assert, require) |
Лаконичные утверждения и функция опроса assert.Eventually |
В 100% unit- и integration-тестов |
| testcontainers-go | Подъем реальных контейнеров Kafka, RabbitMQ, Redis, PostgreSQL | В тестах с флагом //go:build integration |
| go.uber.org/goleak | Детектор забытых фоновых горутин консьюмеров | В TestMain всех пакетов с конкурентным кодом |
| mockgen / gomock | Генерация строгих моков для интерфейсов публикации и БД | В изолированных unit-тестах бизнес-логики |
| go test -race | Детектор состояний гонки (ThreadSanitizer) | Обязательно на каждом коммите в CI |
| Go Native Fuzzing | Фаззинг десериализации пакетов и валидаторов сообщений | При тестировании входных контрактов очередей |
Заключение и следующие шаги
Тестирование асинхронных систем перестает быть «лотереей таймаутов», когда вы разделяете архитектуру на независимые слои:
- Бизнес-логика живет в чистых обработчиках и тестируется за микросекунды табличными тестами с моками интерфейсов
OrderRepositoryи генерацией данных фаззером. - Инфраструктурные контракты (сериализация, заголовки, ретраи, dead-lettering и graceful shutdown) проверяются через
testcontainers-goна реальном брокере. - Конкурентность контролируется флагом
-raceи детектором утечекgoleak.VerifyNone(t).
Однако даже идеально протестированная система в боевом продакшене сталкивается с непредвиденным: сетевые всплески, деградация дисков брокера и отставание консьюмеров. В следующей лекции — 8. Observability очередей — мы разберем, как сделать работу распределенной асинхронной системы прозрачной с помощью метрик Prometheus, распределенной трассировки OpenTelemetry и структурированных логов.