How to test applications in Go using Test Tables? 🧪

Carlos García Rosales
7 min readMay 13, 2023

--

¿Hablas español? Ver en español

In this article we will work using a repository that I previously created in order to better explain how tests work in Go, we will also use a technique called Test Tables that will help us add new cases if necessary, in a simple way. Let’s get to work.

Tools we will use:

Gomock (To generate our mocks):

First, we must understand that a mock is nothing more and nothing less than a controllable version of some entity of our program, which will allow us to induce failures or successes according to our needs, very important for unit tests.

Now gomock is a library that will allow us to generate mocks of our interfaces. This is very useful if we have used interfaces to build our entire project as I do in my code template of this 2023.

Testify/Assert (To perform the verifications):

An important part of the tests is after obtaining the results of the execution of our function, we must verify that the results were as expected, that we will do using the Assert tool from testify.

In short, it is a set of pre-programmed conditions that will help us compare our expected result with the one obtained.

How do Test Tables work?

Now is when we have to venture a little more into the code. What I usually use for the test tables is a map[string]struct structure where the structure varies according to the need of the test.

For this example, we are going to test a user package that has a single function to save a user in a database.

Our user package is made up of the following files:

for use the full widget visit https://carlos.lat

Along with this we also have a package called Validator that shares a similar file structure that will help us test how we can manipulate the behavior of parts of the system at will thanks to the use of mocks.

Inside the project we will also find a Mocks folder which is where our mocks will be generated using the gomock tool and a Scripts folder where we will store useful actions that are better not to memorize and automate them using bash.

This is how our final project structure would look like:

for use the full widget visit https://carlos.lat

As you can see in the project repository, the validator package is not even implemented, only the interface is defined, even so it is possible to generate the mock thanks to the interface and we can test the user package without having to implement the validator package. Completely independent.

The content of our files.

Now we’ll see what’s inside the User package on a file-by-file basis so you can understand the context before testing.

We start with the user.go file found inside the domain/models folder:

package models

import "errors"

type User struct {
ID int
Email string
Password string
}

var (
// Validation errors
ErrInvalidEmail = errors.New("invalid email")
ErrInvalidPassword = errors.New("invalid password")
// Repository errors
ErrSavingUser = errors.New("error saving user")
)

func (u *User) Validate() error {
if u.Email == "" {
return ErrInvalidEmail
}

if u.Password == "" {
return ErrInvalidPassword
}
return nil
}

Here is our user model together with its validation function and the possible errors it is an incomplete implementation of how a User structure should behave do not take it as an example for your projects remember that this is about Unit Testing.

Now let’s move on to the ports.go file found inside domain/ports:

package ports

import "github.com/solrac97gr/go-test-tables/users/domain/models"

type Application interface {
// CreateUser creates a new user
CreateUser(email, password string) (*models.User, error)
}

type Repository interface {
// SaveUser saves a user
SaveUser(user *models. User) error
}

Here we find defined the behavior of the repository and the service or application of our user package, we find two interfaces each with their respective functions.

The function we will test will be the CreateUser function in the application layer.

The corresponding implementation of these interfaces would be the following:

Infrastructure/repositories/repositories. Go:

package repositories

import "github.com/solrac97gr/go-test-tables/users/domain/models"

type FakeStorage struct {
DB map[int]*models.User
}

func NewFakeStorage() *FakeStorage {
return &FakeStorage{
DB: make(map[int]*models.User),
}
}

var (
ErrSavingUser = models.ErrSavingUser
)

func (s *FakeStorage) SaveUser(user *models.User) error {
s.DB[user.ID] = user
return nil
}

application/application. Go:

package application

import (
"github.com/solrac97gr/go-test-tables/users/domain/models"
"github.com/solrac97gr/go-test-tables/users/domain/ports"
val "github.com/solrac97gr/go-test-tables/validator/domain/ports"
)

type UserApp struct {
UserRepo ports.Repository
Validator val.Validator
}

func NewUserApp(repo ports.Repository, val val.Validator) *UserApp {
return &UserApp{
UserRepo: repo,
Validator: val,
}
}

func (app *UserApp) CreateUser(email, password string) (*models.User, error) {
user := &models.User{Email: email, Password: password}

err := app.Validator.Struct(user)
if err != nil {
return nil, err
}

err = app.UserRepo.SaveUser(user)
if err != nil {
return nil, err
}
return user, nil
}

Taking into account our current state of the project we will start with our tests.

Defining our tests

Inside the application folder we create the file aplication_test.go by convention is how test files are called in Go.

In the file we will define a function that starts with Test then the name of the package and finally the function of the package we are testing.

Our function name would look like this: TestApplication_CreateUser Now we will begin to understand the parts of a test function using test tables.

Cases:

Here will be our different test cases within a map as follows:

You may notice that our tests are made up of a name which is the key in the map and different properties in the structure which is the value of the map.

In this test we can see that we need an Input, which in this case is the email and the password. We also have 2 functions testSetup and assertSetup.

email: "mail@car.com",
password: ""

TestSetup: It is where we will determine the behavior of our mocks according to the case to be tested.

testSetup: func(repo *mocks.MockRepository, val *mocks.MockValidator) {
val.EXPECT().Struct(gomock.Any()).Return(models.ErrInvalidPassword)
}

AssertSetup: This is where we will put our comparison rules on whether or not the function behaves properly.

