Creating unit tests with Go from scratch

Creating unit tests with Go from scratch


Unit tests, like integration tests, are essential in every software solution. In fact, they complement each other. While integration tests checks whether the communication between entities functions correctly, unit tests verify the correctness of an individual unit (such as a class, struct, repository, usecase, etc) in isolation. In other words, it isolates that unit and tests just them using stubs and mocks to simulate the communication between modules.

These two types of tests are crucial. If we only have unit tests, we cannot guarantee that modules communicate correctly. At best, a good unit test confirms that unit 1 calls unit 2, but it doesn’t verify whether this call is correct or meaningful. On the other hand, if we rely only integration tests, we will not guarantee that our unit is functioning correctly in isolation. This makes debugging harder, because we cannot confirm where the bug is originating from, since multiple interdependent components are working simultaneously.

In this guide, we’ll explore the best ways to write effective unit tests in our Go codebase.

Setting up our testing environment

Let’s start by setting up our codebase to enable us to write unit tests. The first thing we’re going to need is interfaces. Why? That’s because, we’re going to create mocks for every module that interacts with the unit under test. Let’s continue and create a unit test for our repository.go file from the previous article. Here’s how our full code right now:

type UserRepository interface {
	Save(user models.User) error
}

type userRepositoryImpl struct {
	db *sql.DB
}

func NewUserRepository(db *sql.DB) UserRepository {
	return &userRepositoryImpl{db}
}

func (r *userRepositoryImpl) Save(user models.User) error {
	query := `INSERT INTO users (first_name, username) VALUES ($1, $2)`

	_, err := r.db.Exec(query, user.FirstName, user.Username)
	if err != nil {
		return fmt.Errorf("UserRepository.Save: error saving user - %w", err)
	}

	return nil
}

Can you spot the problem in this code? If not, it is here:

type userRepositoryImpl struct {
	db *sql.DB
}

Notice how userRepositoryImpl directly depends on *sql.DB. This is problematic, because we don’t want to test if this communication with the database is works properly or if the User model is successfully saved, we want to test if Save executes as expected, either by finishing successfully of returning an error. By wrapping this db attribute in a Database interface, we can substitute it with a mock implementation of this interface and verify if the method Exec is called properly. This is key when we’re writing unit tests, isolating the function under test while controlling its dependencies.

Decoupling database connection from repository

Here is how our code that creates a new database connection was before:

// internal/db/db.go

func New(config *DBConfig) (*sql.DB, error) {
	db, err := sql.Open("postgres",
		fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
			config.Host,
			config.Port,
			config.User,
			config.Password,
			config.DBName,
		))

	if err != nil {
		return nil, fmt.Errorf("db.New: %w", err)
	}

	return db, nil
}

And here is our new code:

// internal/db/db.go

type Database interface {
	Exec(query string, args ...interface{}) (sql.Result, error)
	Query(query string, args ...interface{}) (*sql.Rows, error)
	QueryRow(query string, args ...interface{}) *sql.Row
}

type databaseImpl struct {
	conn *sql.DB
}

func New(config *DBConfig) (Database, error) {
	db, err := sql.Open("postgres",
		fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
			config.Host,
			config.Port,
			config.User,
			config.Password,
			config.DBName,
		))

	if err != nil {
		return nil, fmt.Errorf("db.New: %w", err)
	}

	return &databaseImpl{db}, nil
}

func (d *databaseImpl) Exec(query string, args ...interface{}) (sql.Result, error) {
	return d.conn.Exec(query, args...)
}

func (d *databaseImpl) Query(query string, args ...interface{}) (*sql.Rows, error) {
	return d.conn.Query(query, args...)
}

func (d *databaseImpl) QueryRow(query string, args ...interface{}) *sql.Row {
	return d.conn.QueryRow(query, args...)
}

Now that we have a Database interface, we can create a struct implementing its methods and wraps the actual database connection. This allows us to mock our database interactions easily, including calls to Query, QueryRow and Exec. First we’ll change our repository code, instead of receiving a connection directly, it needs to receive a Databaseinterface. Let’s fix our repository constructor:

// internal/repository/repository.go

type userRepositoryImpl struct {
	db db.Database
}

func NewUserRepository(db db.Database) UserRepository {
	return &userRepositoryImpl{db}
}

