Выпекаем тесты на Go с Testo: делимся нашим open-source-фреймворком
- вторник, 4 августа 2026 г. в 00:00:12

Привет, Хабр! Я Вадим, разработчик QA-платформы в Ozon: делаю тестирование на Go лёгким и развиваю нашу собственную TMS-систему.
Большим end-to-end-тестам часто необходима целая инфраструктура: наглядные отчёты, подключение к внешним ресурсам, хуки, фикстуры, параметризация, параллельные запуски и возможность расширения под процессы конкретной команды. Когда таких сценариев становится много, разрозненные решения усложняют разработку и поддержку тестов.
С этим мы столкнулись внутри Ozon. Нам было важно сохранить привычный запуск через go test и возможности Go-экосистемы, при этом добавить то, что обычно требуется большим E2E: расширяемость через плагины, организацию тестов в Suite’ы и Allure-отчёты. Сейчас на нашем решении написано более 60 тысяч уникальных тестов для 500+ сервисов. Мы рады поделиться нашей работой с сообществом, и в этой статье я расскажу про наш open-source-фреймворк для написания автотестов на Go — Testo.
Go — отличный язык для написания сервисов: низкий порог входа, богатая экосистема и отличная производительность. А как обстоит дело с автотестами? Несмотря на то что Go «из коробки» имеет набор инструментов для тестирования, для больших end-to-end-сценариев этого бывает мало. Часто требуются Allure-отчёты, подключения к сторонним ресурсам, и специфичный для каждой команды функционал.
Allure — это формат и инструмент визуализации тестовых отчётов. Он показывает статусы тестов, шаги, вложения, ошибки и историю запусков. Для больших e2e-наборов это удобнее, чем разбирать сырой лог.
Логично задуматься: зачем тогда использовать Go в тестировании, если есть, например, Pytest, в котором имеется поддержка плагинов для решения многих проблем? И всё же есть весомые причины рассмотреть Go для автотестов:
Единый код для сервиса и тестов, если сервис тоже написан на Go, — общая инфраструктура, утилиты и модели.
Крайне эффективная параллельность тестов: в Go можно запускать тысячи тестов одновременно, значительно сокращая время прогона тестов.
Строгая типизация хорошо подходит тестам, часть ошибок можно поймать ещё на этапе компиляции — например, если в важном тесте мы случайно передали строку вместо числа.
Тем не менее упомянутые недостатки всё ещё остаются. Мы захотели разобраться с ними, чтобы Go не уступал другим платформам, и воспользоваться его преимуществами. Для этого нам было нужно:
Расширение функционала через плагины как в Pytest — формирование отчётов, планирование запуска, хуки и т. д.
Совместимость со стандартными инструментами — запуск через команду go test и поддержка библиотек, написанных для testing.
Организация тестов в Suite’ах и вне их.
Поддержка параллельных тестов любой вложенности.
Сохранение привычного вида Go-тестов.
Изучив существующие инструменты для Go, мы не нашли единого решения, которое удовлетворяло бы всем этим требованиям. Поэтому мы решили разработать собственный фреймворк тестирования. А теперь мы делимся им с сообществом.
Раньше часть этих задач мы решали с Allure-Go. Со временем его архитектура стала нас ограничивать: тестовый фреймворк и формирование Allure-отчётов были неразрывно связаны между собой, без возможности дополнительно расширять функционал. Подробнее об этом я рассказал в докладе на нашем QA-митапе. В Testo мы решили эту проблему, сделав фреймворк модульным.
Проще всего показать Testo на примере. Начнём с обычного Go-теста без дополнительных фреймворков и постепенно будем его развивать:
func TestBake(t *testing.T) { t.Run("Медовик", func(t *testing.T) { medovik, ok := Bake("медовик") if !ok { t.Fatal("должны уметь печь медовик") } // не выбрасывать же после теста t.Cleanup(func() { Eat(medovik) }) if !medovik.Tasty { t.Error("медовик должен получиться вкусным") } }) }
Иногда медовик получается не таким вкусным, как хотелось бы, и тест не проходит. Можно вывести лог, чтобы узнать причину подробнее, но если таких тестов наберётся десяток или сотня, то разбор логов станет намного сложнее. Поэтому мы хотим формировать наглядный отчёт о выполнении каждого теста.
В этом поможет Testo. Сначала потребуется его установить:
go get github.com/ozontech/testo
У Testo нет зависимостей, фреймворк в основе требует только стандартную библиотеку;
go.modпроекта останется компактным.
И адаптируем наш тест для запуска через Testo. Для этого достаточно такого изменения:
-t.Run("Медовик", func(t *testing.T) { +t.Run("Медовик", testo.Test(func(t *testo.T) {
testo.T— это надстройка надtesting.T, повторяющая его интерфейс. Она необходима для дальнейшей работы фреймворка;testo.Test— это адаптер, чтобы связатьtesto.Tсtesting.T
В остальном тест остался таким же:
func TestBake(t *testing.T) { t.Run("Медовик", testo.Test(func(t *testo.T) { medovik, ok := Bake("медовик") if !ok { t.Fatal("должны уметь печь медовик") } // не выбрасывать же после теста t.Cleanup(func() { Eat(medovik) }) if !medovik.Tasty { t.Error("медовик должен получиться вкусным") } })) }
Мы всё так же можем запустить его командой go test или через IDE.
Testo не формирует отчёты сам, для этого нужен плагин. Например, мы хотим сформировать Allure-отчёт, для этого установим testo-allure:
go get github.com/ozontech/testo-allure
Теперь можно подключить этот плагин в тест. Для этого сделаем тип T, который расширяет *testo.T , и используем его:
+type T struct { + *testo.T + *allure.PluginAllure +} func TestBake(t *testing.T) { - t.Run("Медовик", testo.Test(func(t *testo.T) { + t.Run("Медовик", testo.Test(func(t T) { medovik, ok := Bake("медовик") if !ok { t.Fatal("должны уметь печь медовик") } // не выбрасывать же после теста t.Cleanup(func() { Eat(medovik) }) if !medovik.Tasty { t.Error("медовик должен получиться вкусным") } })) }
Совсем скоро рассмотрим, что такое
T. Пока о нём можно думать как о наборе плагинов.
Этого уже достаточно, чтобы получить Allure-отчёт, при этом мы даже не меняли код сценария теста.

Но для более детального анализа было бы полезно расширить этот отчёт дополнительной информацией. Например, отобразить используемые ингредиенты, чтобы лучше понять причину, если что-то пойдёт не так.
В этом поможет тот факт, что плагины могут добавлять новые методы к T. Например, плагин testo-allure добавляет метод t.Attach для прикрепления вложений в отчёт:
+t.Attach("ингредиенты", allure.Bytes(medovik.Ingredients)) if !medovik.Tasty { t.Error("медовик должен получиться вкусным") }
Теперь в отчёте мы сможем увидеть, из чего он был приготовлен.

T — это состояние текущего теста, через него мы управляем тестом. В Testo плагины — это часть этого состояния. Сначала мы использовали testing.T , а подключив Testo, заменили его на testo.T , что равнозначно в использовании, так как оба этих типа удовлетворяют интерфейсу testing.TB .
Для подключения плагинов необходим новый тип T, в котором мы описываем набор плагинов в виде полей структуры:
type T struct { *testo.T *allure.PluginAllure }
В основе всегда должен быть
testo.T, поверх которого мы расширяем функционал. Используемые плагины всегда описаны статично в виде типа.
Testo смотрит на то, какой T тест принимает на вход, а затем собирает и инициализирует перечисленные плагины.
Плагин — это структура, удовлетворяющая интерфейсу testoplugin.Plugin :
type Plugin interface { Plugin(parent Plugin, options ...Option) Spec } type Spec struct { // Планирование тестов для запуска. Plan Plan // Хуки на запуск Suite'ов, тестов и шагов. Hooks Hooks // Middleware на встроенные методы T, например // Fail, Log, Skip и другие. Overrides Overrides }
Плагины знают всё о текущем тесте, потому что делят указатель на общий с тестом T. Чтобы стало понятнее, напишем небольшой плагин.
В Go тесты отмечаются параллельными через вызов t.Parallel . В end-to-end-сценариях часто имеет смысл сделать параллельными все тесты. Сделаем плагин, который будет вызывать t.Parallel для каждого теста автоматически:
type PluginParallel struct{ *testo.T } func (p *PluginParallel) Plugin( testoplugin.Plugin, ...testoplugin.Option, ) (spec testoplugin.Spec) { spec.Hooks.BeforeEach.Func = func() { p.Parallel() } return spec } type T struct { *testo.T *allure.PluginAllure *PluginParallel }
Что мы сделали:
Объявили новую структуру PluginParallel, которая получает на вход указатель на состояние текущего теста *testo.T.
Описали функционал плагина — хук, который будет выполняться до каждого теста и вызывать t.Parallel.
Подключили плагин в общий T.
Теперь все тесты будут автоматически объявлены параллельными.
Механизм инициализации
*testo.Tи плагинов можно сравнивать с подходом DI (dependency injection). Testo сам собирает все поля, удовлетворяющие интерфейсу плагина, и инициализирует их. Поэтому плагины также могут подключать другие плагины и взаимодействовать с ними.
Пока мы коснулись лишь вершины айсберга возможностей плагинов. Но у Testo есть и другой полезный функционал.
Как я уже упомянул в начале, в Testo можно группировать тесты в наборы. Посмотрим, как это выглядит на деле:
type Bakery struct{ testo.Suite[T] }
Для создания Suite’а нам необходимо расширить встроенный тип testo.Suite по аналогии с тем, как мы расширили testo.T для подключения плагинов.
[T]указывает на то, какойTбудет использован для этого Suite’а. Иными словами, какие плагины будут использованы для каждого теста в этом Suite’е.
Теперь мы можем переместить тест с медовиком в этот набор:
func (Bakery) TestBake(t T) { medovik, ok := Bake("медовик") if !ok { t.Fatal("должны уметь печь медовик") } t.Cleanup(func() { Eat(medovik) }) t.Attach("ингредиенты", allure.Bytes(medovik.Ingredients)) if !medovik.Tasty { t.Error("медовик должен получиться вкусным") } }
Помним, что мы объявили
Tс плагином для Allure-отчётов и нашим плагином для параллельных тестов. Тут мы используем этот жеT. Тесты должны принимать на вход тот жеT, что и указан вtesto.Suite[T].
Так как в Go нет такого понятия, как Suite’ы, нам необходимо запустить этот набор вручную из обычной тестовой функции:
func Test(t *testing.T) { testo.RunSuite(t, new(Bakery)) }
Весь набор Bakery можно запустить командой go test или нажатием кнопки в IDE. А для запуска отдельных тестов в наборах мы делимся расширением для VS Code.
Мы всё ещё используем Allure-плагин, поэтому получим наглядный отчёт о работе теста:

Отлично, наш медовик теперь под чутким контролем теста. Но со временем пекарня стала расширяться и радовать гостей новыми десертами. Если один сценарий теста применим сразу для нескольких входных данных, то с Testo мы можем сделать этот тест параметризованным.
Посмотрим на тест ещё раз:
func (Bakery) TestBake(t T) { medovik, ok := Bake("медовик") if !ok { t.Fatal("должны уметь печь медовик") } t.Cleanup(func() { Eat(medovik) }) t.Attach("ингредиенты", allure.Bytes(medovik.Ingredients)) if !medovik.Tasty { t.Error("медовик должен получиться вкусным") } }
Видно, что медовик в этом случае может быть параметром, принимаемым тестом на вход. Отразим это в коде:
func (Bakery) TestBake(t T, p struct{ Dessert string }) { baked, ok := Bake(p.Dessert) if !ok { t.Fatalf("должны уметь печь %s", p.Dessert) } t.Cleanup(func() { Eat(baked) }) t.Attach("ингредиенты", allure.Bytes(baked.Ingredients)) if !baked.Tasty { t.Errorf("%s должен получиться вкусным", p.Dessert) } }
Тесты могут принимать на вход второй аргумент — структуру, описывающую требуемые для этого теста параметры. Параметров (полей структуры) может быть несколько, в таком случае тест будет запущен со всеми комбинациями их значений.
Таким образом, тест почти не изменился. Зато теперь он стал более гибким. Осталось описать возможные значения параметра Dessert:
func (Bakery) CasesDessert() []string { return []string{"медовик", "тирамису"} }
Функции вида
CasesXxxописывают возможные значения параметров, гдеXxxимя параметра. В данном случаеDessert.Важно учесть, что параметры — это всегда поля структуры. Поэтому даже если тест принимает только один параметр, то всё равно необходимо принимать структуру с полем под этот параметр.
Теперь мы снова можем запустить этот тест, получив Allure-отчёт:

Если бы мы вдруг опечатались и функции
CasesDessertне оказалось, то Testo дал бы нам об этом знать ошибкой с подробным логом. Вот тут можно посмотреть примеры того, как фреймворк обрабатывает эту и другие ошибки.
Отлично, наш тест постепенно использует всё больше функционала фреймворка, при этом практически не меняясь. Но это ещё не всё. Если взглянуть на тест ещё раз, то заметим такую строку:
baked, ok := Bake(p.Dessert) // ... t.Cleanup(func() { Eat(baked) })
t.Cleanup— это стандартный метод, который вызовет переданную функцию перед завершением теста.
Выходит, что сценарий теста подразумевает некую очистку после исполнения. С одним ресурсом (готовым десертом в нашем случае) это не доставляет проблем. Но представим, что таких ресурсов в тесте несколько и что мы, вполне вероятно, можем забыть очистить их все. Так или иначе, каждый вызов t.Cleanup напрямую в сценарии теста делает его прочтение сложнее, и хотелось бы это спрятать. К счастью, с Testo мы можем это автоматизировать с помощью фикстур.
Было бы удобно переложить создание и очистку ресурса на фреймворк, чтобы не нагружать тесты этой логикой.
Вспомним, что ранее мы объявили T, в котором подключили используемые плагины:
type T struct { *testo.T *allure.PluginAllure *PluginParallel }
Поскольку этот тип объявили мы, то мы же можем добавлять ему новые методы. Добавим метод, который будет вызывать функцию Bake, планировать очистку по окончании теста и возвращать отданные из функции значения:
func (t T) Bake(name string) (pastry Pastry, ok bool) { pastry, ok = Bake(name) if ok { t.Cleanup(func() { Eat(pastry) }) } return }
В этом примере тип
Pastry(выпечка) это некая структура, которую возвращает функцияBake.
Это и есть фикстура! Она создаёт некий ресурс и указывает на то, как его очистить. Осталось использовать её в тесте:
func (Bakery) TestBake(t T, p struct{ Dessert string }) { - baked, ok := Bake(p.Dessert) + baked, ok := t.Bake(p.Dessert) if !ok { t.Fatalf("должны уметь печь %s", p.Dessert) } - t.Cleanup(func() { Eat(baked) }) t.Attach("ингредиенты", allure.Bytes(baked.Ingredients)) if !baked.Tasty { t.Errorf("%s должен получиться вкусным", p.Dessert) } }
Стало проще: теперь сценарий самого теста не описывает утилизацию созданных ресурсов. Более того, мы можем использовать эту фикстуру и в других тестах, не дублируя логику очистки. Таким образом, фикстуры в Testo — это обычные функции, которые можно вызывать в любой момент.
А теперь представим такую ситуацию: тест успешно работал длительное время, пока внезапно пекарня не перестала выпекать вообще всё. Оказалось, что кто-то случайно выключил печь и не включил её обратно.
Самое время дополнить наш тест, чтобы перед началом работы он всегда включал печь, а после работы в целях экономии электроэнергии отключал её.
Suite’ы могут определять особые методы, работающие в качестве хуков. Таким образом, они автоматически будут выполняться на разных этапах жизненного цикла Suite’а. Доступные хуки:
BeforeAll — выполняется до запуска всех тестов.
BeforeEach — выполняется до запуска каждого теста.
AfterEach — выполняется после запуска каждого теста.
AfterAll — выполняется после запуска всех тестов.
Поскольку необходимо включать и выключать печь лишь раз, нам подойдут хуки BeforeAll и AfterAll:
func (Bakery) BeforeAll(t T) { if err := Oven(true); err != nil { t.Fatalf("Не удалось включить печь: %v", err) } } func (Bakery) AfterAll(t T) { if err := Oven(false); err != nil { t.Errorf("Не удалось выключить печь: %v", err) } }
Готово. Теперь мы добавили обязательную подготовку перед запуском тестов в этом Suite’е. Если она не пройдёт, то тесты не запустятся.
Наш тест всё более походит на реальный сценарий с полноценной настройкой окружения. Почти закончили.
Если мы снова посмотрим в отчёт, то заметим, что почти ничего не знаем о том, что именно делал тест: какие функции вызывал, сколько они выполнялись и что проверялось.
Давайте исправлять. Вспомним фикстуру, которую мы сделали и использовали в тесте:
func (t T) Bake(name string) (pastry Pastry, ok bool) { pastry, ok = Bake(name) if ok { t.Cleanup(func() { Eat(pastry) }) } return }
Мы можем обернуть её в подтест, чтобы отделить её от основного сценария, таким образом сделав её видимой как шаг для Allure-плагина:
func (t T) Bake(name string) (pastry Pastry, ok bool) { + testo.Run(t, "bake: "+name, func(t T) { pastry, ok = Bake(name) + }) if ok { t.Cleanup(func() { Eat(pastry) }) } return }
Посмотрим на отчёт:

Стало лучше, но чего-то не хватает. Мы всё ещё не видим, какие проверки делает тест. Мы можем обернуть вызовы t.Fatal и t.Error подобным образом через testo.Run:
func (Bakery) TestBake(t T, p struct{ Dessert string }) { baked, ok := t.Bake(p.Dessert) + testo.Run(t, "проверить, что удалось испечь", func(t T) { if !ok { t.Fatalf("должны уметь печь %s", p.Dessert) } + }) t.Attach("ингредиенты", allure.Bytes(baked.Ingredients)) + testo.Run(t, "проверить, что получилось вкусно", func(t T) { if !baked.Tasty { t.Errorf("%s должен получиться вкусным", p.Dessert) } + }) }
Но это не очень удобно и не совсем верно. Дело в том, что в случае вызова t.Fatal внутри testo.Run внешний тест не прекратит работу.
Разница между
t.Fatalиt.Errorв том, что в первом случае тест выдаст ошибку и завершит исполнение, а во втором ограничится ошибкой и продолжит сценарий. Это стандартное поведение для Go-тестов.
К счастью, в Allure-плагине есть функция allure.Step , которая сделает именно это: запустит testo.Run и пробросит фатальную ошибку в родительский тест, остановив его работу.
func (Bakery) TestBake(t T, p struct{ Dessert string }) { baked, ok := t.Bake(p.Dessert) + allure.Step(t, "проверить, что удалось испечь", func(t T) { if !ok { t.Fatalf("должны уметь печь %s", p.Dessert) } + }) t.Attach("ингредиенты", allure.Bytes(baked.Ingredients)) + allure.Step(t, "проверить, что получилось вкусно", func(t T) { if !baked.Tasty { t.Errorf("%s должен получиться вкусным", p.Dessert) } + }) }
Теперь остался последний штрих. Allure-плагин добавляет в T методы Assert() и Require(), использующие проверки из testify , оборачивающие каждый вызов в шаг, подобно тому как это сделали мы сами. Используем их:
func (Bakery) TestBake(t T, p struct{ Dessert string }) { baked, ok := t.Bake(p.Dessert) t.Require().True(ok, "должны уметь печь %s", p.Dessert) t.Attach("ингредиенты", allure.Bytes(baked.Ingredients)) t.Assert().True(baked.Tasty, "%s должен получиться вкусным", p.Dessert) }

Вот теперь всё! Тест стал более компактным, стабильным и информативным.
Suite целиком теперь выглядит так:
type Bakery struct{ testo.Suite[T] } func (Bakery) BeforeAll(t T) { t.Require().NoError(Oven(true), "включить печь") } func (Bakery) AfterAll(t T) { t.Assert().NoError(Oven(false), "выключить печь") } func (Bakery) CasesDessert() []string { return []string{"медовик", "тирамису"} } func (Bakery) TestBake(t T, p struct{ Dessert string }) { baked, ok := t.Bake(p.Dessert) t.Require().True(ok, "должны уметь печь %s", p.Dessert) t.Attach("ингредиенты", allure.Bytes(baked.Ingredients)) t.Assert().True(baked.Tasty, "%s должен получиться вкусным", p.Dessert) }
В Testo есть и другой функционал, достойный отдельной статьи, который в основном касается написания продвинутых плагинов. Например, рефлексия над состоянием тестов через пакет testoreflect или запуск вложенных Suite’ов.
Раз уж я вновь упомянул плагины, то важно рассказать, какие плагины доступны прямо сейчас.
Вместе с Testo мы также выложили в open source готовые плагины, которыми пользуемся сами:
ozontech/testo-allure предоставляет полный функционал Allure-отчётов. Формирует отчёты под любые тесты «из коробки» с возможностью расширить их дополнительной информацией.
ozontech/testo-toppings — это не плагин сам по себе, а целый набор небольших плагинов (топингов) под разные задачи:
rerun добавляет флаг -rerun.failed для перезапуска упавших тестов по аналогии с --last-failed из Pytest;
xfail позволяет «ожидать» падение теста как допустимый сценарий;
parallel делает все тесты параллельными по умолчанию с возможностью отметить отдельные тесты серийными; это более функциональная версия того плагина, который мы сделали выше;
async предоставляет обёртку над sync.WaitGroup , адаптированную под тесты.
Testo вырос из нашей повседневной задачи: писать и поддерживать большие end-to-end-тесты на Go, не отказываясь от привычного go test и возможностей Go-экосистемы.
В статье я показал, как обычный тест можно постепенно расширить под сложный сценарий, сохраняя код простым: добавить отчёты, плагины, Suite’ы, параметризацию, хуки, фикстуры и шаги. Это те вещи, которых нам не хватало при работе с большими тестовыми наборами. А благодаря плагинам он может быть адаптирован и под новые потребности.
Мы опубликовали Testo под открытой лицензией Apache-2.0. В репозитории есть примеры и документация, а принять участие в развитии проекта может каждый: https://github.com/ozontech/testo
В репозитории открыты обсуждения, где можно задать вопрос, предложить идею и поделиться своими плагинами.