I’m on a mission to build an e-commerce API service in Go. One thing I know for sure is that testing is super important in Go development.
Go has a great testing framework built into its standard library, but I’ve had a bit of trouble picking the best testing tool for my project. I’ve tried out gomock, and testify, but none of them seemed quite right.
In my project, I use SQLC, which is a tool that turns SQL queries into fully type-safe and easy-to-use code. Basically, I write SQLC queries, run the sqlc command to generate code that shows type-safe interfaces to the queries I’ve defined. Then, I add the application code that calls the methods generated by SQLC.
Let’s take a look at some code examples to show you how I’m doing it.
user.sql
1-- name: CreateUser :one2INSERT INTO users (3name,4email,5phone_number,6password,7role_id,8is_verified9) VALUES (10$1, $2, $3, $4, $5, $611) RETURNING *;
This SQL query is simple and creates a user in the database. SQLC will automatically generate a Go interface for this query, which I’ll use in my application.
user.sql.go
1const createUser = `-- name: CreateUser :one2INSERT INTO users (3name,4email,5phone_number,6password,7role_id,8is_verified9) VALUES (10$1, $2, $3, $4, $5, $611) RETURNING id, name, email, avatar, phone_number, password, is_verified, role_id, created_at, updated_at12`1314type CreateUserParams struct {15Name string `json:"name"`16Email string `json:"email"`17PhoneNumber string `json:"phone_number"`18Password string `json:"password"`19RoleID uuid.UUID `json:"role_id"`20IsVerified bool `json:"is_verified"`21}2223func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {24row := q.db.QueryRowContext(ctx, createUser,25arg.Name,26arg.Email,27arg.PhoneNumber,28arg.Password,29arg.RoleID,30arg.IsVerified,31)32var i User33err := row.Scan(34&i.ID,35&i.Name,36&i.Email,37&i.Avatar,38&i.PhoneNumber,39&i.Password,40&i.IsVerified,41&i.RoleID,42&i.CreatedAt,43&i.UpdatedAt,44)45return i, err46}
Here’s the code for the generated method after running the sqlc generate
command. This is the method I’ll be using in my repository to create users.
You should know!
Before I continue, I’m using the repository pattern in my project. This pattern keeps the data access and business logic separate. It’s a great way to make the code more modular and easier to test.
querier.go
1type Querier interface {2CreateUser(ctx context.Context, arg CreateUserParams) (User, error)3}
SQLC also creates the above interface for the query, which I plan to use in my repository. But I’m having trouble testing my repository because I can’t mock the SQLC-generated interface. That’s where Go Mockery comes in handy!
Now, lets be more technical.
What is Go Mockery?
Mockery provides the ability to easily generate mocks for Golang interfaces using the stretchr/testify/mock package. It removes the boilerplate coding required to use mocks.
Let's install mockery:
Install Mockery
1go install github.com/vektra/mockery/v2@v2.44.2
After installing mockery, we need to now create a config file .mockery.yaml
in the root of our project:
mockery.yaml
1all: true2testonly: false3inpackage: true4with-expecter: true5packages:6github.com/repo/project-name/internal/db:7config:8recursive: true9all: True
Run mockery
in the root of your project to generate the mocks:
Generate Mocks
1mockery --all
This creates a mocks
directory in the root of your project with the generated mocks. It creates a mock for the Querier
interface we defined earlier.
Let’s write a Test
Now, let’s write a test for our CreateUser
method.
user_test.go
1package tests23import (4"bytes"5"context"6"database/sql"7"encoding/json"8"errors"9"project-repo/internal/db"10"project-repo/internal/user"11"project-repo/internal/utils"12"project-repo/mocks"13"project-repo/pkg/models"14"net/http"15"net/http/httptest"16"testing"17"time"1819"github.com/google/uuid"20"github.com/stretchr/testify/assert"21)2223func TestCreateUser(t *testing.T) {24mockQuerier := new(mocks.Querier)25ctx := context.Background()2627arg := models.CreateUserWithRoleAndPrivilegesParams{28Name: "test",29Email: "mail@example.com",30PhoneNumber: "234567890",31Password: "password",32RoleName: "admin",33}3435expectedRole := db.GetRoleByNameRow{36ID: uuid.New(),37Name: arg.RoleName,38}3940repo := user.Repository{41Queries: mockQuerier,42}4344hashedPassword, err := utils.HashPassword(arg.Password)45assert.NoError(t, err)4647expectedUser := db.User{48ID: uuid.New(),49Name: "test",50Email: arg.Email,51PhoneNumber: arg.PhoneNumber,52Password: hashedPassword,53}5455t.Run("UserExistsByEmail", func(t *testing.T) {56// Mocking GetUserByEmail to return a user57mockQuerier.On("GetUserByEmail", ctx, arg.Email).Return(expectedUser, nil)5859// Check if the user already exists60existingUser, err := repo.Queries.GetUserByEmail(ctx, arg.Email)61assert.NoError(t, err)62assert.Equal(t, expectedUser, existingUser)6364// Since the user already exists, the creation process should stop here65mockQuerier.AssertExpectations(t)66})6768t.Run("UserExistsByPhoneNumber", func(t *testing.T) {69// Mocking GetUserByPhoneNumber to return a user70mockQuerier.On("GetUserByPhoneNumber", ctx, arg.PhoneNumber).Return(expectedUser, nil)7172// Check if the phone number already exists73existingUser, err := repo.Queries.GetUserByPhoneNumber(ctx, arg.PhoneNumber)74assert.NoError(t, err)75assert.Equal(t, expectedUser, existingUser)7677// Since the phone number already exists, the creation process should stop here78mockQuerier.AssertExpectations(t)79})8081t.Run("RoleNotFound", func(t *testing.T) {82// Mocking GetRoleByName to return an error83mockQuerier.On("GetRoleByName", ctx, arg.RoleName).Return(db.GetRoleByNameRow{}, sql.ErrNoRows)8485// Check if the role exists86role, err := repo.Queries.GetRoleByName(ctx, arg.RoleName)87assert.Error(t, err)88assert.Empty(t, role)8990mockQuerier.AssertExpectations(t)91})9293t.Run("CreateUser_Success", func(t *testing.T) {94// Mocking successful role retrieval95expectedRole := db.GetRoleByNameRow{96ID: uuid.New(),97Name: arg.RoleName,98}99mockQuerier.On("GetRoleByName", ctx, arg.RoleName).Return(expectedRole, nil)100101role, err := repo.Queries.GetRoleByName(ctx, arg.RoleName)102if errors.Is(err, sql.ErrNoRows) {103assert.Empty(t, role)104}105106// Mocking CreateUser107mockQuerier.On("CreateUser", ctx, db.CreateUserParams{108Name: arg.Name,109Email: arg.Email,110PhoneNumber: arg.PhoneNumber,111Password: arg.Password,112RoleID: expectedRole.ID,113}).Return(expectedUser, nil)114115// Act116u, err := repo.Queries.CreateUser(ctx, db.CreateUserParams{117Name: arg.Name,118Email: arg.Email,119PhoneNumber: arg.PhoneNumber,120Password: arg.Password,121RoleID: expectedRole.ID,122})123124// Assert125assert.NoError(t, err)126assert.Equal(t, expectedUser, u)127128mockQuerier.AssertExpectations(t)129})130131t.Run("POST /users - Success", func(t *testing.T) {132// Mocking CreateUser133mockQuerier.On("CreateUser", ctx, db.CreateUserParams{134Name: arg.Name,135Email: arg.Email,136PhoneNumber: arg.PhoneNumber,137Password: arg.Password,138RoleID: expectedRole.ID,139}).Return(expectedUser, nil)140141// HTTP handler to create user142handler := func(w http.ResponseWriter, r *http.Request) {143var req models.CreateUserWithRoleAndPrivilegesParams144err := json.NewDecoder(r.Body).Decode(&req)145if err != nil {146return147}148149// Check if the user already exists150existingUser, err := repo.Queries.GetUserByEmail(ctx, arg.Email)151if err != nil {152http.Error(w, err.Error(), http.StatusInternalServerError)153return154}155156if existingUser.ID != uuid.Nil {157http.Error(w, "user already exists", http.StatusBadRequest)158return159}160161createdUser, err := repo.Queries.CreateUser(ctx, db.CreateUserParams{162Name: req.Name,163Email: req.Email,164PhoneNumber: req.PhoneNumber,165Password: req.Password,166RoleID: expectedRole.ID,167})168if err != nil {169http.Error(w, err.Error(), http.StatusInternalServerError)170return171}172173err = json.NewEncoder(w).Encode(createdUser)174if err != nil {175return176}177}178179server := httptest.NewServer(http.HandlerFunc(handler))180defer server.Close()181182// Act183userJSON, _ := json.Marshal(arg)184resp, err := http.Post(server.URL+"/users", "application/json", bytes.NewBuffer(userJSON))185186// Assert187assert.NoError(t, err)188assert.Equal(t, http.StatusOK, resp.StatusCode)189})190}
I noticed you’re overwhelmed with these cases. These test cases test the CreateUser
method in the repository, while also mocking the Querier
interface methods. Oh, and they also test the HTTP handler that creates a user, mocking the CreateUser
method again.
Let me break these down for you:
UserExistsByEmail and UserExistsByPhoneNumber
1t.Run("UserExistsByEmail", func(t *testing.T) {2// Mocking GetUserByEmail to return a user3mockQuerier.On("GetUserByEmail", ctx, arg.Email).Return(expectedUser, nil)45// Check if the user already exists6existingUser, err := repo.Queries.GetUserByEmail(ctx, arg.Email)7assert.NoError(t, err)8assert.Equal(t, expectedUser, existingUser)910// Since the user already exists, the creation process should stop here11mockQuerier.AssertExpectations(t)12})1314t.Run("UserExistsByPhoneNumber", func(t *testing.T) {15// Mocking GetUserByPhoneNumber to return a user16mockQuerier.On("GetUserByPhoneNumber", ctx, arg.PhoneNumber).Return(expectedUser, nil)1718// Check if the phone number already exists19existingUser, err := repo.Queries.GetUserByPhoneNumber(ctx, arg.PhoneNumber)20assert.NoError(t, err)21assert.Equal(t, expectedUser, existingUser)2223// Since the phone number already exists, the creation process should stop here24mockQuerier.AssertExpectations(t)25})
This test checks if the user already exists. It mocks the GetUserByEmail
and GetUserByPhoneNumber
method to return a user. Then, it calls the methods and check if the returned user is the same as the expected user.
RoleNotFound
1t.Run("RoleNotFound", func(t *testing.T) {2// Mocking GetRoleByName to return an error3mockQuerier.On("GetRoleByName", ctx, arg.RoleName).Return(db.GetRoleByNameRow{}, sql.ErrNoRows)45// Check if the role exists6role, err := repo.Queries.GetRoleByName(ctx, arg.RoleName)7assert.Error(t, err)8assert.Empty(t, role)910mockQuerier.AssertExpectations(t)11})
Just like the above test case, this test also checks if the role provided by the user does exists.
CreateUser_Success
1t.Run("CreateUser_Success", func(t *testing.T) {2// Mocking successful role retrieval3expectedRole := db.GetRoleByNameRow{4ID: uuid.New(),5Name: arg.RoleName,6}7mockQuerier.On("GetRoleByName", ctx, arg.RoleName).Return(expectedRole, nil)89role, err := repo.Queries.GetRoleByName(ctx, arg.RoleName)10if errors.Is(err, sql.ErrNoRows) {11assert.Empty(t, role)12}1314// Mocking CreateUser15mockQuerier.On("CreateUser", ctx, db.CreateUserParams{16Name: arg.Name,17Email: arg.Email,18PhoneNumber: arg.PhoneNumber,19Password: arg.Password,20RoleID: expectedRole.ID,21}).Return(expectedUser, nil)2223// Act24u, err := repo.Queries.CreateUser(ctx, db.CreateUserParams{25Name: arg.Name,26Email: arg.Email,27PhoneNumber: arg.PhoneNumber,28Password: arg.Password,29RoleID: expectedRole.ID,30})3132// Assert33assert.NoError(t, err)34assert.Equal(t, expectedUser, u)3536mockQuerier.AssertExpectations(t)37})
This test case is incredibly interesting. It mocks the successful retrieval of the role, then mocks the CreateUser
method to return the expected user. It then calls the CreateUser
method and checks if the returned user is the same as the expected user.
POST /users - Success
1// HTTP handler to create user2handler := func(w http.ResponseWriter, r *http.Request) {3var req models.CreateUserWithRoleAndPrivilegesParams4err := json.NewDecoder(r.Body).Decode(&req)5if err != nil {6return7}89// Check if the user already exists10existingUser, err := repo.Queries.GetUserByEmail(ctx, arg.Email)11if err != nil {12http.Error(w, err.Error(), http.StatusInternalServerError)13return14}1516if existingUser.ID != uuid.Nil {17http.Error(w, "user already exists", http.StatusBadRequest)18return19}2021createdUser, err := repo.Queries.CreateUser(ctx, db.CreateUserParams{22Name: req.Name,23Email: req.Email,24PhoneNumber: req.PhoneNumber,25Password: req.Password,26RoleID: expectedRole.ID,27})28if err != nil {29http.Error(w, err.Error(), http.StatusInternalServerError)30return31}3233err = json.NewEncoder(w).Encode(createdUser)34if err != nil {35return36}37}3839server := httptest.NewServer(http.HandlerFunc(handler))40defer server.Close()4142// Act43userJSON, _ := json.Marshal(arg)44resp, err := http.Post(server.URL+"/users", "application/json", bytes.NewBuffer(userJSON))4546// Assert47assert.NoError(t, err)48assert.Equal(t, http.StatusOK, resp.StatusCode)49})
With all the test cases in place, we now the handler takes care of incoming POST requests. It checks if the user already exists by querying the database. If they do, it produces an error. If they don’t, it creates a new user and sends back a JSON response. The code also has a test setup using httptest.
Now, you can run the tests:
Run Tests
1go test -v ./...
All tests should pass successfully and pass!
1=== RUN TestCreateUser2=== RUN TestCreateUser/UserExistsByEmail3--- PASS: TestCreateUser/UserExistsByEmail (0.00s)4=== RUN TestCreateUser/UserExistsByPhoneNumber5--- PASS: TestCreateUser/UserExistsByPhoneNumber (0.00s)6=== RUN TestCreateUser/RoleNotFound7--- PASS: TestCreateUser/RoleNotFound (0.00s)8=== RUN TestCreateUser/PrivilegeNotFound9--- PASS: TestCreateUser/PrivilegeNotFound (0.00s)10=== RUN TestCreateUser/CreateUser_Success11--- PASS: TestCreateUser/CreateUser_Success (0.00s)12=== RUN TestCreateUser/POST_/users_-_Success13--- PASS: TestCreateUser/POST_/users_-_Success (0.00s)14--- PASS: TestCreateUser (0.07s)15PASS1617Process finished with the exit code 0
Is this a lot to take in? Yes, it is! But it’s also super important to test your code thoroughly. Mockery makes it easy to mock interfaces and write tests for your Go code.
Liked this article? Share it with a friend on Twitter or contact me let's start a new project. Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll always be available to respond to your messages.
Have a wonderful day.
– Felix
Every year I like to take a moment to reflect on the year so far and set some goals for the rest of the year. This year I'm doing it publicly.