Интеграционные тесты с помощью Go и тестовых контейнеров

Давайте начнем с понимания того, что такое тестирование. Существует пирамида тестирования (юнит-тесты, интеграционное и end-2-end тестирование).

По горизонтали — количество тестов. По вертикали — стоимость обслуживания.

Юнит-тесты тестируют функцию a+b, где мы описываем положительный и отрицательный сценарий. Существует хорошее видео по юнит-тестам. Эта тема не входит в контекст нашей статьи.

E2e-тесты обычно пишут тестировщики, они нужны для тестирования полного потока внутри истории. Например, мы отправляем запрос на поднятый сервер, сервер его обрабатывает (обращается к другим сервисам, идет к базе данных, к редиске и т.д.). Для нас сервис — это «черный ящик». Мы тестируем ответы на наши запросы.

Однако наиболее эффективным подходом на практике остается следующая схема (тестирование Trophy):

Интеграционные тесты — проверка интеграции тестируемого сервиса с другими сервисами. Мы полностью проверяем работу конкретного сервиса.

Интеграционные тесты становятся основой всего тестирования разработки. Обычно их пишут сами разработчики. Цель этой статьи — показать вам, как просто и эффективно писать интеграционные тесты.

Интеграционные тесты на примере http-сервера, который взаимодействует с базой данных и обращается к другим сервисам.

Для начала опишем сам сервис. Сервис на Github. Сервис использует gorilla/mux и Postgres. Он реализует чистую архитектуру и имеет такую файловую структуру:

❯ tree user_service
user_service
├── api
│   └── user.go
├── billing
│   ├── api.go
│   └── client.go
├── cmd
│   └── main.go
├── docker-compose.yml
├── domain
│   └── user.go
├── handler
│   └── handler.go
├── migrate
│   ├── migrate.go
│   └── migrations
│       └── 20220612163022_create_users.sql
├── server
│   └── server.go
├── storage
│   └── storage.go
└── use_case
    └── use_case.go

10 directories, 12 files
Вход в полноэкранный режим Выход из полноэкранного режима

Шаг 1. Тестирование createUser

Метод создания записи о новом пользователе в базе данных содержит основную логику в слое хранилища (мы просто передаем полезную нагрузку от обработчика через use_case в хранилище) и выглядит следующим образом:

func (s *storage) CreateUser(ctx context.Context, name string) (domain.User, error) {
    query := `INSERT INTO users (name) VALUES ($1) RETURNING id, name, balance, created_at, updated_at`
    res, err := s.db.QueryxContext(ctx, query, name)
    if err != nil {
        return domain.User{}, err
    }

    defer res.Close()

    if !res.Next() {
        return domain.User{}, IncorrectQueryResponse
    }
    var resUser domain.User
    if err := res.StructScan(&resUser); err != nil {
        return domain.User{}, err
    }

    return resUser, nil
}
Войти в полноэкранный режим Выход из полноэкранного режима

Теперь давайте напишем простой тест. (тест не будет работать)

