У локальных интерфейсов из предыдущих частей по одной реализации в рабочем приложении. Вторую даёт тест, и он же оказывается единственным местом, где подмена действительно происходит. Разберём тесты, которым не нужен ни PostgreSQL, ни настоящий платёжный провайдер: стабы, unit-тест сценария, тест адаптера и тест HTTP-клиента на httptest.

Это пятая статья серии «Архитектура Go» и продолжение статьи «Границы модулей в Go: транзакции и контроль импортов». Код тот же: условный Go-модуль modular_shop, сценарий checkout.UseCase, модуль catalog за адаптером и платёжный сервис за HTTP-клиентом. Устройство зависимостей разобрано в третьей части, здесь речь только про тесты. Тесты на настоящем PostgreSQL вынесены в шестую часть.

Содержание

  1. Что доказывает каждый уровень тестов
  2. Стабы для локальных интерфейсов
  3. Unit-тест use case проверяет оркестрацию
  4. Тест адаптера проверяет перевод
  5. Тест клиента проверяет формат запроса
  6. Как не стоит тестировать границу
  7. Что остаётся вне этих тестов
  8. Практическая проверка тестов

Что доказывает каждый уровень тестов

Границы разделили код на части с разными зависимостями, и вопрос к каждой части свой:

  • checkout.UseCase знает порядок шагов и реакцию на ошибку. Все его зависимости скрыты за локальными интерфейсами, поэтому тест собирает сценарий из стабов и не поднимает ни базу, ни сеть.
  • adapters.CatalogAdapter и adapters.PaymentAdapter вызывают чужую сторону границы: первый use case соседнего модуля, второй HTTP-клиент. Их работа состоит в том, чтобы собрать вызов в чужом формате, а результат и ошибки перевести обратно в сущности order. Тест подставляет стаб на место вызываемой стороны и проверяет обе половины: что ушло в вызов и что вернулось из него.
  • paymentclient.Client собирает HTTP-запрос и разбирает ответ. Здесь зависимость это сеть, поэтому нужен httptest.Server.
  • PostgreSQL-репозиторий пишет SQL и переводит строку таблицы в order.Order. Заменять базу подделкой здесь нечем: проверять нужно результат запроса. Этот и следующий уровень разобраны в шестой части.
  • Transaction Manager вместе с use case отвечает за COMMIT и ROLLBACK, а постановщик фоновой задачи на отправку чека обязан писать в переданную ему транзакцию. Тоже настоящая база.

Отдельно стоит сквозной тест сценария, который тоже разобран в шестой части. Новых бизнес-веток он не добавляет, зато проходит через реальную сборку компонентов. Он проверяет то, чего не видит ни один изолированный тест: что выбранный счастливый путь работает на настоящих реализациях, а не только на стабах.

Тест отвечает на вопрос своего уровня и молчит про остальные. Unit-тест Execute не скажет, что MarkPaid пишет в нужную колонку. Интеграционный тест репозитория не скажет, что при отказе оплаты MarkPaid вообще не должен вызываться. Ни один из них не заметит, что кто-то импортировал catalog напрямую.

Узкие интерфейсы уменьшают количество методов у стаба, но не количество самих стабов: подстановка нужна для каждой зависимости конструктора. К концу четвёртой части у checkout.New их пять, и все пять придётся передать даже в тест, который проверяет одну ветку.

Стабы для локальных интерфейсов

Сначала соберём то, во что тесты будут подставлять заглушки. За две предыдущие части UseCase набрал пять зависимостей: три шага сценария из третьей части, менеджер транзакций и receiptEnqueuer из четвёртой части.

receiptEnqueuer не отправляет чек сам. После успешной оплаты он сохраняет в PostgreSQL фоновую задачу, а отдельный воркер позже забирает её и отправляет чек. Запись задачи и перевод заказа в paid должны произойти в одной SQL-транзакции. Иначе после сбоя может остаться оплаченный заказ без задачи на чек или задача для заказа, статус которого откатился.

// internal/domain/order/checkout/usecase.go
package checkout

type UseCase struct {
	orderRepo     orderRepository
	productRepo   productPort
	paymentClient paymentClient
	tm            transactionManager
	receipts      receiptEnqueuer
}

func New(
	orderRepo orderRepository,
	productRepo productPort,
	paymentClient paymentClient,
	tm transactionManager,
	receipts receiptEnqueuer,
) *UseCase {
	return &UseCase{
		orderRepo:     orderRepo,
		productRepo:   productRepo,
		paymentClient: paymentClient,
		tm:            tm,
		receipts:      receipts,
	}
}

Пять позиционных аргументов это уже граница читаемости: соседние параметры интерфейсных типов легко перепутать местами при вызове, и компилятор поймает это, только если типы разные. Дальше конструктор стоит переводить на структуру с именованными полями, то есть на New(Deps{OrderRepo: orders, ProductRepo: products, ...}) вместо пяти позиций подряд. В Go-стайлгайде Google этот приём разобран как option structure: там же показано, когда поле стоит делать обязательным аргументом, а когда оставлять нулевое значение осмысленным умолчанием. В тестах ниже используется именно New, а не сборка UseCase литералом: тест должен проходить через тот же конструктор, что и Composition Root, иначе он не заметит забытое поле.

