Menu

Тестирование в Golang: юнит-тесты, табличные тесты и бенчмарки

Как тестировать код на Go стандартным пакетом testing и командой go test: файлы _test.go, функции TestXxx, t.Errorf и t.Fatalf, табличные тесты с t.Run, помощники и временные папки, покрытие, бенчмарки через b.Loop и тесты-примеры.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Тестируемый код

Инструменты тестирования Go входят в стандартный тулчейн: пакет testing и команда go test. Ничего устанавливать не нужно, библиотека утверждений не требуется.

Примеры на этой странице тестируют одну маленькую функцию, Slugify, которая превращает заголовок в slug для URL. Вот она, готовая к запуску, с самодельной проверкой в main:

Редактор в браузере запускает main; go test он запустить не может. Всё, что ниже, записано так, как это выглядит в настоящем проекте, а после каждой команды показан вывод терминала. Всё это запускалось на Go 1.24.

Первый тест

В проекте функция лежит в пакете, а её тесты рядом, в файле, имя которого заканчивается на _test.go:

textutil/
├── go.mod          module example.com/textutil
├── slug.go         package textutil, func Slugify
└── slug_test.go    package textutil, the tests
// slug_test.go
package textutil

import "testing"

func TestSlugify(t *testing.T) {
	got := Slugify("Hello, World!")
	want := "hello-world"
	if got != want {
		t.Errorf("Slugify(%q) = %q, want %q", "Hello, World!", got, want)
	}
}

Правила, которыми пользуется go test:

  • Файлами тестов считаются только файлы с окончанием _test.go. go build их игнорирует, поэтому тестовый код никогда не попадает в ваш бинарник.
  • Тест это функция с именем TestXxx (часть после Test не должна начинаться со строчной буквы), принимающая один *testing.T.
  • Тест проходит, если не вызвал ни одного метода провала и не запаниковал.
go test          # the package in the current directory
go test ./...    # every package in the module
$ go test
PASS
ok  	example.com/textutil	0.318s

Формат сообщения о провале Func(input) = got, want expected это соглашение Go. Указывайте в сообщении входные данные: провал, где написано только got "a", want "b", заставит вас лезть в код, чтобы понять, какой это был ввод.

t.Errorf и t.Fatalf

МетодПомечает провалОстанавливает тест
t.Error, t.Errorfданет, выполнение продолжается
t.Fatal, t.Fatalfдада, немедленно
t.Log, t.Logfнетнет, печатает только с -v или при провале
t.Skip, t.Skipfнетда, отмечается как пропущенный

По умолчанию используйте Errorf, чтобы один запуск сообщил о каждом неверном поле. Fatalf используйте, когда остаток теста не может работать, обычно после неожиданной ошибки:

func TestCountWords(t *testing.T) {
	path := writeFile(t, "doc.txt", "the quick brown\nfox  jumps\n")

	got, err := CountWords(path)
	if err != nil {
		t.Fatalf("CountWords: unexpected error: %v", err) // stop: got is meaningless
	}
	if got != 5 {
		t.Errorf("CountWords = %d, want 5", got)
	}
}

Fatal останавливает тест вызовом runtime.Goexit, поэтому его нужно вызывать из собственной горутины теста. Из запущенной вами горутины сообщайте через t.Error и возвращайтесь.

Табличные тесты с t.Run

Большинство тестов на Go табличные: слайс случаев, один цикл и t.Run, чтобы дать каждому случаю своё имя.

func TestSlugifyTable(t *testing.T) {
	tests := []struct {
		name, in, want string
	}{
		{"simple", "Go Testing", "go-testing"},
		{"punctuation", "What's new in Go 1.24?", "what-s-new-in-go-1-24"},
		{"extra spaces", "  lots   of   space  ", "lots-of-space"},
		{"non-ascii dropped", "Café au lait", "caf-au-lait"},
		{"empty", "", ""},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Slugify(tt.in); got != tt.want {
				t.Errorf("Slugify(%q) = %q, want %q", tt.in, got, tt.want)
			}
		})
	}
}

