golang

Зачем тестовому фреймворку 17 функций?

  • суббота, 28 февраля 2026 г. в 00:00:14
https://habr.com/ru/articles/1004566/

Если посчитать публичный 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-фреймворку для тестирования? Не «что было бы удобно добавить», а что физически необходимо, чтобы описывать деревья тестов с изоляцией состояния.

Оказалось — один метод.

Весь API целиком

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 сводится к минимуму.

Как это работает: перевыполнение builder-а

Чтобы понять 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 рассчитан именно на неё.

Чего в samurai нет

Нет 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+. Ноль зависимостей.

github.com/zerosixty/samurai