orderRepository, productPort и paymentClient доступны пакету checkout. На сборку use case из внешнего тестового пакета это не влияет: checkout.New принимает значения, а называть тип аргумента вызывающему коду не нужно. Тесты checkout всё же объявляют package checkout, а не checkout_test, и повод у этого один: тест транзакции из шестой части вызывает неэкспортированный markOrderPaid, а разносить тесты одного пакета по двум тестовым пакетам ради этого незачем.

Остальные тесты в статье тоже объявлены внутри своих пакетов, но это выбор по умолчанию, а не необходимость: пока тест не трогает неэкспортированное, обе формы работают одинаково. Внешний тестовый пакет добавляет одно свойство: обратиться он может только к публичному API и тем самым доказывает, что публичного API достаточно. Ради этого тест репозитория в шестой части написан как postgres_test.

Стабы удобно держать в отдельном файле рядом с тестами:

// internal/domain/order/checkout/stubs_test.go
package checkout

import (
	"context"

	"github.com/google/uuid"

	"modular_shop/internal/domain/order"
)

var (
	orderID   = uuid.MustParse("6b1d8d43-8f0a-4d3c-9e4b-2f6a5f0f5a11")
	productID = uuid.MustParse("0a2b6f1e-1c44-4a2d-8f0b-9c1f2f7d3e55")
	accountID = uuid.MustParse("f5cba0f2-62ee-43ce-a209-70f5131c8198")
)

type requestMarkerKey struct{}

type callLog struct {
	calls []string
}

func (l *callLog) record(name string) {
	l.calls = append(l.calls, name)
}

type orderRepositoryStub struct {
	log         *callLog
	created     order.Order
	createErr   error
	markPaidErr error

	gotCreateParams order.CreateOrderParams
	gotPaidOrderID  uuid.UUID
	gotPaymentID    string
}

func (s *orderRepositoryStub) Create(
	_ context.Context,
	params order.CreateOrderParams,
) (order.Order, error) {
	s.log.record("create_order")
	s.gotCreateParams = params

	if s.createErr != nil {
		return order.Order{}, s.createErr
	}

	return s.created, nil
}

func (s *orderRepositoryStub) MarkPaid(
	_ context.Context,
	id uuid.UUID,
	paymentID string,
) error {
	s.log.record("mark_paid")
	s.gotPaidOrderID = id
	s.gotPaymentID = paymentID

	return s.markPaidErr
}

type productPortStub struct {
	product order.ProductSnapshot
	err     error

	gotProductID uuid.UUID
}

func (s *productPortStub) GetProduct(
	_ context.Context,
	id uuid.UUID,
) (order.ProductSnapshot, error) {
	s.gotProductID = id
	return s.product, s.err
}

type paymentClientStub struct {
	log    *callLog
	result order.ChargeResult
	err    error

	gotCommand       order.ChargeCommand
	gotRequestMarker string
}

func (s *paymentClientStub) Charge(
	ctx context.Context,
	command order.ChargeCommand,
) (order.ChargeResult, error) {
	s.log.record("charge")
	s.gotCommand = command
	if marker, ok := ctx.Value(requestMarkerKey{}).(string); ok {
		s.gotRequestMarker = marker
	}

	return s.result, s.err
}

type receiptEnqueuerStub struct {
	log *callLog
	err error

	gotOrderID uuid.UUID
}

func (s *receiptEnqueuerStub) EnqueueReceipt(
	_ context.Context,
	id uuid.UUID,
) error {
	s.log.record("enqueue_receipt")
	s.gotOrderID = id

	return s.err
}

Общий callLog появился здесь вместо счётчиков вызовов у каждого стаба. Счётчик отвечает на вопрос «сколько раз», а от unit-теста оркестрации нужен ответ на вопрос «что произошло и в каком порядке». Три отдельных счётчика со значениями 1, 1, 1 этого не говорят: они одинаковы и для правильного сценария, и для реализации, которая пометила заказ оплаченным раньше, чем списала деньги.

В журнал попадают только записи и внешние вызовы. GetProduct в нём нет намеренно: это чтение, повторение которого ничего не меняет, а фиксировать в тесте число обращений к каталогу означает запрещать безобидный рефакторинг. Вместо этого productPortStub сохраняет аргумент. Сохранённый аргумент сильнее счётчика: если бы GetProduct не вызвали вообще, поле gotProductID осталось бы нулевым, и проверка упала бы.

По той же причине аргументы сохраняют и остальные стабы. Без этого тест остаётся зелёным на реализации, которая передала в Create чужой AccountID, вызвала MarkPaid для другого заказа или потеряла PaymentID из ответа провайдера: сам факт вызова у всех этих вариантов одинаковый.

Стаб productPortStub объявлен с указательным приёмником, как и остальные, хотя ни err, ни product он не меняет. Причина в записи аргумента: у приёмника-значения gotProductID записался бы в копию, и тест прочитал бы ноль.

Поле со значением подходит, пока зависимость отвечает одинаково на все вызовы внутри одного случая. Если нужно, чтобы первый вызов упал, а второй прошёл, поле с функцией гибче. В таблице ниже такого случая нет.

Метод, который тест вызывать не должен, лучше сделать падающим. productCatalog из третьей части объявляет два метода, а тест GetProduct использует один:

