Команда Go for Devs подготовила перевод статьи о том, как правильно группировать сабтесты в Go. Автор показывает, что в большинстве случаев достаточно держать тесты плоскими, а когда нужна разная инициализация и очистка — добавить лишь один уровень вложенности. В статье разбираются плюсы и минусы разных подходов: от ручных t.Run до reflection-хаков и сторонних библиотек.


Go поддерживает сабтесты начиная с версии 1.7. С помощью t.Run можно вкладывать тесты друг в друга, задавать имена кейсам и при необходимости позволять раннеру выполнять работу параллельно, вызывая t.Parallel внутри сабтестов.

Для небольших наборов тестов обычно хватает простого списка вызовов t.Run. Именно с этого я обычно и начинаю. Но по мере роста набора требования к подготовке и очистке окружения (setup/teardown) могут потребовать группировки сабтестов. Сделать это можно разными способами.

Один вариант — сгруппировать сабтесты с помощью вложенных t.Run. Однако поскольку t.Run поддерживает произвольное количество уровней вложенности, легко получить тесты, которые трудно читать и понимать, особенно если у каждой группы своя логика setup/teardown. Если же добавить вызовы t.Parallel, становится ещё менее очевидно, какие группы выполняются последовательно, а какие параллельно.

Без примеров это звучит немного абстрактно. Поэтому начнём с самого простого случая группировки сабтестов и постепенно усложним. Подбирать примеры так, чтобы они влезли в статью и при этом были показательными, непросто, так что придётся немного потерпеть мои «игрушечные» примеры и подключить воображение.

Объект тестирования (SUT)

Представим, что мы пишем тесты для калькулятора, который, ради примера, умеет только складывать и умножать. Вместо табличных тестов мы разобьём проверки сложения и умножения на две группы с помощью сабтестов. Допустим, у этих операций почему-то разные сценарии подготовки и очистки окружения.

Да, притянуто за уши, но так проще показать идею без привлечения моков, баз данных или testcontainers и ненужных деталей. В реальных проектах подобное встречается везде, где у вас есть общение с базой, а пути чтения и записи имеют разные жизненные циклы.

Держите тесты плоскими, пока это возможно

Если бы у нас не было разных setup/teardown для двух групп, самый простой способ — табличные тесты:

func TestCalc(t *testing.T) {
    // Common setup and teardown

    tests := []struct {
        name string
        got  int
        want int
    }{
        {"1+1=2", 1 + 1, 2},
        {"2+3=5", 2 + 3, 5},
        {"2*2=4", 2 * 2, 4},
        {"3*3=9", 3 * 3, 9},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if tt.got != tt.want {
                t.Fatalf("got %d, want %d", tt.got, tt.want)
            }
        })
    }
}

Результат выполнения:

--- PASS: TestCalc (0.00s)
    --- PASS: TestCalc/1+1=2 (0.00s)
    --- PASS: TestCalc/2+3=5 (0.00s)
    --- PASS: TestCalc/2*2=4 (0.00s)
    --- PASS: TestCalc/3*3=9 (0.00s)
PASS

Если «развернуть» эти тесты, получится эквивалентная запись с явным перечислением:

func TestCalc(t *testing.T) {
    // Common setup and teardown

    // Addition
    t.Run("1+1=2", func(t *testing.T) {
        if 1+1 != 2 {
            t.Fatal("want 2")
        }
    })

    t.Run("2+3=5", func(t *testing.T) {
        if 2+3 != 5 {
            t.Fatal("want 5")
        }
    })

    // Multiplication
    t.Run("2*2=4", func(t *testing.T) {
        if 2*2 != 4 {
            t.Fatal("want 4")
        }
    })

    t.Run("3*3=9", func(t *testing.T) {
        if 3*3 != 9 {
            t.Fatal("want 9")
        }
    })
}

Все сабтесты находятся на одном уровне. По имени видно, какую функцию калькулятора они проверяют. Но это не даёт нам раздельных жизненных циклов для групп сложения и умножения.

Группировка сабтестов с помощью вложенных t.Run

Чтобы обеспечить разные setup/teardown для сложения и умножения, можно вложить сабтесты в t.Run:

func TestCalc(t *testing.T) {
    // Common setup and teardown

    t.Run("addition", func(t *testing.T) {
        // addition-specific setup
        defer func() {
            // addition-specific teardown
        }()

        t.Run("1+1=2", func(t *testing.T) {
            if 1+1 != 2 {
                t.Fatal("want 2")
            }
        })

        t.Run("2+3=5", func(t *testing.T) {
            if 2+3 != 5 {
                t.Fatal("want 5")
            }
        })
    })

    t.Run("multiplication", func(t *testing.T) {
        // multiplication-specific setup
        defer func() {
            // multiplication-specific teardown
        }()

        t.Run("2*2=4", func(t *testing.T) {
            if 2*2 != 4 {
                t.Fatal("want 4")
            }
        })

        t.Run("3*3=9", func(t *testing.T) {
            if 3*3 != 9 {
                t.Fatal("want 9")
            }
        })
    })
}

Теперь можно запускать общий setup/teardown на верхнем уровне, а группы иметь свои собственные. При выполнении мы увидим имена групп:

--- PASS: TestCalc (0.00s)
    --- PASS: TestCalc/addition (0.00s)
        --- PASS: TestCalc/addition/1+1=2 (0.00s)
        --- PASS: TestCalc/addition/2+3=5 (0.00s)
    --- PASS: TestCalc/multiplication (0.00s)
        --- PASS: TestCalc/multiplication/2*2=4 (0.00s)
        --- PASS: TestCalc/multiplication/3*3=9 (0.00s)
PASS

Также можно запустить группы параллельно, вызвав t.Parallel() внутри каждой:

func TestCalc(t *testing.T) {
    // Common setup and teardown

    t.Run("addition", func(t *testing.T) {
        t.Parallel()
    })

    t.Run("multiplication", func(t *testing.T) {
        t.Parallel()
    })
}

На практике достаточно держать сабтесты плоскими, а при необходимости добавить всего один уровень вложенности. Когда появляется больше уровней, читаемость резко падает, а при t.Parallel сложно предсказать порядок выполнения.

Но даже если вы ограничиваетесь двумя уровнями вложенности, длинная логика отдельных тестов может ухудшить читаемость. В большинстве случаев помогает вынесение кода сабтестов в именованные функции.

Вынос групп в отдельные функции

Чтобы улучшить читаемость, группы можно вынести в функции:

func TestCalc(t *testing.T) {
    // Common setup and teardown

    t.Run("addition", addgroup)
    t.Run("multiplication", multgroup)
}

func addgroup(t *testing.T) {
    // addition-specific setup
    defer func() {
        // addition-specific teardown
    }()

    t.Run("1+1=2", func(t *testing.T) {
        if 1+1 != 2 {
            t.Fatal("want 2")
        }
    })

    t.Run("2+3=5", func(t *testing.T) {
        if 2+3 != 5 {
            t.Fatal("want 5")
        }
    })
}

func multgroup(t *testing.T) {
    // multiplication-specific setup
    defer func() {
        // multiplication-specific teardown
    }()

    t.Run("2*2=4", func(t *testing.T) {
        if 2*2 != 4 {
            t.Fatal("want 4")
        }
    })

    t.Run("3*3=9", func(t *testing.T) {
        if 3*3 != 9 {
            t.Fatal("want 9")
        }
    })
}

Всё, что мы сделали здесь, — вынесли группы в отдельные функции. В остальном этот тест идентичен предыдущему варианту с двумя уровнями вложенности сабтестов. Вы можете вызывать t.Parallel прямо из функций подгрупп:

func TestCalc(t *testing.T) {
    // Common setup and teardown
    // ...
}

func addgroup(t *testing.T) {
    // Run the group in parallel
    t.Parallel()
}

func multgroup(t *testing.T) {
    // Run the group in parallel
    t.Parallel()
}

Или можно вызвать t.Parallel в самой верхнеуровневой функции теста:

func TestCalc(t *testing.T) {
    // Common setup and teardown

    t.Run("addition", func(t *testing.T) {
        t.Parallel()
        addgroup(t)  // addgroup doesn't have t.Parallel
    })

    t.Run("multiplication", func(t *testing.T) {
        t.Parallel()
        multgroup(t) // multgroup doesn't have t.Parallel
    })
}

Вот и всё. Но некоторым не нравится та ручная «проводка», которую нам пришлось сделать в верхнеуровневой функции TestCalc. Кроме того, в большой кодовой базе нужна дисциплина, чтобы остальные, расширяя код, следовали тому же подходу.

Поэтому часто хотят, чтобы группы сабтестов обнаруживались автоматически, без ручного подключения в основной тестовой функции. Я не большой поклонник такой магии, но мне всё же стало интересно. В gRPC-go есть функция обнаружения групп, которая делает именно это.

Автоматическое обнаружение групп через reflection

Если бы мы писали тесты внутри репозитория grpc-go, мы могли бы воспользоваться его небольшим вспомогательным пакетом internal/grpctest. Он использует reflection для анализа переданного значения, находит методы, имена которых начинаются с Test, и запускает каждый из них как под-тест.
Ключевой момент в том, что этот вспомогательный пакет выполняет setup перед каждым найденным методом теста и teardown после него. Это обеспечивает чёткое место для действий, связанных с жизненным циклом каждой группы тестов.

Публичный интерфейс у этого пакета минимальный: RunSubTests(t, x) и стандартный носитель хуков Tester, который можно встроить, чтобы получить методы Setup и Teardown.

Вот как будет выглядеть наш набор тестов калькулятора в этом стиле — как если бы мы добавляли тесты прямо внутри grpc-go:

// NOTE: This import path only works inside the grpc-go repo family.
// External modules cannot import google.golang.org/grpc/internal/*.
package calc