func TestCreateUser(t *testing.T) {

    // copy from main
    repo, err := storage.New(dbDsn)
    require.NoError(t, err)
    useCase := use_case.New(repo, nil)
    h := handler.New(useCase)
    ///

    requestBody := `{"name": "test_name"}`

    // use httptest
    srv := httptest.NewServer(server.New("", h).Router)

    _, err = srv.Client().Post(srv.URL+"/users", "", bytes.NewBufferString(requestBody))
    require.NoError(t, err)
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Тест поднимает сам сервис и вызывает обработчик запроса (используя httptest).

Мы запускаем тест и видим, что он не работает, так как нет соединения с базой данных. Поэтому нам нужно создать соединение с базой данных. Более того, нам нужно убедиться, что база данных автоматически подключается и работает при запуске теста и нет необходимости делать это вручную. Именно здесь нам может помочь популярный инструмент testContainers и его реализация на Go.

Шаг 2. Создание тестового контейнера с Postgres

Давайте опишем, как будет выглядеть запрос на запуск базы данных в контейнере:

testcontainers

req := testcontainers.ContainerRequest{
    Env: map[string]string{
        "POSTGRES_USER":     "user",
        "POSTGRES_PASSWORD": "password",
        "POSTGRES_DB":       "postgres",
    },
    ExposedPorts: []string{"5432/tcp"},
    Image:        "postgres:14.3",
    WaitingFor: wait.ForExec([]string{"pg_isready"}).
        WithPollInterval(2 * time.Second).
        WithExitCodeMatcher(func(exitCode int) bool {
            return exitCode == 0
        }),
}
Войти в полноэкранный режим Выйти из полноэкранного режима

testcontainers.ContainerRequest описывает, как будет выглядеть наш Docker-контейнер.

Давайте посмотрим на наш docker-compose.yml:

version: '3.8'
services:
  db:
    image: postgres:14.3
    ports:
      - "5432:5432"
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=postgres
    healthcheck:
      test: [ "CMD-SHELL", "pg_isready" ]
      interval: 2s
Вход в полноэкранный режим Выход из полноэкранного режима

мы видим, что ContainerRequest почти полностью повторяет описание в файле docker-compose:

container, err := testcontainers.GenericContainer(ctx, 
   testcontainers.GenericContainerRequest{
      ContainerRequest: req,
      Started:true,
   }
)
Войти в полноэкранный режим Выход из полноэкранного режима

testcontainers.GenericContainer создает контейнер.

Наконец, опишем структуру самого контейнера Postgres:

type PostgreSQLContainer struct{
   testcontainers.Container
   MappedPort string
   Host       string
}
Вход в полноэкранный режим Выход из полноэкранного режима

Кроме testcontainers.Container в нашей структуре будут храниться внешний хост и порт Docker-контейнера с Postgres. Получить их можно будет таким образом:

mappedPort, err := container.MappedPort(ctx, "5432")
host, err := container.Host(ctx)
Войти в полноэкранный режим Выйти из полноэкранного режима

Также напишем вспомогательную функцию, которая будет возвращать реальный DSN-адрес для подключения к Postgres:

// GetDSN returns DB connection URL.
func (c PostgreSQLContainer) GetDSN() string {
   return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", "user", "password", c.Host, c.MappedPort, "postgres")
}
Enter fullscreen mode Выход из полноэкранного режима

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

package step_2

import (
    "context"
    "fmt"
    _ "github.com/jackc/pgx/v4/stdlib"
    _ "github.com/lib/pq"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
    "time"
)

type PostgreSQLContainer struct {
    testcontainers.Container
    MappedPort string
    Host       string
}

func (c PostgreSQLContainer) GetDSN() string {
    return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", "user", "password", c.Host, c.MappedPort, "postgres_test")
}

func NewPostgreSQLContainer(ctx context.Context) (*PostgreSQLContainer, error) {
    req := testcontainers.ContainerRequest{
        Env: map[string]string{
            "POSTGRES_USER":     "user",
            "POSTGRES_PASSWORD": "password",
            "POSTGRES_DB":       "postgres_test",
        },
        ExposedPorts: []string{"5432/tcp"},
        Image:        "postgres:14.3",
        WaitingFor: wait.ForExec([]string{"pg_isready"}).
            WithPollInterval(1 * time.Second).
            WithExitCodeMatcher(func(exitCode int) bool {
                return exitCode == 0
            }),
    }
    container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: req,
        Started:          true,
    })
    if err != nil {
        return nil, err
    }

    host, err := container.Host(ctx)
    if err != nil {
        return nil, err
    }

    mappedPort, err := container.MappedPort(ctx, "5432")
    if err != nil {
        return nil, err
    }

    return &PostgreSQLContainer{
        Container:  container,
        MappedPort: mappedPort.Port(),
        Host:       host,
    }, nil
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Шаг 2.1. Рефакторинг

Давайте не будем жестко кодировать пользователя и пароль и предоставим возможность изменять конфигурацию, добавим структуру для конфига и будем использовать шаблонные опции.

package step_2_1_improved_psql_container