After this refactor all our code should work perfectly, try running a make test (supposing you configured our integration test following this previous article). Let’s create our first unit test.


Creating a simple unit test

Setting up mocks

First, we need to analyse what our code will need to mock, here is our function:

func (r *userRepositoryImpl) Save(user models.User) error {
	query := `INSERT INTO users (first_name, username) VALUES ($1, $2)`

	_, err := r.db.Exec(query, user.FirstName, user.Username)
	if err != nil {
		return fmt.Errorf("UserRepository.Save: error saving user - %w", err)
	}

	return nil
}

The main external dependency here, is the database connection )db). And, since our function is calling Exec , this is the method we must mock. Since mocking in Go works differently than in some other languages. I recommend recommend creating a dedicated directory at the same level as your database implementation. Allowing us to reuse the same mock across all our codebase. Alternatively, if you prefer, you can define the mock within the same file as your test. This mock must implement the same interface as your database, so, all methods defined in your interface must also be defined in your mock.

type DatabaseMock struct {
	mock.Mock
}

func (d *DatabaseMock) Exec(query string, args ...interface{}) (sql.Result, error) {
	mockArgs := d.Called(query, args)

	return mockArgs.Get(0).(sql.Result), mockArgs.Error(1)
}

type SqlResultMock struct {
	mock.Mock
}

func (s *SqlResultMock) LastInsertId() (int64, error) {
    return 0, nil
}

func (s *SqlResultMock) RowsAffected() (int64, error) {
    return 0, nil
}

We define a mock struct called DatabaseMock and a SqlResultMock to mock the result from our Exec function. Both mocks will embed mock.Mock from the stretchr/testify/mock package. Each mocked method follows the same pattern as the snippet above. We create a variable to capture the call to that method arguments and return the corresponding element from the mockArgs array. This code ensures that our mock is being called with query and args as parameters. The mockArgs array stores the expected return values (that we’ll define in our test), and the function retrieves the first and second elements from this array. Now that the setup is complete, let’s create our first unit-test.

Creating our first unit test

Now, let’s create our first test, let’s test if the Save function is executing correctly. Let’s create a new file to our unit tests called repository_test.go. As it’s a good practice to leave unit tests in files separated from the integration tests, we can put our integration tests inside another file inside the same directory named repository_integration_test.go.

// internal/repository/repository_test.go

func TestRepositoryUnit(t *testing.T) {
	queryInsertUser := `INSERT INTO users (first_name, username) VALUES ($1, $2)`

	database := new(db.DatabaseMock)
	repo := NewUserRepository(database)

	t.Run("Test create new user", func(t *testing.T) {
		res := new(db.SqlResultMock)
		database.On("Exec", queryInsertUser, []interface{}{"gabriel", "dinizgab"}).Return(res, nil)

		err := repo.Save(models.User{
			FirstName: "gabriel",
			Username:  "dinizgab",
		})

		assert.NoError(t, err)
		database.AssertExpectations(t)
	})
}

To mock the return, we’re using this line:

database.On("Exec", queryInsertUser, []interface{}{"gabriel", "dinizgab"}).Return(res, nil)

Here we’re telling to Go that, when a call to database.Exec appears in our execution with the parameters equal to queryInsertUser and []interface{}{"gabriel", "dinizgab"}, it should return two values, res, which is a mock to SqlResult struct that Exec returns normally and nil, which is the error that we want to return. Since we are testing if the function works correctly, we return nil as the error value, indicating that no error occurred.

In the last two lines we make the assertions:

assert.NoError(t, err) The first one asserts that no errors occurred during the execution of the test.

database.AssertExpectations(t) The second one asserts that all expectations for the database mock was met. In other words, asserts that all calls that we want to execute in this test occurred and returned the values that we wanted.

Conclusion

Through this guide, we explored the importance of building a robust test suite, focusing on two fundamental types of tests: unit and integration. We also applied good practices and a pretty useful design pattern, called Strategy, allowing us to mock and decouple our database connection from our Repository. The best part? These new tests added integrate seamlessly with the make test feature from our previous article about integration tests, still granting a platform agnostic and scalable execution.

The next steps are to grow this test suite and make it come into about 90% coverage of our codebase, a good way to do this is to use a TDD approach, testing before programming, but this is something to another article.

By applying these changes we ensure the creation and maturing of a robust application, catching potential errors before they reach production environment and cause significant issues.