JavaRush /Courses /Go SELF /t.Cleanup — guaranteed resource cleanup in tests

t.Cleanup — guaranteed resource cleanup in tests

Go SELF
Level 29, Lesson 3
Available

1. Why tests need “cleanup”

When you’re just starting to write tests, it feels like a test is “just call a function, compare got and want”. And in an ideal world, that would be true. But in reality, tests almost always touch some kind of state: global variables, package settings, temporary files, connections, “fake” resources. If you don’t restore that state after the test, the next test can start in an unexpected environment—and you get the classic bug “it only fails on Fridays” (or when tests run in a different order).

Imagine a household analogy. A test is like cooking in the kitchen. You can make an omelet and leave, with the pan on the stove, a puddle of oil on the floor, and salt in the sugar bowl. The next person will also “just cook”—and suddenly you’ve got a CSI-level investigation. Tests are the same: don’t clean up—get a mystery.

The main goal of t.Cleanup is to make tests self-contained: “whatever I changed, everything is back to how it was after I’m done.”

2. What t.Cleanup is, and when it runs

Cleanup is a method on *testing.T that registers a cleanup function, and the test runner will call it when the test finishes. Key point: cleanup happens not just “at the end of the current function”, but when the test (or subtest) and all of its subtests finish. This behavior is described in the testing package docs on pkg.go.dev.

This sounds a bit abstract, so let’s lock in a simple mental model: t.Cleanup is like a “deferred defer”, but tied to the test lifecycle rather than to a specific function. That makes it very convenient for subtests and helper functions.

Mini example in “bare” form:

package todo

import "testing"

func TestCleanup_Smoke(t *testing.T) {
	t.Cleanup(func() {
		// cleanup will happen here
	})
}

With no real cleanup, the example is boring. But the semantics already matter: the test will finish (successfully or not), and the registered cleanups will still run.

Call order: LIFO, like a stack of plates

When you register multiple cleanup functions, they run in “last added—first called” (LIFO) order. This is intentional and matches the familiar defer model.

It’s logical: resources are often created in “layers”, and it’s convenient to close them in reverse order.

A small example:

package todo

import "testing"

func TestCleanup_Order(t *testing.T) {
	seq := ""

	t.Cleanup(func() { seq += "A" })
	t.Cleanup(func() { seq += "B" })

	_ = seq
}

If we logged seq after the test finished, we’d get "BA", because "B" was registered later, so it will be called earlier.

Cleanup is tied to a specific t: a key detail for subtests

In t.Run(...) you’re given a new t *testing.T, which lives “inside” the subtest. And t.Cleanup, called inside the subtest, belongs to that subtest: the subtest finishes—its cleanup runs.

Why this matters: table-driven tests with subtests often do small per-case setup, and cleanup must be local rather than global.

The lifecycle looks roughly like this:

flowchart TD
    A["TestParent start"] --> B["t.Run(case1) start"]
    B --> C["case1: t.Cleanup(...) registered"]
    C --> D["case1 finished -> cleanup case1"]
    D --> E["t.Run(case2) start"]
    E --> F["case2 finished -> cleanup case2"]
    F --> G["TestParent finished -> cleanup parent"]

And the key idea from the docs: cleanup is called when the test (or subtest) and all of its subtests have finished.

t.Cleanup vs defer: similar, but not twins

At this point, a question almost always comes up: “Why do I need t.Cleanup if I already have defer?” It’s a good question, because you already know and love defer (or fear it, but respect it). And yes, defer often solves cleanup, but t.Cleanup has important advantages specifically in tests—especially once subtests and helpers show up.

Let’s compare them “in human terms”:

Approach What it’s tied to Where it’s convenient Where it’s inconvenient
defer
the current function “open → defer Close() → work” if setup/rollback is hidden in a helper function, it’s easy to get confused about where exactly the defer will run
t.Cleanup
the test/subtest (*testing.T) helper functions, subtests, “changed state → registered rollback” does not replace defer in regular code; outside tests it doesn’t exist

One more nuance: t.Cleanup pairs especially well with the style “move setup into a helper”. And the testing package as a whole keeps adding test ergonomics: for example, t.Helper() was added specifically to get correct diagnostics from helper functions.

3. The pattern “changed state → immediately registered rollback”

The most useful way to use t.Cleanup is: as soon as you change state—register the rollback immediately. Not “at the end of the test”, not “after a couple of asserts”, not “when there’s time”. Immediately.

Why this matters for beginners: a test can abort on t.Fatalf(...), a panic can happen, or there can be an early return from a subtest. If you keep cleanup “somewhere down below”, you risk never reaching it. But t.Cleanup registers cleanup in advance—so you worry less.

Now let’s do this in our learning package todo.

Mini config in package todo: limit title length

Now we’ll add a simple setting: the maximum length of a task title. This is not “perfect architecture”, but it’s a great learning example of state that tests can easily break if they don’t clean up after themselves.

package todo

var maxTitleLen = 64

func MaxTitleLen() int     { return maxTitleLen }
func SetMaxTitleLen(n int) { maxTitleLen = n }

Now title validation:

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
}

Here we intentionally use len(s) as length in bytes—we’re not focusing on Unicode right now, but on the mechanics of tests and cleanup.

A test that changes global state and cleans up via t.Cleanup

Now the test. We want to temporarily set maxTitleLen = 3, check the behavior, and reliably restore the old value.

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)
	}
}

Notice the “rhythm” of the code: we changed state—and immediately registered cleanup. That’s the pattern that makes tests resilient.

4. t.Cleanup for resources: close, delete, roll back

So far we were cleaning up “logical state” (a variable value). But in real tests you’ll also clean up “physical” resources: close connections, release locks, delete temporary directories, restore the working directory.

That’s exactly why Cleanup exists as a built-in capability of the testing package: it registers a function and guarantees it will run after the test or subtest ends.

Example with a “resource” that must be closed: your own Close()

To avoid diving deep into real files, let’s make a toy resource that needs closing:

package todo

type Resource struct {
	Closed bool
}

func (r *Resource) Close() error {
	r.Closed = true
	return nil
}

A test that reliably “closes” the resource:

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")
	}
}

Yes, in this test we don’t even “use” the resource—this is a learning example. What matters is the mechanism: Close will be called even if the test ends earlier than you expected.

Example with a temp directory: os.MkdirTemp + os.RemoveAll

Sometimes a test needs a place “on disk” (or at least the illusion of one). Even if you haven’t studied files in depth, this basic technique is useful: create a temporary directory and reliably remove it.

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 // the test would work with temporary data here
}

Here os.RemoveAll in cleanup is “return the kitchen to its original state”. Even if the test fails, the trash won’t remain.

When a resource is “for the whole process”: example with Chdir

Some resources change the state of the entire process, not just your function. The working directory is a classic example. In testing there’s even a ready-made method t.Chdir(dir), which internally calls os.Chdir and uses cleanup to restore the original directory after the test.

Why mention this: it’s a good illustration of the philosophy. testing effectively says, “we’ll use cleanup to guarantee rollback”. And at the same time it warns: you can’t use such things in parallel tests, because the working directory is shared.

Even if you’re not running tests in parallel yet, the idea is useful: if you change global process state—cleanup is mandatory.

5. t.Cleanup inside helper functions: cleanup “bundled” with setup

A very common test evolution looks like this: first you write a test, then you see repetition, and you extract that repetition into a helper function. And that’s where t.Cleanup starts to shine, because a helper can do “setup + rollback” as a single atomic step.

It also naturally pairs with t.Helper(), so that when a test fails, the report points to the call site line rather than inside the helper function.

Let’s make a helper:

package todo

import "testing"

func withMaxTitleLen(t *testing.T, n int) {
	t.Helper()

	old := MaxTitleLen()
	SetMaxTitleLen(n)
	t.Cleanup(func() { SetMaxTitleLen(old) })
}

And use it in a test with subtests:

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 {
		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)
			}
		})
	}
}

The nice part is that each subtest cleans up after itself. You don’t need to remember “set maxLen back”, and you don’t risk accidentally forgetting the rollback when you add a new case.

6. Common mistakes when using t.Cleanup

Mistake #1: registering cleanup “at the bottom of the test”, after checks.
It looks logical: “first I’ll do the work, then I’ll clean up”. In practice it breaks when the test does t.Fatalf(...) or exits the function early. The right rhythm is: changed state or created a resource, and immediately t.Cleanup(...), while you’re still right next to the cause of the change.

Mistake #2: cleanup closes over “the wrong value” because the variable changed.
This is a common closure trap: you wrote t.Cleanup(func(){ SetX(x) }), and then somewhere below you changed x. As a result, cleanup rolls back to the wrong value. A simple discipline helps: save the “old” value into a separate variable (old := ...) and use that in cleanup, as we did in the examples.

Mistake #3: turning cleanup into “the place where test logic lives”.
Cleanup is not “one more Assert”. If you start doing complex checks in cleanup, reading files, running half the scenario— the test becomes hard to understand and debug. Cleanup should be short: close, delete, restore. Assertions should stay in the main body of the test.

Mistake #4: mixing up boundaries: parent cleanup vs subtest cleanup.
Sometimes people expect that cleanup registered in the parent test will “run after each subtest”. But it will run only when the parent and all its subtests finish. The semantics of Cleanup are clear: it’s tied to a specific t, and cleanup runs when that test or subtest ends.

Mistake #5: forgetting that some actions change the state of the entire process.
The working directory, environment variables, global singletons—this is all a “shared kitchen” for all tests. Even if you don’t run tests in parallel, test order can change, and such changes can “leak” between tests. If you change that kind of state, cleanup must be mandatory—and very often the most convenient way to do it is via t.Cleanup.

1
Task
Go SELF, level 29, lesson 3
Locked
Cleanup queue
Cleanup queue
1
Task
Go SELF, level 29, lesson 3
Locked
Temporary limit
Temporary limit
1
Task
Go SELF, level 29, lesson 3
Locked
Close the resource
Close the resource
1
Task
Go SELF, level 29, lesson 3
Locked
Directory sandbox
Directory sandbox
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION