Comments 14
Давно использую https://github.com/uber-go/mock, закрывает все потребности при написании unit тестов. В паре с testify получается удобный инструмент.
Testify топчик, сам юзаю, но https://github.com/uber-go/mock это про юниты, в статье речь про E2E / интегры
Как же больно читать весь этот нейрослоп
1) go-стиль - это простой код без "магии",
2) и код должен быть линейным а не функция в функции
3) поэтому лучше всё делать как обычно:
несколько простых строк кода инициализации лучше чем одна магическая строка функция в функции
Поверьте, тут это еще вообще не магия. Вот ниже в комментах товарищ притащил ссылку на Testo - вот там реально ящик Пандоры)
А насчет линейности - для простых юнитов это работает, спору нет. Но на сложных интеграционных тестах с базами и контейнерами вам в любом случае придется что-то придумывать и городить обвязки
Очень похоже на библиотеку, которую переписали для замены allure-go: https://github.com/ozontech/testo
Ага, посмотрел его - тащит абсолютно ту же самую скрытую магию в гошку. Это для Go вообще харам, спасибо, не надо) Доверие к их архитектурным решениям было полностью потеряно еще на allure-go. Уж лучше сидеть на чем-то предсказуемом, где хотя бы в рантайм прозрачный и компилятор все проверяет, чем снова дебажить чужую рефлексию)))))
Сам до сих пор этой библиотекой не пользовался. В компании используем отчеты на allure. После просмотра доклада: https://www.youtube.com/live/WJTrjEFdZAU?t=3463s, в команде решили в ближайшее время попробовать testo вместо allure-go.
В тему рефлексии не совсем понимаю претензии к фреймворку для тестов.
"чем снова дебажить чужую рефлексию" - очень неповезло вам дебажить... даже не понимаю что, обертку над запуском тестовых функций/suite-структур?
Мне видится, что если во фреймворке для написания тестов удобный фасад, который упрощает рутинную работу, например с allure-отчетами, то мне в последнюю очередь важно есть в нем рефлексия или нет.
Библиотека из статьи тоже очень интересная, но привел в комментарии что-то похожее и возможно популярнее, так как вряд ли кто-то в крупных компаниях станет использовать фреймворк, который через какое-то время могут перестать поддерживать и развивать.
upd: в докладе есть ответ разработчика для чего во фреймворке используется рефлексия: https://www.youtube.com/live/WJTrjEFdZAU?si=fC12nMPJGfOPGYuN&t=4804
Вы сами скинули цитату, которая полностью хоронит ваш же аргумент про "надежность крупных компаний". Ozon официально расписался в том, что забросил allure-go на жизнеобеспечение, потому что сами же завели его в архитектурный тупик. И их обещание "с Testo такого точно не повторится" - это чистой воды маркетинг. Еще как повторится, как только их новая неявная магия на рефлексии начнет сыпаться на объемах реальных компаний. Команду мейнтейнеров переведут на другие проекты, и они выпустят третий фреймворк. Доверия к такой "поддержке" ноль)))

По поводу рефлексии: если вам "в последнюю очередь важно" наличие compile-time проверок, вы просто никогда не ловили полностью зеленый CI в мастере при молча пропущенных критичных тестах из-за банальной опечатки в регистре имени метода (CasesOs вместо CasesOS):