import (
    "testing"
    "google.golang.org/grpc/internal/grpctest"
)

// CalcSuite: embed grpctest.Tester so we get Setup and Teardown hooks.
// The runner will discover TestAddition and TestMultiplication below.
type CalcSuite struct{ grpctest.Tester }

// TestAddition is discovered because the name starts with "Test".
func (CalcSuite) TestAddition(t *testing.T) {
    // addition-specific setup and teardown for this group
    defer func() {
        // tear down addition fixtures
    }()

    t.Run("1+1=2", func(t *testing.T) {
        if 1+1 != 2 {
            t.Fatal("want 2")
        }
    })
    t.Run("2+3=5", func(t *testing.T) {
        if 2+3 != 5 {
            t.Fatal("want 5")
        }
    })
}

// A second discovered group.
func (CalcSuite) TestMultiplication(t *testing.T) {
    // multiplication-specific setup and teardown for this group
    defer func() {
        // tear down multiplication fixtures
    }()

    t.Run("2*2=4", func(t *testing.T) {
        if 2*2 != 4 {
            t.Fatal("want 4")
        }
    })
    t.Run("3*3=9", func(t *testing.T) {
        // call t.Parallel() here if overlapping with other subtests is safe
        if 3*3 != 9 {
            t.Fatal("want 9")
        }
    })
}

// Top-level entry that "go test" sees.
// RunSubTests reflects over CalcSuite,
// then runs Setup, the test method, then Teardown.
func TestCalc(t *testing.T) {
    grpctest.RunSubTests(t, CalcSuite{})
}

Внешние проекты использовать этот пакет не могут (он в internal/), но код можно скопировать — он небольшой. Всего несколько десятков строк, без каких-либо зависимостей, кроме проверщика утечек. Вы можете добавить файл в свои тесты, удалить проверку утечек, если она не нужна, подправить пути импортов и начать использовать RunSubTests. Чтобы избежать повторений, оставлю это как упражнение для читателя.

Ещё важно отметить, что grpctest.RunSubTests не меняет стандартный планировщик; вы по-прежнему явно включаете параллелизм с помощью t.Parallel(), когда это безопасно.

Сторонние библиотеки: testify и go-testgroup

Если вам нравится идея автоматического обнаружения подгрупп, но вы хотите использовать что-то вне grpc-go, есть два распространённых варианта — suite из библиотеки testify и go-testgroup от Bloomberg. Обе позволяют организовывать тесты в именованные группы и размещать setup/teardown рядом с самими тестами для каждой группы.

Testify: пакет suite

В testify набор тестов моделируется как структура со встроенными методами Test*. Он предоставляет метод s.Runдля запуска под-тестов и набор вспомогательных функций для проверок (assertions).

package calc

import (
    "testing"
    "github.com/stretchr/testify/suite"
)

type CalcSuite struct{ suite.Suite }

func (s *CalcSuite) TestAddition() {
    s.Run("1+1=2", func() { s.Equal(2, 1+1) })
    s.Run("2+3=5", func() { s.Equal(5, 2+3) })
}

func (s *CalcSuite) TestMultiplication() {
    s.Run("2*2=4", func() { s.Equal(4, 2*2) })
    s.Run("3*3=9", func() { s.Equal(9, 3*3) })
}

func TestCalc(t *testing.T) {
    suite.Run(t, new(CalcSuite))
}

Одно из ограничений заключается в том, что suite из testify не поддерживает использование t.Parallel для параллельного запуска методов набора тестов (например, TestAdditionTestMultiplication). Библиотека go-testgroup от Bloomberg позволяет это делать.

go-testgroup от Bloomberg

Библиотека Bloomberg также группирует тесты по методам, но передаёт в них указатель на *testgroup.T и предоставляет два варианта запускателя (runner), что позволяет выбрать — выполнять тесты групп последовательно или параллельно.

Заключение

Хотя существует множество способов организовать подгруппы тестов, я стараюсь как можно дольше сохранять структуру «плоской». Когда группировка всё же становится необходимой, я добавляю лишь один дополнительный уровень вложенности с помощью t.Run.

В более крупных наборах тестов вынесение групп в отдельные именованные функции значительно улучшает читаемость и сопровождаемость кода. Почти никогда не использую подходы, основанные на reflection, — это просто лишний кусок обвязки, который приходится тянуть за собой.

Я также избегаю подключения сторонних тестовых библиотек, если только проект изначально не построен вокруг них. Такие инструменты, как testify или go-testgroup, требуют определять структуру и «прикреплять» к ней тесты. Я предпочитаю держать тесты как самостоятельные функции. Кроме того, фреймворки для тестирования со временем превращаются в мини-языки со своей логикой, что усложняет вход в проект. Обратите внимание, насколько отличаются API testify suite и go-testgroup, хотя по сути они делают одно и то же.

По моему опыту, даже в крупных кодовых базах немного дисциплины обычно достаточно, чтобы обходиться ручным группированием сабтестов.

Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Комментарии (0)