Skip to content

Latest commit

 

History

History
292 lines (212 loc) · 20.9 KB

File metadata and controls

292 lines (212 loc) · 20.9 KB

Тестирование

В .NET тестирование — это выбор внешнего фреймворка: xUnit, NUnit или MSTest, обычно в паре с FluentAssertions и тест-раннером. В Go тестирование встроено в язык и тулчейн. Пакет testing входит в стандартную библиотеку, команда go test — часть компилятора, а конвенция важнее конфигурации: тест — это функция с именем TestXxx в файле _test.go. Никаких атрибутов, ссылок на NuGet-пакеты и [TestClass].

Эта глава показывает встроенный инструментарий — от первого теста до фаззинга — и главную идиому Go-тестов: table-driven tests.

Встроенный пакет testing

Тест — это функция вида func TestXxx(t *testing.T), лежащая в файле, имя которого оканчивается на _test.go. Такие файлы компилируются только при тестировании и не попадают в обычную сборку.

// math.go
package mathx

func Add(a, b int) int { return a + b }
// math_test.go
package mathx

import "testing"

func TestAdd(t *testing.T) {
    got := Add(2, 3)
    if got != 5 {
        t.Errorf("Add(2, 3) = %d; ожидалось 5", got)
    }
}

Запуск:

go test ./...          # все тесты во всех пакетах рекурсивно
go test -v ./mathx     # подробный вывод по конкретному пакету
go test -run TestAdd   # только тесты, чьё имя матчит регуляркой

В Go нет отдельных «ассертов» в stdlib. Тест проваливается, когда вы это говорите через методы t:

  • t.Error / t.Errorf — пометить тест проваленным, но продолжить выполнение.
  • t.Fatal / t.Fatalf — пометить проваленным и немедленно остановить текущий тест (через runtime.Goexit). Используйте, когда дальше продолжать бессмысленно (например, не удалось создать объект, который нужен в остальных проверках).
  • t.Log / t.Logf — диагностический вывод (печатается при -v или при провале).

Параллель с .NET: func TestXxx(t *testing.T) ≈ метод с атрибутом [Fact] (xUnit) / [Test] (NUnit) / [TestMethod] (MSTest), только маркер — это конвенция имени, а не атрибут. t.Fatal ≈ исключение ассерта, которое прерывает тест (как любой Assert.* в xUnit, кидающий исключение при провале). А вот t.Error особенный: он не прерывает тест и позволяет собрать несколько провалов за один прогон — в xUnit для похожего поведения нужен Assert.Multiple (NUnit) или сбор через FluentAssertions AssertionScope.

Структура и сравнение значений

Так как готовых ассертов нет, сравнение пишут руками. Для структур и слайсов идиоматично использовать reflect.DeepEqual или (с Go 1.21) slices.Equal/maps.Equal для конкретных коллекций:

import "reflect"

func TestParse(t *testing.T) {
    got, err := Parse("a=1;b=2")
    if err != nil {
        t.Fatalf("неожиданная ошибка: %v", err)
    }
    want := map[string]int{"a": 1, "b": 2}
    if !reflect.DeepEqual(got, want) {
        t.Errorf("Parse() = %v; ожидалось %v", got, want)
    }
}

Многие команды для удобного diff-вывода подключают github.com/google/go-cmp/cmp (cmp.Diff(want, got)), но это уже сторонняя библиотека.

Подтесты, параллелизм, хелперы и очистка

Несколько методов t структурируют тесты.

t.Run(name, func(t *testing.T)) создаёт подтест — вложенный именованный тест. Это основа table-driven тестов (ниже) и способ группировать связанные проверки:

func TestUser(t *testing.T) {
    t.Run("valid", func(t *testing.T) { /* ... */ })
    t.Run("empty name", func(t *testing.T) { /* ... */ })
}
// имена подтестов: TestUser/valid, TestUser/empty_name
// запустить один: go test -run TestUser/valid

t.Parallel() помечает тест как параллельный — такие тесты внутри одного пакета выполняются одновременно (степень параллелизма задаёт -parallel N, по умолчанию GOMAXPROCS):

func TestSlowA(t *testing.T) {
    t.Parallel() // выполнится параллельно с другими t.Parallel()-тестами
    // ... долгая операция ...
}

t.Helper() помечает функцию как вспомогательную — тогда при провале номер строки в отчёте указывает на вызов хелпера, а не на его внутренности. Незаменимо для собственных assert-функций:

