1. Зачем тестам “уборка”
Когда вы только начинаете писать тесты, кажется, что тест — это “просто вызвал функцию, сравнил got и want”. И в идеальном мире так бы и было. Но в реальности тесты почти всегда трогают какое-то состояние: глобальные переменные, настройки пакета, временные файлы, соединения, “фейковые” ресурсы. Если после теста это состояние не вернуть назад, следующий тест может стартовать в неожиданной среде — и получится классический баг “у меня падает только по пятницам” (или когда тесты запускаются в другом порядке).
Представьте бытовую аналогию. Тест — это как готовка на кухне. Можно приготовить омлет и уйти, оставив сковородку на плите, лужу масла на полу и соль в сахарнице. Следующий человек тоже “просто приготовит” — и внезапно начнётся расследование уровня CSI. С тестами то же самое: не прибрался — получишь детектив.
Главная цель t.Cleanup — сделать тесты самодостаточными: “что бы я ни поменял, после меня всё вернулось как было”.
2. Что такое t.Cleanup и когда он выполняется
Cleanup — это метод у *testing.T, который регистрирует функцию уборки, и тестовый раннер вызовет её, когда тест закончится. Важный момент: уборка происходит не просто “в конце текущей функции”, а когда завершится тест (или сабтест) и все его сабтесты. Это поведение описано в документации пакета testing на pkg.go.dev.
Это звучит чуть абстрактно, поэтому зафиксируем простую ментальную модель: t.Cleanup — это как “отложенный defer”, но привязанный к жизненному циклу теста, а не к конкретной функции. И это делает его очень удобным для subtests и helper‑функций.
Мини‑пример в “голом” виде:
package todo
import "testing"
func TestCleanup_Smoke(t *testing.T) {
t.Cleanup(func() {
// тут будет уборка
})
}
Пока уборки нет — пример скучный. Но семантика уже важна: тест завершится (успехом или провалом), и зарегистрированные cleanups всё равно отработают.
Порядок вызова: LIFO, как стопка тарелок
Когда вы регистрируете несколько cleanup‑функций, они выполняются в порядке “последний добавлен — первый вызван” (LIFO). Это сделано намеренно и совпадает с привычной моделью defer.
Это логично: ресурсы часто создаются “слоями”, и закрывать их удобно в обратном порядке.
Небольшой пример:
package todo
import "testing"
func TestCleanup_Order(t *testing.T) {
seq := ""
t.Cleanup(func() { seq += "A" })
t.Cleanup(func() { seq += "B" })
_ = seq
}
Если бы мы логировали seq после завершения теста, получилось бы "BA", потому что "B" зарегистрировали позже, значит он вызовется раньше.
Cleanup привязан к конкретному t: важная деталь для сабтестов
В t.Run(...) вам передают новый t *testing.T, который живёт “внутри” сабтеста. И t.Cleanup, вызванный внутри сабтеста, относится именно к нему: сабтест завершился — его уборка выполнилась.
Почему это важно: table-driven тесты с сабтестами часто делают “мелкую настройку” на кейс, и вот тут уборка должна быть локальной, а не глобальной.
Схема жизненного цикла выглядит примерно так:
flowchart TD
A["TestParent старт"] --> B["t.Run(case1) старт"]
B --> C["case1: t.Cleanup(...) зарегистрирован"]
C --> D["case1 завершился -> cleanup case1"]
D --> E["t.Run(case2) старт"]
E --> F["case2 завершился -> cleanup case2"]
F --> G["TestParent завершился -> cleanup parent"]
И ключевая мысль из документации: cleanup вызывается, когда завершится тест (или сабтест) и все его сабтесты.
t.Cleanup и defer: похожи, но не близнецы
На этом месте почти всегда возникает вопрос: “А зачем t.Cleanup, если у меня есть defer?” Вопрос хороший, потому что defer вы уже знаете и любите (или боитесь, но уважаете). И действительно, defer часто решает задачу уборки, но у t.Cleanup есть важные преимущества именно в тестах — особенно когда появляются сабтесты и helper’ы.
Сравним их “по‑человечески”:
| Приём | К чему привязан | Где удобно | Где неудобно |
|---|---|---|---|
|
к текущей функции | “открой → defer Close() → работай” | если настройка/откат спрятаны в helper‑функции, легко запутаться, где именно будет defer |
|
к тесту/сабтесту (*testing.T) | helper‑функции, сабтесты, “изменил состояние → зарегистрировал откат” | не заменяет defer внутри обычного кода, вне тестов не существует |
Ещё один нюанс: t.Cleanup особенно хорошо сочетается со стилем “вынести подготовку в helper”. А testing-пакет в целом развивает такие тестовые удобства: например, t.Helper() был добавлен именно для корректной диагностики в helper‑функциях.
3. Паттерн “изменил состояние → сразу зарегистрировал откат”
Самый полезный стиль использования t.Cleanup звучит так: как только вы поменяли состояние — тут же зарегистрируйте откат. Не “в конце теста”, не “после пары assert’ов”, не “когда будет время”. Сразу.
Почему это важно для новичков: тест может оборваться на t.Fatalf(...), может случиться panic, может быть return из сабтеста. Если вы держите уборку “где-то внизу”, вы рискуете до неё не дойти. А t.Cleanup регистрирует уборку заранее — и вы спокойнее.
Теперь сделаем это на нашем учебном пакете todo.
Мини‑конфиг в пакете todo: ограничим длину заголовка
Сейчас добавим простую настройку: максимальная длина заголовка задачи. Это не “идеальная архитектура”, но отличный учебный пример состояния, которое легко сломать тестами, если не убирать за собой.
package todo
var maxTitleLen = 64
func MaxTitleLen() int { return maxTitleLen }
func SetMaxTitleLen(n int) { maxTitleLen = n }
А теперь валидацию заголовка:
package todo
import "errors"
var ErrEmptyTitle = errors.New("empty title")
var ErrTooLong = errors.New("title too long")
func ValidateTitle(s string) error {
if s == "" {
return ErrEmptyTitle
}
if len(s) > MaxTitleLen() {
return ErrTooLong
}
return nil
}
Здесь мы намеренно используем len(s) как длину в байтах — нас сейчас интересует не Unicode, а механика тестов и уборки.
Тест, который меняет глобальное состояние, и прибирается через t.Cleanup
Теперь тест. Мы хотим временно поставить maxTitleLen = 3, проверить поведение, и гарантированно вернуть старое значение.
package todo
import "testing"
func TestValidateTitle_TooLong_WithCleanup(t *testing.T) {
old := MaxTitleLen()
SetMaxTitleLen(3)
t.Cleanup(func() { SetMaxTitleLen(old) })
if err := ValidateTitle("hello"); err != ErrTooLong {
t.Fatalf("expected ErrTooLong, got %v", err)
}
}
Обратите внимание на “ритм” кода: мы поменяли состояние — и сразу зарегистрировали cleanup. Это и есть тот самый паттерн, который делает тесты устойчивыми.
4. t.Cleanup для ресурсов: закрыть, удалить, откатить
До этого мы убирали “логическое состояние” (значение переменной). Но в реальных тестах вы будете убирать и “физические” ресурсы: закрывать соединения, освобождать блокировки, удалять временные директории, возвращать рабочую директорию.
Именно для этого Cleanup и существует как встроенная возможность testing-пакета: он регистрирует функцию и гарантирует её запуск после завершения теста или сабтеста.
Пример с “ресурсом”, который надо закрыть: свой Close()
Чтобы не залезать в настоящие файлы глубоко, сделаем игрушечный ресурс, который нужно закрывать:
package todo
type Resource struct {
Closed bool
}
func (r *Resource) Close() error {
r.Closed = true
return nil
}
Тест, который гарантированно “закроет” ресурс:
package todo
import "testing"
func TestResource_Close_WithCleanup(t *testing.T) {
r := &Resource{}
t.Cleanup(func() { _ = r.Close() })
if r.Closed {
t.Fatalf("resource should start open")
}
}
Да, в этом тесте мы даже не “используем” ресурс — пример учебный. Важна механика: Close будет вызван, даже если тест закончится раньше, чем вы ожидали.
Пример с временной директорией: os.MkdirTemp + os.RemoveAll
Иногда тесту нужно место “на диске” (или хотя бы иллюзия этого места). Даже если вы не изучали файлы подробно, базовый приём полезен: создаём временную директорию и гарантированно удаляем.
package todo
import (
"os"
"testing"
)
func TestTempDir_WithCleanup(t *testing.T) {
dir, err := os.MkdirTemp("", "todo-test-")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
_ = dir // тут бы тест работал с временными данными
}
Здесь os.RemoveAll в cleanup — это “вернуть кухню в исходное состояние”. Даже если тест упал, мусор не останется.
Когда ресурс “на весь процесс”: пример с Chdir
Есть ресурсы, которые меняют состояние всего процесса, а не только вашей функции. Рабочая директория — классический пример. В testing даже есть готовый метод t.Chdir(dir), который внутри делает os.Chdir и использует cleanup, чтобы восстановить исходную директорию после теста.
Почему я это упоминаю: это хорошая иллюстрация философии. testing прямо говорит: “мы будем использовать cleanup, чтобы гарантировать откат”. И одновременно предупреждает: такие штуки нельзя применять в параллельных тестах, потому что рабочая директория — общая.
Даже если вы пока не запускаете тесты параллельно, мысль полезная: если меняете глобальное состояние процесса — уборка обязательна.
5. t.Cleanup внутри helper‑функций: уборка “в комплекте” с настройкой
Очень частая эволюция тестов выглядит так: сначала вы пишете тест, потом видите повтор, выносите повтор в helper‑функцию. И вот тут t.Cleanup начинает сиять, потому что helper может делать “настройку + откат” как единый атомарный шаг.
Сюда же логично добавить t.Helper(), чтобы при падении теста отчёт указывал на строку вызова helper, а не внутрь helper‑функции.
Сделаем helper:
package todo
import "testing"
func withMaxTitleLen(t *testing.T, n int) {
t.Helper()
old := MaxTitleLen()
SetMaxTitleLen(n)
t.Cleanup(func() { SetMaxTitleLen(old) })
}
И применим в тесте с сабтестами:
package todo
import "testing"
func TestValidateTitle_Subtests_WithCleanup(t *testing.T) {
tests := []struct {
name string
maxLen int
title string
want error
}{
{"ok", 10, "hi", nil},
{"too_long", 3, "hello", ErrTooLong},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
withMaxTitleLen(t, tt.maxLen)
if err := ValidateTitle(tt.title); err != tt.want {
t.Fatalf("ValidateTitle(%q)=%v, want %v", tt.title, err, tt.want)
}
})
}
}
Красота здесь в том, что каждый сабтест сам прибирается. Вам не нужно помнить “вернуть maxLen назад”, и вы не рискуете случайно забыть откат, когда добавите новый кейс.
6. Типичные ошибки при использовании t.Cleanup
Ошибка №1: регистрируют cleanup “внизу теста”, после проверок.
Так выглядит логично: “сначала сделаю дело, потом уберу”. На практике это ломается, когда тест делает t.Fatalf(...) или рано выходит из функции. Правильный ритм — поменял состояние или создал ресурс, и сразу t.Cleanup(...), пока вы ещё находитесь рядом с причиной изменений.
Ошибка №2: cleanup замыкает “не то значение”, потому что переменная успела измениться.
Это частая ловушка с замыканиями: вы написали t.Cleanup(func(){ SetX(x) }), а потом где‑то ниже поменяли x. В результате cleanup откатывает не туда. Спасает простая дисциплина: сохраняйте “старое” значение в отдельную переменную (old := ...) и именно её используйте в cleanup, как мы делали в примерах.
Ошибка №3: cleanup превращают в “место, где живёт тестовая логика”.
Cleanup — это не “ещё один Assert”. Если вы начинаете в cleanup делать сложные проверки, читать файлы, запускать половину сценария — тест становится трудно понимать и отлаживать. Cleanup должен быть коротким: закрыть, удалить, восстановить. Проверки должны оставаться в основной части теста.
Ошибка №4: путают границы: cleanup родителя и cleanup сабтеста.
Иногда ожидают, что cleanup, зарегистрированный в родительском тесте, “сработает после каждого сабтеста”. Но он сработает только когда завершится родитель и все его сабтесты. Семантика у Cleanup чёткая: привязка идёт к конкретному t, и cleanup выполняется при завершении этого теста или сабтеста.
Ошибка №5: забывают, что некоторые действия меняют состояние всего процесса.
Рабочая директория, переменные окружения, глобальные синглтоны — это всё “общая кухня” для всех тестов. Даже если вы не запускаете тесты параллельно, порядок тестов может меняться, и такие изменения могут “течь” между тестами. Если уж меняете такое состояние, то уборка должна быть обязательной, и очень часто удобнее всего делать её через t.Cleanup.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