← Все статьи

Go-автотесты

Ресурсы в Go-автотестах: как переиспользовать инфраструктуру между тестами

HTTP-клиенты, gRPC-клиенты, базы, тестовые серверы и другие инфраструктурные зависимости не всегда нужно создавать заново в каждом Go-тесте. В статье показываю, как работают ресурсы в Axiom, чем они отличаются от фикстур и как помогают переиспользовать инфраструктуру между тест кейсами без глобальных переменных и копипасты.

интеграционные тестыресурсыфикстурыgogolangавтотестыaxiomqa automationqaавтоматизация тестирования

Ресурсы в Go-автотестах: как переиспользовать инфраструктуру между тестами

Вступление

Когда автотесты на Go выходят за рамки простых unit-тестов, почти сразу появляются зависимости, которые не хочется создавать заново в каждом тесте.

Например:

  • HTTP-клиент;
  • gRPC-клиент;
  • подключение к базе;
  • connection pool;
  • тестовый сервер;
  • мок внешнего сервиса;
  • клиент для Kafka, Redis или MinIO.

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

В Axiom для таких случаев есть Resource.

Ресурс — это долгоживущая инфраструктурная зависимость, привязанная к lifecycle Runner, а не к конкретному тест кейсу.

В этой статье разберёмся, зачем нужны ресурсы, чем они отличаются от фикстур и как использовать их в Go-автотестах.

Как обычно пишут без ресурсов

Представим, что у нас есть несколько интеграционных тестов для пользователей. Для каждого теста нужен API-клиент.

Самый простой вариант — создавать клиент прямо внутри теста.

package users_test

import "testing"

func TestUserCanBeCreated(t *testing.T) {
	client, err := NewUserClient("http://localhost:8080")
	if err != nil {
		t.Fatalf("failed to create client: %v", err)
	}
	defer client.Close()

	userID, err := client.CreateUser()
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}

	user, err := client.GetUser(userID)
	if err != nil {
		t.Fatalf("failed to get user: %v", err)
	}

	if user.ID != userID {
		t.Fatalf("unexpected user id")
	}
}

func TestUserCanBeBlocked(t *testing.T) {
	client, err := NewUserClient("http://localhost:8080")
	if err != nil {
		t.Fatalf("failed to create client: %v", err)
	}
	defer client.Close()

	userID, err := client.CreateUser()
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}

	err = client.BlockUser(userID)
	if err != nil {
		t.Fatalf("failed to block user: %v", err)
	}
}

На первый взгляд всё нормально. Код явный, никаких абстракций, всё понятно.

Но проблема уже появилась: инфраструктура живёт внутри каждого теста.

Каждый тест знает:

  • как создать клиента;
  • какой base URL использовать;
  • когда закрывать соединение;
  • как обработать ошибку инициализации;
  • как не забыть cleanup.

Если таких тестов два — это терпимо. Если их десятки или сотни, инициализация инфраструктуры начинает размазываться по всему проекту.

Глобальные переменные как «решение»

Следующий шаг обычно выглядит так: давайте сделаем клиента глобальным.

package users_test

import "testing"

var client *UserClient

func TestMain(m *testing.M) {
	client, _ = NewUserClient("http://localhost:8080")
	code := m.Run()
	client.Close()
	os.Exit(code)
}

func TestUserCanBeCreated(t *testing.T) {
	userID, err := client.CreateUser()
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}

	user, err := client.GetUser(userID)
	if err != nil {
		t.Fatalf("failed to get user: %v", err)
	}

	if user.ID != userID {
		t.Fatalf("unexpected user id")
	}
}

Копипасты стало меньше, но появилась другая проблема. Теперь инфраструктура живёт в глобальном состоянии.

Такой код сложнее переиспользовать, сложнее расширять и сложнее контролировать. Если понадобится несколько клиентов, разные конфигурации, разные наборы тестов или разные lifecycle-границы, TestMain быстро станет точкой, куда начнёт стекаться вся инфраструктура проекта.

В итоге тесты вроде бы стали короче, но управление инфраструктурой стало ещё более неявным.

Почему фикстуры не всегда подходят

Можно было бы сказать: раз это зависимость для теста, давайте сделаем фикстуру.

Но фикстуры решают другую задачу. Фикстура в Axiom живёт на уровне конкретного выполнения Case. Она создаётся для теста, используется внутри этого теста и очищается после него. При retry фикстура создаётся заново для каждой попытки.

Это правильно для тестовых данных:

  • создать пользователя;
  • подготовить счёт;
  • сгенерировать документ;
  • создать операцию;
  • очистить состояние после теста.

Но для инфраструктуры это не всегда оптимально. HTTP-клиент, gRPC-клиент, connection pool или тестовый сервер часто не нужно пересоздавать на каждый тест кейс. Они могут быть безопасно переиспользованы между тестами.

Именно для этого в Axiom есть ресурсы.

Что такое Resource в Axiom

Resource — это долгоживущая зависимость, которая регистрируется на уровне Runner.

У ресурса есть простой контракт:

type Resource func(r *axiom.Runner) (any, func(), error)

