В .NET тестирование — это выбор внешнего фреймворка: xUnit, NUnit или MSTest, обычно в паре с FluentAssertions и тест-раннером. В Go тестирование встроено в язык и тулчейн. Пакет testing входит в стандартную библиотеку, команда go test — часть компилятора, а конвенция важнее конфигурации: тест — это функция с именем TestXxx в файле _test.go. Никаких атрибутов, ссылок на NuGet-пакеты и [TestClass].
Эта глава показывает встроенный инструментарий — от первого теста до фаззинга — и главную идиому Go-тестов: table-driven tests.
Тест — это функция вида 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) или сбор через FluentAssertionsAssertionScope.
Так как готовых ассертов нет, сравнение пишут руками. Для структур и слайсов идиоматично использовать 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/validt.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.Cleanup≈IDisposable.Dispose()тест-класса или[TearDown](NUnit) /Dispose(xUnit) — освобождение ресурсов после теста.t.Helper()аналога в .NET почти не имеет: там стектрейс ассерта и так указывает на нужное место, а в Go об этом надо сказать явно.
Самый распространённый стиль 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 -cover≈dotnet 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:
BenchmarkXxx≈BenchmarkDotNetс атрибутом[Benchmark], а-benchmem≈[MemoryDiagnoser]. Принципиальная разница: в .NET BenchmarkDotNet — это отдельный мощный NuGet-пакет со своим раннером, а в Go бенчмарки встроены вgo testтем же синтаксисом, что и обычные тесты.
Функции 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 же гарантирует, что примеры в документации компилируются и дают заявленный вывод.
Фаззинг встроен в 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-тулчейна.
Хотя 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