Давайте начнем с понимания того, что такое тестирование. Существует пирамида тестирования (юнит-тесты, интеграционное и end-2-end тестирование).
По горизонтали — количество тестов. По вертикали — стоимость обслуживания.
Юнит-тесты тестируют функцию a+b, где мы описываем положительный и отрицательный сценарий. Существует хорошее видео по юнит-тестам. Эта тема не входит в контекст нашей статьи.
E2e-тесты обычно пишут тестировщики, они нужны для тестирования полного потока внутри истории. Например, мы отправляем запрос на поднятый сервер, сервер его обрабатывает (обращается к другим сервисам, идет к базе данных, к редиске и т.д.). Для нас сервис — это «черный ящик». Мы тестируем ответы на наши запросы.
Однако наиболее эффективным подходом на практике остается следующая схема (тестирование Trophy):
Интеграционные тесты — проверка интеграции тестируемого сервиса с другими сервисами. Мы полностью проверяем работу конкретного сервиса.
Интеграционные тесты становятся основой всего тестирования разработки. Обычно их пишут сами разработчики. Цель этой статьи — показать вам, как просто и эффективно писать интеграционные тесты.
- Интеграционные тесты на примере http-сервера, который взаимодействует с базой данных и обращается к другим сервисам.
- Шаг 1. Тестирование createUser
- Шаг 2. Создание тестового контейнера с Postgres
- Шаг 2.1. Рефакторинг
- Шаг 3. Миграции
- Шаг 4. Тест getUser
- Шаг 5. Тесты
- Шаг 6. Testsuite
- Шаг 7. Тест UpdateUserBalance
- Шаг 8. httpmock
- Шаг 9. Фиксы API
- Заключение
Интеграционные тесты на примере 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")
}
Вот и все для настройки и запуска нашего тестового контейнера. Весь код выглядит следующим образом:
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/