В этой статье мы рассмотрим практический пример написания API-автотестов на Go с использованием Axiom, Allure, Testify и Resty. Цель — показать, как может выглядеть тестовый проект, построенный с учётом лучших практик и ориентированный не на инфраструктуру, а на проверку бизнес-логики.
В результате у нас получатся «скучные» автотесты. Скучные — потому что чистые, предсказуемые и легко читаемые. В самих тестах не будет логики инициализации клиентов, конфигураций, логирования или интеграций с внешними системами. Всё это мы вынесем за пределы тестовых сценариев и настроим один раз на уровне тестовой платформы. Отдельно мы также настроим запуск тестов в CI/CD с использованием GitHub Actions и генерацией Allure-отчётов.
Ключевым элементом решения будет Axiom — execution engine для Go-тестов, который расширяет стандартный пакет testing, не заменяя его и не вводя DSL. Axiom позволяет описывать фикстуры, управлять жизненным циклом тестов, повторными запусками, параллелизмом, метаданными и плагинами, сохраняя при этом привычную модель go test. Подробнее о том, что такое Axiom и какую задачу он решает, можно прочитать в документации проекта.
В качестве тестового API мы будем использовать сервис https://dummyjson.com — публичный HTTP-API с фиктивными данными, предназначенный для обучения и тестирования. Он предоставляет набор REST-эндпоинтов и поддерживает CRUD-операции над различными сущностями. В рамках статьи мы будем работать с сущностями users и products, взаимодействуя с ними по HTTP-протоколу.
Клиенты
Прежде чем переходить к написанию автотестов, необходимо подготовить базовый слой взаимодействия с API. Речь идёт не о тестах, а о транспортном уровне, который будет отвечать за HTTP-взаимодействие, логирование запросов и ответов, обработку ошибок и интеграцию с тестовой платформой.
Важно сразу отделить этот слой от самих тестов. Тесты не должны знать, каким HTTP-клиентом мы пользуемся, как настраиваются таймауты или где именно происходит логирование. Их задача — работать с бизнес-операциями и проверять результат.
Начнём с базового HTTP-клиента, поверх которого позже будут построены клиенты для конкретных сущностей.
Конфигурация HTTP-клиента
Конфигурация клиента выносится в отдельную структуру и загружается извне. Это позволяет легко переопределять параметры для разных окружений без изменения кода.
package http
import (
"time"
)
// ClientConfig описывает параметры HTTP-клиента.
// Используется для инициализации resty.Client и может
// загружаться из YAML-конфигурации или переменных окружения.
type ClientConfig struct {
// URL — базовый URL API
URL string `yaml:"url"`
// Timeout — таймаут HTTP-запросов
Timeout time.Duration `yaml:"timeout"`
}
Логирование HTTP-запросов
Для логирования используется стандартный log/slog. Resty ожидает логгер с определённым интерфейсом, поэтому добавляется небольшой адаптер.
package http
import (
"fmt"
"log/slog"
)
// ClientLogger — адаптер slog.Logger под интерфейс Resty.
type ClientLogger struct {
l *slog.Logger
}
// NewClientLogger создаёт HTTP-логгер.
func NewClientLogger(l *slog.Logger) *ClientLogger {
return &ClientLogger{l: l}
}
func (s *ClientLogger) Errorf(format string, args ...any) {
s.l.Error("http", slog.String("msg", fmt.Sprintf(format, args...)))
}
func (s *ClientLogger) Warnf(format string, args ...any) {
s.l.Warn("http", slog.String("msg", fmt.Sprintf(format, args...)))
}
func (s *ClientLogger) Debugf(format string, args ...any) {
s.l.Debug("http", slog.String("msg", fmt.Sprintf(format, args...)))
}
Такой подход позволяет централизованно управлять логированием HTTP-вызовов и при необходимости легко изменить формат, уровень логов или способ их обработки, не затрагивая тесты.
HTTP-хуки и интеграция с тестовой платформой
Базовый HTTP-клиент сам по себе решает только транспортную задачу. Однако для автотестов этого недостаточно. Нам важно понимать, что именно было отправлено, что вернул сервер, и иметь эту информацию не в логах, а в структурированном виде — доступном для отчётов и отладки.
Resty предоставляет хуки на различных этапах выполнения запроса, и мы используем их как точки интеграции с execution engine. Через эти хуки HTTP-взаимодей��твие становится частью тестового сценария, а не просто побочным эффектом.
package http
import (
"fmt"
"github.com/Nikita-Filonov/axiom"
"github.com/go-resty/resty/v2"
)
// onErrorHook вызывается при ошибке выполнения HTTP-запроса.
// Используется для фиксации ошибки, сохранения артефактов
// и логирования в рамках текущего тестового шага.
func onErrorHook(cfg *axiom.Config) func(req *resty.Request, err error) {
return func(req *resty.Request, err error) {
cfg.Step(fmt.Sprintf("Request error for %s %s", req.Method, req.URL), func() {
if len(req.Header) > 0 {
artefact, err := axiom.NewJSONArtefact("Request Headers", req.Header)
if err == nil {
cfg.Artefact(artefact)
}
}
if req.Body != nil {
artefact, err := axiom.NewJSONArtefact("Request Body", req.Body)
if err == nil {
cfg.Artefact(artefact)
}
}
cfg.Artefact(
axiom.NewTextArtefact("Error", err.Error()),
)
cfg.Log(
axiom.NewErrorLog(
fmt.Sprintf("request failed: %s %s", req.Method, req.URL),
),
)
})
}
}
...Этот хук срабатывает только в случае ошибки. Он оформляет ошибку как отдельный шаг теста, сохраняет заголовки и тело запроса в виде artefacts и добавляет структурированный лог. Благодаря этому информация об ошибке автоматически попадает в Allure-отчёт и остаётся связанной с конкретным тестом и шагом.
...
// onBeforeRequestHook вызывается перед отправкой HTTP-запроса.
// Используется для фиксации параметров запроса в отчёте.
func onBeforeRequestHook(cfg *axiom.Config) func(_ *resty.Client, req *resty.Request) error {
return func(_ *resty.Client, req *resty.Request) error {
cfg.Step(fmt.Sprintf("Send %s request to %s", req.Method, req.URL), func() {
if len(req.Header) > 0 {
artefact, err := axiom.NewJSONArtefact("Request Headers", req.Header)
if err == nil {
cfg.Artefact(artefact)
}
}
if req.Body != nil {
artefact, err := axiom.NewJSONArtefact("Request Body", req.Body)
if err == nil {
cfg.Artefact(artefact)
}
}
})
return nil
}
}
...Этот хук делает каждый HTTP-запрос явным шагом теста. В отчётах становится видно, какой запрос был отправлен, с какими заголовками и телом, даже если сам тест не содержит ни одной строки, связанной с HTTP.
...
// onAfterResponseHook вызывается после получения HTTP-ответа.
// Используется для сохранения статуса и тела ответа.
func onAfterResponseHook(cfg *axiom.Config) func(_ *resty.Client, resp *resty.Response) error {
return func(_ *resty.Client, resp *resty.Response) error {
cfg.Step(fmt.Sprintf("Response: %s %s", resp.Request.Method, resp.Request.URL), func() {
cfg.Artefact(
axiom.NewTextArtefact("Response Status", resp.Status()),
)
cfg.Artefact(
axiom.NewTextArtefact("Response Body", string(resp.Body())),
)
})
return nil
}
}После получения ответа мы сохраняем статус и тело ответа как артефакты. Эти данные автоматически прикрепляются к отчёту и не требуют ручного логирования или отладочного кода в тестах.
Таким образом, весь HTTP-трафик становится частью execution-модели теста. Тесты при этом остаются лаконичными и не знают ничего о логировании, артефактах или формате отчётов. Эта логика полностью сосредоточена в одном месте — в HTTP-клиенте и его хуках.
Базовый HTTP-клиент
В качестве HTTP-клиента используется библиотека Resty. Она хорошо подходит для API-тестов, так как предоставляет удобный API, поддержку middleware, хуков и встроенное логирование.
package http
import (
"context"
"github.com/Nikita-Filonov/axiom"
"github.com/go-resty/resty/v2"
)
// Client — тонкая обёртка над resty.Client.
// Она инкапсулирует настройки клиента и точки интеграции
// с тестовой платформой (Axiom).
type Client struct {
client *resty.Client
}
// GetHTTPRequest описывает параметры GET-запроса.
type GetHTTPRequest struct {
URL string
Headers map[string]string
}
// PostHTTPRequest описывает параметры POST-запроса.
type PostHTTPRequest struct {
URL string
Body any
Headers map[string]string
}
// NewClient создаёт и настраивает HTTP-клиент.
// Здесь происходит вся инфраструктурная настройка:
// логирование, таймауты, базовый URL и хуки.
func NewClient(cfg ClientConfig, log *ClientLogger, cfgTest *axiom.Config) *Client {
client := resty.New().
SetLogger(log).
SetBaseURL(cfg.URL).
SetTimeout(cfg.Timeout).
OnError(onErrorHook(cfgTest)).
OnBeforeRequest(onBeforeRequestHook(cfgTest)).
OnAfterResponse(onAfterResponseHook(cfgTest))
return &Client{client: client}
}
// Get выполняет GET-запрос.
func (c *Client) Get(ctx context.Context, req GetHTTPRequest) (*resty.Response, error) {
return c.client.R().
SetContext(ctx).
SetHeaders(req.Headers).
Get(req.URL)
}
// Post выполняет POST-запрос.
func (c *Client) Post(ctx context.Context, req PostHTTPRequest) (*resty.Response, error) {
return c.client.R().
SetContext(ctx).
SetHeaders(req.Headers).
SetBody(req.Body).
Post(req.URL)
}Здесь важно обратить внимание на несколько моментов.
Во-первых, HTTP-клиент не содержит тестовой логики и ассертов. Он работает с context.Context, выполняет HTTP-вызовы и возвращает *resty.Response, не принимая решений о корректности результата. Это позволяет использовать его как в автотестах, так и в других сценариях при необходимости.
При этом в клиент передаётся axiom.Config, который представляет собой тестовый execution-контекст. Он используется исключительно для интеграции с тестовой инфраструктурой: формирования шагов, логов и артефактов, но не связывает клиент с конкретными тестами или testing.T.
Во-вторых, точки расширения (OnBeforeRequest, OnAfterResponse, OnError) изначально интегрированы с Axiom. Через них HTTP-взаимодействие становится частью execution-модели теста: запросы и ответы логируются как шаги, данные сохраняются в виде artefacts для Allure, а отчёты обогащаются технической информацией без необходимости дублировать эту логику в каждом тестовом сценарии.
Клиенты доменных сущностей
Когда базовый HTTP-клиент готов, следующий шаг — перейти от транспортного уровня к доменной модели API. На этом уровне нас уже не интересуют URL, HTTP-методы и детали сериализации. Мы хотим работать с операциями и сущностями предметной области.
Для этого поверх базового HTTP-клиента создаются клиенты для конкретных сущностей — в нашем случае users и products. Каждый такой клиент инкапсулирует:
контракт API (эндпоинты),
модели запросов и ответов,
базовую обработку ошибок.
Структура моделей и схем полностью повторяет API сервиса https://dummyjson.com. Это публичный сервис с фиктивными данными, который часто используют для обучения и тестирования. Его документацию можно посмотреть непосредственно на сайте, там же описаны все доступные эндпоинты и форматы данных.
Клиент для сущности users
Модели данных
Модели описывают JSON-контракты API и используются как для сериализации запросов, так и для десериализации ответов.
package users
// User описывает пользователя, возвращаемого API.
type User struct {
ID int `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
LastName string `json:"lastName"`
FirstName string `json:"firstName"`
}
// GetUsersResponse описывает ответ API при получении списка пользователей.
type GetUsersResponse struct {
Skip int `json:"skip"`
Limit int `json:"limit"`
Total int `json:"total"`
Users []User `json:"users"`
}
// CreateUserRequest описывает тело запроса для создания пользователя.
type CreateUserRequest struct {
Email string `json:"email"`
Username string `json:"username"`
LastName string `json:"lastName"`
FirstName string `json:"firstName"`
}Модели намеренно остаются простыми. В них нет бизнес-логики или валидации — это чистое отражение API-контракта.
Реализация клиента
Клиент для сущности users использует базовый HTTP-клиент и предоставляет два уровня методов:
низкоуровневые методы, возвращающие
*resty.Response,высокоуровневые методы, которые возвращают уже готовые структуры.
package users
import (
"context"
"encoding/json"
"fmt"
"go-api-tests/http"
"github.com/Nikita-Filonov/axiom"
"github.com/go-resty/resty/v2"
)
// Client инкапсулирует работу с users API.
type Client struct {
http *http.Client
}
// NewClient создаёт клиент users,
// используя общий HTTP-клиент и тестовый контекст.
func NewClient(cfg http.ClientConfig, log *http.ClientLogger, cfgTest *axiom.Config) *Client {
return &Client{
http: http.NewClient(cfg, log, cfgTest),
}
}
// GetUserAPI выполняет низкоуровневый запрос получения пользователя по id.
func (c *Client) GetUserAPI(ctx context.Context, id int) (*resty.Response, error) {
return c.http.Get(ctx, http.GetHTTPRequest{
URL: fmt.Sprintf("/users/%d", id),
})
}
// GetUsersAPI выполняет низкоуровневый запрос получения списка пользователей.
func (c *Client) GetUsersAPI(ctx context.Context) (*resty.Response, error) {
return c.http.Get(ctx, http.GetHTTPRequest{
URL: "/users",
})
}
// CreateUserAPI выполняет низкоуровневый запрос создания пользователя.
func (c *Client) CreateUserAPI(ctx context.Context, req CreateUserRequest) (*resty.Response, error) {
return c.http.Post(ctx, http.PostHTTPRequest{
URL: "/users/add",
Body: req,
})
}
...Низкоуровневые методы полезны в тех случаях, когда тесту важно работать со статусами или заголовками напрямую.
...
// GetUser возвращает пользователя в виде доменной структуры.
func (c *Client) GetUser(ctx context.Context, id int) (*User, *resty.Response, error) {
resp, err := c.GetUserAPI(ctx, id)
if err != nil {
return nil, resp, err
}
if resp.IsError() {
return nil, resp, fmt.Errorf("unexpected status: %d", resp.StatusCode())
}
var result User
err = json.Unmarshal(resp.Body(), &result)
return &result, resp, err
}
// GetUsers возвращает список пользователей.
func (c *Client) GetUsers(ctx context.Context) (*GetUsersResponse, *resty.Response, error) {
resp, err := c.GetUsersAPI(ctx)
if err != nil {
return nil, resp, err
}
if resp.IsError() {
return nil, resp, fmt.Errorf("unexpected status: %d", resp.StatusCode())
}
var result GetUsersResponse
err = json.Unmarshal(resp.Body(), &result)
return &result, resp, err
}
// CreateUser создаёт пользователя и возвращает результат.
func (c *Client) CreateUser(ctx context.Context, req CreateUserRequest) (*User, *resty.Response, error) {
resp, err := c.CreateUserAPI(ctx, req)
if err != nil {
return nil, resp, err
}
if resp.IsError() {
return nil, resp, fmt.Errorf("unexpected status: %d", resp.StatusCode())
}
var result User
err = json.Unmarshal(resp.Body(), &result)
return &result, resp, err
}На этом уровне происходит минимальная обработка ошибок и десериализация ответа. Все проверки бизнес-логики остаются в тестах.
Клиент для сущности products
Клиент для products полностью повторяет архитектуру клиента users. Это сделано намеренно — единый шаблон упрощает поддержку и масштабирование тестов.
Модели данных
package products
// Product описывает товар, возвращаемый API.
type Product struct {
ID int `json:"id"`
Title string `json:"title"`
Price float64 `json:"price"`
Stock int `json:"stock"`
Category string `json:"category"`
Description string `json:"description"`
}
// GetProductsResponse описывает ответ API при получении списка товаров.
type GetProductsResponse struct {
Skip int `json:"skip"`
Limit int `json:"limit"`
Total int `json:"total"`
Products []Product `json:"products"`
}
// CreateProductRequest описывает тело запроса для создания товара.
type CreateProductRequest struct {
Title string `json:"title"`
Price float64 `json:"price"`
Stock int `json:"stock"`
Category string `json:"category"`
Description string `json:"description"`
}Реализация клиента
package products
import (
"context"
"encoding/json"
"fmt"
"go-api-tests/http"
"github.com/Nikita-Filonov/axiom"
"github.com/go-resty/resty/v2"
)
// Client инкапсулирует работу с products API.
type Client struct {
http *http.Client
}
// NewClient создаёт клиент products.
func NewClient(cfg http.ClientConfig, log *http.ClientLogger, cfgTest *axiom.Config) *Client {
return &Client{http: http.NewClient(cfg, log, cfgTest)}
}
func (c *Client) GetProductAPI(ctx context.Context, id int) (*resty.Response, error) {
return c.http.Get(ctx, http.GetHTTPRequest{URL: fmt.Sprintf("/products/%d", id)})
}
func (c *Client) GetProductsAPI(ctx context.Context) (*resty.Response, error) {
return c.http.Get(ctx, http.GetHTTPRequest{URL: "/products"})
}
func (c *Client) CreateProductAPI(ctx context.Context, req CreateProductRequest) (*resty.Response, error) {
return c.http.Post(ctx, http.PostHTTPRequest{URL: "/products/add", Body: req})
}
func (c *Client) GetProduct(ctx context.Context, id int) (*Product, *resty.Response, error) {
resp, err := c.GetProductAPI(ctx, id)
if err != nil {
return nil, resp, err
}
if resp.IsError() {
return nil, resp, fmt.Errorf("unexpected status: %d", resp.StatusCode())
}
var result Product
err = json.Unmarshal(resp.Body(), &result)
return &result, resp, err
}
func (c *Client) GetProducts(ctx context.Context) (*GetProductsResponse, *resty.Response, error) {
resp, err := c.GetProductsAPI(ctx)
if err != nil {
return nil, resp, err
}
if resp.IsError() {
return nil, resp, fmt.Errorf("unexpected status: %d", resp.StatusCode())
}
var result GetProductsResponse
err = json.Unmarshal(resp.Body(), &result)
return &result, resp, err
}
func (c *Client) CreateProduct(ctx context.Context, req CreateProductRequest) (*Product, *resty.Response, error) {
resp, err := c.CreateProductAPI(ctx, req)
if err != nil {
return nil, resp, err
}
if resp.IsError() {
return nil, resp, fmt.Errorf("unexpected status: %d", resp.StatusCode())
}
var result Product
err = json.Unmarshal(resp.Body(), &result)
return &result, resp, err
}Таким образом, на этом этапе у нас сформирован чёткий слой доменных клиентов. Тесты будут работать уже с ними, не зная ничего о HTTP, сериализации или логировании. Это позволяет писать тесты, которые выглядят как сценарии работы с системой, а не как набор сетевых вызовов.
Фикстуры
На этом этапе нам необходимо подготовить слой фикстур. Он будет отвечать за инициализацию конфигурации, логгера и API-клиентов, а также за управление их жизненным циклом в рамках тестов.
Важно сразу отметить, что в Go нет встроенного понятия фикстур. Стандартный пакет testing сознательно не предоставляет механизма для декларативного описания зависимостей теста и управления их жизненным циклом. В результате в реальных проектах часто появляются самодельные решения: глобальные переменные, хелперы и DI-контейнеры вроде go.uber.org/dig.
Такие подходы имеют ряд системных проблем. Они создают жёсткие связи между модулями, плохо масштабируются, не предоставляют явного механизма очистки ресурсов и, как правило, приводят к дублированию кода. При этом сами тесты всё равно начинают зависеть от деталей инфраструктуры.
Фикстуры решают эту проблему, вводя отдельный слой, в котором живёт логика подготовки и инициализации тестового окружения. Благодаря Axiom этот слой появляется и в Go: фикстуры описываются декларативно, инициализируются лениво и автоматически привязываются к жизненному циклу теста.
Фикстура конфигурации
Начнём с фикстуры конфигурации. Она отвечает за загрузку параметров приложения и предоставляет их всем остальным фикстурам и тестам.
package fixtures
import (
"os"
"time"
"go-api-tests/http"
"github.com/Nikita-Filonov/axiom"
"gopkg.in/yaml.v3"
)
// Config описывает конфигурацию тестового окружения.
type Config struct {
HTTP http.ClientConfig `yaml:"http"`
}
// SetConfigFixture инициализирует конфигурацию.
// По умолчанию используются значения по умолчанию,
// которые могут быть переопределены через config.yaml.
func SetConfigFixture(_ *axiom.Config) (any, func(), error) {
out := &Config{
HTTP: http.ClientConfig{
URL: "https://dummyjson.com",
Timeout: 10 * time.Second,
},
}
data, err := os.ReadFile("config.yaml")
if err != nil {
return out, nil, nil
}
if err = yaml.Unmarshal(data, out); err != nil {
return nil, nil, err
}
return out, nil, nil
}
// GetConfigFixture возвращает конфигурацию из контекста теста.
func GetConfigFixture(cfg *axiom.Config) *Config {
return axiom.GetFixture[*Config](cfg, "config")
}Фикстура инициализируется только один раз на тест и кэшируется в рамках его выполнения. При необходимости здесь же можно добавить очистку ресурсов или дополнительную логику.
Фикстура логгера
Логгер также инициализируется через фикстуру. Это позволяет централизованно управлять логированием и при необходимости переиспользовать один и тот же логгер во всех клиентах.
package fixtures
import (
"log/slog"
"os"
"github.com/Nikita-Filonov/axiom"
)
// SetLoggerFixture создаёт slog-логгер для тестов.
func SetLoggerFixture(_ *axiom.Config) (any, func(), error) {
logger := slog.New(
slog.NewTextHandler(
os.Stdout,
&slog.HandlerOptions{Level: slog.LevelInfo},
),
)
return logger, nil, nil
}
// GetLoggerFixture возвращает логгер из контекста теста.
func GetLoggerFixture(cfg *axiom.Config) *slog.Logger {
return axiom.GetFixture[*slog.Logger](cfg, "logger")
}Логгер становится общей зависимостью, которую можно использовать как в HTTP-клиентах, так и в других частях тестовой платформы.
Фикстуры клиентов
Теперь, когда конфигурация и логгер готовы, можно объявить фикстуры для клиентов доменных сущностей. Эти фикстуры собирают все зависимости в одном месте и возвращают полностью готовый к использованию клиент.
package fixtures
import (
"go-api-tests/clients/users"
"go-api-tests/http"
"github.com/Nikita-Filonov/axiom"
)
// SetUsersClientFixture инициализирует клиент для users API.
func SetUsersClientFixture(cfg *axiom.Config) (any, func(), error) {
config := GetConfigFixture(cfg)
logger := GetLoggerFixture(cfg)
client := users.NewClient(
config.HTTP,
http.NewClientLogger(logger),
cfg,
)
return client, nil, nil
}
// GetUsersClientFixture возвращает users-клиент из контекста теста.
func GetUsersClientFixture(cfg *axiom.Config) *users.Client {
return axiom.GetFixture[*users.Client](cfg, "users")
}Аналогичным образом описывается фикстура для клиента products.
package fixtures
import (
"go-api-tests/clients/products"
"go-api-tests/http"
"github.com/Nikita-Filonov/axiom"
)
// SetProductsClientFixture инициализирует клиент для products API.
func SetProductsClientFixture(cfg *axiom.Config) (any, func(), error) {
config := GetConfigFixture(cfg)
logger := GetLoggerFixture(cfg)
client := products.NewClient(
config.HTTP,
http.NewClientLogger(logger),
cfg,
)
return client, nil, nil
}
// GetProductsClientFixture возвращает products-клиент из контекста теста.
func GetProductsClientFixture(cfg *axiom.Config) *products.Client {
return axiom.GetFixture[*products.Client](cfg, "products")
}В результате фикстуры образуют отдельный, хорошо изолированный слой. Тесты не занимаются инициализацией зависимостей и не знают, как именно создаются клиенты. Они просто запрашивают нужную фикстуру и работают с уже готовыми объектами.
Хуки и подготовка окружения для Allure
Помимо фикстур и плагинов, Axiom позволяет подключать хуки жизненного цикла выполнения тестов. Хуки используются для подготовки окружения, инициализации внешних инструментов и выполнения действий, которые должны происходить до или после запуска тестов.
В нашем случае нам нужно корректно подготовить окружение для генерации Allure-отчётов. Allure использует переменную окружения ALLURE_RESULTS_PATH, чтобы определить, куда сохранять результаты выполнения тестов. В локальном запуске или в CI эта переменная может быть не задана.
Для этого добавим простой хук, который будет выполняться один раз перед запуском всех тестов.
package hooks
import (
"os"
"github.com/Nikita-Filonov/axiom"
)
// AllureBeforeAllHook выполняется перед запуском всех тестов.
// Он гарантирует, что переменная окружения ALLURE_RESULTS_PATH
// установлена и Allure сможет сохранить результаты выполнения.
func AllureBeforeAllHook(_ *axiom.Runner) {
if _, ok := os.LookupEnv("ALLURE_RESULTS_PATH"); !ok {
_ = os.Setenv("ALLURE_RESULTS_PATH", ".")
}
}Этот хук подключается на уровне базового раннера и выполняется автоматически. Тесты при этом не знают ничего о переменных окружения и не содержат кода, связанного с Allure. Вся логика подготовки окружения остаётся централизованной и изолированной в одном месте.
Такой подход хорошо масштабируется: при необходимости можно добавить дополнительные хуки для инициализации тестовых данных, настройки окружений или интеграции с другими инструментами, не затрагивая код тестов.
Раннеры
На этом этапе мы собираем тестовую платформу целиком. Если фикстуры отвечают за подготовку зависимостей, то Runner в Axiom отвечает за всё, что связано с выполнением тестов: жизненный цикл, метаданные, плагины, повторные запуски, параллелизм и интеграцию с внешними системами.
Ключевая идея заключается в том, что вся инфраструктура настраивается один раз на уровне раннера. Тесты при этом остаются максимально простыми и не содержат кода, связанного с логированием, отчётами, фильтрацией или повторными запусками.
Базовый раннер
Начнём с базового раннера, который будет использоваться всеми тестами в проекте. Он описывает общее поведение тестовой платформы и подключает все необходимые фикстуры и плагины.
package tests
import (
"time"
"go-api-tests/fixtures"
"go-api-tests/hooks"
"github.com/Nikita-Filonov/axiom"
"github.com/Nikita-Filonov/axiom/plugins/testallure"
"github.com/Nikita-Filonov/axiom/plugins/testlogger"
"github.com/Nikita-Filonov/axiom/plugins/teststats"
"github.com/Nikita-Filonov/axiom/plugins/testtags"
)
// TestStats используется для сбора статистики выполнения тестов.
var TestStats = teststats.NewStats()
// BaseRunner — базовый раннер тестовой платформы.
// Он содержит общую конфигурацию и используется
// как основа для всех доменных раннеров.
var BaseRunner = axiom.NewRunner(
// Глобальные метаданные для всех тестов
axiom.WithRunnerMeta(
axiom.WithMetaEpic("api-tests"),
axiom.WithMetaLayer("api"),
axiom.WithMetaSeverity(axiom.SeverityNormal),
axiom.WithMetaTag("dummyjson"),
),
// Глобальные фикстуры
axiom.WithRunnerFixture("config", fixtures.SetConfigFixture),
axiom.WithRunnerFixture("logger", fixtures.SetLoggerFixture),
axiom.WithRunnerFixture("users", fixtures.SetUsersClientFixture),
axiom.WithRunnerFixture("products", fixtures.SetProductsClientFixture),
// Глобальные хуки
axiom.WithRunnerHooks(
axiom.WithBeforeAll(hooks.AllureBeforeAllHook),
),
// Плагины расширяют поведение раннера
axiom.WithRunnerPlugins(
testtags.Plugin(), // фильтрация тестов по тегам
teststats.Plugin(TestStats), // сбор статистики
testlogger.Plugin(), // логирование выполнения
testallure.Plugin(), // генерация Allure-отчётов
),
// Политика повторных запусков
axiom.WithRunnerRetry(
axiom.WithRetryTimes(3),
axiom.WithRetryDelay(2*time.Second),
),
// Включение параллельного выполнения тестов
axiom.WithRunnerParallel(),
)В этом месте сосредоточена вся инфраструктурная конфигурация проекта. Если потребуется изменить стратегию повторных запусков, отключить параллелизм или добавить новый плагин, это делается здесь и автоматически применяется ко всем тестам.
Доменные раннеры
В реальных проектах часто требуется логически группировать тесты по доменам или подсистемам. Для этого Axiom предоставляет механизм композиции раннеров через Join.
Доменные раннеры расширяют базовый раннер, добавляя собственные метаданные, но при этом полностью наследуют его конфигурацию.
Раннер для users
package users
import (
"go-api-tests/tests"
"github.com/Nikita-Filonov/axiom"
)
// runner — доменный раннер для тестов users.
// Он расширяет базовый раннер и добавляет
// доменные метаданные.
var runner = tests.BaseRunner.Join(
axiom.NewRunner(
axiom.WithRunnerMeta(
axiom.WithMetaTag("users"),
axiom.WithMetaFeature("users"),
),
),
)Раннер для products
package products
import (
"go-api-tests/tests"
"github.com/Nikita-Filonov/axiom"
)
// runner — доменный раннер для тестов products.
var runner = tests.BaseRunner.Join(
axiom.NewRunner(
axiom.WithRunnerMeta(
axiom.WithMetaTag("products"),
axiom.WithMetaFeature("products"),
),
),
)Такой подход позволяет выстраивать иерархию раннеров, сохраняя единый стандарт выполнения тестов и при этом добавляя доменную специфику. Тесты автоматически получают нужные фикстуры, метаданные, плагины и политику выполнения, не требуя ручной настройки.
Тесты
На этом этапе вся инфраструктура уже подготовлена: HTTP-клиенты, фикстуры и раннеры настроены и связаны между собой. Теперь можно переходить к написанию самих тестов.
Ключевая идея здесь простая — тесты не должны знать ничего о платформе. Они не инициализируют клиентов, не читают конфигурацию, не настраивают логирование и не взаимодействуют с Allure напрямую. Всё это уже сделано на уровне фикстур и раннеров.
В тестах остаётся только описание сценария и проверки бизнес-поведения API.
Тесты для сущности users
Создание пользователя
Начнём с теста на создание пользователя.
./tests/users/create_user_test.go
package users
import (
"net/http"
"testing"
"go-api-tests/clients/users"
"go-api-tests/fixtures"
"github.com/Nikita-Filonov/axiom"
"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateUser(t *testing.T) {
// Описание тестового кейса и его метаданных
c := axiom.NewCase(
axiom.WithCaseName("create user"),
axiom.WithCaseMeta(
axiom.WithMetaStory("create user"),
axiom.WithMetaTag("regression"),
),
)
// Запуск теста через доменный раннер
runner.RunCase(t, c, func(cfg *axiom.Config) {
// Получаем готовый users-клиент из фикстур
client := fixtures.GetUsersClientFixture(cfg)
// Формируем данные для создания пользователя
req := users.CreateUserRequest{
Email: gofakeit.Email(),
Username: gofakeit.Username(),
LastName: gofakeit.LastName(),
FirstName: gofakeit.FirstName(),
}
user, resp, err := client.CreateUser(cfg.Context.Raw, req)
require.NoError(cfg.SubT, err)
// Проверяем результат
assert.Equal(cfg.SubT, http.StatusCreated, resp.StatusCode())
assert.Equal(cfg.SubT, req.Email, user.Email)
assert.Equal(cfg.SubT, req.Username, user.Username)
assert.Equal(cfg.SubT, req.LastName, user.LastName)
assert.Equal(cfg.SubT, req.FirstName, user.FirstName)
})
}Тест читается как простой сценарий: подготовка данных, выполнение операции и проверка результата. Ни одной строки, связанной с инфраструктурой или HTTP.
Получение списка пользователей
./tests/users/get_users_test.go
package users
import (
"net/http"
"testing"
"go-api-tests/fixtures"
"github.com/Nikita-Filonov/axiom"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetUsers(t *testing.T) {
c := axiom.NewCase(
axiom.WithCaseName("get users list"),
axiom.WithCaseMeta(
axiom.WithMetaTag("smoke"),
axiom.WithMetaStory("list users"),
),
)
runner.RunCase(t, c, func(cfg *axiom.Config) {
client := fixtures.GetUsersClientFixture(cfg)
result, resp, err := client.GetUsers(cfg.Context.Raw)
require.NoError(cfg.SubT, err)
assert.Equal(cfg.SubT, http.StatusOK, resp.StatusCode())
assert.NotEmpty(cfg.SubT, result.Users)
assert.Greater(cfg.SubT, result.Total, 0)
})
}Этот тест проверяет базовую работоспособность эндпоинта и может использоваться как smoke-тест.
Получение пользователя по идентификатору
./tests/users/get_user_test.go
package users
import (
"net/http"
"testing"
"go-api-tests/fixtures"
"github.com/Nikita-Filonov/axiom"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetUserByID(t *testing.T) {
c := axiom.NewCase(
axiom.WithCaseName("get user by id"),
axiom.WithCaseMeta(
axiom.WithMetaStory("get user"),
axiom.WithMetaTag("regression"),
),
)
runner.RunCase(t, c, func(cfg *axiom.Config) {
client := fixtures.GetUsersClientFixture(cfg)
user, resp, err := client.GetUser(cfg.Context.Raw, 1)
require.NoError(cfg.SubT, err)
assert.Equal(cfg.SubT, http.StatusOK, resp.StatusCode())
assert.Equal(cfg.SubT, 1, user.ID)
assert.NotEmpty(cfg.SubT, user.Username)
assert.NotEmpty(cfg.SubT, user.Email)
})
}Негативный сценарий
Негативные кейсы выглядят точно так же — без специальных хаков или отдельной инфраструктуры.
./tests/users/get_user_not_found_test.go
package users
import (
"net/http"
"testing"
"go-api-tests/fixtures"
"github.com/Nikita-Filonov/axiom"
"github.com/stretchr/testify/assert"
)
func TestGetUserNotFound(t *testing.T) {
c := axiom.NewCase(
axiom.WithCaseName("get non-existing user"),
axiom.WithCaseMeta(
axiom.WithMetaStory("get user"),
axiom.WithMetaTag("negative"),
),
)
runner.RunCase(t, c, func(cfg *axiom.Config) {
client := fixtures.GetUsersClientFixture(cfg)
user, resp, err := client.GetUser(cfg.Context.Raw, 999999)
assert.Error(cfg.SubT, err)
assert.Nil(cfg.SubT, user)
assert.Equal(cfg.SubT, http.StatusNotFound, resp.StatusCode())
})
}В результате тесты получаются компактными, легко читаемыми и не зависят от деталей реализации тестовой платформы. Вся инфраструктура остаётся за пределами тестовых сценариев, а сами тесты фокусируются исключительно на проверке поведения API.
Тесты для сущности products
Тесты для сущности products полностью повторяют структуру и подход, использованные для users. Это сделано намеренно: единый шаблон тестов позволяет легко добавлять новые домены и поддерживать читаемость тестового набора по мере роста проекта.
Создание продукта
./tests/products/create_product_test.go
package products
import (
"net/http"
"testing"
"go-api-tests/clients/products"
"go-api-tests/fixtures"
"github.com/Nikita-Filonov/axiom"
"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateProduct(t *testing.T) {
c := axiom.NewCase(
axiom.WithCaseName("create product"),
axiom.WithCaseMeta(
axiom.WithMetaTag("regression"),
axiom.WithMetaStory("create product"),
),
)
runner.RunCase(t, c, func(cfg *axiom.Config) {
client := fixtures.GetProductsClientFixture(cfg)
req := products.CreateProductRequest{
Title: gofakeit.ProductName(),
Description: gofakeit.Sentence(8),
Price: gofakeit.Price(10, 1000),
Stock: gofakeit.Number(1, 100),
Category: "electronics",
}
product, resp, err := client.CreateProduct(cfg.Context.Raw, req)
require.NoError(cfg.SubT, err)
assert.Equal(cfg.SubT, http.StatusCreated, resp.StatusCode())
assert.Equal(cfg.SubT, req.Title, product.Title)
assert.Equal(cfg.SubT, req.Price, product.Price)
assert.Equal(cfg.SubT, req.Stock, product.Stock)
})
}Тест проверяет корректность создания продукта и соответствие возвращаемых данных переданному запросу. Генерация данных вынесена в тест, чтобы каждый запуск был независимым и не зависел от состояния внешнего сервиса.
Получение списка продуктов
./tests/products/get_products_test.go
package products
import (
"net/http"
"testing"
"go-api-tests/fixtures"
"github.com/Nikita-Filonov/axiom"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetProducts(t *testing.T) {
c := axiom.NewCase(
axiom.WithCaseName("get products list"),
axiom.WithCaseMeta(
axiom.WithMetaStory("list products"),
axiom.WithMetaTag("smoke"),
),
)
runner.RunCase(t, c, func(cfg *axiom.Config) {
client := fixtures.GetProductsClientFixture(cfg)
result, resp, err := client.GetProducts(cfg.Context.Raw)
require.NoError(cfg.SubT, err)
assert.Equal(cfg.SubT, http.StatusOK, resp.StatusCode())
assert.NotEmpty(cfg.SubT, result.Products)
assert.Greater(cfg.SubT, result.Total, 0)
})
}Этот тест мо��ет использоваться как smoke-проверка доступности и корректной работы API для продуктов.
Получение продукта по идентификатору
./tests/products/get_product_test.go
package products
import (
"net/http"
"testing"
"go-api-tests/fixtures"
"github.com/Nikita-Filonov/axiom"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetProductByID(t *testing.T) {
c := axiom.NewCase(
axiom.WithCaseName("get product by id"),
axiom.WithCaseMeta(
axiom.WithMetaStory("get product"),
axiom.WithMetaTag("regression"),
),
)
runner.RunCase(t, c, func(cfg *axiom.Config) {
client := fixtures.GetProductsClientFixture(cfg)
product, resp, err := client.GetProduct(cfg.Context.Raw, 1)
require.NoError(cfg.SubT, err)
assert.Equal(cfg.SubT, http.StatusOK, resp.StatusCode())
assert.Equal(cfg.SubT, 1, product.ID)
assert.NotEmpty(cfg.SubT, product.Title)
assert.Greater(cfg.SubT, product.Price, 0.0)
})
}Негативный сценарий
./tests/products/get_product_not_found_test.go
package products
import (
"net/http"
"testing"
"go-api-tests/fixtures"
"github.com/Nikita-Filonov/axiom"
"github.com/stretchr/testify/assert"
)
func TestGetProductNotFound(t *testing.T) {
c := axiom.NewCase(
axiom.WithCaseName("get non-existing product"),
axiom.WithCaseMeta(
axiom.WithMetaTag("negative"),
axiom.WithMetaStory("get product"),
),
)
runner.RunCase(t, c, func(cfg *axiom.Config) {
client := fixtures.GetProductsClientFixture(cfg)
product, resp, err := client.GetProduct(cfg.Context.Raw, 999999)
assert.Error(cfg.SubT, err)
assert.Nil(cfg.SubT, product)
assert.Equal(cfg.SubT, http.StatusNotFound, resp.StatusCode())
})
}Таким образом, тесты для products не отличаются по структуре от тестов для users. Это демонстрирует, что выбранная архитектура хорошо масштабируется и позволяет добавлять новые доменные области без увеличения сложности тестового кода.
Конфигурация
Для вынесения параметров окружения добавим простой файл конфигурации. Он используется фикстурой конфигурации и позволяет изменять настройки HTTP-клиента без правок кода.
http:
url: https://dummyjson.com
timeout: 10sВ текущем примере конфигурация содержит только параметры HTTP-клиента, но при необходимости сюда легко добавить настройки для разных окружений, таймауты, флаги или любые другие параметры, необходимые тестовой платформе.
Конфигурация загружается один раз на тест через фикстуру и автоматически становится доступной всем клиентам и тестам.
Запуск на CI/CD
Для автоматического запуска автотестов и публикации отчётов используем GitHub Actions. Воркфлоу будет запускаться при каждом push и pull request в ветку main, выполнять тесты и публиковать Allure-отчёт с историей на GitHub Pages.
Перед использованием воркфлоу необходимо убедиться, что GITHUB_TOKEN имеет права на запись в репозиторий. Это требуется для публикации отчёта в ветку gh-pages.
# Название workflow, отображается в GitHub Actions
name: API tests
# Триггеры запуска workflow
on:
# Запуск при пуше в ветку main
push:
branches:
- main
# Запуск при создании pull request в main
pull_request:
branches:
- main
# Права, необходимые workflow
# Нужны для публикации Allure-отчёта на GitHub Pages
permissions:
contents: write
pages: write
id-token: write
jobs:
# Основной job для запуска автотестов
run-tests:
runs-on: ubuntu-latest
steps:
# Получаем исходный код репозитория
- name: Checkout repository
uses: actions/checkout@v6
# Устанавливаем нужную версию Go
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
# Загружаем зависимости проекта
- name: Download Go modules
run: go mod download
# Запускаем API-автотесты
# Вся логика retry, тегов и отчётов уже встроена через Axiom
- name: Run API tests
run: go test ./... -v
# Собираем результаты Allure со всех пакетов
# Делается всегда, даже если тесты упали
- name: Collect Allure results
if: always()
run: |
mkdir -p allure-results
find . -type d -name allure-results | while read dir; do
if [ "$dir" != "./allure-results" ]; then
cp -r "$dir/"* allure-results/ || true
fi
done
# Сохраняем результаты Allure как артефакт
- name: Upload Allure results
if: always()
uses: actions/upload-artifact@v6
with:
name: allure-results
path: allure-results
# Job для генерации и публикации Allure-отчёта
publish-report:
# Выполняется всегда, независимо от результата тестов
if: always()
needs: [ run-tests ]
runs-on: ubuntu-latest
steps:
# Чекаутим ветку gh-pages для публикации отчёта
- name: Check out repository
uses: actions/checkout@v6
with:
ref: gh-pages
path: gh-pages
# Загружаем сохранённые результаты Allure
- name: Download Allure results
uses: actions/download-artifact@v6
with:
name: allure-results
path: allure-results
# Генерируем Allure-отчёт с поддержкой истории прогонов
- name: Allure Report action from marketplace
uses: simple-elf/allure-report-action@v1.13
if: always()
with:
allure_results: allure-results
allure_history: allure-history
# Публикуем отчёт на GitHub Pages
- name: Deploy report to Github Pages
if: always()
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_branch: gh-pages
publish_dir: allure-history


Заключение
В этой статье мы собрали полноценный пример API-автотестов на Go: от HTTP-клиентов и фикстур до тестов и запуска в CI/CD. В результате получилась система, где тесты остаются простыми и читаемыми, а вся инфраструктурная логика вынесена за их пределы.
Ключевую роль в этом подходе играет Axiom. Он добавляет в Go отсутствующий execution layer и позволяет управлять жизненным циклом тестов, фикстурами, ретраями, параллелизмом и отчётами, не ломая стандартный testing и не вводя DSL. Благодаря этому автотесты действительно становятся «скучными» — сфокусированными на бизнес-поведении, а не на технических деталях.
⭐ Если Axiom оказался полезным и подход откликнулся, проект можно поддержать звездой на GitHub. Это напрямую влияет на его развитие и дальнейшее улучшение экосистемы тестирования на Go.
Все ссылки на код, отчеты и запуски тестов в CI/CD можно найти на моем GitHub:
