#Using T.Cleanup for Test Teardown in Go

Elliot Forbes Elliot Forbes · Dec 21, 2023 · 6 min read

When you’re writing tests in Go, you often need to set up resources—temporary files, database connections, test servers—and then clean them up after your tests complete. For years, Gophers relied on defer statements and careful test organization to handle this. Then came Go 1.14 with t.Cleanup(), a cleaner and more reliable way to manage test teardown.

In this tutorial, we’ll explore how t.Cleanup() works, how it differs from defer, and when you should use it to write better, more maintainable tests.

What is t.Cleanup()?

The t.Cleanup() method, introduced in Go 1.14, registers a cleanup function that runs when your test (or subtest) completes. It’s a method on the *testing.T type, and it accepts a function with no parameters and no return value:

t.Cleanup(func() {
    // cleanup code here
})

The key advantage over defer is that t.Cleanup() understands the test lifecycle—it runs after your test finishes, even in subtests, and multiple cleanup functions execute in LIFO (last-in-first-out) order.

A Simple Example

Let’s start with a straightforward example. We’ll create a temporary file in a test, register a cleanup function to remove it, and verify the cleanup works:

main_test.go
package main

import (
	"os"
	"testing"
)

func TestCleanupWithTempFile(t *testing.T) {
	// Create a temporary file
	tmpFile, err := os.CreateTemp("", "test-*.txt")
	if err != nil {
		t.Fatalf("Failed to create temp file: %v", err)
	}

	// Register cleanup to remove the file when the test completes
	t.Cleanup(func() {
		os.Remove(tmpFile.Name())
	})

	// Use the file in our test
	_, err = tmpFile.WriteString("test data")
	if err != nil {
		t.Fatalf("Failed to write to temp file: %v", err)
	}
	tmpFile.Close()

	// Verify the file exists
	if _, err := os.Stat(tmpFile.Name()); err != nil {
		t.Errorf("Temp file should exist: %v", err)
	}
	// After this test completes, t.Cleanup will remove the file automatically
}

Run this test and you’ll see it passes. The temporary file is created, used, and then automatically removed by the cleanup function.

T.Cleanup vs Defer

You might wonder: why not just use defer? The difference becomes clear when you use helper functions. With defer, cleanup logic defined in a helper function runs when the helper returns, not when the test completes. With t.Cleanup(), you can register cleanup in a helper, and it will still run at the end of the test.

Here’s an example showing the difference:

main_test.go
package main

import (
	"fmt"
	"testing"
)

// Helper that uses defer (problem: cleanup runs when helper exits, not when test exits)
func setupWithDefer(t *testing.T) string {
	resourceID := "resource-123"
	defer func() {
		fmt.Println("Defer cleanup for", resourceID)
	}()
	return resourceID
}

// Helper that uses t.Cleanup (correct: cleanup runs when test exits)
func setupWithCleanup(t *testing.T) string {
	resourceID := "resource-456"
	t.Cleanup(func() {
		fmt.Println("T.Cleanup for", resourceID)
	})
	return resourceID
}

func TestDeferVsCleanup(t *testing.T) {
	fmt.Println("Starting test")

	id1 := setupWithDefer(t)    // Defer cleanup runs HERE
	id2 := setupWithCleanup(t)  // Cleanup registered but doesn't run yet

	fmt.Println("Using resources:", id1, id2)
	fmt.Println("Test finishing...")
	// t.Cleanup runs HERE (after test completes)
}

When you run this test, you’ll see that the defer cleanup runs immediately after the helper function returns, while the t.Cleanup() cleanup runs after the entire test finishes. This is especially important when you need cleanup to happen after assertions or when multiple helpers are involved.

Using T.Cleanup with Subtests

One of the most powerful features of t.Cleanup() is its integration with subtests. Cleanup functions execute in LIFO order, and subtests clean up before their parent test:

main_test.go
package main

import (
	"fmt"
	"testing"
)

func TestCleanupWithSubtests(t *testing.T) {
	t.Cleanup(func() {
		fmt.Println("Parent cleanup")
	})

	t.Run("SubtestA", func(t *testing.T) {
		t.Cleanup(func() {
			fmt.Println("Subtest A cleanup")
		})
		fmt.Println("Running Subtest A")
	})

	t.Run("SubtestB", func(t *testing.T) {
		t.Cleanup(func() {
			fmt.Println("Subtest B cleanup")
		})
		fmt.Println("Running Subtest B")
	})

	fmt.Println("Parent test body")
}

The output shows the execution order:

  • Running Subtest A
  • Subtest A cleanup
  • Running Subtest B
  • Subtest B cleanup
  • Parent test body
  • Parent cleanup

This order guarantees that subtests clean up before their parent, which is exactly what you need for proper resource management.

Practical Example: Database Test Setup

Here’s a realistic pattern you’ll use frequently: a helper function that sets up a database connection for testing and registers cleanup to tear it down:

main_test.go
package main

import (
	"database/sql"
	"testing"
)

// setupTestDB is a helper that creates a test database and registers cleanup
func setupTestDB(t *testing.T) *sql.DB {
	// In a real test, you'd use a test database
	// For this example, we'll just create a mock
	db := &sql.DB{}

	// Register cleanup to close the database when the test completes
	t.Cleanup(func() {
		// In real code: db.Close()
		// For testing: we just log that cleanup happened
	})

	return db
}

func TestUserRepository(t *testing.T) {
	// Setup is clean—just one function call
	db := setupTestDB(t)

	// Now you can use db in your test without worrying about cleanup
	// Cleanup is already registered and will run when the test finishes
	if db == nil {
		t.Errorf("Expected database connection")
	}
}

func TestUserRepositoryMultipleCalls(t *testing.T) {
	db1 := setupTestDB(t)
	db2 := setupTestDB(t)

	// Both cleanups are registered, and they'll run in LIFO order
	// (db2 cleanup runs first, then db1 cleanup)
	if db1 == nil || db2 == nil {
		t.Errorf("Expected valid database connections")
	}
}

This pattern is powerful because:

  • All setup and cleanup logic lives in one place
  • The helper function owns the cleanup responsibility
  • You can call the helper multiple times, and each call registers its own cleanup

Multiple Cleanup Functions

You can register multiple cleanup functions in a single test. They execute in LIFO order—the last cleanup registered is the first to execute:

main_test.go
package main

import (
	"fmt"
	"testing"
)

func TestMultipleCleanups(t *testing.T) {
	fmt.Println("Test starts")

	t.Cleanup(func() {
		fmt.Println("Cleanup 1")
	})

	t.Cleanup(func() {
		fmt.Println("Cleanup 2")
	})

	t.Cleanup(func() {
		fmt.Println("Cleanup 3")
	})

	fmt.Println("Test body executes")
	// Output will show:
	// Test starts
	// Test body executes
	// Cleanup 3 (last registered, first to run)
	// Cleanup 2
	// Cleanup 1
}

This LIFO order ensures that resources are cleaned up in the reverse order they were created, which is often what you need for proper teardown (like closing database connections before deleting test files).

Conclusion

The t.Cleanup() method is a cleaner, more reliable way to manage test teardown than defer statements scattered throughout your test code. It’s especially powerful when used with helper functions and subtests. By using t.Cleanup() in your tests, you ensure that cleanup logic runs at the right time and in the right order, making your tests more maintainable and less prone to resource leaks.

Further Reading