> For the complete documentation index, see [llms.txt](https://eda-1.gitbook.io/lgwt/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://eda-1.gitbook.io/lgwt/osnovy-testirovaniya/intro-to-acceptance-tests.md).

# Введение в приемочное тестирование

В `$WORK` мы столкнулись с необходимостью реализовать "graceful shutdown" (изящное завершение) для наших сервисов. Изящное завершение гарантирует, что система корректно завершит свою работу перед остановкой. Если провести аналогию с реальным миром, это похоже на попытку завершить телефонный разговор, прежде чем перейти к следующей встрече, вместо того чтобы бросить трубку на полуслове.

Эта глава познакомит вас с изящным завершением работы HTTP-сервера и расскажет, как писать "приемочные тесты", чтобы быть уверенным в поведении вашего кода.

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

## Немного о Kubernetes

Мы запускаем наше программное обеспечение на [Kubernetes](https://kubernetes.io/) (K8s). K8s завершает "поды" (по сути, наше ПО) по разным причинам, и одна из самых частых — когда мы выкатываем новый код.

Мы придерживаемся высоких стандартов в отношении [метрик DORA](https://cloud.google.com/blog/products/devops-sre/using-the-four-keys-to-measure-your-devops-performance), поэтому работаем так, чтобы выкатывать небольшие инкрементальные улучшения и функции в продакшн несколько раз в день.

Когда k8s хочет завершить под, он запускает ["жизненный цикл завершения"](https://cloud.google.com/blog/products/containers-kubernetes/kubernetes-best-practices-terminating-with-grace), частью которого является отправка сигнала SIGTERM нашему ПО. Это способ k8s сказать нашему коду:

> Тебе нужно завершить себя, закончи всю текущую работу, потому что после определённого "периода ожидания" я отправлю SIGKILL, и для тебя наступит конец.

При получении SIGKILL любая работа, которую выполняла ваша программа, немедленно прекращается.

## Если у вас нет изящного завершения

В зависимости от природы вашего ПО, игнорирование SIGTERM может привести к проблемам.

Наша конкретная проблема была связана с выполняющимися HTTP-запросами. Когда автоматизированный тест обращался к нашему API, если k8s решал остановить под, сервер умирал, тест не получал ответа от сервера и падал.

Это вызывало оповещение в нашем канале инцидентов, и разработчику приходилось останавливаться и решать проблему. Такие периодические сбои отвлекают нашу команду.

Эти проблемы уникальны не только для наших тестов. Если пользователь отправляет запрос к вашей системе, а процесс завершается на полпути, он, скорее всего, увидит HTTP-ошибку 5xx — не лучший пользовательский опыт.

## Когда у вас есть изящное завершение

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

* Прекратить принимать новые запросы
* Дать завершиться всем выполняющимся запросам
* *Затем* завершить процесс

## Как реализовать изящное завершение

К счастью, в Go уже есть механизм для изящного завершения сервера с помощью [net/http/Server.Shutdown](https://pkg.go.dev/net/http#Server.Shutdown).

> Shutdown изящно завершает работу сервера, не прерывая активные соединения. Shutdown сначала закрывает все открытые слушатели (listeners), затем закрывает все простаивающие соединения, а затем бесконечно ожидает, пока соединения не перейдут в состояние простоя, и завершает работу. Если переданный контекст истекает до завершения Shutdown, возвращается ошибка контекста, в противном случае возвращается ошибка, полученная при закрытии базовых слушателей сервера.

Для обработки SIGTERM мы можем использовать [os/signal.Notify](https://pkg.go.dev/os/signal#Notify), который будет отправлять входящие сигналы в предоставленный нами канал.

Используя эти две возможности стандартной библиотеки, вы можете прослушивать SIGTERM и выполнять изящное завершение.

## Пакет для изящного завершения

С этой целью я написал <https://pkg.go.dev/github.com/quii/go-graceful-shutdown>. Он предоставляет функцию-декоратор для `*http.Server`, которая вызывает его метод `Shutdown` при обнаружении сигнала SIGTERM.

```go
func main() {
	var (
		ctx        = context.Background()
		httpServer = &http.Server{Addr: ":8080", Handler: http.HandlerFunc(acceptancetests.SlowHandler)}
		server     = gracefulshutdown.NewServer(httpServer)
	)

	if err := server.ListenAndServe(ctx); err != nil {
		// обычно это происходит, если ответы не были отправлены до истечения дедлайна ctx, мало что можно сделать
		log.Fatalf("не удалось завершиться изящно, некоторые ответы могли быть потеряны %v", err)
	}

	// вы всегда должны видеть это сообщение
	log.Println("завершение выполнено изящно! все ответы были отправлены")
}
```

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

## Тесты и петли обратной связи

Когда мы писали пакет `gracefulshutdown`, у нас были модульные тесты, подтверждающие его корректное поведение, что давало нам уверенность для агрессивного рефакторинга. Однако мы всё ещё не были "уверены", что это **действительно** работает.

Мы добавили пакет `cmd` и создали реальную программу для использования нашего пакета. Мы вручную запускали её, отправляли HTTP-запрос и затем посылали SIGTERM, чтобы посмотреть, что произойдёт.

**В вас, как в инженере, должно зародиться беспокойство по поводу ручного тестирования**. Это скучно, не масштабируется, неточно и расточительно. Если вы пишете пакет, который планируете распространять, но также хотите, чтобы его было просто и дёшево изменять, ручное тестирование не подойдёт.

## Приемочные тесты

Если вы читали остальные части этой книги, вы в основном писали "модульные тесты". Модульные тесты — отличный инструмент для бесстрашного рефакторинга, стимулирования хорошей модульной архитектуры, предотвращения регрессий и обеспечения быстрой обратной связи.

По своей природе они тестируют только небольшие части вашей системы. Обычно одних модульных тестов *недостаточно* для эффективной стратегии тестирования. Помните, мы хотим, чтобы наши системы были **всегда готовы к выкатке**. Мы не можем полагаться на ручное тестирование, поэтому нам нужен другой вид тестирования: **приемочные тесты**.

### Что это такое?

Приемочные тесты — это разновидность "тестирования чёрного ящика". Их иногда называют "функциональными тестами". Они должны проверять систему так, как это делал бы её пользователь.

Термин "чёрный ящик" означает, что тестовый код не имеет доступа к внутренностям системы, он может использовать только её публичный интерфейс и делать утверждения о наблюдаемом поведении. Это означает, что они могут тестировать систему только целиком.

Это преимущество, потому что тесты проверяют систему так же, как и пользователь, они не могут использовать специальные обходные пути, которые могли бы сделать тест проходным, но на самом деле не доказать то, что нужно. Это похоже на принцип размещения файлов модульных тестов в отдельном тестовом пакете, например, `package mypkg_test` вместо `package mypkg`.

### Преимущества приемочных тестов

* Когда они проходят, вы знаете, что вся ваша система ведёт себя так, как вы хотите.
* Они точнее, быстрее и требуют меньше усилий, чем ручное тестирование.
* При хорошем написании они служат точной, проверенной документацией вашей системы. Они не страдают проблемой расхождения документации с реальным поведением системы.
* Нет моков! Всё по-настоящему.

### Потенциальные недостатки по сравнению с модульными тестами

* Их дорого писать.
* Они выполняются дольше.
* Они зависят от архитектуры системы.
* Когда они падают, они обычно не указывают на первопричину, и их может быть сложно отлаживать.
* Они не дают обратной связи о внутреннем качестве вашей системы. Вы можете написать полную ерунду, и при этом приемочный тест пройдёт.
* Из-за природы чёрного ящика не все сценарии практично проверять.

По этой причине полагаться только на приемочные тесты неразумно. У них нет многих качеств модульных тестов, и система с большим количеством приемочных тестов будет страдать от высоких затрат на поддержку и плохого времени выполнения (lead time).

#### Время выполнения?

Время выполнения (lead time) — это время от момента, когда коммит вливается в вашу основную ветку, до его развёртывания в продакшне. Этот показатель может варьироваться от недель и даже месяцев для некоторых команд до нескольких минут. Опять же, в `$WORK` мы ценим выводы DORA и хотим удерживать время выполнения менее 10 минут.

Для создания надёжной системы с отличным временем выполнения требуется сбалансированный подход к тестированию, который обычно описывается в терминах [Пирамиды тестирования](https://martinfowler.com/articles/practical-test-pyramid.html).

## Как писать базовые приемочные тесты

Как это относится к исходной проблеме? Мы только что написали пакет, и он полностью покрывается модульными тестами.

Как я уже упоминал, модульных тестов было недостаточно для нужной нам уверенности. Мы хотим быть *действительно* уверены, что пакет работает при интеграции с реальной, работающей программой. Мы должны иметь возможность автоматизировать те ручные проверки, которые мы проводили.

Давайте посмотрим на тестовую программу:

```go
func main() {
	var (
		ctx        = context.Background()
		httpServer = &http.Server{Addr: ":8080", Handler: http.HandlerFunc(acceptancetests.SlowHandler)}
		server     = gracefulshutdown.NewServer(httpServer)
	)

	if err := server.ListenAndServe(ctx); err != nil {
		// обычно это происходит, если ответы не были отправлены до истечения дедлайна ctx, мало что можно сделать
		log.Fatalf("не удалось завершиться изящно, некоторые ответы могли быть потеряны %v", err)
	}

	// вы всегда должны видеть это сообщение
	log.Println("завершение выполнено изящно! все ответы были отправлены")
}
```

Вы, возможно, догадались, что `SlowHandler` использует `time.Sleep` для задержки ответа, чтобы у меня было время отправить SIGTERM и посмотреть, что произойдёт. Остальное довольно шаблонно:

* Создаём `net/http/Server`;
* Оборачиваем его в библиотеку (см.: [Шаблон Декоратор](https://en.wikipedia.org/wiki/Decorator_pattern));
* Используем обёрнутую версию для `ListenAndServe`.

### Основные шаги приемочного теста

* Собрать программу
* Запустить её (и дождаться, пока она начнёт слушать на порту `8080`)
* Отправить HTTP-запрос на сервер
* До того, как сервер успеет отправить HTTP-ответ, отправить SIGTERM
* Проверить, получили ли мы всё ещё ответ

### Сборка и запуск программы

```go
package acceptancetests

import (
	"fmt"
	"math/rand"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"syscall"
	"time"
)

const (
	baseBinName = "temp-testbinary"
)

func LaunchTestProgram(port string) (cleanup func(), sendInterrupt func() error, err error) {
	binName, err := buildBinary()
	if err != nil {
		return nil, nil, err
	}

	sendInterrupt, kill, err := runServer(binName, port)

	cleanup = func() {
		if kill != nil {
			kill()
		}
		os.Remove(binName)
	}

	if err != nil {
		cleanup() // даже если он не слушает корректно, программа всё ещё может выполняться
		return nil, nil, err
	}

	return cleanup, sendInterrupt, nil
}

func buildBinary() (string, error) {
	binName := randomString(10) + "-" + baseBinName

	build := exec.Command("go", "build", "-o", binName)

	if err := build.Run(); err != nil {
		return "", fmt.Errorf("не удалось собрать инструмент %s: %s", binName, err)
	}
	return binName, nil
}

func runServer(binName string, port string) (sendInterrupt func() error, kill func(), err error) {
	dir, err := os.Getwd()
	if err != nil {
		return nil, nil, err
	}

	cmdPath := filepath.Join(dir, binName)

	cmd := exec.Command(cmdPath)

	if err := cmd.Start(); err != nil {
		return nil, nil, fmt.Errorf("не удалось запустить конвертер: %s", err)
	}

	kill = func() {
		_ = cmd.Process.Kill()
	}

	sendInterrupt = func() error {
		return cmd.Process.Signal(syscall.SIGTERM)
	}

	err = waitForServerListening(port)

	return
}

func waitForServerListening(port string) error {
	for i := 0; i < 30; i++ {
		conn, _ := net.Dial("tcp", net.JoinHostPort("localhost", port))
		if conn != nil {
			conn.Close()
			return nil
		}
		time.Sleep(100 * time.Millisecond)
	}
	return fmt.Errorf("ничего не слушает на localhost:%s", port)
}

func randomString(n int) string {
	var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")

	s := make([]rune, n)
	for i := range s {
		s[i] = letters[rand.Intn(len(letters))]
	}
	return string(s)
}
```

`LaunchTestProgram` отвечает за:

* сборку программы
* запуск программы
* ожидание, пока она начнёт слушать на порту `8080`
* предоставление функции `cleanup` для завершения программы и её удаления, чтобы после завершения тестов мы оставались в чистом состоянии
* предоставление функции `interrupt` для отправки программе SIGTERM, чтобы мы могли протестировать поведение

Стоит признать, что это не самый красивый код в мире, но сосредоточьтесь на экспортируемой функции `LaunchTestProgram`, вызываемые ею неэкспортируемые функции — неинтересный шаблонный код.

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

### Приемочные тесты

Мы хотели создать два приемочных теста для двух программ: одну с изящным завершением и одну без, чтобы мы и читатели могли увидеть разницу в поведении. С помощью `LaunchTestProgram` для сборки и запуска программ довольно просто писать приемочные тесты для обоих случаев, и мы выигрываем от повторного использования с помощью вспомогательных функций.

Вот тест для сервера *с* изящным завершением, [тест без него вы можете найти на GitHub](https://github.com/quii/go-graceful-shutdown/blob/main/acceptancetests/withoutgracefulshutdown/main_test.go)

```go
package main

import (
	"testing"
	"time"

	"github.com/quii/go-graceful-shutdown/acceptancetests"
	"github.com/quii/go-graceful-shutdown/assert"
)

const (
	port = "8080"
	url  = "<http://localhost:" + port
)

func TestGracefulShutdown(t *testing.T) {
	cleanup, sendInterrupt, err := acceptancetests.LaunchTestProgram(port)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(cleanup)

	// просто проверяем, что сервер работает до завершения
	assert.CanGet(t, url)

	// отправляем запрос и до того, как он получит ответ, отправляем SIGTERM.
	time.AfterFunc(50*time.Millisecond, func() {
		assert.NoError(t, sendInterrupt())
	})
	// Без изящного завершения этот тест бы упал
	assert.CanGet(t, url)

	// после прерывания сервер должен завершиться, и новые запросы не будут работать
	assert.CantGet(t, url)
}
```

Благодаря инкапсуляции настройки тесты получаются исчерпывающими, описывают поведение и относительно просты для понимания.

`assert.CanGet/CantGet` — это вспомогательные функции, которые я создал, чтобы избежать повторения в этом наборе тестов.

```go
func CanGet(t testing.TB, url string) {
	errChan := make(chan error)

	go func() {
		res, err := http.Get(url)
		if err != nil {
			errChan <- err
			return
		}
		res.Body.Close()
		errChan <- nil
	}()

	select {
	case err := <-errChan:
		NoError(t, err)
	case <-time.After(3 * time.Second):
		t.Errorf("таймаут ожидания запроса к %q", url)
	}
}
```

Эта функция отправляет `GET` на `URL` в горутине, и если она отвечает без ошибок в течение 3 секунд, тест не падает. `CantGet` опущена для краткости, [но вы можете посмотреть её на GitHub здесь](https://github.com/quii/go-graceful-shutdown/blob/main/assert/assert.go#L61).

Важно отметить, что в Go есть все необходимые инструменты для написания приемочных тестов "из коробки". Вам не нужен *специальный* фреймворк для создания приемочных тестов.

### Небольшие вложения с большой отдачей

Благодаря этим тестам читатели могут посмотреть на примеры программ и быть уверенными, что пример *действительно* работает, и, следовательно, доверять утверждениям пакета.

Важно, что как автор мы получаем **быструю обратную связь** и **огромную уверенность** в том, что пакет работает в реальных условиях.

```shell
go test -count=1 ./...
ok  	github.com/quii/go-graceful-shutdown	0.196s
?   	github.com/quii/go-graceful-shutdown/acceptancetests	[no test files]
ok  	github.com/quii/go-graceful-shutdown/acceptancetests/withgracefulshutdown	4.785s
ok  	github.com/quii/go-graceful-shutdown/acceptancetests/withoutgracefulshutdown	2.914s
?   	github.com/quii/go-graceful-shutdown/assert	[no test files]
```

## Подведение итогов

В этом посте мы добавили приемочные тесты в ваш арсенал инструментов тестирования. Они бесценны, когда вы начинаете создавать реальные системы, и являются важным дополнением к вашим модульным тестам.

Природа *того, как* писать приемочные тесты, зависит от системы, которую вы создаёте, но принципы остаются теми же. Относитесь к своей системе как к "чёрному ящику". Если вы создаёте веб-сайт, ваши тесты должны действовать как пользователь, поэтому вам понадобится безголовый веб-браузер, например [Selenium](https://www.selenium.dev/), чтобы кликать по ссылкам, заполнять формы и т.д. Для RESTful API вы будете отправлять HTTP-запросы с помощью клиента.

### Продолжаем для более сложных систем

Нетривиальные системы редко бывают однопроцессными приложениями, как та, которую мы обсуждали. Обычно вы зависите от других систем, таких как база данных. Для таких сценариев вам понадобится автоматизировать локальное окружение для тестирования. Такие инструменты, как [docker-compose](https://docs.docker.com/compose/), полезны для запуска контейнеров с окружением, необходимым для работы вашей системы локально.

### Следующая глава

В этом посте приемочный тест был написан постфактум. Однако в книге [Growing Object-Oriented Software](http://www.growing-object-oriented-software.com) авторы показывают, что мы можем использовать приемочные тесты в тесто-ориентированном подходе как "путеводную звезду" для направления наших усилий.

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

Следующая глава познакомит с использованием приемочных тестов для направления нашей архитектуры, а также с принципами и техниками управления затратами на приемочные тесты.

### Повышение качества открытого кода

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

Как [Testable Examples](https://go.dev/blog/examples), проявление этого небольшого дополнительного усилия в разработке очень помогает укрепить доверие к вашей работе и снизить ваши собственные затраты на поддержку.

## Рекрутинговое объявление для `$WORK`

Если вы хотите работать в среде с другими инженерами, решающими интересные проблемы, живёте в Лондоне или Порту, и вам понравилось содержание этой главы и книги — пожалуйста, [напишите мне в Twitter](https://twitter.com/quii), возможно, мы скоро будем работать вместе!


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://eda-1.gitbook.io/lgwt/osnovy-testirovaniya/intro-to-acceptance-tests.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
