JavaRush /Курсы /Go SELF /t.Cleanup — гарантированная очистка ресурсов в тестах

t.Cleanup — гарантированная очистка ресурсов в тестах

Go SELF
29 уровень , 3 лекция
Открыта

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
к текущей функции “открой → defer Close() → работай” если настройка/откат спрятаны в helper‑функции, легко запутаться, где именно будет defer
t.Cleanup
к тесту/сабтесту (*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.

1
Задача
Go SELF, 29 уровень, 3 лекция
Недоступна
Очередь уборки
Очередь уборки
1
Задача
Go SELF, 29 уровень, 3 лекция
Недоступна
Временный лимит
Временный лимит
1
Задача
Go SELF, 29 уровень, 3 лекция
Недоступна
Закрыть ресурс
Закрыть ресурс
1
Задача
Go SELF, 29 уровень, 3 лекция
Недоступна
Песочница каталога
Песочница каталога
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