Для пет-проектов и вебинаров "удобный фасад" важнее надежности, спору нет. Но для нормального продакшена сознательно ломать строгую типизацию Go в тест-рантайме - это профнепригодность. Посмотрите доклад внимательнее)
Привет! Я автор Testo.
@Dimmur @blessyocean Спасибо большое за обратную связь и уделенное проекту / докладу время, для меня это очень ценно!
Хочу прокомментировать пару моментов.
полностью зеленый CI в мастере при молча пропущенных критичных тестах из-за банальной опечатки в регистре имени метода (CasesOs вместо CasesOS)
Согласен, подобное поведение было бы неправильным. В Testo этот момент учтен. До запуска тестов должны пройти проверки, которые иначе вызовут ошибку. Например, если нужный параметр не найден, то произойдет подобная ошибка до запуска любых тестов в сьюте:
=== RUN Test
main_test.go:15: testo: wrong param signature for (*main.Suite).TestCompile: missing (*main.Suite).CasesOS() []string for param "OS"
--- FAIL: Test
Таким образом, CI точно не будет зеленым, а лог укажет на ошибку.
Если интересно, вот еще примеры подобных проверок.
Ozon официально расписался в том, что забросил allure-go на жизнеобеспечение
Мы (как мейнтейнеры Allure-Go) все еще занимаемся исправлением багов. Речь лишь о том, что не стоит более ожидать в нем нового функционала. Но это скорее не новость, а больше фиксация текущего состояния проекта. Новых фичей в нем не было уже давно, только стабилизация. Иными словами, Allure-Go не “превратился в тыкву”, просто мы рекомендуем Testo вместо Allure-Go, особенно для новых проектов.
“с Testo такого точно не повторится” - это чистой воды маркетинг. Еще как повторится, как только их новая неявная магия на рефлексии начнет сыпаться на объемах реальных компаний.
Понимаю почему так может показаться и тут важно прояснить пару моментов:
Testo довольно давно и активно используется для работы более 10 тысяч тестов в сотне команд и сервисов. По нашим наблюдениям, он намного стабильнее и гораздо гибче предшественника. Множество команд, благодаря плагинам, уже использует функционал, недоступный ранее ни в Allure-Go, ни в иных фреймворках. Так что не могу согласиться с утверждением “начнет сыпаться на объемах реальных компаний”, наш опыт говорит об обратном.
Вся магия на рефлексии происходит исключительно в паре контролируемых мест внутри фреймворка и тщательно протестирована “в бою” и юнитами (ссылка на покрытие). У пользователя не должно быть возможности сломать что-то в фреймворке.
Testo был спроектирован на основе ошибок, выявленных за 5 лет развития Allure-Go. Это был осознанный переход и мы хотели сделать инструмент, решающий эти проблемы и у которого не будет возможности устареть, благодаря плагинам. Это говоря про то, почему подобная история не должна повториться.
Я надеюсь, что удалось прояснить некоторые недопонимания. Буду рад услышать мнение на этот счет и ответить на вопросы!
Спасибо еще раз за фидбек, он помогает делать проект лучше для всех.
Раз уж вы пришли с кодом и логами, я не поленился, залез в ваш репозиторий Testo и детально изучил исходники :) И знаете что? Все оказалось гораздо хуже, чем просто "неидиоматичная рефлексия". Вы зашили в ядро фреймворка тяжелейшие рантайм-костыли, которые прямо противоречат вашим же заявлениям о безопасности)))
Давайте разберём по фактам то, что лежит у вас в main-ветке.
1. Дырявый CI и молча пропадающие тесты (collector.go)
Вы утверждаете, что "CI точно не будет зеленым при проблемах с параметрами". Смотрим в ваш код collector.go:
if len(values) == 0 {
fmt.Fprintf(
os.Stderr,
"testo: warning: ... will not run\n",
...
)
return nil
}Что это значит? Если по какой-то причине (баг в логике, не доехавшие данные из бэка, пустой слайс окружения) ваш метод CasesOS() вернет пустой список - параметризованный тест просто молча не запустится. Он не падает, не помечается как Failed, не уходит в Skipped. Он просто тихо возвращает nil, плюнув варнингом в stderr
Для любого нормального CI варнинг в логах - это абсолютно зеленый билд. Критичный тест пропал из прогона, проверки не было, но пайплайн успешно прошел. Ваша "гарантия безопасности" сломалась в первом же файле
2. Это не "удобный фасад", это агрессивный DI-контейнер (construct.go)
Вы говорите, что рефлексия у вас "в паре контролируемых мест". На деле Testo пытается быть скрытым Spring-подобным DI-контейнером, который бесцеремонно вторгается во внутренности пользовательских структур. Смотрим в construct.go:
for i := range v.NumField() {
field := v.Field(i)
if !v.Type().Field(i).IsExported() {
continue
}
if field.Kind() != reflect.Pointer {
panic("testo: all exported fields in T must be pointers")
}
setPlugins(field.Addr(), plugins, specs)
}Фреймворк рекурсивно обходит структуру T, сам аллоцирует указатели, сам подменяет поля и требует, чтобы все exported fields были строго указателями. Шаг влево, забыл звtздочку - получай runtime panic прямо на старте. Экспортируемые поля внутри T перестают быть обычным Go-кодом, они превращаются в жесткую рантайм-схему вашего фреймворка
3. Иллюзия гибкости плагинов
Вы заявляете, что благодаря плагинам Testo никогда не устареет. Но смотрим, как эти плагины хранятся в том же construct.go:
plugins := make(map[reflect.Type]testoplugin.Plugin, len(pluginTypes))Ключом вашей мапы является reflect.Type. То есть ваша архитектура плагинов - это обычный Singleton-by-type Service Locator. Из-за этого два независимых экземпляра одного и того же плагина (например, с разной конфигурацией) внутри одной структуры T физически невозможно выразить. Они просто схлопнутся по типу. О какой "безграничной гибкости" тут речь?)))
В чем разница с нормальным подходом?
В том же Axiom, о котором статья, runtime-check используется локально и честно - только для проверки пользовательских данных в точке вызова: params := axiom.GetParams[LoginParams](cfg). Код прозрачен. Инженер видит, что и где он запрашивает.
Axiom не сканирует структуру тестов рефлексией, не строит неявный DI-контейнер по exported-полям, не паникует из-за отсутствия указателей и не завязывает логику плагинов на reflect.Type
Поэтому сорри, ребят, но вы облажались. Вы попытались исправить болячки allure-go, но вместо прозрачного Go-кода построили переусложненный комбайн, который держится на неявных рантайм-контрактах. Про такой софт обычно говорят: "работает — не трогай, сломалось — не починишь". Тащить это в продакшен крупных компаний - это лютый харам, и не только по-гошному, а вообще по любым канонам нормальной разработки.
За доклад на митапе все равно спасибо, но мы точно остаемся на предсказуемом Axiom
UPD: И да, ваш аргумент про "10 тысяч тестов в сотне команд внутри Ozon" не доказывает вообще ничего. В бигтехе внедрение внутренних инструментов слишком часто работает по принципу административного ресурса: сверху спустили архитектурную директиву "все новые сервисы пишем на Testo", и сотня команд берет под козырек, даже если плюется от рантайм-паник и переписывания общих хелперов. Реальное качество опенсорса проверяется свободным рынком и независимыми инженерами, а не корпоративной обязаловкой)))
Привет еще раз!) Давай сразу по пунктам:
Если по какой-то причине ваш метод CasesOS() вернет пустой список - параметризованный тест просто молча не запустится
Параметры, чаще всего, объявляются статично. То есть, имея такой код, сложно случайно что-то упустить:
func (Suite) CasesOS() []string {
return []string{"darwin", "windows"}
}
Но получать параметры извне тоже можно. В таком случае, если запуск с нуля параметрами является нежеланным поведением (это не всегда так), то достаточно лишь сделать на это проверку:
func (s *Suite) BeforeAll(t T) {
s.casesOS := fetchOSes()
if len(s.casesOS) == 0 {
t.Fatal("at least 1 os is required")
}
}
func (s *Suite) CasesOS() []string {
return s.casesOS
}
Тогда проблема не сильно отличается от сценария, когда для цикла в табличном тесте тоже может не оказаться значений:
func Test(t *testing.T) {
cases := fetchCases() // а что если тут 0 значений?
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) { ... })
}
}
Но я могу согласиться, что, возможно, была бы полезна опция, которая делала бы это сама. Подумаю как можно улучшить этот момент, спасибо.
Вы говорите, что рефлексия у вас “в паре контролируемых мест”. На деле Testo пытается быть скрытым Spring-подобным DI-контейнером, который бесцеремонно вторгается во внутренности пользовательских структур…
…Экспортируемые поля внутри T перестают быть обычным Go-кодом
Это действительно одно из контролируемых мест. Невозможно неправильно описать T и запустить тесты.
“Шаг влево, забыл звездочку” - это единственное требование, но на самом деле IDE подскажет так не делать, потому что отсутствие звездочки вызовет стандартное предупреждение от go vet “passes lock by value” при дальнейшем использовании такого типа T. Но это больше приятный бонус.
В любом случае T предназначена исключительно для подключения плагинов. В языке с указателями есть много мест, где можно забыть звездочку и почти всегда это приведет к ошибке, как и тут. В Testo эта ошибка контролируется.
Возможно, нам повезло обойти подобную проблему, но не могу придумать или привести в пример ни один сценарий, чтобы бы конкретно этот момент вызвал трудности использования или оказался неожиданным.
Из-за этого два независимых экземпляра одного и того же плагина (например, с разной конфигурацией) внутри одной структуры T физически невозможно выразить.
Я не думаю, что есть реальный сценарий, когда необходимо иметь две регистрации одного плагина. Насколько мне известно, в Pytest, например, это тоже невозможно. Если плагин в разной конфигурации может иметь несколько (неконфликтующих) фичей, то почему бы плагину не поддерживать их в единой конфигурации? Таким образом плагин сам решает, что может конфликтовать, а что нет. Или, быть двумя разными плагинами? В общем, звучит как антипаттерн.
Но если есть примеры подобного использования, то будет интересно узнать подробнее 🤔
Axiom не сканирует структуру тестов рефлексией, …, не паникует из-за отсутствия указателей
Не считаю это проблемой, но справедливости ради, это же не так:
Не пойми неправильно, я не ставлю цель убедить использовать одно, вместо другого. В конце концов, мы же оба говорим про Open-Source и на одной стороне =)
В разных проектах и командах одни инструменты могут быть оптимальнее других. В нашем случае, Testo закрыл все былые проблемы и потому решили им поделиться со всеми. С некоторыми аргументами я не могу согласиться и решил рассказать почему. Возможно, у автора, @sound_right, тоже есть мнение на этот счет, будет интересно послушать!
Как бы то ни было, спасибо за уделенное время! Надеюсь, удалось ответить. Если будут еще вопросы, то буду рад их обсудить - мне эта тема интересна.
Спасибо за подробный деф, но, честно говоря, ваши аргументы только подтвердили то, о чем я писал) Вместо решения системных проблем вы пытаетесь выдать баги проектирования за фичи
Давайте разберем вашу защиту по пунктам, потому что дьявол кроется в исходниках :)
1. Пустые параметры: вы не решили проблему, а переложили ее на пользователя
Вы пишете:
если запуск с нуля параметрами является нежеланным поведением (это не всегда так), то достаточно лишь сделать на это проверку:
В этом-то и соль. Фреймворк стоит в точке (collector.go), где он точно знает, что конкретный параметризованный тест прогонится ровно 0 раз. Но вместо Fail, Skip или паники он просто тихонько выплевывает варнинг в stderr и возвращает nil.
if len(values) == 0 {
fmt.Fprintf(os.Stderr, "testo: warning: ... will not run\n")
return nil
}Совет "ну допишите руками проверку в BeforeAll на каждый сьют" - это классический перевод стрелок. Зачем мне тогда вообще ваш фреймворк, если я должен сам руками страховать его логику?)))
Ваше сравнение с обычным table-driven тестом некорректно. В обычном Go-коде цикл у меня перед глазами, и я сам полностью контролирую семантику выполнения:
cases := fetchCases()
if len(cases) == 0 {
t.Fatal("no cases")
}
for _, tt := range cases { ... }В Testo же параметризация автоматизирована магией фреймворка. Он сам собирает CasesXXX, сам строит матрицу, сам планирует тесты и сам решает, запускать их или нет. В итоге ваш лозунг "критичные тесты не могут молча пропасть" превращается в тыкву: матрица параметров пустая, в stderr улетел варнинг, который на CI никто не читает, билд зеленый, мастер катится в прод без проверок.
2. Тип T — это не обычный Go-код, а рантайм-схема
Вы пишете, что структура T предназначена исключительно для подключения плагинов.
Но это как раз и доказывает мою мысль: T в Testo - это не обычный пользовательский тип. Это декларативная runtime-схема для вашего скрытого DI-контейнера. Фреймворк бесцеремонно лезет рефлексией в поля, требует только указатели, сам все аллоцирует и сам строит граф зависимостей. Шаг влево - рантайм-паника на старте:
if field.Kind() != reflect.Pointer {
panic("testo: all exported fields in T must be pointers")
}
setPlugins(field.Addr(), plugins, specs)Ссылка на go vet - это вообще мимо. go vet - это внешний статический линтер, а не система типов Go и не компиляторные гарантии. Его можно отключить, проигнорировать, криво настроить на CI или тупо не знать про его существование :) Он никак не превращает ваши рантайм-костыли в легитимный контракт языка. Если корректность структуры проверяется не компилятором, а вашим reflection-конструктором - это рантайм-модель, как ее ни называй.
3. Два одинаковых плагина - это не антипаттерн, а нормальная композиция
Вы пишете:
Я не думаю, что есть реальный сценарий, когда необходимо иметь две регистрации одного плагина.
Говорить так - значит расписаться в непонимании гибкой архитектуры и композиции. Такой сценарий элементарен: последовательная фильтрация тестов несколькими независимыми условиями. В том же Axiom можно спокойно сделать так:
axiom.WithRunnerPlugins(
testtags.Plugin(testtags.WithConfigInclude(os.Getenv("REGION"))),
testtags.Plugin(testtags.WithConfigInclude(os.Getenv("TEST_TAGS"))),
testplugin.AllurePlugin(),
)Мне нужно подключить один и тот же тип плагина тегов дважды: первый раз с конфигом из одного окружения, второй - из другого. Это один и тот же тип плагина, но две разные конфигурации и две разные стадии обработки.
В Axiom плагины - это просто слайс функций type Plugin func(cfg *Config), они хранятся в массиве и предсказуемо применяются в том порядке, в котором их передал инженер:
for _, p := range c.Runner.Plugins {
p(c)
}В Testo это сделать невозможно физически, потому что у вас identity плагина - это reflect.Type:
plugins := make(map[reflect.Type]testoplugin.Plugin, len(pluginTypes))Один тип - один инстанс. Все. Это не "безгранично гибкая система плагинов", это кастрированный Singleton-by-type Service Locator.
4. Потеря порядка плагинов из-за Go map
Вот тут начинается самое прекрасное))) Вы рассказываете в докладах про приоритеты и SortStableFunc, но ваш код теряет исходный порядок плагинов еще до того, как доходит до сортировки.
Вы собираете плагины в map[reflect.Type], потом спецификации складываете в map[reflect.Type]testoplugin.Spec, а потом передаете их дальше через итерацию по мапе. Как мы все знаем, порядок обхода map в Go рандомизируется на уровне рантайма языка.
Если у пользователя объявлено три плагина с дефолтным приоритетом 0, порядок выполнения их хуков превращается в рулетку. Порядок их работы - это не порядок их объявления инженером в структуре T, а следствие случайного обхода мапы. Чтобы это починить, пользователю придется руками расставлять приоритеты там, где все изначально должно было работать по цепочке.
В Axiom такой проблемы нет физически. Передал плагины в слайс - они ровно в этом порядке друг за другом и выполнились:
WithRunnerPlugins(p1, p2, p3)
// и получил применение:
p1(cfg)
p2(cfg)
p3(cfg)Без рефлексии, без итерации по мапам и без костылей с ручной сортировкой приоритетов.
5. Сравнение с Axiom по reflection - это ложная эквивалентность
Ваша попытка ткнуть пальцем в Axiom:
Не считаю это проблемой, но справедливости ради, это же не так:
это классический прием подмены понятий :)
Да, в Axiom есть рефлексия в методе RunSuite. Но RunSuite в Axiom - это опциональный адаптер-переходник для тех, кто привык к старым xUnit-сьютам. Дока тык. Основной движок Axiom полностью прозрачен, декларативен и работает на чистом Go:
r := axiom.NewRunner(...)
c := axiom.NewCase(...)
r.RunCase(t, c, func(cfg *axiom.Config) { ... })Здесь нет неявного сканирования структур, нет DI-контейнеров, нет магии завязки на имена методов CasesXXX.
В Axiom рефлексия - это сбоку прикрученный адаптер, который еще и полностью опционален
В Testo рефлексия - это фундамент, без которого фреймворк вообще не работает (даже ваш
RunTestпод капотом заворачивается в синглтон-сьют и идет по reflection-пути).
Архитектурный диагноз
Вы пытаетесь представить это как набор мелких, изолированных компромиссов: тут подкрутим, там пользователь сам проверит, здесь go vet подскажет. Но если сложить все эти костыли вместе, перед нами открывается совершенно четкая и пугающая архитектурная картина)
Testo - это не "тонкий фасад" и не "пара контролируемых мест с рефлексией". Вы построили полноценный, тяжелый reflection-driven runtime, который живет поверх Go и полностью игнорирует его философию:
Проход по сьютам - через неявную рефлексию
Биндинг параметров - через угадывание имен методов (
CasesXXX);Инъекция плагинов - через бесцеремонное сканирование и мутацию полей структуры
Идентификация плагинов - через жесткий
singleton-by-type, убивающий композициюСортировка плагинов - завязан на случайный порядок обхода Go-мап.
Вы утверждаете, что проблема в одном баге с пустыми параметрами, который можно закрыть опцией. Нет. Проблема фундаментальна: вся архитектура Testo держится на неявной рантайм-интерпретации пользовательского кода.
Тот же Axiom решает абсолютно аналогичную задачу в разы чище и честнее: через явный Runner, явный Case, явный слайс плагинов и явное получение параметров в точке использования. Компилятор все видит, компилятор все проверяет :)
Главный вывод
Знаете, почему этот спор зашел в тупик? Потому что вы написали фреймворк не для Go. Вы написали его вопреки Go.
Язык Go создавался Робом Пайком и командой именно для того, чтобы навсегда избавить индустрию от неявной магии, скрытых DI-контейнеров, рантайм-паник и развесистых enterprise-абстракций в стиле Java Spring. Инженеры уходят в Go ради предсказуемости, явности (explicit over implicit) и строгого контроля компилятора.
Вы же взяли Go и попытались насильно превратить его в Pytest или Spring. В итоге получился инструмент, который борется с собственной экосистемой.
Для внутренней автоматизации Ozon под административным ресурсом это может работать годами - спору нет, там люди подневольные. Но для независимого open-source и продакшена крупных компаний строить критически важный тестовый фундамент на рантайм-магии - это огромный, неоправданный риск.
За доклад на митапе и за эту дискуссию еще раз спасибо, она получилась максимально вскрывающей. Но мы все равно выбираем предсказуемость и остаемся на Axiom
Выглядит как удобное приближение к pytest.
Для тех кто с питона придёт может будет и удобно. Только непонятно почему lifecycle не сделали ещё на class? кмк это нужно.
Почему в Go больно писать автотесты (и дело не в синтаксисе)