Зачем тестовому фреймворку 17 функций?
- суббота, 28 февраля 2026 г. в 00:00:14
Если посчитать публичный API Ginkgo, получается внушительный список. Describe, Context, When, It, Specify, By, BeforeEach, AfterEach, BeforeAll, AfterAll, JustBeforeEach, JustAfterEach, BeforeSuite, AfterSuite, SynchronizedBeforeSuite, SynchronizedAfterSuite, DeferCleanup. Семнадцать функций, и это без F- и P-вариантов для focus и pending.
GoConvey проще, но и у него набирается не меньше: Convey, So, ShouldEqual, SkipConvey, FocusConvey, Reset и собственный DSL для assertion-ов.
Я не считаю, что большой API — это обязательно плохо. У каждой из этих функций есть причина существования, за ней стоит конкретный use case, который кому-то был нужен. Но меня давно интересовал другой вопрос: какой минимальный API нужен scoped-фреймворку для тестирования? Не «что было бы удобно добавить», а что физически необходимо, чтобы описывать деревья тестов с изоляцией состояния.
Оказалось — один метод.
s.Test("name", fn) // leaf-тест s.Test("name", fn, builder) // узел с дочерними тестами
Это samurai. Весь публичный интерфейс фреймворка:
func Run(t *testing.T, builder func(*Scope), opts ...Option) func RunWith[V Context](t, factory, builder, opts...) type TestScope[V Context] // один метод: Test() type Scope = TestScope[W] // alias для типичного случая type W = *BaseContext // Testing() и Cleanup() Sequential() // опция Parallel() // опция (по умолчанию)
Маленький API не значит автоматически лучший. У Ginkgo есть BeforeAll, потому что людям это нужно. Но мне кажется, что в Go-экосистеме тестирования накопился избыток сложности, и значительная часть этой сложности существует ради одной задачи — управления разделяемым изменяемым состоянием между тестами. Когда разные тесты работают с одними и теми же переменными, нужен BeforeEach для инициализации, AfterEach для очистки, BeforeAll для того, что создаётся один раз, DeferCleanup для корректного порядка уничтожения. Каждая новая функция в API — это ответ на конкретную проблему совместного доступа к состоянию. Если убрать разделяемое состояние из уравнения, все эти функции становятся ненужными, и API сводится к минимуму.
Чтобы понять samurai, нужно понять одну вещь, которая поначалу может смутить: builder-функция запускается не один раз, а столько раз, сколько leaf-тестов в дереве. Это ключевой механизм, из которого вытекает вся изоляция.
samurai.Run(t, func(s *samurai.Scope) { var db *sql.DB // создаётся заново при каждом запуске s.Test("with database", func(ctx context.Context, w samurai.W) { db = openTestDB(ctx) w.Cleanup(func() { db.Close() }) }, func(s *samurai.Scope) { s.Test("can ping", func(ctx context.Context, w samurai.W) { assert.NoError(w.Testing(), db.PingContext(ctx)) }) s.Test("can query", func(ctx context.Context, w samurai.W) { _, err := db.QueryContext(ctx, "SELECT 1") assert.NoError(w.Testing(), err) }) }) })
В этом примере два leaf-теста: can ping и can query. Поскольку samurai выполняет builder-функцию заново для каждого пути от корня до листа, она запустится дважды. При первом запуске переменная db объявляется, callback "with database" открывает соединение с базой, а затем выполняется только can ping. При втором запуске db объявляется заново — это новая переменная в новом вызове функции, — "with database" открывает второе, совершенно независимое соединение, и выполняется can query.
В итоге два теста работают с двумя разными базами данных. Они могут выполняться параллельно (а samurai запускает тесты параллельно по умолчанию) без какой-либо синхронизации. Нет data race. Нет необходимости в mutex. Нет проблемы «кто первый закрыл соединение». Изоляция вытекает из модели выполнения, а не из дисциплины разработчика.
Сравните это с подходом BeforeEach, где setup-код отрабатывает один раз, а два sibling-теста используют один и тот же результат. Такая схема работает до тех пор, пока кто-то не добавит t.Parallel() и не обнаружит, что указатель на *sql.DB перезаписывается посреди запроса из соседней горутины. Подробнее об этой проблеме — во второй статье.
Перевыполнение builder-а — это единственная концепция в samurai, которую нужно усвоить. Всё остальное — следствие. Setup не нужен, потому что родительский callback и есть setup. BeforeAll не нужен, потому что разделяемое состояние отсутствует. Reset не нужен, потому что состояние сбрасывается автоматически — каждый путь начинает с чистого листа.
Для наглядности — один и тот же тест, написанный в Ginkgo и в samurai.
Ginkgo:
var db *sql.DB BeforeEach(func() { db = openTestDB() DeferCleanup(func() { db.Close() }) }) It("can ping", func() { Expect(db.Ping()).To(Succeed()) }) It("can query", func() { _, err := db.Query("SELECT 1") Expect(err).NotTo(HaveOccurred()) })
Samurai:
var db *sql.DB s.Test("with database", func(ctx context.Context, w samurai.W) { db = openTestDB(ctx) w.Cleanup(func() { db.Close() }) }, func(s *samurai.Scope) { s.Test("can ping", func(ctx context.Context, w samurai.W) { assert.NoError(w.Testing(), db.PingContext(ctx)) }) s.Test("can query", func(ctx context.Context, w samurai.W) { _, err := db.QueryContext(ctx, "SELECT 1") assert.NoError(w.Testing(), err) }) })
Количество строк примерно одинаковое. Структура тоже похожа: объявление переменной, инициализация, два теста. Разница проявляется в runtime. В Ginkgo-версии переменная db одна на оба теста — BeforeEach создаёт соединение, и оба It-блока работают с ним. В samurai-версии каждый leaf-тест получает свой db, потому что всё замыкание выполняется заново для каждого пути.
С точки зрения объёма кода выигрыша нет. Выигрыш в другом: в samurai-версии нет способа случайно получить data race между тестами, потому что делить нечего. В Ginkgo-версии эта гарантия зависит от того, как Ginkgo внутренне управляет выполнением spec-ов — и от того, не добавит ли кто-нибудь параллелизм позже.
Ещё одно отличие: samurai не привязан к конкретной assertion-библиотеке. В примере выше используется testify, но с тем же успехом можно использовать is, стандартный t.Errorf или любую другую библиотеку. В Ginkgo связка с Gomega не обязательна формально, но на практике API рассчитан именно на неё.
Нет BeforeAll. Если нужна инфраструктура, разделяемая между тестами (контейнер, тестовый сервер, пул соединений), её следует поднимать в TestMain или в начале тестовой функции, до вызова samurai.Run. Фреймворк намеренно не предоставляет механизм для разделения состояния между путями. Это не упущение — это основной принцип дизайна. Если бы samurai позволял разделять state между leaf-тестами, исчезла бы главная гарантия, ради которой он создан. На практике BeforeAll чаще всего используется для тяжёлой инфраструктуры (поднять контейнер, запустить сервер), и для такого кода TestMain — более подходящее место, потому что оно явно находится вне scope тестов.
Нет встроенных assertion-ов. Используйте testify, is, обычный t.Errorf — что угодно. Через RunWith можно встроить assertion-библиотеку прямо в контекст теста, чтобы не передавать t в каждый вызов вручную, но это необязательно. Фреймворк отвечает за структуру и изоляцию тестов, а не за то, как вы проверяете результаты. Это осознанный выбор: assertion-ы — это ортогональная задача, и нет причин связывать их с конкретным фреймворком для тестирования.
Нет Focus/Pending-вариантов Test. Пропуск — s.Skip() на scope. Фокусировка — go test -run. Это стандартные механизмы Go, и samurai не изобретает для них отдельные обёртки. Если IDE поддерживает запуск подтестов через gutter-иконки (а GoLand поддерживает), то go test -run работает прозрачно. Для GoLand есть плагин, который добавляет навигацию к s.Test() вызовам и отображение статусов прохождения.
go get github.com/zerosixty/samurai
Go 1.24+. Ноль зависимостей.