Back to Scaling Go Applications guides

Testing in Go with Testify

Ayooluwa Isaiah
Updated on March 14, 2025

Go comes with a built-in testing package that provides the fundamentals for creating tests, but sometimes you need more powerful assertion tools and test organization capabilities. This is where the Testify package shines.

This article explores how to enhance your Go testing experience using Testify, a popular testing toolkit that extends Go's standard testing features with more expressive assertions, test suites, and mocking capabilities.

Go's built-in testing fundamentals

Before diving into Testify, let's briefly review Go's standard testing approach. Go's philosophy on testing is minimalistic but effective. Tests are written in files with names ending in _test.go and placed alongside the code they test. The standard library provides a testing package that enables you to write test functions that verify the behavior of your code.

In Go's standard library approach, each test function follows a specific naming convention: it must start with the word "Test" followed by a capitalized name, and take a single parameter of type *testing.T. The testing framework identifies and runs these functions when you execute the go test command.

Here's a simple example of a Go test using the standard library:

calc.go
package calc

func Add(a, b int) int {
    return a + b
}
calc_test.go
package calc

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5

    if result != expected {
        t.Errorf("Add(2, 3) = %d; expected %d", result, expected)
    }
}

To run the test, you would use the go test command:

 
go test
Output
PASS
ok      github.com/betterstack-community/testify        0.001s

While functional, this standard approach has a few limitations. First, you must write your own conditional logic to check whether the actual result matches the expected one.

Second, you need to manually format error messages to provide context when tests fail. As your test suite grows in complexity, this approach can become verbose and harder to maintain, with error messages that might not always clearly identify what went wrong.

The standard library also lacks built-in support for common testing patterns like test suites with setup and teardown functionality, or tools for creating mock objects to test components in isolation. That's where Testify comes in to fill these gaps.

Introducing Testify

Testify package

Testify is a comprehensive testing toolkit for Go that builds upon the standard testing package. It enhances Go's testing capabilities without replacing the core framework, providing a set of packages that work together to make your tests more expressive, organized, and powerful.

Testify addresses several pain points in Go testing by offering:

  1. More expressive assertions that reduce boilerplate and provide clear error messages.
  2. Test organization through suites with setup and teardown methods.
  3. Mock object creation for isolating components during testing.
  4. Utilities for common testing scenarios.

To get started with Testify, you need to download it using Go modules:

 
go get github.com/stretchr/testify

Testify is organized into several subpackages, each serving a specific testing need:

  • assert: Provides assertion functions that make test conditions more readable and produce descriptive error messages.
  • require: Similar to assert but stops test execution immediately when an assertion fails.
  • suite: Allows organization of tests into suites with shared setup and teardown functionality.
  • mock: Facilitates the creation of mock objects for testing components in isolation.

The modular design of Testify allows you to use only the features you need without bringing in unnecessary dependencies.

Using Testify's assert package

The assert package is perhaps the most commonly used part of Testify. It provides functions that make your test assertions more readable and provide better error messages on failure.

Let's rewrite our previous example using Testify's assert package:

cacl_test.go
package calc