// internal/domain/order/adapters/catalog_test.go
package adapters

import (
	"context"
	"testing"

	"github.com/google/uuid"

	"modular_shop/internal/domain/catalog"
)

type productCatalogStub struct {
	t       *testing.T
	product catalog.Product
	err     error
}

func (s productCatalogStub) Get(
	_ context.Context,
	_ uuid.UUID,
) (catalog.Product, error) {
	return s.product, s.err
}

func (s productCatalogStub) Reserve(
	_ context.Context,
	_ uuid.UUID,
	_ int,
) error {
	s.t.Fatalf("unexpected Reserve call")
	return nil
}

Стаб обязан реализовать интерфейс целиком, даже если тест использует один метод. Пустой Reserve, возвращающий nil, пропустил бы лишний вызов молча, а t.Fatalf превращает его в упавший тест. Возврат после Fatalf недостижим, но нужен компилятору.

Генератор моков здесь ничего не сэкономил бы. Локальный интерфейс из двух методов короче написать руками, и стаб читается на той же странице, что и тест. Mockery или gomock начинают окупаться на чужих интерфейсах, которые вы не контролируете, и там, где подстановок в проекте десятки, а писать их руками дороже, чем держать генерацию в go:generate.

Само по себе количество методов ничего не решает. Интерфейс из десяти методов это в первую очередь повод разделить его по потребителям, а не повод сгенерировать для него мок: сгенерированный код спрячет проблему, оставив у теста доступ ко всем десяти. Хрупким тест делает при этом не генератор, а лишние ожидания вызовов. Просто у сгенерированных моков всё API построено вокруг ожиданий, и написать их больше, чем нужно, оказывается проще, чем не написать.

Unit-тест use case проверяет оркестрацию

Оркестрация состоит из порядка шагов, данных, которые передаются между ними, и реакции на сбой каждого шага. Шагов у Execute четыре, а операций, способных сломаться, пять: последний шаг пишет в базу дважды. Случаи поэтому удобнее держать таблицей: строка говорит, где сценарий споткнулся, а журнал показывает, докуда он успел дойти.

// internal/domain/order/checkout/usecase_test.go
func TestUseCase_Execute(t *testing.T) {
	product := order.ProductSnapshot{ID: productID, PriceCents: 12500}
	createdOrder := order.Order{
		ID:         orderID,
		AccountID:  accountID,
		Status:     order.StatusPending,
		TotalCents: 12500,
	}
	queueErr := errors.New("queue is down")

	tests := []struct {
		name        string
		productErr  error
		createErr   error
		chargeErr   error
		markPaidErr error
		receiptErr  error
		wantErr     error
		wantStatus  order.Status
		wantCalls   []string
	}{
		{
			name:       "creates paid order",
			wantStatus: order.StatusPaid,
			wantCalls: []string{
				"create_order",
				"charge",
				"mark_paid",
				"enqueue_receipt",
			},
		},
		{
			name:       "stops when product is unavailable",
			productErr: order.ErrProductUnavailable,
			wantErr:    order.ErrProductUnavailable,
		},
		{
			name:      "stops when order is not created",
			createErr: order.ErrOrderPersistenceFailed,
			wantErr:   order.ErrOrderPersistenceFailed,
			wantCalls: []string{"create_order"},
		},
		{
			name:       "keeps order pending when payment is declined",
			chargeErr:  order.ErrPaymentDeclined,
			wantErr:    order.ErrPaymentDeclined,
			wantStatus: order.StatusPending,
			wantCalls:  []string{"create_order", "charge"},
		},
		{
			name:       "keeps cancellation returned by payment client",
			chargeErr:  context.Canceled,
			wantErr:    context.Canceled,
			wantStatus: order.StatusPending,
			wantCalls:  []string{"create_order", "charge"},
		},
		{
			name:        "keeps order pending when status is not saved",
			markPaidErr: order.ErrOrderPersistenceFailed,
			wantErr:     order.ErrOrderPersistenceFailed,
			wantStatus:  order.StatusPending,
			wantCalls:   []string{"create_order", "charge", "mark_paid"},
		},
		{
			name:       "keeps order pending when receipt is not queued",
			receiptErr: queueErr,
			wantErr:    queueErr,
			wantStatus: order.StatusPending,
			wantCalls: []string{
				"create_order",
				"charge",
				"mark_paid",
				"enqueue_receipt",
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			log := &callLog{}
			products := &productPortStub{
				product: product,
				err:     tt.productErr,
			}
			orders := &orderRepositoryStub{
				log:         log,
				created:     createdOrder,
				createErr:   tt.createErr,
				markPaidErr: tt.markPaidErr,
			}
			payments := &paymentClientStub{
				log:    log,
				result: order.ChargeResult{PaymentID: "pay_42"},
				err:    tt.chargeErr,
			}
			receipts := &receiptEnqueuerStub{log: log, err: tt.receiptErr}

			ctx := context.WithValue(
				context.Background(),
				requestMarkerKey{},
				"request_42",
			)

			useCase := New(
				orders,
				products,
				payments,
				transaction.FakeManager{},
				receipts,
			)

			got, err := useCase.Execute(ctx, Command{
				ProductID: productID,
				AccountID: accountID,
			})

			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("Execute() error = %v, want %v", err, tt.wantErr)
			}
			if got.Status != tt.wantStatus {
				t.Errorf(
					"Execute() status = %q, want %q",
					got.Status,
					tt.wantStatus,
				)
			}
			if !slices.Equal(log.calls, tt.wantCalls) {
				t.Errorf(
					"Execute() calls = %v, want %v",
					log.calls,
					tt.wantCalls,
				)
			}
			if products.gotProductID != productID {
				t.Errorf(
					"GetProduct() id = %s, want %s",
					products.gotProductID,
					productID,
				)
			}
			if !slices.Contains(tt.wantCalls, "create_order") {
				return
			}

			wantParams := order.CreateOrderParams{
				AccountID:  accountID,
				TotalCents: product.PriceCents,
			}
			if orders.gotCreateParams != wantParams {
				t.Errorf(
					"Create() params = %+v, want %+v",
					orders.gotCreateParams,
					wantParams,
				)
			}
			if !slices.Contains(tt.wantCalls, "charge") {
				return
			}

			wantCommand := order.ChargeCommand{
				IdempotencyKey: orderID.String(),
				AccountID:      accountID,
				Amount:         product.PriceCents,
			}
			if payments.gotCommand != wantCommand {
				t.Errorf(
					"Charge() command = %+v, want %+v",
					payments.gotCommand,
					wantCommand,
				)
			}
			if payments.gotRequestMarker != "request_42" {
				t.Errorf(
					"Charge() context marker = %q, want %q",
					payments.gotRequestMarker,
					"request_42",
				)
			}
			if !slices.Contains(tt.wantCalls, "mark_paid") {
				return
			}

			if orders.gotPaidOrderID != orderID {
				t.Errorf(
					"MarkPaid() id = %s, want %s",
					orders.gotPaidOrderID,
					orderID,
				)
			}
			if orders.gotPaymentID != "pay_42" {
				t.Errorf(
					"MarkPaid() payment = %q, want %q",
					orders.gotPaymentID,
					"pay_42",
				)
			}
		})
	}
}

