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

Рекомендации к написанию автотестов

Перед началом следует ознакомиться с Autotests Guide.
Это руководство дополняет его техническими деталями для Go/Goravel: где лежат тесты и фабрики, как устроены компонентные тесты HTTP API, search-эндпоинтов и Kafka-handlers.

Структура сервиса с точки зрения тестов

Компонентные тесты размещаются рядом с модулем, который они проверяют. Корневая директория tests/ содержит только общую инфраструктуру, которую переиспользуют все модули.

.
├── app
│ ├── adapters
│ │ └── db
│ │ ├── offer.go
│ │ └── offer_factory.go
│ └── modules
│ └── offers
│ ├── factories
│ ├── handlers
│ └── tests
│ ├── offers
│ │ ├── main_test.go
│ │ ├── search_test.go
│ │ ├── search_filter_test.go
│ │ └── search_include_test.go
│ └── kafka
│ ├── main_test.go
│ └── handler_test.go
└── tests
├── test_case.go
├── spec_request.go
├── spec_validate.go
├── component.go
├── component_suite.go
└── search_suite.go

Внутри app/modules/{module}/tests/ обычно создают подпапку по ресурсу или хэндлеру (offers, brands, auth). Один файл тестов соответствует одному методу API или одной группе близких сценариев: login_test.go, search_test.go, patch_test.go.

Файлы, которые генерирует OpenAPI (ensi-gog server), создаются один раз. Повторный запуск генератора не перезаписывает уже существующие тесты — их дополняют вручную.

Корневой пакет tests/

Пакет goravel/tests (директория tests/ в корне сервиса) подключается в тестах модуля как apptests "goravel/tests".

Основные элементы:

  • TestCase — встраивается в suite и даёт метод Http, который оборачивает стандартный HTTP-клиент Goravel и добавляет проверку запроса и ответа по OpenAPI (через SpecRequest и kin-openapi).
  • ComponentSuite — расширяет TestCase для сценариев с PostgreSQL: перед каждым тестом очищает таблицы и предоставляет хелпер Create для создания записей через ORM-фабрики.
  • RunComponentTests — вызывается из TestMain пакета тестов, когда нужна изолированная БД на весь пакет (см. ниже).
  • SearchSuite[T] — обёртка для типизированных :search-запросов: POST с валидацией по спеке и разбор ответа в SearchResponse[T].

При старте тестов приложение поднимается через bootstrap.Boot() (см. tests/test_case.go).

TestMain и настройка базы данных

Если тестам нужна PostgreSQL (подготовка данных в таблицах, проверка side effects после HTTP или Kafka), в том Go-пакете, где лежат такие тесты, добавляют файл main_test.go с функцией TestMain:

package tests

import (
"os"
"testing"

apptests "goravel/tests"
)

func TestMain(m *testing.M) {
os.Exit(apptests.RunComponentTests(m))
}

RunComponentTests выполняет одноразовую настройку для всего пакета:

  1. создаёт отдельную временную базу данных;
  2. переключает конфигурацию приложения на неё;
  3. прогоняет миграции;
  4. запускает тесты пакета;
  5. по завершении удаляет временную БД.

Между отдельными тестами внутри пакета ComponentSuite.SetupTest очищает прикладные таблицы, чтобы тесты не влияли друг на друга. Тесты одного пакета с общей БД должны выполняться последовательно (не параллелить внутри пакета без отдельной изоляции).

Если пакет тестов не обращается к БД (достаточно подменить внешние HTTP-клиенты и вызвать эндпоинт), main_test.go и RunComponentTests не нужны. Достаточно suite с встроенным apptests.TestCase и сброса фейков в TearDownTest.

Kafka-handlers, которые пишут в БД, обычно тестируют в отдельном подпакете tests/kafka/ со своим main_test.go, по той же схеме.

Базовый suite для HTTP-компонентных тестов

Типичный тест API оформляют через testify/suite:

type LoginTestSuite struct {
suite.Suite
apptests.TestCase
}

func TestLoginTestSuite(t *testing.T) {
suite.Run(t, new(LoginTestSuite))
}

Для сценариев с БД вместо одного TestCase встраивают ComponentSuite (он уже содержит TestCase):

type PatchTestSuite struct {
apptests.ComponentSuite
}

После каждого теста, где использовались фейковые HTTP-клиенты к другим сервисам Ensi, в TearDownTest сбрасывают состояние фейков, иначе stub одного теста «протечёт» в следующий:

func (s *LoginTestSuite) TearDownTest() {
ensihttpfake.Reset(facades.App().MakeHttp())
}

Если тест использует кэш или другие глобальные singletons, их тоже очищают в TearDownTest.

Вызов эндпоинта и проверка по OpenAPI

Вызов тестируемого эндпоинта делается через метод Http текущего suite. Метод принимает *testing.T (или s.T() внутри suite) и возвращает обёртку над HTTP-клиентом Goravel с валидацией по спецификации из resources/openapi/.

Примеры:

resp, err := s.Http(s.T()).Get("/api/v1/catalog/brands/10")
resp, err := s.Http(s.T()).Post(
"/api/v1/auth/login",
factories.LoginRequest().WithLogin(login).WithPassword(password).MustMakeReader(),
)
resp, err := s.Http(s.T()).PostJSON("/api/v1/offers/offers:search", map[string]any{
"sort": []string{"-id"},
})
resp, err := s.Http(s.T()).PatchJSON(
fmt.Sprintf("/api/v1/elastic/indexer-timestamps/%d", id),
dto.PatchIndexerTimestampRequest{LastSchedule: &updated},
)

После вызова проверяют, что ошибки транспорта нет (s.Require().NoError(err)), затем статус и тело:

resp.AssertStatus(200)
resp.AssertOk()
resp.AssertJson(map[string]interface{}{
"data": map[string]interface{}{
"access_token": accessToken,
},
})

Для разбора ответа в структуру используют resp.Bind(&result).

По умолчанию и запрос, и ответ сверяются с OpenAPI. Если нужно намеренно передать невалидное тело (для проверки 400 ValidationError), перед POST включают отключение проверки запроса:

resp, err := s.Http(s.T()).
SkipRequestValidation().
Post("/api/v1/auth/login", strings.NewReader(`{}`))

Генератор OpenAPI создаёт заготовки TestStatus_{code} для кодов ответа из спеки в диапазоне 1xx–4xx (5xx обычно не генерируются). Эти тесты — минимальный каркас. Их нужно дополнять кейсами на реальную бизнес-логику: маппинг полей, filter/sort/include, пустые результаты, права доступа, проксирование ошибок апстрима, обогащение ответа несколькими клиентами и т.д.

Подмена внешних HTTP-клиентов

В компонентных тестах сервис не должен ходить в другие микросервисы по сети. Для этого используются сгенерированные клиенты Ensi (*-client-go) и пакет goravel-ensi-http-client/fake.

В стандартном Goravel HTTP-тестировании обычно один вызов Http().Fake(map[string]any{...}), который задаёт карту URL → ответ. В Ensi-клиентах fake устроен иначе: у каждого клиента есть метод Fake(), который возвращает fluent-объект для конкретных методов API (SearchOneBrands, CreateToken, SearchOrders и т.д.). Это позволяет:

  • регистрировать несколько stub-ов подряд (несколько методов одного клиента или несколько клиентов в одном тесте);
  • собирать ответы через типизированные response-фабрики;
  • проверять исходящие запросы через Assert....

Stub-ы накапливаются в registry и применяются к HTTP factory приложения только после Bind(). Если описали ответ, но забыли вызвать Bind(), тест пойдёт в сеть или упадёт на «stray request» — stub просто не подключится.

Пример мока ответа внешнего сервиса:

func (s *GetTestSuite) TestStatus_200() {
fake := pimclient.Fake()
fake.SearchOneBrands(
pimclient.NewBrandResponseFactory().
WithData(
pimclient.NewBrandFactory().
WithId(10).
WithCode("sony").
WithName("Sony"),
),
).Bind() // обязательно: без Bind stub не активен

resp, err := s.Http(s.T()).Get("/api/v1/catalog/brands/10")
s.Require().NoError(err)
resp.AssertStatus(200)
resp.AssertJson(map[string]interface{}{
"data": map[string]interface{}{
"id": float64(10),
"name": "Sony",
"code": "sony",
},
})

s.Require().True(fake.AssertSearchOneBrands(func(request client.Request) bool {
var body map[string]any
if json.Unmarshal([]byte(request.Body()), &body) != nil {
return false
}
filter, _ := body["filter"].(map[string]any)
return filter["id"] == float64(10)
}))
}

В одном тесте часто нужно несколько апстримов. Для каждого клиента можно вызвать Fake() столько раз, сколько нужно, описать методы и в конце вызвать Bind() (отдельно на каждый клиент или после цепочки — как удобнее в конкретном API fake-объекта):

omsFake := omsclient.Fake()
omsFake.SearchOrders(/* response factory or map */).Bind()

catalogcacheclient.Fake().SearchElasticOffers(/* ... */).Bind()
crmclient.Fake().SearchCustomerFavorite(/* ... */).Bind()

После каждого теста с фейками вызывают ensihttpfake.Reset(facades.App().MakeHttp()) в TearDownTest, чтобы registry не перешёл в следующий кейс.

Подготовка данных в PostgreSQL

Когда тест проверяет чтение или изменение локальной БД, suite встраивает ComponentSuite, а пакет — TestMain с RunComponentTests.

Записи создают через хелпер Create, который внутри вызывает ORM-фабрику модели:

var offer db.Offer
s.Create(&offer)

var offer db.Offer
s.Create(&offer, map[string]any{
"ProductID": product.ProductID,
"IsActive": false,
})

