
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 Database
interface. 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.