Журнал вызовов читается как сам сценарий: полный список на успехе, пустой при недоступном товаре, create_order при сбое сохранения, create_order, charge при отклонённом платеже. Каждая строка после первой отвечает на вопрос «что не должно было произойти», а это половина смысла unit-теста оркестрации. Сравнение через slices.Equal проверяет заодно и порядок: реализация, которая пометит заказ оплаченным до вызова провайдера, даст тот же набор вызовов в другой последовательности и упадёт.

Последняя строка ломает эту симметрию намеренно. Журнал у неё такой же, как у успеха, различают их только ошибка и статус. Это случай, когда деньги списаны, заказ помечен оплаченным, а задача на чек не поставлена. С настоящим менеджером транзакций запись статуса при этом откатывается, и заказ возвращается в pending, поэтому Execute отдаёт и ошибку, и сам заказ: вызывающий код знает идентификатор и может довести операцию повторной попыткой.

Здесь же видна граница этого теста. transaction.FakeManager из второй части серии просто вызывает fn(ctx), поэтому unit-тест пройдёт одинаково и с InTransaction, и без него, а откат в нём вообще не участвует. Статус pending в последней строке проверяется не в базе, а в структуре, которую вернул Execute. Что запись действительно откатилась, проверяет интеграционный тест из шестой части.

Ступенька из ранних return по slices.Contains повторяет форму сценария: смотреть на аргументы несостоявшегося вызова нечего, а писать if orders.gotPaidOrderID != uuid.Nil для каждой ранней строки означало бы дублировать журнал.

Сами аргументы закрывают то, чего журнал не видит. wantParams фиксирует источник суммы: она приходит из каталога, а не из HTTP-запроса пользователя. wantCommand фиксирует перевод в команду оплаты и заодно доказывает порядок ещё раз, потому что ключ идемпотентности равен идентификатору уже созданного заказа. Проверка gotPaymentID требует, чтобы наверх ушёл идентификатор из ответа провайдера, а не какое-нибудь поле заказа.

Передача контекста и обёртка ошибки проверяются раздельно. Маркер request_42 проходит через context.WithValue и должен появиться в paymentClientStub. Если Execute заменит полученный контекст на context.Background(), маркер пропадёт и тест упадёт. Отдельная строка с chargeErr: context.Canceled доказывает, что Execute оборачивает ошибку через %w и не теряет context.Canceled.

Отменённый контекст здесь не передаётся намеренно. Корректный use case может проверить ctx.Err() в самом начале и не дойти ни до одной зависимости. Тест не должен запрещать такую оптимизацию ожиданием, что сценарий обязательно дойдёт до Charge с уже отменённым контекстом.

В строке успеха wantErr остаётся нулевым, и errors.Is(err, nil) проверяет ровно err == nil. Отдельная ветка для успешного случая не нужна.

В остальных строках ошибка сравнивается через errors.Is, а не по тексту. Сообщение растёт по мере прохождения слоёв: Execute добавляет к нему свой контекст, адаптер добавляет идентификатор. Сравнение подстроки сломается на первой же правке формулировки, при этом пропустит подмену самой sentinel-ошибки. Почему ошибка принадлежит order и не приходит из пакета реализации, разобрано в первой части серии.

Стаб возвращает order.ErrPaymentDeclined без обёртки, хотя настоящий адаптер вернёт её обёрнутой через %w вместе с ключом идемпотентности. Для этого теста разницы нет: errors.Is работает одинаково в обоих случаях. Разницу проверяет тест адаптера.

t.Parallel() внутри подтеста безопасен: стабы и журнал создаются в каждой строке заново, общего состояния нет. Копировать tt перед t.Run не нужно, если в go.mod объявлена версия языка 1.22 или выше: своя переменная на каждой итерации появилась именно там, и определяет это директива go в модуле, а не версия установленного компилятора. В модуле со старой директивой строка tt := tt обязательна.

Тест адаптера проверяет перевод

Адаптер вызывает операцию на той стороне границы и приводит её итог к типам order: CatalogAdapter обращается к use case соседнего модуля, PaymentAdapter к клиенту внешнего сервиса. Проверять поэтому нужно и аргументы вызова, и то, что вернулось, а такой набор случаев удобнее держать таблицей:

// internal/domain/order/adapters/payment_test.go
package adapters

import (
	"context"
	"errors"
	"strings"
	"testing"

	"github.com/google/uuid"

	"modular_shop/internal/clients/paymentclient"
	"modular_shop/internal/domain/order"
)

type paymentAPIStub struct {
	response   paymentclient.ChargeResponse
	err        error
	gotRequest paymentclient.ChargeRequest
}

func (s *paymentAPIStub) Charge(
	ctx context.Context,
	request paymentclient.ChargeRequest,
) (paymentclient.ChargeResponse, error) {
	s.gotRequest = request

	if err := ctx.Err(); err != nil {
		return paymentclient.ChargeResponse{}, err
	}

	return s.response, s.err
}

func TestPaymentAdapter_Charge(t *testing.T) {
	accountID := uuid.MustParse("f5cba0f2-62ee-43ce-a209-70f5131c8198")
	clientErr := errors.New("connection reset")

	tests := []struct {
		name             string
		response         paymentclient.ChargeResponse
		err              error
		cancel           bool
		skipRequestCheck bool
		hiddenErr        error
		wantText         string
		want             order.ChargeResult
		wantErr          error
	}{
		{
			name: "maps captured payment",
			response: paymentclient.ChargeResponse{
				TransactionID: "pay_42",
				Status:        "captured",
			},
			want: order.ChargeResult{PaymentID: "pay_42"},
		},
		{
			name:      "maps declined payment",
			err:       paymentclient.ErrDeclined,
			hiddenErr: paymentclient.ErrDeclined,
			wantErr:   order.ErrPaymentDeclined,
		},
		{
			name:      "maps client failure",
			err:       clientErr,
			hiddenErr: clientErr,
			wantText:  "connection reset",
			wantErr:   order.ErrPaymentFailed,
		},
		{
			name:             "keeps cancellation of the caller",
			cancel:           true,
			skipRequestCheck: true,
			hiddenErr:        order.ErrPaymentFailed,
			wantErr:          context.Canceled,
		},
		{
			name: "maps unexpected status",
			response: paymentclient.ChargeResponse{
				Status: "authorized",
			},
			wantText: "authorized",
			wantErr:  order.ErrPaymentFailed,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			client := &paymentAPIStub{
				response: tt.response,
				err:      tt.err,
			}
			adapter := NewPaymentAdapter(client)

			ctx := context.Background()
			if tt.cancel {
				canceled, cancel := context.WithCancel(ctx)
				cancel()
				ctx = canceled
			}

			got, err := adapter.Charge(ctx, order.ChargeCommand{
				IdempotencyKey: "order_42",
				AccountID:      accountID,
				Amount:         12500,
			})

			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("Charge() error = %v, want %v", err, tt.wantErr)
			}

			if tt.hiddenErr != nil && errors.Is(err, tt.hiddenErr) {
				t.Errorf(
					"Charge() error = %v, must not match %v",
					err,
					tt.hiddenErr,
				)
			}

			if tt.wantText != "" && !strings.Contains(
				err.Error(),
				tt.wantText,
			) {
				t.Errorf(
					"Charge() error = %q, want it to mention %q",
					err,
					tt.wantText,
				)
			}

			if got != tt.want {
				t.Errorf("Charge() = %+v, want %+v", got, tt.want)
			}

			if !tt.skipRequestCheck {
				wantRequest := paymentclient.ChargeRequest{
					IdempotencyKey: "order_42",
					CustomerID:     accountID.String(),
					AmountCents:    12500,
				}
				if client.gotRequest != wantRequest {
					t.Errorf(
						"Charge() request = %+v, want %+v",
						client.gotRequest,
						wantRequest,
					)
				}
			}
		})
	}
}

Стаб назван по интерфейсу, который реализует. В пакете checkout paymentClientStub подменял paymentClient и принимал типы order, здесь paymentAPIStub подменяет paymentAPI и принимает DTO клиента. Это две разные подстановки на двух сторонах одной границы, и путать их не стоит.

Три проверки в этом тесте относятся к границе напрямую.

wantRequest фиксирует перевод команды наружу. Цена в копейках, идентификатор аккаунта строкой, ключ идемпотентности на месте. Компилятор здесь почти ничем не помогает: CustomerID и IdempotencyKey в ChargeRequest оба строки, поэтому перепутать их местами он не мешает. Расхождение масштаба тоже пройдёт мимо него, если Amount в order окажется в рублях, а AmountCents ждёт копейки.

hiddenErr перечисляет то, чего в ошибке быть не должно. У строки с отказом это paymentclient.ErrDeclined: если она останется в цепочке, вызывающий код сможет завязаться на неё и получить зависимость от чужого пакета в обход границы, а тест на order.ErrPaymentDeclined этого не заметит, потому что обе ошибки в цепочке уживаются. Проверка «нужная ошибка есть» и проверка «чужой ошибки нет» независимы: первая проходит в обоих случаях, поэтому сама по себе не гарантирует ничего.

wantText требует обратного: текст чужой ошибки должен остаться в сообщении, иначе connection reset пропадёт из логов. Ровно эту пару даёт формат %w: %v, где через %w уходит ошибка order, а через %v текст чужой. У строки с отказом текста провайдера нет, потому что ветка ErrDeclined его и не добавляет: отказ это исход, а не сбой, диагностировать в нём нечего.

Отдельно стоит строка с отменой, потому что под правило «чужая ошибка не проходит через errors.Is» она не подходит. Отмена и истёкший дедлайн приходят не от провайдера, а от вызывающей стороны. Переводить их в ErrPaymentFailed нельзя: обработчик увидит доменную ошибку и уйдёт в повтор запроса, ответа на который уже никто не ждёт. Отдать обе ошибки сразу, двумя %w в одном fmt.Errorf, тоже не выход: код, который первым делом спрашивает про ErrPaymentFailed, получит утвердительный ответ и повторит вызов. Поэтому ветка возвращает только ошибку контекста, и в листинге четвёртой части она встаёт первой, до проверки на paymentclient.ErrDeclined:

response, err := a.client.Charge(ctx, request)
if err != nil && ctx.Err() != nil {
	return order.ChargeResult{}, fmt.Errorf(
		"charge payment %s: %w",
		command.IdempotencyKey,
		ctx.Err(),
	)
}

Условие смотрит на ctx.Err(), а не на саму ошибку, и разница тут существенная. errors.Is(err, context.DeadlineExceeded) окажется истинным и тогда, когда сработал собственный таймаут клиента, а это противоположный случай: вызывающая сторона всё ещё ждёт ответа, повтор с тем же ключом идемпотентности уместен, и ошибка должна остаться ErrPaymentFailed. Различает эти два случая только контекст вызова: если ctx жив, отменял не вызывающий. Универсального правила «отмена всегда важнее домена» здесь нет, есть правило «чей дедлайн истёк, тот и решает».

Строка keeps cancellation of the caller передаёт реальный отменённый контекст. Если адаптер всё же вызовет клиент, paymentAPIStub увидит ctx.Err() и вернёт отмену. Адаптер может и завершиться раньше, поэтому для этой строки не фиксируется сам факт вызова. Важен наблюдаемый результат: context.Canceled доступен через errors.Is, а ErrPaymentFailed в цепочке нет.

Случай authorized в таблице проверяет решение адаптера, а не перевод. Провайдер вернул успешный HTTP-ответ и понятный ему статус, а адаптер решил, что захолдированные деньги списанием не считаются. Тест это решение фиксирует, и при смене политики придётся поменять и его.

Тест CatalogAdapter устроен так же: productCatalogStub вместо *catalog.ProductUseCase, catalog.ErrProductNotFound на входе, order.ErrProductUnavailable на выходе. Отличие в том, что перевод там односторонний: наружу уходит uuid.UUID, собирать чужой запрос не нужно.

Тест клиента проверяет формат запроса

Адаптер работает с paymentclient.ChargeRequest как со структурой Go. Что из неё получится в HTTP-запросе, решают теги JSON и код клиента, и в тесте адаптера это не видно. IdempotencyKey помечен json:"-" и передаётся в заголовке, а метод, путь и заголовки задаёт сам Charge. Проверка нужна отдельная:

// internal/clients/paymentclient/client_test.go
package paymentclient

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"reflect"
	"testing"
)

func TestClient_ChargeSendsRequest(t *testing.T) {
	var (
		gotMethod string
		gotPath   string
		gotType   string
		gotKey    string
		gotBody   map[string]any
	)

	server := httptest.NewServer(http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) {
			gotMethod = r.Method
			gotPath = r.URL.Path
			gotType = r.Header.Get("Content-Type")
			gotKey = r.Header.Get("Idempotency-Key")
			if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
				t.Errorf("decode request body: %v", err)
			}

			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(
				`{"transaction_id":"pay_42","status":"captured"}`,
			))
		},
	))
	defer server.Close()

	response, err := New(server.URL).Charge(
		context.Background(),
		ChargeRequest{
			IdempotencyKey: "order_42",
			CustomerID:     "acc_7",
			AmountCents:    12500,
		},
	)
	if err != nil {
		t.Fatalf("Charge() error = %v", err)
	}

	if gotMethod != http.MethodPost {
		t.Errorf("method = %q, want %q", gotMethod, http.MethodPost)
	}
	if gotPath != "/v1/charges" {
		t.Errorf("path = %q, want %q", gotPath, "/v1/charges")
	}
	if gotType != "application/json" {
		t.Errorf("Content-Type = %q, want %q", gotType, "application/json")
	}
	if gotKey != "order_42" {
		t.Errorf("Idempotency-Key = %q, want %q", gotKey, "order_42")
	}

	wantBody := map[string]any{
		"customer_id":  "acc_7",
		"amount_cents": float64(12500),
	}
	if !reflect.DeepEqual(gotBody, wantBody) {
		t.Errorf("request body = %v, want %v", gotBody, wantBody)
	}

	if response.TransactionID != "pay_42" {
		t.Errorf(
			"Charge() transaction = %q, want %q",
			response.TransactionID,
			"pay_42",
		)
	}
	if response.Status != "captured" {
		t.Errorf(
			"Charge() status = %q, want %q",
			response.Status,
			"captured",
		)
	}
}