Он возвращает:

  • значение ресурса;
  • cleanup-функцию;
  • ошибку инициализации.

Ресурс создаётся лениво — только при первом обращении. После этого он кешируется внутри Runner и переиспользуется другими тестами.

Cleanup выполняется один раз в AfterAll.

Если упростить, lifecycle выглядит так:

Runner start

First MustResource / GetResource call

Resource is created

Resource is cached in Runner

Cases reuse the same resource

Runner finish

Cleanup runs once

То есть ресурс — это не test-level setup. Это runner-level инфраструктура.

Пример ресурса

Представим простой API-клиент.

type UserClient struct {
	baseURL string
}

func NewUserClient(baseURL string) (*UserClient, error) {
	fmt.Println("create user client")

	return &UserClient{
		baseURL: baseURL,
	}, nil
}

func (c *UserClient) Close() {
	fmt.Println("close user client")
}

func (c *UserClient) CreateUser() (string, error) {
	return "user-1", nil
}

func (c *UserClient) GetUser(id string) (*User, error) {
	return &User{ID: id}, nil
}

func (c *UserClient) BlockUser(id string) error {
	return nil
}

type User struct {
	ID string
}

Теперь опишем ресурс:

func UserClientResource(r *axiom.Runner) (any, func(), error) {
	client, err := NewUserClient("http://localhost:8080")
	if err != nil {
		return nil, nil, err
	}

	cleanup := func() {
		client.Close()
	}

	return client, cleanup, nil
}

Здесь ресурс отвечает только за инфраструктуру:

  • создать клиента;
  • вернуть готовую зависимость;
  • описать cleanup.

Внутри ресурса нет тестового сценария, проверок и бизнес-логики.

Подключение ресурса к Runner

Ресурсы регистрируются на уровне Runner.

var runner = axiom.NewRunner(
	axiom.WithRunnerResource("user-client", UserClientResource),
)

После этого ресурс можно получить в тесте через MustResource:

client := axiom.MustResource[*UserClient](cfg.Runner, "user-client")

Важно: ресурс не создаётся в момент регистрации. Он будет создан только при первом обращении.

Использование ресурса в тестах

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

Лучше всего показать это через suite, потому что suite задаёт явную границу выполнения группы тестов. Тогда AfterAll действительно означает “после всех тестов suite”.

package users_test

import (
	"fmt"
	"testing"

	"github.com/Nikita-Filonov/axiom"
)

type User struct {
	ID string
}

type UserClient struct {
	baseURL string
}

func NewUserClient(baseURL string) (*UserClient, error) {
	fmt.Println("create user client")

	return &UserClient{
		baseURL: baseURL,
	}, nil
}

func (c *UserClient) Close() {
	fmt.Println("close user client")
}

func (c *UserClient) CreateUser() (string, error) {
	return "user-1", nil
}

func (c *UserClient) GetUser(id string) (*User, error) {
	return &User{ID: id}, nil
}

func (c *UserClient) BlockUser(id string) error {
	return nil
}

func UserClientResource(r *axiom.Runner) (any, func(), error) {
	client, err := NewUserClient("http://localhost:8080")
	if err != nil {
		return nil, nil, err
	}

	cleanup := func() {
		client.Close()
	}

	return client, cleanup, nil
}

var runner = axiom.NewRunner(
	axiom.WithRunnerResource("user-client", UserClientResource),
)

type UsersSuite struct {
	axiom.Suite
}

func TestUsersSuite(t *testing.T) {
	suite := axiom.NewSuite(t, new(UsersSuite), axiom.WithSuiteConfigRunner(runner))

	suite.Test("user can be created", (*UsersSuite).UserCanBeCreated)
	suite.Test("user can be blocked", (*UsersSuite).UserCanBeBlocked)

	suite.Run()
}

func (s *UsersSuite) UserCanBeCreated() {
	c := axiom.NewCase(
		axiom.WithCaseName("user can be created"),
	)

	s.RunCase(c, func(cfg *axiom.Config) {
		client := axiom.MustResource[*UserClient](cfg.Runner, "user-client")

		userID, err := client.CreateUser()
		if err != nil {
			cfg.SubT.Fatalf("failed to create user: %v", err)
		}

		user, err := client.GetUser(userID)
		if err != nil {
			cfg.SubT.Fatalf("failed to get user: %v", err)
		}

		if user.ID != userID {
			cfg.SubT.Fatalf("unexpected user id")
		}
	})
}

func (s *UsersSuite) UserCanBeBlocked() {
	c := axiom.NewCase(
		axiom.WithCaseName("user can be blocked"),
	)

	s.RunCase(c, func(cfg *axiom.Config) {
		client := axiom.MustResource[*UserClient](cfg.Runner, "user-client")

		userID, err := client.CreateUser()
		if err != nil {
			cfg.SubT.Fatalf("failed to create user: %v", err)
		}

		err = client.BlockUser(userID)
		if err != nil {
			cfg.SubT.Fatalf("failed to block user: %v", err)
		}
	})
}

При запуске порядок будет примерно таким:

create user client

=== RUN   TestUsersSuite/user_can_be_created/user_can_be_created
=== RUN   TestUsersSuite/user_can_be_blocked/user_can_be_blocked

close user client

Клиент создаётся один раз, переиспользуется в нескольких тест кейсах и закрывается один раз после завершения suite.

Тесты при этом не знают, как именно создаётся клиент. Они просто получают готовую инфраструктурную зависимость.

Что здесь принципиально важно

Ресурс создаётся лениво. Если ни один тест не запросил user-client, он не будет создан.

Ресурс кешируется на уровне Runner. Если несколько тестов запросят один и тот же ресурс, они получат одну и ту же активную зависимость.

Cleanup выполняется один раз. Не после каждого теста, не после каждой retry-попытки, а на runner-level finish. В примере выше этот finish наступает после завершения suite.

Именно поэтому ресурс хорошо подходит для инфраструктуры, которую можно безопасно разделять между тестами.

Resource и Fixture — не одно и то же

Самая частая ошибка — воспринимать ресурс как “большую фикстуру”. Но это разные сущности. Фикстура привязана к тест кейсу. Ресурс привязан к runner lifecycle.

  • Фикстура нужна, когда состояние должно быть свежим для каждого теста.
  • Ресурс нужен, когда зависимость можно переиспользовать между тестами.

Например, пользователь, операция или документ — это фикстуры. Они относятся к конкретному сценарию и должны очищаться после него. А HTTP-клиент, gRPC-клиент, connection pool или тестовый сервер — это ресурсы. Они относятся к инфраструктуре выполнения и могут жить дольше одного теста.

Если упростить:

Fixture = данные и setup конкретного теста
Resource = общая инфраструктура для группы тестов

Что происходит при retry

Отдельно важно проговорить retry.

Фикстуры в Axiom создаются заново для каждой retry-попытки. Это даёт изоляцию тестового состояния.

Ресурсы, наоборот, не пересоздаются при retry. Они остаются в кеше Runner и переиспользуются между попытками.

Это сделано намеренно. Если тест упал из-за нестабильного состояния пользователя, операция или документ должны быть пересозданы. Это зона ответственности фикстур. Но если тест использует HTTP-клиент или gRPC-клиент, обычно нет смысла создавать его заново на каждую попытку. Это инфраструктура, а не данные сценария.

Поэтому важно не складывать в ресурсы то, что должно быть изолировано между retry-попытками.

Зависимости между ресурсами

Ресурс может зависеть от другого ресурса.

Например, API-клиент может использовать общий конфиг:

type AppConfig struct {
	BaseURL string
}

func ConfigResource(r *axiom.Runner) (any, func(), error) {
	return &AppConfig{
		BaseURL: "http://localhost:8080",
	}, nil, nil
}

func UserClientResource(r *axiom.Runner) (any, func(), error) {
	cfg := axiom.MustResource[*AppConfig](r, "config")

	client, err := NewUserClient(cfg.BaseURL)
	if err != nil {
		return nil, nil, err
	}

	return client, func() {
		client.Close()
	}, nil
}

Подключение:

var runner = axiom.NewRunner(
	axiom.WithRunnerResource("config", ConfigResource),
	axiom.WithRunnerResource("user-client", UserClientResource),
)

Теперь user-client не знает, где именно хранится конфигурация. Он просто получает другой ресурс через тот же runner-level механизм.

Когда использовать Resource

Ресурс хорошо подходит, когда зависимость:

  • дорогая в создании;
  • безопасна для переиспользования;
  • не должна пересоздаваться на каждый тест кейс;
  • не должна сбрасываться между retry-попытками;
  • очищается один раз после всей группы тестов.

Типичные примеры:

  • HTTP-клиент;
  • gRPC-клиент;
  • connection pool;
  • тестовый сервер;
  • общий мок-сервис;
  • клиент для Redis, Kafka, MinIO;
  • общая инфраструктурная обвязка.

Когда не стоит использовать Resource

Ресурс не стоит использовать для данных, которые должны быть свежими в каждом тесте.

Например:

  • созданный пользователь;
  • банковская операция;
  • документ;
  • счёт;
  • состояние конкретного сценария;
  • временные данные, которые нужно очистить после тест кейса.

Для этого лучше использовать фикстуры. Если зависимость должна пересоздаваться при retry, это тоже не ресурс. Ресурс переживает retry. Фикстура — нет.

Заключение

Ресурсы решают простую, но важную проблему: они выносят долгоживущую инфраструктуру из тела тестов.

Без ресурсов каждый тест сам создаёт клиентов, соединения, серверы и другие зависимости. Код начинает дублироваться, cleanup расползается по тестам, а инфраструктурный слой становится неявным.

Axiom позволяет описать такую зависимость один раз на уровне Runner.

Ресурс создаётся лениво, кешируется, переиспользуется между тест кейсами и очищается один раз через AfterAll.

Главное — не путать ресурсы с фикстурами. Фикстуры нужны для состояния конкретного теста. Ресурсы нужны для общей инфраструктуры выполнения.