assertSetup: func(t *testing.T, user *models.User, email, password string, err error) {
assert.Nil(t, user)
assert.EqualError(t, err, models.ErrInvalidPassword.Error())
}

Leaving us with the following structure in this test case:

"Empty Password [Validation Error]": {
email: "mail@car.com",
password: "",
testSetup: func(repo *mocks.MockRepository, val *mocks.MockValidator) {
val.EXPECT().Struct(gomock.Any()).Return(models.ErrInvalidPassword)
},
assertSetup: func(t *testing.T, user *models.User, email, password string, err error) {
assert.Nil(t, user)
assert.EqualError(t, err, models.ErrInvalidPassword.Error())
},
}

Here you can clearly see that we have defined 3 important things: the information we need for the function to work, the behavior of the internal elements of the function, and the evaluation of the result.

Forloop:

Here we are going to loop through our cases and initialize the boilerplate part of our tests:

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()

// Create a mock repository
repo := mocks.NewMockRepository(ctrl)
val := mocks.NewMockValidator(ctrl)

// Setup the mock repository
if tc.testSetup != nil {
tc.testSetup(repo, val)
}

app := application.NewUserApp(repo, val)
user, err := app.CreateUser(tc.email, tc.password)

// Assert the result
if tc.assertSetup != nil {
tc.assertSetup(t, user, tc.email, tc.password, err)
}
})
}

We can observe in detail that we start by creating the controller for our mocks, after these we create our necessary mocks for the operation of the function and we inject the controller into them, immediately afterwards we validate the existence of the function that determines the behavior of the mocks and once validated we proceed to execute it, that will provide our mocks with the behavior that the test needs.

Now we create our application structure, we inject into it the mocks we need (which already have the expected behavior) and now we execute our function that we want to test using the input that we defined in the test case.

In the final part we validate the existence of the function that determines the result of the execution of our function and we execute it to verify that our function returned the expected results.

This would be the end result:

package application_test

import (
"testing"

"github.com/golang/mock/gomock"
"github.com/solrac97gr/go-test-tables/mocks"
"github.com/solrac97gr/go-test-tables/users/application"
"github.com/solrac97gr/go-test-tables/users/domain/models"
"github.com/solrac97gr/go-test-tables/users/infrastructure/repositories"
"github.com/stretchr/testify/assert"
)

func TestApplication_CreateUser(t *testing.T) {
cases := map[string]struct {
email string
password string
testSetup func(*mocks.MockRepository, *mocks.MockValidator)
assertSetup func(*testing.T, *models.User, string, string, error)
}{
"Empty Password [Validation Error]": {
email: "mail@car.com",
password: "",
testSetup: func(repo *mocks.MockRepository, val *mocks.MockValidator) {
val.EXPECT().Struct(gomock.Any()).Return(models.ErrInvalidPassword)
},
assertSetup: func(t *testing.T, user *models.User, email, password string, err error) {
assert.Nil(t, user)
assert.EqualError(t, err, models.ErrInvalidPassword.Error())
},
},
"Empty Email [Validation Error]": {
email: "",
password: "123456",
testSetup: func(repo *mocks.MockRepository, val *mocks.MockValidator) {
val.EXPECT().Struct(gomock.Any()).Return(models.ErrInvalidEmail)
},
assertSetup: func(t *testing.T, user *models.User, email, password string, err error) {
assert.Nil(t, user)
assert.EqualError(t, err, models.ErrInvalidEmail.Error())
},
},

"Error saving [Repository Error]": {
email: "test@mail.com",
testSetup: func(repo *mocks.MockRepository, val *mocks.MockValidator) {
val.EXPECT().Struct(gomock.Any()).Return(nil)
repo.EXPECT().SaveUser(gomock.Any()).Return(repositories.ErrSavingUser)
},
assertSetup: func(t *testing.T, user *models.User, email, password string, err error) {
assert.Nil(t, user)
assert.EqualError(t, err, repositories.ErrSavingUser.Error())
},
},

"Valid User [Success]": {
email: "test@mail.com",
password: "123456",
testSetup: func(repo *mocks.MockRepository, val *mocks.MockValidator) {
val.EXPECT().Struct(gomock.Any()).Return(nil)
repo.EXPECT().SaveUser(gomock.Any()).Return(nil)
},
assertSetup: func(t *testing.T, user *models.User, email, password string, err error) {
assert.NotNil(t, user)
assert.Equal(t, email, user.Email)
assert.Equal(t, password, user.Password)
assert.NoError(t, err)
},
},
}

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
// Create a mock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()

// Create a mock repository
repo := mocks.NewMockRepository(ctrl)
val := mocks.NewMockValidator(ctrl)

// Setup the mock repository
if tc.testSetup != nil {
tc.testSetup(repo, val)
}

app := application.NewUserApp(repo, val)
user, err := app.CreateUser(tc.email, tc.password)

// Assert the result
if tc.assertSetup != nil {
tc.assertSetup(t, user, tc.email, tc.password, err)
}
})
}
}

Conclusions

As you may have noticed, this allows us flexibility to add very large tests, it also allows us to add within the structure, for example, some expected result for a specific function. At the moment this is the testing method that I use in my projects together with TDD I usually do my mocks and my tests before starting to program, soon I will write an article about how this methodology works.

--

--

Carlos García Rosales
Carlos García Rosales

Written by Carlos García Rosales

I am Software Developer working in @bayonet.io using the amazing programming language Go! I hope help you with my articles. follow me in my social medias!

No responses yet