import (
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestAddWithTestify(t *testing.T) {
    result := Add(2, 3)
    expected := 5

    assert.Equal(t, expected, result, "Add(2, 3) should equal 5")
}

This test accomplishes the same verification as our original test, but with less code and a more expressive syntax. The assert.Equal function takes care of comparing the values and generating an appropriate error message if they don't match.

You'll need to install the assert subpackage with:

 
go get github.com/stretchr/testify/assert

When a test fails, Testify produces detailed error messages that help identify the problem quickly. For example, if our Add() function had a bug that returned 6 instead of 5, the error message would clearly show both the expected and actual values, along with our custom message:

Output
--- FAIL: TestAddWithTestify (0.00s)
    calc_test.go:13:
                Error Trace:    /home/ayo/dev/betterstack/demo/testify/calc_test.go:13
                Error:          Not equal:
                                expected: 5
                                actual  : 6
                Test:           TestAddWithTestify
                Messages:       Add(2, 3) should equal 5
FAIL
exit status 1
FAIL    github.com/betterstack-community/testify        0.003s

This detailed output makes it much easier to understand what went wrong compared to the standard library's more basic error messages.

Basic assertions

Testify offers a rich set of assertion functions for different types of comparisons. These functions make your tests more readable and provide clear, consistent error messages. Let's explore some of the most commonly used assertions:

calc_test.go
package calc

import (
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestBasicAssertions(t *testing.T) {
    // Equality assertions
    assert.Equal(t, 123, 123, "numbers should be equal")
    assert.NotEqual(t, 123, 456, "numbers should not be equal")

    // Boolean assertions
    assert.True(t, 1 < 2, "1 should be less than 2")
    assert.False(t, 1 > 2, "1 should not be greater than 2")

    // Nil assertions
    var nilPointer *int
    assert.Nil(t, nilPointer, "pointer should be nil")

    nonNilPointer := new(int)
    assert.NotNil(t, nonNilPointer, "pointer should not be nil")
}

Each assertion function follows a consistent pattern: it takes the testing.T instance, the values to compare, and an optional message to include in the failure output. The message argument accepts format specifiers similar to Printf(), allowing you to include dynamic values in your error messages.

Testify also provides type-specific assertions for different data types. For example, when working with slices, you can use assert.ElementsMatch() to verify that two slices contain the same elements regardless of order, or assert.Subset() to check if one slice is a subset of another. These specialized assertions save you from writing complex comparison logic and provide clear error messages when tests fail.

Working with errors

Error handling is a fundamental part of Go programming, and testing error scenarios is essential for robust code. Testify provides specialized assertions for working with errors that make these tests more concise and readable:

parser.go
package parser

import "errors"

func Parse(input string) (int, error) {
    if input == "" {
        return 0, errors.New("empty input")
    }
    // Imagine parsing logic here
    return 42, nil
}
parser_test.go
package parser

import (
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestParse(t *testing.T) {
    // Test success case
    result, err := Parse("valid input")
    assert.NoError(t, err, "valid input should not cause an error")
    assert.Equal(t, 42, result, "valid input should return 42")

    // Test error case
    result, err = Parse("")
    assert.Error(t, err, "empty input should cause an error")
    assert.Equal(t, "empty input", err.Error(), "error message should match")
    assert.Equal(t, 0, result, "error case should return zero value")
}

The assert.Error() function checks that an error is not nil, while assert.NoError() ensures an error is nil. These functions make your intent explicit and provide clear error messages when the expected error behavior doesn't occur.

For more specific error testing, Testify also offers assert.EqualError() to verify both that an error occurred and that it has a specific error message. This is particularly useful when testing functions that might return different types of errors based on the input.

Using require for fatal assertions

While the assert package allows tests to continue after a failed assertion, there are cases where it makes no sense to proceed with additional checks if an initial condition fails. For these scenarios, Testify provides the require package, which stops test execution immediately if an assertion fails.

The require package has the same API as assert but behaves like calling t.FailNow() instead of t.Fail() when an assertion fails. This is particularly useful when subsequent test steps depend on the success of earlier assertions:

 
go get github.com/stretchr/testify/require
user_test.go
package user

import (
    "testing"
    "github.com/stretchr/testify/require"
)

func TestUserCreation(t *testing.T) {
    u, err := CreateUser("john", "john@example.com")

    // If user creation fails, no point continuing
    require.NoError(t, err, "user creation should not fail")
    require.NotNil(t, u, "user should not be nil")

    // These will only run if the above assertions pass
    require.Equal(t, "john", u.Name, "user name should match")
    require.Equal(t, "john@example.com", u.Email, "user email should match")
}

In this example, if creating the user fails or returns a nil user, the test will stop immediately rather than proceeding to check the user's properties, which would likely cause a nil pointer panic. Using require in such cases makes your tests more robust and avoids confusing error messages from secondary failures.

A good practice is to use require for preconditions that must be met for the test to proceed safely, and assert for the actual conditions you're testing. This approach provides a balance between stopping tests when necessary and allowing multiple independent assertions to be checked in a single test.

Testify's suite package

As your test suite grows, you might find yourself repeating setup and teardown code across multiple test functions. This repetition can make your tests harder to maintain and understand. Testify's suite package addresses this issue by allowing you to group related tests into a test suite with shared setup and teardown methods.

A test suite is a struct that embeds suite.Suite and defines methods that begin with Test. The suite package provides hooks for running code before and after the entire suite, as well as before and after each test method.

Here's a comprehensive example of a test suite for a database component:

 
// database_test.go
package database

import (
    "testing"
    "github.com/stretchr/testify/suite"
)

// Define a test suite
type DatabaseTestSuite struct {
suite.Suite
db *Database
}
// This will run once before all tests in the suite
func (s *DatabaseTestSuite) SetupSuite() {
// Initialize resources that are shared by all tests
InitializeTestEnvironment()
}
// This will run once after all tests in the suite
func (s *DatabaseTestSuite) TearDownSuite() {
// Clean up resources used by the entire suite
CleanupTestEnvironment()
}
// This will run before each test
func (s *DatabaseTestSuite) SetupTest() {
// Create a fresh database instance for each test
s.db = NewTestDatabase()
}
// This will run after each test
func (s *DatabaseTestSuite) TearDownTest() {
// Close the database connection after each test
s.db.Close()
}
// Test methods must begin with "Test" func (s *DatabaseTestSuite) TestInsert() { err := s.db.Insert("key1", "value1") s.NoError(err, "insert should not fail") value, err := s.db.Get("key1") s.NoError(err, "get should not fail") s.Equal("value1", value, "retrieved value should match inserted value") } func (s *DatabaseTestSuite) TestDelete() { // Insert first err := s.db.Insert("key2", "value2") s.NoError(err, "insert should not fail") // Then delete err = s.db.Delete("key2") s.NoError(err, "delete should not fail") // Verify it's gone _, err = s.db.Get("key2") s.Error(err, "getting deleted key should fail") } // This function runs the suite func TestDatabaseSuite(t *testing.T) { suite.Run(t, new(DatabaseTestSuite)) }

The test suite approach provides several benefits:

  1. Shared resources can be set up once and reused across tests, improving performance
  2. Each test runs with a fresh instance of dependent resources, preventing test interference
  3. Cleanup code is guaranteed to run even if tests panic, preventing resource leaks
  4. The suite's structure makes the relationship between tests explicit

Test suites are particularly valuable when testing components that interact with external resources like databases, file systems, or network services. By providing standard hooks for setup and cleanup, they ensure that these resources are properly managed throughout the testing process.

Note that within test methods, you can use the assertion methods directly on the suite instance (e.g., s.Equal, s.NoError) rather than importing the assert package separately. This is because the suite.Suite type includes the assertion methods.

Mock objects with Testify/mock

When testing a component that depends on other components, it's often desirable to isolate the component under test by replacing its dependencies with mock implementations. This approach allows you to test the component's behavior without being affected by the complexities or side effects of its dependencies.

Testify's mock package provides tools for creating mock implementations of interfaces for testing. The package allows you to define expectations about how your mock objects should be called and what they should return, making it easier to verify that your component interacts correctly with its dependencies.

Here's an example of using mocks to test a user service that depends on a data store:

user.go
package user

type DataStore interface {
    GetUser(id string) (*User, error)
    SaveUser(user *User) error
}

type User struct {
    ID    string
    Name  string
    Email string
}

type UserService struct {
    store DataStore
}

func NewUserService(store DataStore) *UserService {
    return &UserService{store: store}
}

func (s *UserService) GetUserByID(id string) (*User, error) {
    return s.store.GetUser(id)
}

func (s *UserService) UpdateUserEmail(id, newEmail string) error {
    user, err := s.store.GetUser(id)
    if err != nil {
        return err
    }

    user.Email = newEmail
    return s.store.SaveUser(user)
}

Now let's test the UserService using a mock implementation of the DataStore interface:

user_test.go
package user

import (
    "errors"
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

// Create a mock implementation of DataStore
type MockDataStore struct {
    mock.Mock
}

func (m *MockDataStore) GetUser(id string) (*User, error) {
    args := m.Called(id)

    // If the first return value is nil, return nil, args.Error(1)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }

    // Otherwise return the user
    return args.Get(0).(*User), args.Error(1)
}

func (m *MockDataStore) SaveUser(user *User) error {
    args := m.Called(user)
    return args.Error(0)
}

func TestUserService_GetUserByID(t *testing.T) {
    // Create a mock data store
    mockStore := new(MockDataStore)

    // Create a user service with the mock store
    service := NewUserService(mockStore)

    // Setup expectations
    testUser := &User{ID: "123", Name: "John", Email: "john@example.com"}
    mockStore.On("GetUser", "123").Return(testUser, nil)

    // Test the service
    user, err := service.GetUserByID("123")

    // Assertions
    assert.NoError(t, err, "should not return error")
    assert.Equal(t, testUser, user, "should return the user")

    // Verify expectations were met
    mockStore.AssertExpectations(t)
}

func TestUserService_UpdateUserEmail(t *testing.T) {
    // Create a mock data store
    mockStore := new(MockDataStore)

    // Create a user service with the mock store
    service := NewUserService(mockStore)

    // Setup expectations
    testUser := &User{ID: "123", Name: "John", Email: "john@example.com"}

    mockStore.On("GetUser", "123").Return(testUser, nil)
    mockStore.On("SaveUser", mock.MatchedBy(func(u *User) bool {
        return u.ID == "123" && u.Email == "newemail@example.com"
    })).Return(nil)

    // Test the service
    err := service.UpdateUserEmail("123", "newemail@example.com")

    // Assertions
    assert.NoError(t, err, "should not return error")

    // Verify expectations were met
    mockStore.AssertExpectations(t)
}

func TestUserService_UpdateUserEmail_Error(t *testing.T) {
    // Create a mock data store
    mockStore := new(MockDataStore)

    // Create a user service with the mock store
    service := NewUserService(mockStore)

    // Setup expectations - user not found
    mockStore.On("GetUser", "999").Return(nil, errors.New("user not found"))

    // Test the service
    err := service.UpdateUserEmail("999", "newemail@example.com")

    // Assertions
    assert.Error(t, err, "should return error")
    assert.Equal(t, "user not found", err.Error(), "error message should match")

    // Verify expectations were met
    mockStore.AssertExpectations(t)
}

The mock package works by allowing you to:

  1. Create a mock implementation of an interface by embedding mock.Mock in a struct
  2. Implement the interface methods to delegate to the mock's Called method
  3. Set up expectations about how the mock should be called using the On method
  4. Specify what the mock should return using the Return method
  5. Verify that all expectations were met using AssertExpectations

For more complex argument matching, the mock package provides several matchers like mock.Anything, mock.AnythingOfType, and mock.MatchedBy. The MatchedBy matcher is particularly powerful as it allows you to provide a custom function that determines whether an argument matches your expectations.

Mocking is particularly valuable when testing components that interact with external systems like databases, APIs, or file systems. By replacing these dependencies with mocks, you can test your component's behavior in isolation, including error handling paths that might be difficult to trigger with real dependencies.

Practical examples

Let's explore some practical examples of using Testify in real-world scenarios.

Testing a REST API handler

Testing HTTP handlers is a common task in Go web applications. Testify makes it easier to verify the behavior of your handlers by providing clear assertions for HTTP status codes, headers, and response bodies:

handler.go
package api

import (
    "encoding/json"
    "net/http"
)

type Response struct {
    Message string `json:"message"`
}

func HelloHandler(w http.ResponseWriter, r *http.Request) {
    response := Response{Message: "Hello, World!"}

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(response)
}

Now let's test this handler using Testify and Go's httptest package:

handler_test.go
package api

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestHelloHandler(t *testing.T) {
    // Create a request
    req, err := http.NewRequest("GET", "/hello", nil)
    assert.NoError(t, err, "creating request should not fail")

    // Create a response recorder
    rr := httptest.NewRecorder()

    // Call the handler
    handler := http.HandlerFunc(HelloHandler)
    handler.ServeHTTP(rr, req)

    // Check the status code
    assert.Equal(t, http.StatusOK, rr.Code, "handler should return 200 OK")

    // Check the content type
    assert.Equal(t, "application/json", rr.Header().Get("Content-Type"),
                "content type should be application/json")

    // Check the response body
    var response Response
    err = json.Unmarshal(rr.Body.Bytes(), &response)
    assert.NoError(t, err, "unmarshaling response should not fail")
    assert.Equal(t, "Hello, World!", response.Message, "message should match")
}

This test creates an HTTP request, passes it to our handler, and then verifies that the handler returns the expected status code, headers, and response body. Testify's assertions make it easy to check each aspect of the response and provide clear error messages if any part of the test fails.

For more complex API tests, you might combine this approach with the suite package to share setup code across multiple handler tests, or with the mock package to mock out dependencies like database connections or external services.

Table-driven tests

Table-driven tests are a powerful pattern in Go for testing multiple similar cases with different inputs and expected outputs. Testify works well with this pattern, allowing you to create concise and readable table-driven tests:

 
// validator_test.go
package validator

import (
    "testing"
    "github.com/stretchr/testify/assert"
)

func IsValidEmail(email string) bool {
    // Simple validation for demonstration
    return len(email) > 5 && contains(email, "@") && contains(email, ".")
}

func contains(s string, substr string) bool {
    for i := 0; i < len(s); i++ {
        if i+len(substr) <= len(s) && s[i:i+len(substr)] == substr {
            return true
        }
    }
    return false
}

func TestIsValidEmail(t *testing.T) {
    tests := []struct {
        name     string
        email    string
        expected bool
    }{
        {"valid email", "user@example.com", true},
        {"missing @", "userexample.com", false},
        {"missing .", "user@examplecom", false},
        {"too short", "u@e.c", false},
        {"empty string", "", false},
    }

    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            result := IsValidEmail(test.email)
            assert.Equal(t, test.expected, result,
                        "IsValidEmail(%q) should be %v", test.email, test.expected)
        })
    }
}

The table-driven approach has several advantages:

  1. It makes it easy to add new test cases without duplicating code
  2. It clearly shows the relationship between inputs and expected outputs
  3. Each test case is run as a separate subtest, so failures are clearly identified
  4. The test code is more maintainable because the logic is separate from the test data

Testify enhances table-driven tests by providing clear assertions that work well with the pattern. The t.Run function creates a subtest for each table entry, and Testify's assertions provide clear error messages that include the specific test case that failed.

Final thoughts

Testify enhances Go's built-in testing capabilities with more expressive assertions, better test organization through suites, and powerful mocking for isolated component testing. By using Testify, you can create more readable and maintainable tests while still leveraging Go's standard testing patterns.

As with any testing approach, the key is to focus on creating tests that effectively verify your code's behavior and catch regressions early. Testify provides the tools to make this process more efficient and enjoyable, allowing you to spend less time writing test infrastructure and more time ensuring your application works correctly.

Thanks for reading!

Author's avatar
Article by
Ayooluwa Isaiah
Ayo is a technical content manager at Better Stack. His passion is simplifying and communicating complex technical ideas effectively. His work was featured on several esteemed publications including LWN.net, Digital Ocean, and CSS-Tricks. When he's not writing or coding, he loves to travel, bike, and play tennis.
Got an article suggestion? Let us know
Next article
Dockerizing Go Applications: A Step-by-Step Guide
Learn how to run Go applications confidently within Docker containers either locally or on your chosen deployment platform
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github