Go Test Functions
Introduction
Testing is a critical part of software development that helps ensure your code works as expected. Go comes with a built-in testing framework in its standard library, making it straightforward to write and run tests. In this guide, we'll explore how to create and use test functions in Go, which are the fundamental building blocks of Go's testing infrastructure.
Test functions in Go follow specific conventions that make them recognizable to the Go testing tool. They allow you to validate your code's behavior, catch bugs early, and build confidence in your codebase.
What Are Go Test Functions?
In Go, a test function:
- Lives in a file named with the
_test.go
suffix - Takes a pointer to
testing.T
as a parameter - Is named with the prefix
Test
followed by a name that starts with a capital letter - Is part of a package with the same name as the code it tests or with the
_test
suffix
Let's start with a simple example to understand the basics.
Basic Test Function Example
Imagine we have a simple addition function in a file called math.go
:
// math.go
package math
// Add returns the sum of two integers
func Add(a, b int) int {
return a + b
}
Now, let's write a test for this function in a file named math_test.go
:
// math_test.go
package math
import (
"testing"
)
func TestAdd(t *testing.T) {
// Arrange
a, b := 2, 3
expected := 5
// Act
result := Add(a, b)
// Assert
if result != expected {
t.Errorf("Add(%d, %d) = %d; expected %d", a, b, result, expected)
}
}
Running the Test
To run the test, navigate to the directory containing your test file and run:
go test
Output:
PASS
ok example/math 0.002s
If the test fails, you'd see an error message:
--- FAIL: TestAdd (0.00s)
math_test.go:16: Add(2, 3) = 6; expected 5
FAIL
exit status 1
FAIL example/math 0.002s
Anatomy of a Test Function
Let's break down the essential components of a Go test function:
-
Test Function Signature:
gofunc TestAdd(t *testing.T)
- The function name starts with
Test
followed by a capitalized name - It takes a single parameter:
t *testing.T
- The function name starts with
-
The testing.T Type: The
*testing.T
parameter gives you access to testing methods like:t.Error()
/t.Errorf()
: Log an error and mark the test as failedt.Fatal()
/t.Fatalf()
: Log an error, mark the test as failed, and stop executiont.Log()
/t.Logf()
: Log information without failing the testt.Skip()
/t.Skipf()
: Skip the current test
-
Assertions: Go doesn't have built-in assertions, so we typically use conditional statements and error reporting:
goif result != expected {
t.Errorf("Add(%d, %d) = %d; expected %d", a, b, result, expected)
}
Table-Driven Tests
One of Go's testing best practices is to use table-driven tests. This approach lets you test multiple inputs and expected outputs within a single test function.
func TestAdd_TableDriven(t *testing.T) {
// Define test cases
testCases := []struct {
name string
a, b int
expected int
}{
{"Positive numbers", 2, 3, 5},
{"Zero and positive", 0, 5, 5},
{"Negative numbers", -2, -3, -5},
{"Mixed signs", -2, 5, 3},
}
// Run all test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := Add(tc.a, tc.b)
if result != tc.expected {
t.Errorf("Add(%d, %d) = %d; expected %d",
tc.a, tc.b, result, tc.expected)
}
})
}
}
This approach has several advantages:
- Organized test cases with clear names
- Easy to add new test cases
- Each case can be run individually
- Failures show which specific case failed
Running this test with go test -v
will show:
=== RUN TestAdd_TableDriven
=== RUN TestAdd_TableDriven/Positive_numbers
=== RUN TestAdd_TableDriven/Zero_and_positive
=== RUN TestAdd_TableDriven/Negative_numbers
=== RUN TestAdd_TableDriven/Mixed_signs
--- PASS: TestAdd_TableDriven (0.00s)
--- PASS: TestAdd_TableDriven/Positive_numbers (0.00s)
--- PASS: TestAdd_TableDriven/Zero_and_positive (0.00s)
--- PASS: TestAdd_TableDriven/Negative_numbers (0.00s)
--- PASS: TestAdd_TableDriven/Mixed_signs (0.00s)
PASS
Testing More Complex Functions
Let's test a slightly more complex function that validates email addresses:
// validator.go
package validator
import (
"regexp"
"strings"
)
// IsValidEmail checks if the provided string is a valid email
func IsValidEmail(email string) bool {
// Remove spaces
email = strings.TrimSpace(email)
// Basic email pattern
pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
// Compile and match
re := regexp.MustCompile(pattern)
return re.MatchString(email)
}
And now the test:
// validator_test.go
package validator
import (
"testing"
)
func TestIsValidEmail(t *testing.T) {
testCases := []struct {
name string
email string
expected bool
}{
{"Valid email", "[email protected]", true},
{"Valid email with numbers", "[email protected]", true},
{"Valid email with special chars", "[email protected]", true},
{"No @ symbol", "userexample.com", false},
{"No domain", "user@", false},
{"No username", "@example.com", false},
{"Invalid TLD", "[email protected]", false},
{"With spaces", " [email protected] ", true}, // should trim spaces
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := IsValidEmail(tc.email)
if result != tc.expected {
t.Errorf("IsValidEmail(%q) = %v; expected %v",
tc.email, result, tc.expected)
}
})
}
}
Subtest Functions with t.Run()
As shown in the table-driven test examples, Go allows you to create subtests using t.Run()
. This is useful for:
- Grouping related test cases
- Creating setup and teardown logic
- Running specific subtests with
go test -run
func TestExample(t *testing.T) {
// Setup code
data := setupTestData()
// Clean up when done
defer cleanupTestData()
t.Run("FirstCase", func(t *testing.T) {
// Test the first case
if !checkCondition1(data) {
t.Error("First condition failed")
}
})
t.Run("SecondCase", func(t *testing.T) {
// Test the second case
if !checkCondition2(data) {
t.Error("Second condition failed")
}
})
}
Parallel Testing
Go allows tests to run in parallel, which can significantly speed up your test suite:
func TestParallel(t *testing.T) {
t.Run("Group1", func(t *testing.T) {
t.Parallel() // This test will run in parallel with others
// Test code here
})
t.Run("Group2", func(t *testing.T) {
t.Parallel() // This test will run in parallel with others
// Test code here
})
}
Test Helper Functions
When you have common test logic, you can create helper functions. Mark them with t.Helper()
so that error messages point to the calling test, not the helper:
func assertEquality(t *testing.T, got, want interface{}) {
t.Helper() // Mark this as a helper function
if got != want {
t.Errorf("Got %v, want %v", got, want)
}
}
func TestWithHelper(t *testing.T) {
result := Add(2, 3)
assertEquality(t, result, 5) // Error will point to this line, not inside the helper
}
Testing Edge Cases
Good test functions should cover edge cases. For a numerical function, that might include:
func TestAdd_EdgeCases(t *testing.T) {
testCases := []struct {
name string
a, b int
expected int
}{
{"Max integers", int(^uint(0)>>1), 1, int(^uint(0)>>1)}, // This will overflow
{"Min integers", -int(^uint(0)>>1)-1, -1, -int(^uint(0)>>1)}, // This will overflow
{"Large difference", 1000000, -1000000, 0},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if tc.name == "Max integers" || tc.name == "Min integers" {
t.Skip("Skipping overflow tests for now")
}
result := Add(tc.a, tc.b)
if result != tc.expected {
t.Errorf("Add(%d, %d) = %d; expected %d",
tc.a, tc.b, result, tc.expected)
}
})
}
}
Test Setup and Teardown
Go doesn't have built-in setup/teardown hooks like some testing frameworks, but you can achieve similar functionality:
func TestWithSetupAndTeardown(t *testing.T) {
// Setup
resource := createTestResource()
defer cleanupTestResource(resource) // Teardown
// Test using the resource
result := resource.Process()
if result != "expected" {
t.Errorf("Got %q, want %q", result, "expected")
}
}
Best Practices for Go Test Functions
- Name tests clearly: Use descriptive names like
TestUserRegistration_ValidInput
. - Keep tests independent: Each test should run independently without relying on others.
- Test one thing per function: Focus each test on a specific behavior.
- Use table-driven tests: For multiple similar test cases.
- Test both happy paths and error cases: Don't just test when things work.
- Keep tests simple: Complex test logic can have its own bugs.
- Test exported functionality: Focus on testing the public API.
- Use
testdata
directory: For test fixtures. - Use
t.Parallel()
when appropriate: Speed up test execution. - Use test coverage: Run
go test -cover
to identify untested code.
Example Workflow
Let's tie everything together with a real-world example. Imagine we're building a user service:
// user/service.go
package user
import (
"errors"
"regexp"
"strings"
)
type User struct {
ID int
Name string
Email string
}
type Service struct {
users map[int]User
nextID int
}
func NewService() *Service {
return &Service{
users: make(map[int]User),
nextID: 1,
}
}
func (s *Service) Create(name, email string) (User, error) {
// Validate input
name = strings.TrimSpace(name)
if name == "" {
return User{}, errors.New("name cannot be empty")
}
email = strings.TrimSpace(email)
if !isValidEmail(email) {
return User{}, errors.New("invalid email format")
}
// Create user
user := User{
ID: s.nextID,
Name: name,
Email: email,
}
// Save user
s.users[user.ID] = user
s.nextID++
return user, nil
}
func (s *Service) Get(id int) (User, error) {
user, exists := s.users[id]
if !exists {
return User{}, errors.New("user not found")
}
return user, nil
}
func isValidEmail(email string) bool {
pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
re := regexp.MustCompile(pattern)
return re.MatchString(email)
}
And now the test file:
// user/service_test.go
package user
import (
"testing"
)
func TestService_Create(t *testing.T) {
t.Run("Valid user creation", func(t *testing.T) {
// Arrange
service := NewService()
name := "John Doe"
email := "[email protected]"
// Act
user, err := service.Create(name, email)
// Assert
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if user.ID != 1 {
t.Errorf("Expected ID 1, got %d", user.ID)
}
if user.Name != name {
t.Errorf("Expected name %q, got %q", name, user.Name)
}
if user.Email != email {
t.Errorf("Expected email %q, got %q", email, user.Email)
}
})
t.Run("Empty name", func(t *testing.T) {
// Arrange
service := NewService()
name := " " // Empty after trimming
email := "[email protected]"
// Act
_, err := service.Create(name, email)
// Assert
if err == nil {
t.Error("Expected error for empty name, got nil")
}
})
t.Run("Invalid email", func(t *testing.T) {
// Arrange
service := NewService()
name := "John Doe"
email := "not-an-email"
// Act
_, err := service.Create(name, email)
// Assert
if err == nil {
t.Error("Expected error for invalid email, got nil")
}
})
}
func TestService_Get(t *testing.T) {
// Setup
service := NewService()
user, _ := service.Create("John Doe", "[email protected]")
t.Run("Get existing user", func(t *testing.T) {
// Act
foundUser, err := service.Get(user.ID)
// Assert
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if foundUser.ID != user.ID {
t.Errorf("Expected ID %d, got %d", user.ID, foundUser.ID)
}
})
t.Run("Get non-existent user", func(t *testing.T) {
// Act
_, err := service.Get(999)
// Assert
if err == nil {
t.Error("Expected error for non-existent user, got nil")
}
})
}
Test Visualization
Here's a diagram representing the flow of a typical Go test function:
Summary
Go test functions are a powerful and straightforward way to verify your code's correctness. In this guide, we've covered:
- The basic structure of a Go test function
- How to write simple unit tests
- Table-driven tests for multiple test cases
- Subtests with
t.Run()
- Parallel testing for improved performance
- Helper functions to reduce duplication
- Testing edge cases and handling errors
- Best practices for effective Go testing
By following these patterns and practices, you can build a comprehensive test suite that gives you confidence in your Go code.
Additional Resources
- Go Testing Package Documentation
- Go Blog: Using Subtests and Sub-benchmarks
- Go Blog: Testable Examples in Go
- Write good unit tests for Go
Exercises
- Write a test function for a function that reverses a string.
- Create a table-driven test for a function that converts temperatures between Celsius and Fahrenheit.
- Write a test that uses
t.Parallel()
to run multiple subtests in parallel. - Create a test for a function that reads data from a file, using a temporary test file.
- Write tests that cover edge cases for a function that divides two numbers.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)