Тело запроса разбирается в map[string]any, а не в ChargeRequest. Разбор в ту же структуру, из которой запрос собран, вернул бы её обратно даже при неверных тегах и проверил бы только симметричность кодирования. Карта показывает набор ключей, которые реально ушли в сеть.

Сравнивается она целиком, а не по одному ключу. Так тест одновременно требует правильные имена и значения полей и запрещает лишние: idempotency_key в теле не должно быть, потому что провайдер ждёт его в заголовке. Число в карте приходит как float64, отсюда явное приведение в wantBody. Это особенность разбора в any, а не довод в пользу целых копеек: сам клиент декодирует ответ в int64 и с float64 не встречается вовсе. Целые значения float64 представляет точно до 1<<53, и для суммы в копейках это запас с огромным избытком. Если сравнения с плавающей точкой не хочется в принципе, у декодера есть UseNumber: числа придут как json.Number, и в wantBody встанет строка.

Ответ задан строкой JSON по той же причине. Собрать его через json.Marshal(ChargeResponse{...}) означало бы проверять ChargeResponse против неё самой. Строка приходит из документации провайдера или из записанного ответа sandbox, и тогда тест проверяет разбор чужого формата.

Вторая половина контракта это неуспешные ответы, и начинается она с вопроса, кто вообще распознаёт отказ. Провайдер из четвёртой части отвечает 200 OK и на отклонённый платёж тоже, а исход кладёт в поле status. Значение declined описано в его документации, поэтому переводит его клиент: контрактом внешнего API владеет он, и документированный отказ превращается в ErrDeclined здесь же. Не-2xx становится ошибкой с кодом статуса, а незнакомый статус клиент отдаёт наверх как есть, потому что решать, считать ли authorized успехом, будет адаптер.

func TestClient_ChargeFailures(t *testing.T) {
	tests := []struct {
		name    string
		status  int
		body    string
		wantErr error
	}{
		{
			name:   "declined",
			status: http.StatusOK,
			body: `{"transaction_id":"pay_42",` +
				`"status":"declined","provider_code":"51"}`,
			wantErr: ErrDeclined,
		},
		{
			name:   "server error",
			status: http.StatusInternalServerError,
			body:   `{"message":"upstream is down"}`,
		},
		{
			name:   "broken json",
			status: http.StatusOK,
			body:   `{"transaction_id":`,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			server := httptest.NewServer(http.HandlerFunc(
				func(w http.ResponseWriter, _ *http.Request) {
					w.Header().Set("Content-Type", "application/json")
					w.WriteHeader(tt.status)
					_, _ = w.Write([]byte(tt.body))
				},
			))
			defer server.Close()

			_, err := New(server.URL).Charge(
				context.Background(),
				ChargeRequest{IdempotencyKey: "order_42"},
			)
			if err == nil {
				t.Fatal("Charge() error = nil, want error")
			}
			if tt.wantErr != nil && !errors.Is(err, tt.wantErr) {
				t.Errorf("Charge() error = %v, want %v", err, tt.wantErr)
			}
			if tt.wantErr == nil && errors.Is(err, ErrDeclined) {
				t.Errorf("Charge() error = %v, want a generic failure", err)
			}
		})
	}
}

func TestClient_ChargeKeepsUnknownStatus(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(
		func(w http.ResponseWriter, _ *http.Request) {
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(
				`{"transaction_id":"pay_42","status":"authorized"}`,
			))
		},
	))
	defer server.Close()

	response, err := New(server.URL).Charge(
		context.Background(),
		ChargeRequest{IdempotencyKey: "order_42"},
	)
	if err != nil {
		t.Fatalf("Charge() error = %v", err)
	}
	if response.Status != "authorized" {
		t.Errorf(
			"Charge() status = %q, want %q",
			response.Status,
			"authorized",
		)
	}
}

func TestClient_ChargeKeepsCancellation(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(
		func(http.ResponseWriter, *http.Request) {
			t.Error("payment provider received a canceled request")
		},
	))
	defer server.Close()

	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	_, err := New(server.URL).Charge(
		ctx,
		ChargeRequest{IdempotencyKey: "order_42"},
	)
	if !errors.Is(err, context.Canceled) {
		t.Fatalf("Charge() error = %v, want %v", err, context.Canceled)
	}
}

