Deep Engineering

ЗАМЕР

bench/gotest/internals.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, а не лучший прогон.

Скрипт

185 строк
//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("  внутри него компилятор вызов не встраивает.")
}