Новый случай добавляется одной строкой. О каждом подтесте сообщается отдельно, Fatal внутри одного завершает только этот подтест, а запустить можно один подтест по имени. Запустите всё с -v:

$ go test -v
=== RUN   TestSlugify
--- PASS: TestSlugify (0.00s)
=== RUN   TestSlugifyTable
=== RUN   TestSlugifyTable/simple
=== RUN   TestSlugifyTable/punctuation
=== RUN   TestSlugifyTable/extra_spaces
=== RUN   TestSlugifyTable/non-ascii_dropped
=== RUN   TestSlugifyTable/empty
--- PASS: TestSlugifyTable (0.00s)
    --- PASS: TestSlugifyTable/simple (0.00s)
    --- PASS: TestSlugifyTable/punctuation (0.00s)
    --- PASS: TestSlugifyTable/extra_spaces (0.00s)
    --- PASS: TestSlugifyTable/non-ascii_dropped (0.00s)
    --- PASS: TestSlugifyTable/empty (0.00s)
=== RUN   TestCountWords
--- PASS: TestCountWords (0.00s)
=== RUN   TestCountWordsMissingFile
--- PASS: TestCountWordsMissingFile (0.00s)
=== RUN   ExampleSlugify
--- PASS: ExampleSlugify (0.00s)
PASS
ok  	example.com/textutil	0.182s

Пробелы в именах подтестов превращаются в подчёркивания. Когда случай проваливается, вывод называет подтест и строку:

--- FAIL: TestSlugifyTable (0.00s)
    --- FAIL: TestSlugifyTable/underscore (0.00s)
        slug_test.go:27: Slugify("snake_case_name") = "snake-case-name", want "snake_case_name"
FAIL
FAIL	example.com/textutil	0.289s

Начиная с Go 1.22 у каждой итерации цикла свой tt, поэтому замыкания внутри t.Run видят правильный случай, даже когда подтесты выполняются параллельно. В старом коде по этой причине в начале тела цикла часто стоит tt := tt; теперь это не нужно.

Чтобы подтесты выполнялись параллельно, вызовите t.Parallel() в начале функции подтеста. Делайте так, только когда случаи независимы и достаточно медленные, чтобы это имело значение.

Флаги go test, которыми вы будете пользоваться

КомандаЧто делает
go test -vпечатает имя, результат и вывод t.Log каждого теста
go test -run TestSlugifyзапускает тесты, имена которых подходят под регулярное выражение
go test -run 'TestSlugifyTable/empty'запускает один подтест
go test -count=1игнорирует кешированные результаты и запускает заново
go test -raceзапускает с детектором гонок данных
go test -shortпросит долгие тесты пропустить себя (if testing.Short() { t.Skip() })
go test -failfastостанавливается после первого проваленного теста
go test -timeout 30sпроваливает запуск, если он длится дольше (по умолчанию 10 минут)
go test -coverпечатает покрытие инструкций
go test -bench=.запускает ещё и бенчмарки

Когда вы указываете пакеты (go test ., go test ./...), go test кеширует результаты для пакетов, код и входные данные которых не менялись, и печатает после них (cached). Просто go test без аргументов никогда не кеширует. Кеш отслеживает файлы и переменные окружения, которые тест читает через пакет os, но не внешнее состояние вроде базы данных или сетевого сервиса, так что тест, который от них зависит, может пройти из кеша, хотя сейчас бы провалился; -count=1 заставляет выполнить настоящий запуск.

Помощники, временные папки и очистка

Вызов writeFile в TestCountWords выше это тестовый помощник:

// writeFile is a test helper: t.Helper makes failures point at the caller.
func writeFile(t *testing.T, name, content string) string {
	t.Helper()
	path := filepath.Join(t.TempDir(), name) // removed automatically after the test
	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
		t.Fatalf("writing %s: %v", name, err)
	}
	return path
}
  • t.Helper() помечает функцию как помощник, поэтому о провале сообщается на строке теста, которая её вызвала, а не внутри помощника.
  • t.TempDir() создаёт новую папку и удаляет её, когда тест заканчивается. Тестам никогда не нужно самим убирать файлы.
  • t.Cleanup(func() { ... }) регистрирует любую другую очистку (закрыть сервер, удалить таблицу). Функции очистки выполняются после теста и его подтестов, последняя зарегистрированная первой.
  • t.Setenv("KEY", "value") задаёт переменную окружения только для этого теста и потом восстанавливает её.
  • t.Context() (Go 1.24) возвращает контекст, который отменяется прямо перед выполнением функций очистки.

Файлы с тестовыми данными кладутся в папку testdata рядом с тестами. Утилита go не считает её пакетом, а тесты запускаются с папкой пакета в качестве рабочей, так что os.ReadFile("testdata/input.json") работает.

Тестирование ошибок и HTTP-обработчиков

Проверяйте ошибки через errors.Is или errors.As, так же как это делал бы вызывающий код:

func TestCountWordsMissingFile(t *testing.T) {
	_, err := CountWords(filepath.Join(t.TempDir(), "nope.txt"))
	if !errors.Is(err, fs.ErrNotExist) {
		t.Errorf("err = %v, want fs.ErrNotExist", err)
	}
}

Сравнение строк ошибок ломается, как только кто-то переформулирует сообщение.

Для HTTP-обработчиков net/http/httptest даёт поддельный ResponseWriter, так что обработчик можно вызвать напрямую, без сети:

func TestHealth(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/health", nil)
	rec := httptest.NewRecorder()

	healthHandler(rec, req)

	if rec.Code != http.StatusOK {
		t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
	}
	if body := rec.Body.String(); body != "ok\n" {
		t.Errorf("body = %q, want %q", body, "ok\n")
	}
}

httptest.NewServer запускает настоящий сервер на localhost для тестирования клиентского кода. Страница про HTTP-клиент использует его в каждом примере.

Покрытие

$ go test -cover
PASS
coverage: 100.0% of statements
ok  	example.com/textutil	0.578s

$ go test -coverprofile=cover.out
$ go tool cover -func=cover.out
example.com/textutil/slug.go:11:	Slugify		100.0%
example.com/textutil/words.go:9:	CountWords	100.0%
total:					(statements)	100.0%

$ go tool cover -html=cover.out    # opens a browser with covered lines in green

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

Бенчмарки

Бенчмарк это func BenchmarkXxx(b *testing.B). В Go 1.24 появился b.Loop, и теперь рекомендуется именно эта форма:

func BenchmarkSlugify(b *testing.B) {
	for b.Loop() {
		Slugify("The Go Programming Language, 2nd Edition")
	}
}

b.Loop выполняет тело столько раз, сколько нужно для стабильного измерения, исключает из замера подготовительный код до цикла и не даёт компилятору выбросить вызов при оптимизации. Код, написанный до Go 1.24, использует for i := 0; i < b.N; i++; это по-прежнему работает, но требует b.ResetTimer() после дорогой подготовки и может быть обмануто удалением мёртвого кода.

Обычный go test бенчмарки не запускает. Попросите об этом явно, а обычные тесты пропустите через -run='^$':

$ go test -bench=. -benchmem -run='^$'
goos: darwin
goarch: arm64
pkg: example.com/textutil
cpu: Apple M4
BenchmarkSlugify-10    	 5061945	       238.1 ns/op	     168 B/op	       5 allocs/op
PASS
ok  	example.com/textutil	1.410s

Колонки: имя с добавленным GOMAXPROCS, число выполненных итераций, время на вызов, байты, выделенные на вызов, аллокации на вызов. Числа зависят от машины; сравнивайте запуски на одной и той же. Чтобы надёжно сравнить до и после изменения, запустите каждую сторону несколько раз с -count=10 и передайте оба вывода в benchstat (golang.org/x/perf/cmd/benchstat).

Примеры это тоже тесты

Функция-пример что-то печатает и объявляет ожидаемый вывод в комментарии. go test запускает её и проваливается, если вывод отличается, а go doc и pkg.go.dev показывают её как документацию:

// example_test.go
package textutil_test

import (
	"fmt"

	"example.com/textutil"
)

func ExampleSlugify() {
	fmt.Println(textutil.Slugify("Hello, World!"))
	// Output: hello-world
}

Имя пакета textutil_test делает это внешним тестом: он может использовать только экспортированный API, как настоящий вызывающий код. Такие файлы могут лежать в той же папке, что и пакет. Без комментария // Output: пример компилируется, но не запускается. Используйте // Unordered output:, когда строки могут идти в любом порядке.

Фаззинг вкратце

В Go 1.18 появились фазз-тесты, которые генерируют входные данные, чтобы находить падения и нарушенные инварианты:

func FuzzSlugify(f *testing.F) {
	f.Add("Hello, World!") // seed input
	f.Fuzz(func(t *testing.T, s string) {
		slug := Slugify(s)
		if strings.Contains(slug, "--") || strings.HasPrefix(slug, "-") {
			t.Errorf("Slugify(%q) = %q: bad hyphens", s, slug)
		}
	})
}

Обычный go test запускает только начальные входные данные (значения f.Add и сохранённые файлы в testdata/fuzz). go test -fuzz=FuzzSlugify продолжает генерировать новые, пока не найдёт провал или вы его не остановите, и сохраняет проваливающиеся входные данные в testdata/fuzz, чтобы они стали обычными тестовыми случаями.

Частые ошибки

  • Файл теста не там или не так назван. Он должен заканчиваться на _test.go и лежать в папке пакета. slug_tests.go компилируется в пакет, а не запускается как тесты.
  • Строчная буква после Test. Testslugify не тест. TestSlugify и Test_slugify тесты.
  • t.Fatal из другой горутины. Там сообщайте через t.Error и дожидайтесь горутины до возврата из теста.
  • Сообщения без входных данных. Указывайте, что передали, что получили и что ожидали.
  • Доверие к прохождению из кеша. Используйте -count=1, когда тест зависит от чего-то вне пакета.
  • Тесты, зависящие от порядка друг друга или общих глобальных переменных. Каждый тест должен сам готовить своё состояние, для этого и существуют t.TempDir, t.Setenv и t.Cleanup.

Часто задаваемые вопросы

Как написать юнит-тест в Go?

Создайте файл с окончанием _test.go в той же папке, что и код, импортируйте testing и напишите функцию func TestName(t *testing.T), которая вызывает ваш код и сообщает о несовпадениях через t.Errorf. Запустите go test в этой папке или go test ./... для всего модуля. Ни фреймворк, ни библиотека утверждений не нужны.

Чем t.Error отличается от t.Fatal в Go?

t.Error и t.Errorf помечают тест как проваленный и продолжают его выполнение, так что один запуск может сообщить о нескольких проблемах. t.Fatal и t.Fatalf помечают тест проваленным и сразу его останавливают. Используйте Fatal, когда продолжать нет смысла, например после неожиданной ошибки, из-за которой результат непригоден.

Как запустить один тест в Go?

Передайте регулярное выражение в -run: go test -run TestSlugify запускает все тесты с подходящим именем, а go test -run 'TestSlugifyTable/punctuation' запускает один подтест (пробелы в именах подтестов превращаются в подчёркивания). Добавьте -v, чтобы видеть имя и результат каждого теста, и -count=1, чтобы обойти кеш тестов.

Как написать бенчмарк в Go?

Напишите func BenchmarkName(b *testing.B) в файле _test.go и поместите измеряемый код в цикл for b.Loop() { ... } (Go 1.24; старый код использует for i := 0; i < b.N; i++). Запустите через go test -bench=. -benchmem, и он сообщит наносекунды, байты и число аллокаций на операцию.

Как посмотреть покрытие тестами в Go?

go test -cover печатает процент инструкций, выполненных тестами. Для подробностей запишите профиль через go test -coverprofile=cover.out, затем запустите go tool cover -func=cover.out для цифр по функциям или go tool cover -html=cover.out, чтобы увидеть покрытые и непокрытые строки в браузере.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