Deep Engineering

MEASUREMENT

bench/gotest/internals.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

185 lines
//go:build ignore

// Тестирование в Go: что на самом деле делают t.Run, t.Parallel, кеш
// результатов и бенчмарк.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. Вопросы о тестах на собеседовании звучат как вопросы о
// синтаксисе — «что такое табличный тест», «зачем t.Parallel». А ответы,
// которые отличают знающего, — о поведении: в каком порядке что выполняется,
// что кешируется, что именно замерил бенчмарк. Здесь каждое такое поведение
// получено запуском `go test`, а не пересказано.
//
// ШЕСТЬ СЮЖЕТОВ:
//
//  1. Табличный тест: каждый случай — подтест со своим именем, и его можно
//     запустить отдельно.
//  2. Параллельные подтесты и defer: defer родителя срабатывает раньше, чем
//     параллельные подтесты начнут работу; t.Cleanup — после них.
//  3. Переменная цикла: в модуле с go 1.21 все параллельные подтесты видят
//     последний случай таблицы, с go 1.22+ — каждый свой.
//  4. Сколько подтестов идёт одновременно: не больше -parallel, а он по
//     умолчанию равен GOMAXPROCS — даже если подтесты спят.
//  5. Кеш результатов: повторный go test не запускает тесты, а печатает
//     сохранённый результат.
//  6. Бенчмарк: результат, который никто не использует, компилятор
//     выбрасывает вместе с вызовом.
//
// ЗАПУСК:
//
//	go run bench/gotest/internals.go
package main

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

var here string

func goCmd(dir string, args ...string) string {
	cmd := exec.Command("go", args...)
	cmd.Dir = dir
	out, _ := cmd.CombinedOutput()
	return string(out)
}

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

