Deep Engineering

MEASUREMENT

bench/gotest/boundaries.go

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/golang/testing
How to run it
go run bench/gotest/internals.go
go run bench/gotest/practice.go
go run bench/gotest/boundaries.go

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

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

Файл Что делает
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, а не лучший прогон.

Script

104 lines
//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.")
}