Последняя проверка в теле смотрит на то, чем ошибка быть не должна. Если 500 или обрыв разбора превращаются в ErrDeclined, адаптер выше переведёт их в order.ErrPaymentDeclined, и временная недоступность провайдера доедет до пользователя как отказ банка. Условие «это ошибка, но не ErrDeclined» и отделяет технический сбой от бизнес-решения.

Тест с authorized фиксирует обратную границу: клиент знает формат ответа и документированный отказ declined, но не решает, что делать с остальными статусами. authorized возвращается без ошибки, а дальше его классифицирует PaymentAdapter.

Для отмены тест передаёт в Charge уже отменённый контекст и дополнительно требует, чтобы запрос не дошёл до провайдера. net/http возвращает *url.Error, внутри которой остаётся context.Canceled. Если клиент добавляет к этой ошибке свой контекст, он должен использовать %w, иначе errors.Is потеряет отмену.

Граница у этих тестов тоже есть: они доказывают, что клиент отправляет и разбирает то, что вы задумали, но не то, что провайдер ждёт именно это. Чем это дополняется, разобрано ниже.

Как не стоит тестировать границу

Каждый из следующих подходов выглядит разумно и проверяет не то, что нужно.

Мок конкретного типа из другого модуля. Сгенерировать мок для *catalog.ProductUseCase нельзя: генераторы делают реализации интерфейсов, а это структура. Подставить что-либо вместо неё можно только после того, как NewCatalogAdapter начнёт принимать интерфейс, и тогда вопрос сводится к тому, какой именно. Интерфейс со всем API catalog даст тесту адаптера зависимость от методов, которых адаптер не вызывает. Локальный productCatalog держит её такой же узкой, как в рабочем коде.

Проверка вызовов ради вызовов. Assert на то, что GetProduct вызван ровно один раз, фиксирует реализацию: добавьте кеш или второй запрос за остатком, и тест упадёт, ничего не сообщив о поведении. В журнал попадают вызовы, которые сами по себе меняют состояние: MarkPaid, Charge и постановка задачи. То, что чтение состоялось и получило правильный аргумент, надёжнее проверять по сохранённому аргументу, а не по счётчику.

Сравнение текста ошибки. strings.Contains(err.Error(), "payment declined") пройдёт и в том случае, если адаптер вернёт чужую ошибку с похожим текстом. Проверять принадлежность нужно через errors.Is, а err.Error() использовать только для проверки, что исходный текст сохранился.

Один тест на весь сценарий. Функция на сто строк с шестью стабами и пятнадцатью проверками падает целиком и не говорит, какая ветка сломалась. Разбиение по веткам сценария или таблица с t.Run дают имя каждому случаю.

Отдельный список ошибок собирается вокруг базы: sqlmock вместо PostgreSQL и непроверенный fake-репозиторий в памяти. Он разобран в шестой части.

Что остаётся вне этих тестов

Изменение контракта провайдера. Тест клиента зафиксировал формат, который вы разобрали сегодня. Если провайдер добавит статус или переименует поле, тест продолжит проходить на записанном ответе. Расхождение ловится только обращением к настоящему API: регулярным прогоном по тестовому контуру провайдера, обновлением записанных ответов вместе с версией API или contract-тестом, если провайдер публикует схему.

И главное: ни один тест этой части не открыл соединение с базой. orderRepositoryStub возвращает то, что ему велели вернуть, поэтому опечатка в имени колонки, потерянный payment_id и UPDATE, не задевший ни одной строки, остаются за пределами всего написанного выше. Туда же попадают откат транзакции и участие постановщика чека в чужой транзакции: transaction.FakeManager не открывает транзакцию, и откатывать ему нечего. Всё это проверяется на настоящем PostgreSQL в шестой части.

Практическая проверка тестов

  1. Тест собирает use case тем же конструктором, что и Composition Root, а у стаба только методы локального интерфейса.
  2. Unit-тест сценария проверяет и то, что произошло, и то, что не должно было произойти, и в каком порядке.
  3. Стабы сохраняют аргументы, иначе тест пройдёт на чужом идентификаторе и потерянном значении.
  4. Принадлежность ошибки проверяется через errors.Is, а не по тексту сообщения.
  5. Use case передаёт зависимостям исходный контекст, а адаптер и HTTP-клиент сохраняют context.Canceled вместо подмены на доменную ошибку.
  6. Тест адаптера фиксирует перевод в обе стороны: команду наружу и результат обратно.
  7. Тест адаптера доказывает, что чужая sentinel-ошибка не проходит через errors.Is, а её текст остаётся в сообщении.
  8. Тест HTTP-клиента разбирает тело запроса независимо от структуры, из которой оно собрано, и проверяет метод, путь и заголовки.
  9. Не-2xx и битый ответ превращаются в ошибку, но не в бизнес-отказ.

Тесты этой части повторяют структуру пакетов: сценарий, адаптеры, клиент. Ценность такого набора в том, что каждый уровень падает по своей причине. Если новый статус платёжного провайдера заставил править тест каталожного адаптера, дело не в тесте: границу между ними что-то пересекает.

Следующая часть проверяет оставшиеся обещания на настоящем PostgreSQL: изоляция схемой на тест, репозиторий, откат транзакции и сквозной тест сценария.

Вернуться к предыдущей части.

Материалы