func mustEqual(t *testing.T, got, want int) {
    t.Helper() // строка в отчёте укажет на место вызова mustEqual, а не сюда
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

t.Cleanup(func()) регистрирует функцию очистки, которая выполнится по завершении теста (включая подтесты) — аналог defer, но работающий и через границы хелперов, и в правильном порядке относительно подтестов:

func setupDB(t *testing.T) *DB {
    db := openTestDB()
    t.Cleanup(func() { db.Close() }) // закроется автоматически по окончании теста
    return db
}

Параллель с .NET: t.Run ≈ вложенные/параметризованные тесты, но как обычные вызовы функций, а не атрибуты. t.Parallel()[Collection]/[assembly: CollectionBehavior(...)] и параллелизм xUnit, только включается per-test одной строкой. t.CleanupIDisposable.Dispose() тест-класса или [TearDown] (NUnit) / Dispose (xUnit) — освобождение ресурсов после теста. t.Helper() аналога в .NET почти не имеет: там стектрейс ассерта и так указывает на нужное место, а в Go об этом надо сказать явно.

Table-driven tests — главная идиома

Самый распространённый стиль Go-тестов — табличный. Вместо копирования одной и той же логики проверки на каждый случай вы описываете случаи как слайс структур и прогоняете их в цикле, оборачивая каждый в t.Run. Это идиоматический ответ Go на «параметризованные тесты».

func TestDivide(t *testing.T) {
    tests := []struct {
        name    string
        a, b    int
        want    int
        wantErr error
    }{
        {name: "обычное деление", a: 10, b: 2, want: 5},
        {name: "деление на единицу", a: 7, b: 1, want: 7},
        {name: "ноль в числителе", a: 0, b: 5, want: 0},
        {name: "деление на ноль", a: 1, b: 0, wantErr: ErrDivByZero},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got, err := Divide(tc.a, tc.b)

            if !errors.Is(err, tc.wantErr) {
                t.Fatalf("Divide(%d, %d) ошибка = %v; ожидалось %v",
                    tc.a, tc.b, err, tc.wantErr)
            }
            if err == nil && got != tc.want {
                t.Errorf("Divide(%d, %d) = %d; ожидалось %d",
                    tc.a, tc.b, got, tc.want)
            }
        })
    }
}

Что это даёт:

  • Каждый кейс — отдельный подтест с понятным именем (TestDivide/деление_на_ноль). При провале сразу видно, какой именно случай сломался, и можно запустить его в одиночку.
  • Добавить случай = добавить строку в таблицу, а не копировать тело теста.
  • Граничные случаи на виду — таблица читается как спецификация поведения.

Этот паттерн настолько укоренён, что table-driven тесты считают «дефолтным» способом тестировать чистую логику в Go.

Историческая ремарка: до Go 1.22 при t.Parallel() внутри такого цикла нужно было «захватывать» переменную (tc := tc), иначе все горутины видели последнее значение. В Go 1.22 семантику переменной цикла исправили — теперь каждая итерация получает свежую tc, и этот трюк больше не нужен.

Параллель с .NET: table-driven тест — это прямой аналог [Theory] + [InlineData(...)] (xUnit) или [TestCase(...)] (NUnit). Разница в том, что данные кейсов — это обычный Go-код (слайс структур), а не атрибуты с константами в скобках. Для сложных данных в xUnit берут [MemberData]/[ClassData] — и вот это уже один в один соответствует Go-слайсу: данные как код. Плюс Go-подхода — кейсы можно вычислять, фильтровать, генерировать программно; минус — нет «декларативности» атрибута.

Покрытие

Покрытие встроено в go test:

go test -cover ./...                       # процент покрытия по пакетам
go test -coverprofile=cover.out ./...      # детальный профиль в файл
go tool cover -html=cover.out              # HTML-отчёт с подсветкой строк
go tool cover -func=cover.out              # покрытие по функциям в консоль

-coverprofile сохраняет, какие строки исполнялись, а go tool cover превращает это в наглядный HTML, где зелёным выделен покрытый код, красным — нет.

Параллель с .NET: go test -coverdotnet test --collect:"XPlat Code Coverage" (Coverlet) или Visual Studio Code Coverage. Только в Go это часть базового тулчейна без отдельных пакетов и SDK-плагинов.

Бенчмарки

Бенчмарк — функция func BenchmarkXxx(b *testing.B). Тело крутит измеряемую операцию b.N раз; рантайм сам подбирает b.N, чтобы набрать статистически значимое время:

func BenchmarkParse(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _, _ = Parse("a=1;b=2;c=3")
    }
}

Запуск (бенчмарки по умолчанию не выполняются вместе с тестами):

go test -bench=. -benchmem ./mathx

-bench=. отбирает бенчмарки регуляркой, -benchmem добавляет к отчёту аллокации:

BenchmarkParse-10    3512904    340.2 ns/op    128 B/op    3 allocs/op

То есть на операцию: 340 нс, 128 байт и 3 аллокации. B/op и allocs/op бесценны для оптимизации в Go, где контроль над аллокациями — повседневная задача. Для надёжного сравнения «до/после» используют утилиту benchstat.

Параллель с .NET: BenchmarkXxxBenchmarkDotNet с атрибутом [Benchmark], а -benchmem[MemoryDiagnoser]. Принципиальная разница: в .NET BenchmarkDotNet — это отдельный мощный NuGet-пакет со своим раннером, а в Go бенчмарки встроены в go test тем же синтаксисом, что и обычные тесты.

Example-тесты: документация, которая проверяется

Функции func ExampleXxx() — это одновременно документация и тест. Если в функции есть комментарий // Output:, тулчейн выполнит её и сравнит реальный вывод в stdout с ожидаемым:

func ExampleAdd() {
    fmt.Println(Add(2, 3))
    // Output: 5
}

Такие примеры:

  • показываются в документации (go doc, pkg.go.dev) рядом с описанием функции;
  • проверяются командой go test — если поведение изменится, пример «сломается», и документация не сможет устареть незаметно.

Есть вариант // Unordered output: для случаев, когда порядок строк недетерминирован (например, обход мапы).

Параллель с .NET: прямого аналога в .NET-тестировании нет. Ближайшее по духу — XML-doc <example> или примеры в README, но они не исполняются и не проверяются. Go же гарантирует, что примеры в документации компилируются и дают заявленный вывод.

Фаззинг (Go 1.18)

Фаззинг встроен в testing начиная с Go 1.18. Фаз-тест — функция func FuzzXxx(f *testing.F): вы задаёте «затравочные» примеры через f.Add, а движок генерирует тысячи случайных входов, ища падение или нарушение инварианта.

func FuzzParse(f *testing.F) {
    f.Add("a=1;b=2")        // затравка: примеры валидного ввода
    f.Add("")
    f.Fuzz(func(t *testing.T, input string) {
        // движок будет подставлять сюда сгенерированные строки.
        // Цель — не свалить функцию: паника = найденный баг.
        _, err := Parse(input)
        _ = err // нас интересует, что Parse не паникует ни на каком входе
    })
}

Запуск:

go test -fuzz=FuzzParse        # бесконечный фаззинг до первого падения
go test -fuzz=FuzzParse -fuzztime=30s

Найдя вход, ломающий тест, движок сохраняет его в testdata/fuzz/ как обычный кейс — и дальше он проверяется при каждом go test (регрессия). Фаззинг особенно ценен для парсеров, декодеров и любой логики, работающей с недоверенным вводом.

Параллель с .NET: в .NET фаззинг исторически жил во внешних инструментах (SharpFuzz поверх AFL, или коммерческие решения). Встроенного в тест-фреймворк фаззинга «из коробки», как в Go, в стандартном стеке .NET нет — это заметное преимущество Go-тулчейна.

Сторонний testify: assert и require

Хотя stdlib намеренно обходится без ассертов, на практике многие проекты подключают github.com/stretchr/testify — самую популярную тест-библиотеку Go. Она даёт богатый набор проверок в двух вариантах:

  • assert.* — при провале помечает тест проваленным, но продолжает (как t.Error).
  • require.* — при провале останавливает тест (как t.Fatal).
import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestWithTestify(t *testing.T) {
    got, err := Parse("a=1")
    require.NoError(t, err) // если ошибка — дальше нет смысла, останавливаемся
    assert.Equal(t, map[string]int{"a": 1}, got)
    assert.Len(t, got, 1)
}

Почему популярен: убирает ручные if got != want { t.Errorf(...) }, даёт читаемые диффы и десятки готовых проверок (Equal, ErrorIs, Contains, Eventually, Panics и т.д.). При этом stdlib-стиль остаётся абсолютно нормальным — часть сообщества (и сама команда Go) предпочитает писать тесты на чистом testing без зависимостей. Это вопрос вкуса команды, а не «правильно/неправильно».

Параллель с .NET: testify ≈ FluentAssertions (или Shouldly) — внешний слой выразительных проверок поверх базового раннера. Разница в дефолтах: в .NET почти всегда берут FluentAssertions/Assert.*, потому что в самом фреймворке проверки минимальны или специфичны; в Go же писать тесты вообще без ассерт-библиотеки — распространённая и уважаемая практика.

Итог

  • Тестирование в Go встроено: пакет testing из stdlib + команда go test. Тест — это func TestXxx(t *testing.T) в файле _test.go; маркер — конвенция имени, а не атрибут.
  • Провал задаёте вы: t.Error/t.Errorf (продолжить) и t.Fatal/t.Fatalf (остановить). Готовых ассертов в stdlib нет.
  • Структурирующие методы: t.Run (подтесты), t.Parallel (параллелизм), t.Helper (правильные номера строк в хелперах), t.Cleanup (очистка после теста).
  • Table-driven tests — главная идиома: слайс структур-кейсов + цикл с t.Run. Аналог [Theory]/[InlineData], но данные — обычный Go-код.
  • В тулчейн встроены покрытие (-cover, go tool cover), бенчмарки (BenchmarkXxx, -benchmem), Example-тесты (исполняемая документация) и фаззинг (FuzzXxx, Go 1.18).
  • Сторонний testify (assert/require) популярен и удобен, но stdlib-стиль без зависимостей — равноправная норма в Go.

Дальше — как изолировать зависимости в тестах: моки на интерфейсах (вручную и кодогенерацией) и интеграционные тесты на реальных сервисах через Testcontainers.


⌂ Главная · ↑ Раздел · ← Предыдущий: defer, panic и recover · → Следующий: Моки и Testcontainers