Deep Engineering

ЗАМЕР

bench/gotest/boundaries.go

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/golang/testing
Как запустить
go run bench/gotest/internals.go
go run bench/gotest/practice.go
go run bench/gotest/boundaries.go

Запись прогона

Замеры для урока «Тестирование»

Файл Что делает
internals.go запускает go test на пакетах из cases/ и legacy/ и печатает шесть наблюдений: подтесты табличного теста, порядок defer и t.Cleanup при параллельных подтестах, переменная цикла в модулях с go 1.21 и go 1.24, предел -parallel, кеш результатов, бенчмарк с выброшенным результатом
boundaries.go добавлен после аудита 07.10.2026, чтобы не перезапускать internals.go с его числами: группа t.Run и defer внутри подтеста, t.Setenv в параллельном тесте, t.Fatal из чужой горутины и go vet, testing/synctest (в go1.24.7 — эксперимент, GOEXPERIMENT=synctest программа ставит сама), разброс бенчмарка за десять прогонов
practice.go источник ответов двух практических задач урока; тест первой задачи запускает настоящим пакетом testing через testing.Main
cases/table функция Clamp и её табличный тест
cases/order defer против t.Cleanup при параллельных подтестах
cases/loopvar переменная цикла в параллельных подтестах, модуль с go 1.24
legacy/loopvar тот же тест в отдельном модуле, чей go.mod объявляет go 1.21
cases/parallel подтесты со сном: сколько из них идёт одновременно
cases/group группа t.Run как барьер для параллельных подтестов и defer внутри подтеста
cases/setenv t.Setenv вместе с t.Parallel
cases/fatalgo t.Fatal из горутины, запущенной тестом
cases/synctest сутки сна в «пузыре» synctest.Run; собирается только с GOEXPERIMENT=synctest
cases/bench три способа написать один замер: результат выброшен, сохранён, b.Loop
go run bench/gotest/internals.go
go run bench/gotest/practice.go
go run bench/gotest/boundaries.go

Все три программы сами вызывают go test, поэтому нужен установленный Go. Раздел 5 internals.go выполняет go clean -testcache: кеш результатов тестов на машине очищается. Записи прогонов — в runs/, go1.24.7 linux/amd64, GOMAXPROCS=2.

Что получилось

Табличный тест — это подтесты. Каждый случай таблицы запускается через t.Run под своим именем, и -run 'TestClamp/выше' запускает его один.

defer в тесте с параллельными подтестами срабатывает слишком рано. Подтест с t.Parallel() ждёт, пока тело родителя закончится, а defer срабатывает именно тогда: оба подтеста увидели уже закрытый ресурс. С t.Cleanup — открытый: он вызывается после всех подтестов.

Переменная цикла зависит от go.mod, а не от компилятора. Один и тот же код, собранный go1.24.7: в модуле с go 1.21 все три параллельных подтеста видят последний случай (tc = c), в модуле с go 1.24 — каждый свой. go vet в старом модуле предупреждает: loop variable tc captured by func literal.

-parallel ограничивает число подтестов, а не загрузку ядер. Четыре подтеста по 200 мс сна: 404 мс по умолчанию (-parallel = GOMAXPROCS = 2), 805 мс с -parallel=1, 203 мс с -parallel=4.

Кеш. Повторный go test печатает (cached) и тестов не запускает; -count=1 — идиоматичный способ кеш обойти.

Бенчмарк выброшенного результата меряет пустой цикл. Лучший из пяти: 0,32 нс против 1,65 нс, когда результат сохранён; b.Loop — 2,20 нс: внутри него компилятор вызов не встраивает (skip inlining within testing.B.loop). Наносекунды не переносятся; переносится то, что пустой цикл в разы быстрее.

После аудита 07.10.2026 (boundaries.go)

Группа — второй барьер. Код после t.Run("group", …) выполняется, когда параллельные подтесты группы закончились: оба видели ресурс открытым. defer внутри подтеста срабатывает после работы этого подтеста — для своего ресурса подтеста он годится.

t.Setenv с t.Parallel — паника пакета: test using t.Setenv or t.Chdir can not use t.Parallel.

t.Fatal из чужой горутины останавливает её, а не тест. Тест помечен упавшим, но код после ожидания горутины выполнился; go vet сообщает call to (*testing.T).Fatal from a non-test goroutine.

testing/synctest (go1.24.7, эксперимент). Сутки сна внутри пузыря — 24h0m0s по часам пузыря и меньше секунды по настоящим.

Разброс. Десять прогонов: BenchmarkDiscard 0,33–0,42 нс (медиана 0,34), BenchmarkSink 1,61–1,95 нс (медиана 1,76). Для выводов «стало быстрее» сравнивают серии через benchstat, а не лучший прогон.

Скрипт

104 строк
//go:build ignore

// Границы тестов: уточнения к internals.go после аудита 07.10.2026.
//
// ЗАЧЕМ ОТДЕЛЬНАЯ ПРОГРАММА. internals.go меряет то, что урок цитирует
// числами, и его перезапуск менял бы их. Здесь — то, что добавлено позже.
//
// ПЯТЬ СЮЖЕТОВ:
//
//  1. Ещё два способа закрыть ресурс параллельных подтестов: код после группы
//     t.Run и defer внутри самого подтеста.
//  2. t.Setenv в параллельном тесте — паника пакета testing.
//  3. t.Fatal из чужой горутины останавливает её, а не тест; go vet это ловит.
//  4. testing/synctest в go1.24.7 (эксперимент): сутки сна в пузыре — без
//     ожидания.
//  5. Разброс бенчмарка: десять прогонов вместо «лучшего из пяти».
//
// ЗАПУСК:
//
//	go run bench/gotest/boundaries.go
package main

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime"
	"sort"
	"strconv"
	"strings"
)

var here string

func goCmd(env []string, args ...string) string {
	cmd := exec.Command("go", args...)
	cmd.Dir = here
	cmd.Env = append(os.Environ(), env...)
	out, _ := cmd.CombinedOutput()
	return string(out)
}

func show(title string) {
	fmt.Println()
	fmt.Println(title)
	fmt.Println(strings.Repeat("─", len([]rune(title))))
}

func lines(out string, keep func(string) bool) {
	for _, l := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
		if keep(l) {
			fmt.Println("  " + strings.TrimSpace(l))
		}
	}
}

func event(l string) bool { return strings.HasPrefix(l, "EVENT ") }

func main() {
	_, file, _, _ := runtime.Caller(0)
	here = filepath.Dir(file)
	fmt.Printf("%s %s/%s, GOMAXPROCS=%d | границы тестов\n", runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0))

	show("1) ГРУППА t.Run И defer ВНУТРИ ПОДТЕСТА")
	lines(goCmd(nil, "test", "-count=1", "-v", "./cases/group/"), event)

	show("2) t.Setenv В ПАРАЛЛЕЛЬНОМ ТЕСТЕ")
	lines(goCmd(nil, "test", "-count=1", "./cases/setenv/"), func(l string) bool {
		return strings.HasPrefix(l, "panic: testing:") || strings.HasPrefix(l, "--- FAIL")
	})

	show("3) t.Fatal ИЗ ЧУЖОЙ ГОРУТИНЫ")
	lines(goCmd(nil, "test", "-count=1", "-v", "./cases/fatalgo/"), func(l string) bool {
		return event(l) || strings.Contains(l, "ошибка в горутине") || strings.HasPrefix(l, "--- FAIL")
	})
	fmt.Println("  go vet:")
	lines(goCmd(nil, "vet", "./cases/fatalgo/"), func(l string) bool { return strings.Contains(l, "goroutine") })

	show("4) testing/synctest (GOEXPERIMENT=synctest)")
	lines(goCmd([]string{"GOEXPERIMENT=synctest"}, "test", "-count=1", "-v", "./cases/synctest/"), event)

	show("5) РАЗБРОС БЕНЧМАРКА: ДЕСЯТЬ ПРОГОНОВ")
	out := goCmd(nil, "test", "-run", "^$", "-bench", "Discard|Sink", "-count=10", "./cases/bench/")
	nsop := regexp.MustCompile(`^(Benchmark\w+)-\d+\s+\d+\s+([\d.]+) ns/op`)
	runs := map[string][]float64{}
	for _, l := range strings.Split(out, "\n") {
		if m := nsop.FindStringSubmatch(l); m != nil {
			v, _ := strconv.ParseFloat(m[2], 64)
			runs[m[1]] = append(runs[m[1]], v)
		}
	}
	fmt.Printf("  %-18s %8s %8s %8s\n", "", "мин", "медиана", "макс")
	for _, name := range []string{"BenchmarkDiscard", "BenchmarkSink"} {
		r := runs[name]
		sort.Float64s(r)
		med := (r[len(r)/2-1] + r[len(r)/2]) / 2
		fmt.Printf("  %-18s %8.2f %8.2f %8.2f   нс/оп, %d прогонов\n", name, r[0], med, r[len(r)-1], len(r))
	}
	fmt.Println("  «Лучший из N» отбрасывает разброс. Для выводов о скорости")
	fmt.Println("  сравнивают распределения серий — это делает benchstat.")
}