Привет! Меня зовут Сергей, я старший разработчик в Ozon и раньше вообще не был замечен в QA.
Все мы привыкли к лёгкому написанию тестов на Python и Java — это основные языки автотестировщиков с богатым инструментарием утилит и всего, что упрощает жизнь. Что нужно для написания автотестов для HTTP-сервиса на Python или Java? Гугл, бутылочка крафта и два часа времени.
А как быть в случае с Go? Как раз на нём мы в большинстве случаев пишем микросервисы. И если тесты написаны на другом языке, разработчики не могут внести в них свой вклад или отревьюить их. Поэтому внутри Ozon активно развивается Go-сообщество QA, и этим ребятам тоже нужно тестировать HTTP-сервисы и проверять отчёты в Allure. Как настоящие сварщики мы подумали: «Если чего-то не хватает, нужно написать своё». Сказано — сделано: встречайте опенсорс-библиотеку CUTE в BDD-стиле, которая облегчает тяготы создания автотестов и упрощает переход на Go. Главные фичи: создание HTTP-тестов, возможность реализовывать проверки из коробки, Allure-отчёты и низкий порог входа.

Дисклеймер: Мои коллеги ранее уже рассказывали, как у нас тестируют на Go. Также не так давно мы писали про опенсорс-библиотеку Allure-Go. А сегодня речь пойдёт о библиотеке CUTE (Create Your Tests Easily).
Моя команда делает бэкенд для мобильного приложения, но к нам не ходят по gRPC, как это принято в Ozon. Подходящих инструментов для тестирования HTTP-сервисов внутри компании раньше не было, а те, что существовали вне, не подходили нам и не работали с Allure (что было важно).
Мы решили облегчить тяготы наших тестировщиков и создать инструмент для тестирования HTTP-сервисов, который в итоге перерос в библиотеку.
Как было раньше?
Обычно тест состоит из следующих шагов:
Подготовить HTTP-клиент.
Создать данные для теста.
Выполнить HTTP-запрос.
Убедиться, что запрос выполнился.
Считать ответ в структуру.
Начать проверять структуру.
Если вы хотите ещё всё обернуть в Allure, то может получиться немало кода, который нужно поддерживать и передавать другим.
Ранее на Хабре уже рассказывали, как писать тесты для HTTP-сервисов в связке с Allure (рекомендую к прочтению, чтобы сравнить сложность подходов).
Как будет?
Рассмотрим самый простой кейс, когда нужно сделать запрос и проверить пару полей:
import ( "context" "net/http" "testing" "time" "github.com/ozontech/cute" "github.com/ozontech/cute/asserts/json" ) func TestExample(t *testing.T) { cute.NewTestBuilder(). Title("Title"). // Задаём название для теста Description("Description"). // Придумываем описание // Тут можно ещё добавить много разных тегов и лейблов, которые поддерживаются Allure Create(). RequestBuilder( // Создаём HTTP-запрос cute.WithURI("https://jsonplaceholder.typicode.com/posts/1/comments"), cute.WithMethod(http.MethodGet), ). ExpectExecuteTimeout(10*time.Second). // Указываем, что запрос должен выполниться за 10 секунд ExpectStatus(http.StatusOK). // Ожидаем, что ответ будет 200 (OK) AssertBody( // Задаём проверку JSON в response body по определённым полям json.Equal("$[0].email", "super@puper.biz"), json.Present("$[1].name"), ). ExecuteTest(context.Background(), t) }
А если в тесте возникнут какие-то проблемы, то отчёт будет такой:

При этом мы поддержали все возможные теги и лейблы Allure.
Сейчас мы рассмотрели самый простой тест с минимальным количеством информации, проверок и без каких-либо дополнений.
Заинтересовались? Тогда давайте рассмотрим все возможности библиотеки.
Строим тест
Шаг 0. Начало начал
Всё с чего-то начинается. Наш тест начинается с подготовки сервиса, который в дальнейшем будет нам помогать создавать тесты cute.NewHTTPTestMakerSuite(opts ...Option)
testMaker := cute.NewHTTPTestMakerSuite(opts ...Option)
При инициализации testMaker вы можете указать базовые настройки для тестов, например HTTP-клиент, если это вам важно. А можете ничего не настраивать — и всё будет работать из коробки.
В дальнейшем testMaker будет нам пригождаться на протяжении всего пути создания тестов. Поэтому если вы создаёте пакет/набор тестов, то советую сохранить testMaker в общую для тестов структуру. Реализацию таких тестов можете найти в репозитории проекта.
Далее необходимо инициализировать сам билдер, его уже нужно создавать для каждого теста, так как он содержит в себе всю информацию о тесте.
testBuilder := testMaker.NewTestBuilder()
Если вам не важны настройки, то вы можете использовать заготовленный билдер:
cute.NewTestBuilder()
Отлично, на этом шаг 0 закончен! И мы готовы приступать к созданию теста.
Шаг 1. Информируем всех
Как это обычно бывает, нам необходимо указывать подробную информацию о тесте, чтобы в будущем не забыть его назначение. Благо Allure позволяет это сделать, и мы можем добавить много информации о тесте:
cute.NewTestBuilder(). Title("TestExample_Simple"). // Заголовок Tags("simple", "some_local_tag" ,"some_global_tag", "json"). // Теги для поиска Feature("some_feature"). // Фича Epic("some_epic"). // Эпик Description("some_description"). // Описание теста
В примере выше указаны не все лейблы, со всеми остальными вы можете познакомиться внутри проекта.
Теперь информация о тесте появится у нас в отчётах.

Шаг 2. Помни о прошлом, не забывай о будущем
Порой бывают ситуации, когда необходимо предварительно подготовить запрос или выполнить какие-то действия после прохождения теста.
Вы можете это сделать вне теста, но также предусмотрены методы для упрощения работы:
BeforeExecute(func(req *http.Request) error) AfterExecute(func(resp *http.Response, errs []error) error)
Данные методы не обязательны, поэтому вы можете их пропустить.
Пример:
cute.NewTestBuilder(). Create(). BeforeExecute(func(req *http.Request) error { /* Необходимо добавить реализацию */ return nil }). AfterExecute(func(resp *http.Response, errs []error) error { /* Необходимо добавить реализацию */ return nil })
Ещё есть AfterExecuteT и BeforeExecuteT, которые позволяют добавить информацию в Allure, например создать новый шаг:
func BeforeExample(t cute.T, req *http.Request) error { t.WithNewStep("insideBefore", func(stepCtx provider.StepCtx) { now := time.Now() stepCtx.Logf("Test. Start time %v", now) stepCtx.WithNewParameters("Test. Start time", now) time.Sleep(2 * time.Second) }) return nil }
Allure:

Шаг 3. Создаем запрос
Существует два способа передать запрос в тесте.
Если у вас уже создан *http.Request, вы можете просто воспользоваться Request(*http.Request):
req, _ := http.NewRequest(http.MethodGet, "http://google.com", nil) cute.NewTestBuilder(). Create(). Request(req) // Передача запроса
Второй способ подойдёт, если у вас нет подготовленного запроса. Необходимо вызвать RequestBuilder и самостоятельно собрать запрос.
Выглядит это примерно так:
cute.NewTestBuilder(). Create(). RequestBuilder( // Создание запроса cute.WithHeaders(map[string][]string{ "some_header": []string{"something"}, "some_array_header": []string{"1", "2", "3", "some_thing"}, }), cute.WithURI("http://google.com"), cute.WithMethod(http.MethodGet), )
Все данные отображаются в Allure, поэтому любой человек, не погружаясь в код, может повторить запрос.

Отлично, самое скучное позади! Мы заполнили информацию о тесте, подготовили дополнительные шаги и создали запрос — настало время проверок!
Шаг 4. Доверяй, но проверяй!
Хочу заметить, что все проверки не обязательны и вы вольны выбирать, какие из них вам необходимы. Я же покажу все возможные варианты.
Response code
Начнём с элементарного — с проверки кода ответа. В этом нам поможет ExpectStatus(int).
Пример:
cute.NewTestBuilder(). Create(). Request(*http.Request). // Передача запроса ExpectStatus(201) // Ожидаем, что ответ будет 201 (Created)
Allure:

JSON-схема
Перейдём к простому — к проверке JSON-схемы. Многим важно всегда иметь чёткую схему ответа.
Существует три способа проверить JSON-схему. Всё зависит от того, где она у вас находится.
ExpectJSONSchemaString(string)— получает и сравнивает JSON-схему из строки.ExpectJSONSchemaByte([]byte)— получает и сравнивает JSON-схему из массива байтов.ExpectJSONSchemaFile(string)— получает и сравнивает JSON-схему из файла или удалённого ресурса.
Пример:
cute.NewTestBuilder(). Create(). Request(*http.Request). // Передача запроса ExpectJSONSchemaFile("file://./project/resources/schema.json"). // Проверка response body по JSON schema
В случае ошибки отчёт в Allure будет таким:

Отлично, самые простые проверки позади. Пора проверить наши запросы по-настоящему!
Шаг 5. Я сказал, нам нужны настоящие проверки!
Настало время создать настоящие ассерты и проверить полностью наш response.
В библиотеке есть подготовленные ассерты для проверки заголовков, для работы с JSON в теле ответа, но также вы можете создавать свои.
Существует три типа проверок:
Проверка тела ответа (response body)
AssertBodyиAssertBodyTПроверка заголовков ответа (response headers)
AssertHeadersиAssertHeadersTПолная проверка ответа (response)
AssertResponseиAssertResponseT
Рассмотрим подробнее.
Проверка тела ответа (AssertBody)
Для проверок тела ответа в библиотеке есть несколько готовых решений.
Рассмотрим пример: допустим, нам надо из JSON достать поле “email” и убедиться, что значение равно “lol@arbidol.com”:
{ "email": "lol@arbidol.com" }
Для этого воспользуемся заготовленным ассертом из пакета:
cute.NewTestBuilder(). Create(). Request(*http.Request). // Передача запроса AssertBody(json.Equal("$.email", "lol@arbidol.com")) // Валидация поля “email” в response body
И это только один из примеров. В пакете есть целый набор ассертов:
ContainsEqualNotEqualLengthGreaterThanLessThanPresentNotPresent
В случае если ассерт не выполнился, в Allure появится красивый результат:

Проверка заголовков ответа (AssertHeaders)
С заголовками такая же история, как с телом ответа. Необходимо использовать:
AssertHeaders(func(headers http.Headers) error) AssertHeadersT(func(t cute.T, headers http.Headers) error)
Есть несколько готовых ассертов:
PresentNotPresent
Пример:
cute.NewTestBuilder(). Create(). Request(*http.Request). // Передача запроса AssertHeaders(headers.Present("Content-Type")) // Проверка, что в заголовках есть “Content-Type”
Полная проверка ответа (AssertResponse)
Когда необходимо проверить одновременно headers, body и ещё что-то из структуры http.Response, подойдёт:
AssertResponse(func(resp *http.Response) error) AssertResponseT(func(t cute.T, resp *http.Response) error)
Пример:
func CustomAssertResponse() cute.AssertResponse { return func(resp *http.Response) error { if resp.ContentLength == 0 { return errors.New("content length is zero") } return nil } }
Шаг 6. Хочу быть самостоятельным!
В 5 шаге мы рассмотрели уже готовые ассерты и какие типы существуют. Но бывает, что хочется создать что-то своё и написать свой ассерт.
Для этого необходимо создать функцию, которая будет реализовывать один из типов:
type AssertBody func(body []byte) errortype AssertHeaders func(headers http.Header) errortype AssertResponse func(response *http.Response) errortype AssertBodyT func(t cute.T, body []byte) errortype AssertHeadersT func(t cute.T, headers http.Header) errortype AssertResponseT func(t cute.T, response *http.Response) error
Пример:
func customAssertHeaders() cute.AssertHeaders { return func(headers http.Header) error { if len(headers) == 0 { return errors.New("response without headers") } return nil } }
Ещё есть функции c cute.T, которые позволяют добавить информацию в Allure, например создать новый шаг:
func customAssertBody() cute.AssertBodyT { return func(t cute.T, body []byte) error { step := allure.NewSimpleStep("Custom assert step") defer func() { t.Step(step) }() if len(body) == 0 { step.Status = allure.Failed step.WithAttachments(allure.NewAttachment("Error", allure.Text, []byte("response body is empty"))) return nil } return nil }
И далее просто добавляете свой ассерт в тест:
cute.NewTestBuilder(). Create(). Request(*http.Request). // Передача запроса AssertHeaders(customAssertHeaders). AssertBodyT(customAssertBody)
Custom error
Вы могли заметить, что в результатах заготовленных ассертов существуют набор полей: Name, Error, Action и Expected.
Чтобы добавить такие данные в ваши кастомные ассерты, вы можете использовать:
cuteErrors.NewAssertError(name string, err string, actual interface{}, expected interface{}) error
Пример:
import ( cuteErrors "github.com/ozontech/cute/errors" ) func customErrorExample (t cute.T, headers http.Header) error { return cuteErrors.NewAssertError("custom_assert", "example custom assert", "empty", "not empty") // Пример создания красивой ошибки }
Соответственно, в Allure появится такая красота:

Optional asserts
Иногда необходимо добавить проверку, при невыполнении которой, тест не будет считаться проваленным.
import ( cuteErrors "github.com/ozontech/cute/errors" ) func customOptionalError(body []byte) error { return cuteErrors.NewOptionalError(errors.New("some optional error from creator")) // Пример создания опциональной ошибки }
Шаг 7. Финал
Для создания самого теста нам нужно вызвать ExecuteTest(context.Context, testing.TB).
В ExecuteTest вы можете передать обычный testing.T или provider.T. Если вы используете allure-go, это не имеет значения — всё равно будет создан отчёт.
В итоге, если соединить все шаги, у нас получится такой пример:
import ( "context" "errors" "net/http" "testing" "time" "github.com/ozontech/cute" "github.com/ozontech/cute/asserts/headers" "github.com/ozontech/cute/asserts/json" ) func TestExampleTest(t *testing.T) { cute.NewTestBuilder(). Title("TestExample_OneStep"). Tags("one_step", "some_local_tag", "json"). Feature("some_feature"). Epic("some_epic"). Description("some_description"). CreateWithStep(). StepName("Example GET json request"). AfterExecuteT(func(t cute.T, resp *http.Response, errs []error) error { if len(errs) != 0 { return nil } /* Необходимо добавить реализацию */ return nil }, ). RequestBuilder( cute.WithHeaders(map[string][]string{ "some_header": []string{"something"}, "some_array_header": []string{"1", "2", "3", "some_thing"}, }), cute.WithURI("https://jsonplaceholder.typicode.com/posts/1/comments"), cute.WithMethod(http.MethodGet), ). ExpectExecuteTimeout(10*time.Second). ExpectStatus(http.StatusOK). AssertBody( json.Equal("$[0].email", "Eliseo@gardner.biz"), json.Present("$[1].name"), json.NotPresent("$[1].some_not_present"), json.GreaterThan("$", 3), json.Length("$", 5), json.LessThan("$", 100), json.NotEqual("$[3].name", "kekekekeke"), // Custom assert body func(bytes []byte) error { if len(bytes) == 0 { return errors.New("response body is empty") } return nil }, ). AssertBodyT( func(t cute.T, body []byte) error { /* Здесь должна быть реализация с T */ return nil }, ). AssertHeaders( headers.Present("Content-Type"), ). AssertResponse( func(resp *http.Response) error { if resp.ContentLength == 0 { return errors.New("content length is zero") } return nil }, ). ExecuteTest(context.Background(), t) }
Allure:

Милый, принеси нам итоги, мы дочитали статью
В Go активно развивается культура тестирования. Не многие компании готовы к экспериментам — пробовать Go в QA. Мы в Ozon пошли на это и не пожалели: удобно, когда разработчики и тестировщики общаются на одном языке и разработчик может отревьюить или поправить автотесты.
Надеюсь, наша милая библиотека вам пригодится. Вы можете самостоятельно изучить примеры и, если будут вопросы, задайте их в комментариях.
Если вас заинтересовало QA на Go, приходите на наш бесплатный курс «Автоматическое тестирование веб-сервисов на Go» — это 52 часа теории и практики от экспертов Ozon. Следующий набор стартует в августе.