import (
    "context"
    "fmt"

    "github.com/docker/go-connections/nat"
    _ "github.com/jackc/pgx/v4/stdlib"
    _ "github.com/lib/pq"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
)

type (
    PostgreSQLContainer struct {
        testcontainers.Container
        //add Config
        Config PostgreSQLContainerConfig
    }
    //also add options pattern method
    PostgreSQLContainerOption func(c *PostgreSQLContainerConfig)

    PostgreSQLContainerConfig struct {
        ImageTag   string
        User       string
        Password   string
        MappedPort string
        Database   string
        Host       string
    }
)

func (c PostgreSQLContainer) GetDSN() string {
    return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", c.Config.User, c.Config.Password, c.Config.Host, c.Config.MappedPort, c.Config.Database)
}

func NewPostgreSQLContainer(ctx context.Context, opts ...PostgreSQLContainerOption) (*PostgreSQLContainer, error) {
    const (
        psqlImage = "postgres"
        psqlPort  = "5432"
    )

    config := PostgreSQLContainerConfig{
        ImageTag: "11.5",
        User:     "user",
        Password: "password",
        Database: "db_test",
    }
    //handle possible options
    for _, opt := range opts {
        opt(&config)
    }

    containerPort := psqlPort + "/tcp"

    req := testcontainers.GenericContainerRequest{
        ContainerRequest: testcontainers.ContainerRequest{
            Env: map[string]string{
                "POSTGRES_USER":     config.User,
                "POSTGRES_PASSWORD": config.Password,
                "POSTGRES_DB":       config.Database,
            },
            ExposedPorts: []string{
                containerPort,
            },
            Image:      fmt.Sprintf("%s:%s", psqlImage, config.ImageTag),
            WaitingFor: wait.ForListeningPort(nat.Port(containerPort)),
        },
        Started: true,
    }

    container, err := testcontainers.GenericContainer(ctx, req)
    if err != nil {
        return nil, fmt.Errorf("getting request provider: %w", err)
    }

    host, err := container.Host(ctx)
    if err != nil {
        return nil, fmt.Errorf("getting host for: %w", err)
    }

    mappedPort, err := container.MappedPort(ctx, nat.Port(containerPort))
    if err != nil {
        return nil, fmt.Errorf("getting mapped port for (%s): %w", containerPort, err)
    }
    config.MappedPort = mappedPort.Port()
    config.Host = host

    fmt.Println("Host:", config.Host, config.MappedPort)

    return &PostgreSQLContainer{
        Container: container,
        Config:    config,
    }, nil
}
Вход в полноэкранный режим Выход из полноэкранного режима

Шаг 3. Миграции

Запустим тест еще раз. Мы увидим еще одну ошибку. БД встала и работает, но вставка не работает, так как нет схемы. Чтобы решить эту проблему, запустим скрипт миграции. Мы будем делать это при каждом запуске теста. Просто скопируйте код из cmd/main.go в наш тест.

// run migrations
err = migrate.Migrate(psqlContainer.GetDSN(), migrate.Migrations)
require.NoError(t, err)
Вход в полноэкранный режим Выйдите из полноэкранного режима

Запустите тест еще раз и убедитесь, что он наконец-то прошел.

Шаг 4. Тест getUser

Тест для getUser будет выглядеть следующим образом:

func TestGetUser(t *testing.T) {
    //---------------- common part for all tests
    ctx, ctxCancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer ctxCancel()

    psqlContainer, err := step2.NewPostgreSQLContainer(ctx)
    defer psqlContainer.Terminate(context.Background())
    require.NoError(t, err)

    err = migrate.Migrate(psqlContainer.GetDSN(), migrate.Migrations)
    require.NoError(t, err)

    repo, err := storage.New(psqlContainer.GetDSN())
    require.NoError(t, err)

    useCase := use_case.New(repo, nil)

    h := handler.New(useCase)

    srv := httptest.NewServer(server.New("", h).Router)
  //------------------------------------------------

    // test body of below ----------------------------
    res, err := srv.Client().Get(srv.URL + "/users/1")
    require.NoError(t, err)

    defer res.Body.Close()

    require.Equal(t, http.StatusOK, res.StatusCode)

    // check response
    response := api.GetUserResponse{}
    err = json.NewDecoder(res.Body).Decode(&response)
    require.NoError(t, err)

    // id maybe any
    // so we will check each field separately
    assert.Equal(t, 1, response.ID)
    assert.Equal(t, "test_name", response.Name)
    assert.Equal(t, "0", response.Balance.String())
}
Вход в полноэкранный режим Выход из полноэкранного режима