var timing = regexp.MustCompile(`\t\d+\.\d+s$|\(\d+\.\d+s\)`)

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

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) ТАБЛИЧНЫЙ ТЕСТ: КАЖДЫЙ СЛУЧАЙ — ПОДТЕСТ")
	fmt.Println("  go test -v ./cases/table/")
	pick(goCmd(here, "test", "-count=1", "-v", "./cases/table/"), func(l string) bool {
		return strings.HasPrefix(strings.TrimSpace(l), "--- PASS")
	})
	fmt.Println("  go test -v -run 'TestClamp/выше' ./cases/table/")
	pick(goCmd(here, "test", "-count=1", "-v", "-run", "TestClamp/выше", "./cases/table/"), func(l string) bool {
		return strings.HasPrefix(strings.TrimSpace(l), "--- PASS")
	})

	show("2) ПАРАЛЛЕЛЬНЫЕ ПОДТЕСТЫ: defer ПРОТИВ t.Cleanup")
	pick(goCmd(here, "test", "-count=1", "-v", "./cases/order/"), event)
	fmt.Println("  Подтест с t.Parallel() ждёт, пока тело родителя закончится.")
	fmt.Println("  defer срабатывает в этот момент — до подтестов; t.Cleanup —")
	fmt.Println("  после того, как закончились все подтесты.")

	show("3) ПЕРЕМЕННАЯ ЦИКЛА В ПАРАЛЛЕЛЬНЫХ ПОДТЕСТАХ")
	fmt.Println("  модуль с go 1.21 в go.mod:")
	legacy := goCmd(filepath.Join(here, "legacy"), "test", "-count=1", "-v", "./loopvar/")
	ev := []string{}
	for _, l := range strings.Split(legacy, "\n") {
		if event(l) {
			ev = append(ev, l)
		}
	}
	sort.Strings(ev)
	pick(strings.Join(ev, "\n"), event)
	pick(legacy, func(l string) bool { return strings.HasPrefix(l, "ok") || strings.HasPrefix(l, "FAIL") })
	fmt.Println("  модуль с go 1.24 в go.mod — тот же код:")
	ev = ev[:0]
	for _, l := range strings.Split(goCmd(here, "test", "-count=1", "-v", "./cases/loopvar/"), "\n") {
		if event(l) {
			ev = append(ev, l)
		}
	}
	sort.Strings(ev)
	pick(strings.Join(ev, "\n"), event)
	fmt.Println("  go vet в модуле с go 1.21:")
	pick(goCmd(filepath.Join(here, "legacy"), "vet", "./loopvar/"), func(l string) bool {
		return strings.Contains(l, "captured")
	})

	show("4) СКОЛЬКО ПОДТЕСТОВ ИДЁТ ОДНОВРЕМЕННО")
	fmt.Println("  четыре подтеста по 200 мс сна, все с t.Parallel()")
	// Тестовый бинарник собирается один раз и запускается напрямую: так в
	// замер не попадает время сборки, а только время самих тестов.
	bin := filepath.Join(os.TempDir(), "gotest-parallel.test")
	goCmd(here, "test", "-c", "-o", bin, "./cases/parallel/")
	for _, p := range []string{"", "1", "4"} {
		args := []string{"-test.run", "TestSleep"}
		label := "-parallel по умолчанию (= GOMAXPROCS)"
		if p != "" {
			args = append(args, "-test.parallel="+p)
			label = "-parallel=" + p
		}
		lo := time.Duration(1 << 62)
		for i := 0; i < 3; i++ {
			start := time.Now()
			if out, err := exec.Command(bin, args...).CombinedOutput(); err != nil {
				panic(fmt.Sprintf("%v\n%s", err, out))
			}
			if d := time.Since(start); d < lo {
				lo = d
			}
		}
		fmt.Printf("  %-40s %4.0f мс\n", label, float64(lo.Microseconds())/1000)
	}
	fmt.Println("  Лучший из 3 запусков. Сон процессор не занимает, но флаг")
	fmt.Println("  -parallel ограничивает число подтестов, а не загрузку ядер.")

	show("5) КЕШ РЕЗУЛЬТАТОВ")
	goCmd(here, "clean", "-testcache")
	for _, args := range [][]string{
		{"test", "./cases/table/"},
		{"test", "./cases/table/"},
		{"test", "-count=1", "./cases/table/"},
	} {
		fmt.Printf("  go %s\n", strings.Join(args, " "))
		pick(goCmd(here, args...), func(l string) bool { return strings.HasPrefix(l, "ok") })
	}

	show("6) БЕНЧМАРК: РЕЗУЛЬТАТ, КОТОРЫЙ НИКТО НЕ ИСПОЛЬЗУЕТ")
	out := goCmd(here, "test", "-run", "^$", "-bench", ".", "-count=5", "./cases/bench/")
	best := map[string]float64{}
	nsop := regexp.MustCompile(`^(Benchmark\w+)-\d+\s+\d+\s+([\d.]+) ns/op`)
	for _, l := range strings.Split(out, "\n") {
		if m := nsop.FindStringSubmatch(l); m != nil {
			v, _ := strconv.ParseFloat(m[2], 64)
			if old, ok := best[m[1]]; !ok || v < old {
				best[m[1]] = v
			}
		}
	}
	for _, name := range []string{"BenchmarkDiscard", "BenchmarkSink", "BenchmarkLoop"} {
		fmt.Printf("  %-18s %6.2f нс/оп\n", name, best[name])
	}
	fmt.Printf("  Sink / Discard: %.1f\n", best["BenchmarkSink"]/best["BenchmarkDiscard"])
	fmt.Println("  Лучший из 5 прогонов. Что решил компилятор (-gcflags=-m):")
	// Путь к файлу печатается без каталога: строки вида «каталог/файл:строка»
	// индекс замеров сайта принял бы за ссылку на скрипт.
	gcm := goCmd(here, "test", "-run", "^$", "-bench", "Loop", "-benchtime=1x", "-gcflags=-m", "./cases/bench/")
	pick(strings.ReplaceAll(gcm, "cases/bench/", ""), func(l string) bool {
		return strings.Contains(l, "inlining call to work") || strings.Contains(l, "skip inlining within testing.B.loop")
	})
	fmt.Println("  В Discard вызов work встроен, а его результат никому не нужен —")
	fmt.Println("  остаётся пустой цикл. b.Loop не даёт выбросить тело цикла:")
	fmt.Println("  внутри него компилятор вызов не встраивает.")
}