Фабрики моделей лежат рядом с моделью в app/adapters/db/*_factory.go. У каждой есть метод Definition() с набором полей по умолчанию и опциональные With* для переопределения отдельных атрибутов в тесте.

После HTTP-запроса часто дополнительно читают строку из БД и сравнивают с ожиданием:

var stored db.IndexerTimestamp
s.Require().NoError(facades.Orm().Query().FindOrFail(&stored, timestamp.ID))
s.Equal(updated.Truncate(time.Microsecond), stored.LastSchedule.StdTime().UTC())

Для POST :search удобно использовать SearchSuite[T]: в suite задают SearchPath, метод Search(payload) отправляет JSON, проверяет OpenAPI и возвращает типизированный SearchResponse[T].

type offerSearchSuite = apptests.SearchSuite[dto.Offer]

func newOffersSearch(t *testing.T) *offerSearchSuite {
s := &offerSearchSuite{SearchPath: "/api/v1/offers/offers:search"}
s.SetT(t)
s.SetupTest()
return s
}

На один ресурс обычно выделяют несколько файлов:

  • search_test.go — базовый успешный сценарий, сортировка по умолчанию;
  • search_filter_test.go — каждый публичный filter (в том числе границы дат);
  • search_sort_test.go — каждое поле сортировки;
  • search_include_test.go — каждый include и осмысленные комбинации.

Table-driven тесты

Для однотипных проверок (каждый filter, каждый sort, матрица «поле × значение») удобен table-driven стиль: таблица кейсов и один цикл for _, tc := range cases. Так проще добавлять новые поля без копирования boilerplate.

Пример для search filter (упрощённо):

func TestOffersSearchFilters(t *testing.T) {
for _, tc := range []struct {
name string
attr string
attrValue any
filterKey string
filterVal any
}{
{"offer_id", "OfferID", uint64(123), "offer_id", uint64(123)},
{"is_active/true", "IsActive", true, "is_active", true},
{"is_active/false", "IsActive", false, "is_active", false},
} {
t.Run(tc.name, func(t *testing.T) {
s := newOffersSearch(t)

var offer db.Offer
s.Create(&offer, map[string]any{tc.attr: tc.attrValue})

response := s.Search(map[string]any{
"filter": map[string]any{tc.filterKey: tc.filterVal},
})
s.Require().Len(response.Data, 1)
s.Equal(offer.ID, response.Data[0].Id)
})
}
}

Имя подтеста (t.Run(tc.name, ...)) помогает быстро найти упавший filter в выводе go test.

Фабрики

Тела запросов к своему API

Для POST/PATCH/PUT к своему API используют фабрики в app/modules/{module}/factories/ (генерируются из OpenAPI). Каркас можно править: Definition() и With*.

Фабрики клиентов и DTO

У сгенерированных клиентов и response/request DTO те же builder-ы на базе factory.Builder[T]. Для получения значения есть несколько методов — выбирайте по контексту:

МетодКогда использовать
Make()нужен (T, error) — обработка ошибки decode
MustMake()в тестах, когда ошибка сборки = падение теста
MakeMap() / MustMakeMap()нужен map[string]any с JSON-ключами
MakeJSON() / MustMakeJSON()JSON в виде []byte
MakeReader() / MustMakeReader()io.Reader для Post(..., reader) в HTTP-тесте
Count(n)n независимых экземпляров с одним набором With*

Пример тела запроса в HTTP-тесте:

factories.LoginRequest().
WithLogin("user").
WithPassword("secret").
MustMakeReader()

Вложенные фабрики

Builder поддерживает вложенность: значение поля может быть другой фабрикой, а не готовым объектом. При финальном Make* вложенные builder-ы разрешаются автоматически через MakeAttribute().

Поэтому в WithData, WithItems и похожих методах передают фабрику без предварительного MustMake() / Make():

// верно: вложенная фабрика разрешится при сборке ответа
pimclient.NewBrandResponseFactory().
WithData(
pimclient.NewBrandFactory().
WithId(10).
WithName("Sony"),
)

// для слайса сущностей — элементы могут быть как MustMake(), так и фабрикой,
// если метод принимает []any и фабрики внутри map/response builder
omsclient.NewSearchOrdersResponseFactory().WithData([]any{
omsclient.NewOrderFactory().WithId(1).WithItems(&items),
})

Если вызвать MustMake() слишком рано и передать уже готовую структуру туда, где ожидается вложенный builder, можно потерять ленивое переопределение полей или получить лишний шум в тесте. Правило: фабрику в фабрику — без Make*, Make* — на границе (тело HTTP-запроса, локальная переменная, аргумент, который API клиента не разрешает сам).

Тесты Kafka-handlers

Обработчики сообщений Kafka тестируют как компоненты модуля: поднимают БД (TestMain + ComponentSuite), создают handler в SetupTest, вызывают Handle с синтетическим appkafka.Message (или внутренним хелпером пакета тестов), затем проверяют строки в таблицах и побочные эффекты (например обновление updated_at связанных сущностей).

Имеет смысл покрыть отдельными тестами:

  • создание записи по событию create;
  • update с «интересными» dirty fields и без них (игнорирование шума);
  • delete и каскадные побочные эффекты.

Unit-тесты инфраструктуры

Логику адаптеров (app/adapters/kafka, queue, logging и т.д.) можно тестировать unit-тестами в том же Go-пакете (*_test.go рядом с кодом). Такие тесты не обязаны поднимать HTTP-сервер и OpenAPI; они проверяют чистые функции, конфигурацию, registry, формат сообщений.

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

Go-команды в проектах Ensi выполняют через elc, переменные окружения для тестов — из .env.testing:

elc go test ./... --env=.env.testing
elc go test ./app/modules/offers/tests/offers --env=.env.testing -count=1

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