Проблема с тестированием getUser заключается в том, что нам необходимо иметь запись об этом пользователе в нашей БД. Конечно, мы можем решить ее, просто выполнив getUser сразу после createUser последовательно. Но такой подход является анти-паттерном, поскольку каждый тест должен работать изолированно и проверять только требуемую функциональность.

Шаг 5. Тесты

Для решения проблемы мы будем использовать testfixtures. Создайте папки fixtures и fixtures/storage и поместите в них файл users.yaml:

- id: 1
  name: test_name
  balance: 0
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь установите библиотеку go get "github.com/go-testfixtures/testfixtures/v3" и добавьте этот код после общей части и перед вызовом get.

db, err := sql.Open("postgres", psqlContainer.GetDSN())
require.NoError(t, err)

fixtures, err := testfixtures.New(
    testfixtures.Database(db),
    testfixtures.Dialect("postgres"),
    testfixtures.Directory("fixtures/storage"),
)
require.NoError(t, err)
require.NoError(t, fixtures.Load())
Вход в полноэкранный режим Выйдите из полноэкранного режима

Запустите тест еще раз и убедитесь, что он прошел.

Шаг 6. Testsuite

Как вы могли заметить, каждый тест имеет общую часть. Чтобы оптимизировать наш код и избежать дублирования, мы будем использовать testsuites из библиотеки testify. Этот инструмент поможет нам описать действия, которые необходимо предпринять перед каждым тестом.

Давайте создадим структуру для нашего TestSuite:

type TestSuite struct {
    suite.Suite
    psqlContainer *step2.PostgreSQLContainer
    server        *httptest.Server
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь опишем специальный метод SetupSuite(), который будет выполняться перед запуском каждого теста этого TestSuite. Переместите общую часть внутрь него:

func (s *TestSuite) SetupSuite() {
    // create db container
    ctx, ctxCancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer ctxCancel()

    psqlContainer, err := step2.NewPostgreSQLContainer(ctx)
    s.Require().NoError(err)

    s.psqlContainer = psqlContainer
    //

    // run migrations
    err = migrate.Migrate(psqlContainer.GetDSN(), migrate.Migrations)
    s.Require().NoError(err)
    //

    // copy from main
    repo, err := storage.New(psqlContainer.GetDSN())
    s.Require().NoError(err)

    useCase := use_case.New(repo, nil)
    h := handler.New(useCase)
    ///

    // use httptest
    s.server = httptest.NewServer(server.New("", h).Router)
    //
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Также опишем метод TearDownSuite(), который будет выполняться после завершения всех тестов из TestSuite. Чтобы избежать утечки памяти, давайте завершим наш контейнер:

func (s *TestSuite) TearDownSuite() {
    ctx, ctxCancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer ctxCancel()

    s.Require().NoError(s.psqlContainer.Terminate(ctx))

    s.server.Close()
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Последнее, что нам нужно для нашего TestSuite — это тестовая функция, которая будет принимать аргумент t *testing.T и внедрять его в TestSuite:

func TestSuite_Run(t *testing.T) {
    suite.Run(t, new(TestSuite))
}
Вход в полноэкранный режим Выход из полноэкранного режима

Замечательно. TestSuite описан и может работать. Теперь все наши тесты содержат только тестирование методов, проверку возвращаемых значений и исправления, где это необходимо:

func (s *TestSuite) TestCreateUser() {
    requestBody := `{"name": "test_name"}`

    res, err := s.server.Client().Post(s.server.URL+"/users", "", bytes.NewBufferString(requestBody))
    s.Require().NoError(err)

    defer res.Body.Close()

    s.Require().Equal(http.StatusOK, res.StatusCode)

    // check response
    response := api.CreateUserResponse{}
    err = json.NewDecoder(res.Body).Decode(&response)
    s.Require().NoError(err)

    // id maybe any
    // so we will check each field separately
    s.Assert().Equal("test_name", response.Name)
    s.Assert().Equal("0", response.Balance.String())
}

func (s *TestSuite) TestGetUser() {
    // create fixtures
    db, err := sql.Open("postgres", s.psqlContainer.GetDSN())
    s.Require().NoError(err)

    fixtures, err := testfixtures.New(
        testfixtures.Database(db),
        testfixtures.Dialect("postgres"),
        testfixtures.Directory("../step_5_add_testfixtures/fixtures/storage"),
    )
    s.Require().NoError(err)
    s.Require().NoError(fixtures.Load())
    //

    res, err := s.server.Client().Get(s.server.URL + "/users/1")
    s.Require().NoError(err)

    defer res.Body.Close()

    s.Require().Equal(http.StatusOK, res.StatusCode)

    // check response
    response := api.GetUserResponse{}
    err = json.NewDecoder(res.Body).Decode(&response)
    s.Require().NoError(err)

    // so we will check each field separately
    s.Assert().Equal(1, response.ID)
    s.Assert().Equal("test_name", response.Name)
    s.Assert().Equal("0", response.Balance.String())
}
Войти в полноэкранный режим Выход из полноэкранного режима

Шаг 7. Тест UpdateUserBalance

Метод updateUserBalance сначала вызывает внешний сервис Billing, запрашивает некоторую информацию и на ее основе обновляет баланс. Давайте напишем тест:

func (s *TestSuite) TestDepositBalance() {
    // create fixtures
    db, err := sql.Open("postgres", s.psqlContainer.GetDSN())
    s.Require().NoError(err)

    fixtures, err := testfixtures.New(
        testfixtures.Database(db),
        testfixtures.Dialect("postgres"),
        testfixtures.Directory("../step_5/fixtures/storage"),
    )
    s.Require().NoError(err)
    s.Require().NoError(fixtures.Load())
    //

    requestBody := `{"id": 1, "amount": "100"}`

    res, err := s.server.Client().Post(s.server.URL+"/users/deposit", "", bytes.NewBufferString(requestBody))
    s.Require().NoError(err)

    defer res.Body.Close()

    s.Require().Equal(http.StatusOK, res.StatusCode)

    // check response
    response := api.GetUserResponse{}
    err = json.NewDecoder(res.Body).Decode(&response)
    s.Require().NoError(err)

    s.Assert().Equal(1, response.ID)
    s.Assert().Equal("test_name", response.Name)
    s.Assert().Equal("100", response.Balance.String())
}
Вход в полноэкранный режим Выход из полноэкранного режима

И этот тест тоже не будет работать 🙂 Проблема теперь в том, что нам нужно обратиться к внешнему серверу. Поскольку мы пишем тест для определенного обработчика в нашем сервисе User, нам не нужно тестировать внешний сервис. Единственное, что нам нужно, это обеспечить интеграцию с его API. Другими словами, нам нужно сымитировать вызов внешнего сервиса и предоставить ответ.

Шаг 8. httpmock

Для этой цели мы будем использовать httpmock. Внутри функции setupSuite(), где мы создали useCase и предоставили nil в качестве billingClient, теперь мы передадим насмешливый http-клиент:

func (s *TestSuite) SetupSuite() {
    //...
    mockClient := &http.Client{}
    httpmock.ActivateNonDefault(mockClient)

    billingClient := billing.New(mockClient, billingAddr)
    useCase := use_case.New(repo, billingClient)
    //...
}
Вход в полноэкранный режим Выйти из полноэкранного режима

В конце функции TearDownSuite() деактивируем сымитированный httpClient:

func (s *TestSuite) TearDownSuite() {
    //...
    httpmock.DeactivateAndReset()
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь давайте сымитируем вызов внешнего сервиса:

httpmock.RegisterResponder(
    http.MethodPost,
    billingAddr+"/deposit",
    httpmock.NewStringResponder(http.StatusOK, ""),
)
Вход в полноэкранный режим Выход из полноэкранного режима

И теперь тест будет окончательно пройден.

Шаг 9. Фиксы API

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

В каталоге fixtures создайте новый /api и файл fixtures.go:

package fixtures
import (
    "embed"
)

//go:embed fixtures
var Fixtures embed.FS
Вход в полноэкранный режим Выйти из полноэкранного режима

Мы будем использовать go:embed FS, подробнее о нем можно прочитать здесь или здесь. В двух словах, он позволяет получить путь к файлу, в котором находится структура, без В двух словах, он позволяет получить путь к файлу, в котором находится структура, без необходимости писать полный путь к файлу, что часто бывает проблематично.

Также давайте добавим структуру в этот файл:

type FixtureLoader struct {
    t           *testing.T
    currentPath fs.FS
}

func NewFixtureLoader(t *testing.T, fixturePath fs.FS) *FixtureLoader {
    return &FixtureLoader{
        t:           t,
        currentPath: fixturePath,
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Также добавим к ней 2 метода. Первый из них считывает содержимое файла и возвращает строку с содержимым:

func (l *FixtureLoader) LoadString(path string) string {
    file, err := l.currentPath.Open(path)
    require.NoError(l.t, err)

    defer file.Close()

    data, err := io.ReadAll(file)
    require.NoError(l.t, err)

    return string(data)
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Второй метод использует первый метод для получения строки, а затем разбирает шаблон, используя стандартную библиотеку html/template:

func (l *FixtureLoader) LoadTemplate(path string, data any) string {
    tempData := l.LoadString(path)

    temp, err := template.New(path).Parse(tempData)
    require.NoError(l.t, err)

    buf := bytes.Buffer{}

    err = temp.Execute(&buf, data)
    require.NoError(l.t, err)

    return buf.String()
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы также создадим два файла в папке /api:

create_user_request.json

{
  "name": "test_name"
}
Вход в полноэкранный режим Выход из полноэкранного режима

create_user_response.json.temp

{
  "id": {{.id}},
  "name": "test_name",
  "balance": "0"
}
Войти в полноэкранный режим Выход из полноэкранного режима

В самом тесте мы разберем эти файлы и сравним фактические и ожидаемые значения. Для этого мы создадим две вспомогательные функции, которые помогут сравнить два набора json-данных:

func JSONEq(t *testing.T, expected, actual any) bool {
    return assert.JSONEq(t, jsonMarshal(t, expected), jsonMarshal(t, actual))
}

func jsonMarshal(t *testing.T, data any) string {
    switch v := data.(type) {
    case string:
        return v
    case []byte:
        return string(v)
    case io.Reader:
        data, err := io.ReadAll(v)
        require.NoError(t, err)
        return string(data)
    default:
        res, err := json.Marshal(v)
        require.NoError(t, err)
        return string(res)
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

И тест для createUser:

func (s *TestSuite) TestCreateUser() {
    requestBody := s.loader.LoadString("fixtures/api/create_user_request.json")

    res, err := s.server.Client().Post(s.server.URL+"/users", "", bytes.NewBufferString(requestBody))
    s.Require().NoError(err)
    defer res.Body.Close()

    s.Require().Equal(http.StatusOK, res.StatusCode)

    response := api.CreateUserResponse{}
    err = json.NewDecoder(res.Body).Decode(&response)
    s.Require().NoError(err)

    expected := s.loader.LoadTemplate("fixtures/api/create_user_response.json.temp", map[string]interface{}{
        "id": response.ID,
    })
    JSONEq(s.T(), expected, response)
}
Войти в полноэкранный режим Выход из полноэкранного режима

В результате содержание наших тестов значительно сократилось.

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

Заключение

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

Соавтор: Андрей Лукин

Все примеры кода можно найти здесь

Ссылки:
https://www.sohamkamani.com/golang/options-pattern/

Оцените статью
devanswers.ru
Добавить комментарий