Writing/Engineering
Mock Testing with Go Mockery
Mocking the interfaces SQLC generates with Mockery, so the repository and HTTP handler of a Go e-commerce API can be tested in isolation.
- Published
- Reading
- 7 min read
- Topics
- Engineering, Go
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.
-- name: CreateUser :oneINSERT INTO users ( name, email, phone_number, password, role_id, is_verified) VALUES ( $1, $2, $3, $4, $5, $6) 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.
const createUser = `-- name: CreateUser :oneINSERT INTO users ( name, email, phone_number, password, role_id, is_verified) VALUES ( $1, $2, $3, $4, $5, $6) RETURNING id, name, email, avatar, phone_number, password, is_verified, role_id, created_at, updated_at`type CreateUserParams struct { Name string `json:"name"` Email string `json:"email"` PhoneNumber string `json:"phone_number"` Password string `json:"password"` RoleID uuid.UUID `json:"role_id"` IsVerified bool `json:"is_verified"`}func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { row := q.db.QueryRowContext(ctx, createUser, arg.Name, arg.Email, arg.PhoneNumber, arg.Password, arg.RoleID, arg.IsVerified, ) var i User err := row.Scan( &i.ID, &i.Name, &i.Email, &i.Avatar, &i.PhoneNumber, &i.Password, &i.IsVerified, &i.RoleID, &i.CreatedAt, &i.UpdatedAt, ) return i, err}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.
type Querier interface { CreateUser(ctx context.Context, arg CreateUserParams) (User, error)}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:
go install github.com/vektra/mockery/v2@v2.44.2After installing mockery, we need to now create a config file .mockery.yaml in the root of our project:
all: truetestonly: falseinpackage: truewith-expecter: truepackages: github.com/repo/project-name/internal/db: config: recursive: true all: TrueRun mockery in the root of your project to generate the mocks:
mockery --allThis 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.
package testsimport ( "bytes" "context" "database/sql" "encoding/json" "errors" "project-repo/internal/db" "project-repo/internal/user" "project-repo/internal/utils" "project-repo/mocks" "project-repo/pkg/models" "net/http" "net/http/httptest" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert")func TestCreateUser(t *testing.T) { mockQuerier := new(mocks.Querier) ctx := context.Background() arg := models.CreateUserWithRoleAndPrivilegesParams{ Name: "test", Email: "mail@example.com", PhoneNumber: "234567890", Password: "password", RoleName: "admin", } expectedRole := db.GetRoleByNameRow{ ID: uuid.New(), Name: arg.RoleName, } repo := user.Repository{ Queries: mockQuerier, } hashedPassword, err := utils.HashPassword(arg.Password) assert.NoError(t, err) expectedUser := db.User{ ID: uuid.New(), Name: "test", Email: arg.Email, PhoneNumber: arg.PhoneNumber, Password: hashedPassword, } t.Run("UserExistsByEmail", func(t *testing.T) { // Mocking GetUserByEmail to return a user mockQuerier.On("GetUserByEmail", ctx, arg.Email).Return(expectedUser, nil) // Check if the user already exists existingUser, err := repo.Queries.GetUserByEmail(ctx, arg.Email) assert.NoError(t, err) assert.Equal(t, expectedUser, existingUser) // Since the user already exists, the creation process should stop here mockQuerier.AssertExpectations(t) }) t.Run("UserExistsByPhoneNumber", func(t *testing.T) { // Mocking GetUserByPhoneNumber to return a user mockQuerier.On("GetUserByPhoneNumber", ctx, arg.PhoneNumber).Return(expectedUser, nil) // Check if the phone number already exists existingUser, err := repo.Queries.GetUserByPhoneNumber(ctx, arg.PhoneNumber) assert.NoError(t, err) assert.Equal(t, expectedUser, existingUser) // Since the phone number already exists, the creation process should stop here mockQuerier.AssertExpectations(t) }) t.Run("RoleNotFound", func(t *testing.T) { // Mocking GetRoleByName to return an error mockQuerier.On("GetRoleByName", ctx, arg.RoleName).Return(db.GetRoleByNameRow{}, sql.ErrNoRows) // Check if the role exists role, err := repo.Queries.GetRoleByName(ctx, arg.RoleName) assert.Error(t, err) assert.Empty(t, role) mockQuerier.AssertExpectations(t) }) t.Run("CreateUser_Success", func(t *testing.T) { // Mocking successful role retrieval expectedRole := db.GetRoleByNameRow{ ID: uuid.New(), Name: arg.RoleName, } mockQuerier.On("GetRoleByName", ctx, arg.RoleName).Return(expectedRole, nil) role, err := repo.Queries.GetRoleByName(ctx, arg.RoleName) if errors.Is(err, sql.ErrNoRows) { assert.Empty(t, role) } // Mocking CreateUser mockQuerier.On("CreateUser", ctx, db.CreateUserParams{ Name: arg.Name, Email: arg.Email, PhoneNumber: arg.PhoneNumber, Password: arg.Password, RoleID: expectedRole.ID, }).Return(expectedUser, nil) // Act u, err := repo.Queries.CreateUser(ctx, db.CreateUserParams{ Name: arg.Name, Email: arg.Email, PhoneNumber: arg.PhoneNumber, Password: arg.Password, RoleID: expectedRole.ID, }) // Assert assert.NoError(t, err) assert.Equal(t, expectedUser, u) mockQuerier.AssertExpectations(t) }) t.Run("POST /users - Success", func(t *testing.T) { // Mocking CreateUser mockQuerier.On("CreateUser", ctx, db.CreateUserParams{ Name: arg.Name, Email: arg.Email, PhoneNumber: arg.PhoneNumber, Password: arg.Password, RoleID: expectedRole.ID, }).Return(expectedUser, nil) // HTTP handler to create user handler := func(w http.ResponseWriter, r *http.Request) { var req models.CreateUserWithRoleAndPrivilegesParams err := json.NewDecoder(r.Body).Decode(&req) if err != nil { return } // Check if the user already exists existingUser, err := repo.Queries.GetUserByEmail(ctx, arg.Email) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if existingUser.ID != uuid.Nil { http.Error(w, "user already exists", http.StatusBadRequest) return } createdUser, err := repo.Queries.CreateUser(ctx, db.CreateUserParams{ Name: req.Name, Email: req.Email, PhoneNumber: req.PhoneNumber, Password: req.Password, RoleID: expectedRole.ID, }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } err = json.NewEncoder(w).Encode(createdUser) if err != nil { return } } server := httptest.NewServer(http.HandlerFunc(handler)) defer server.Close() // Act userJSON, _ := json.Marshal(arg) resp, err := http.Post(server.URL+"/users", "application/json", bytes.NewBuffer(userJSON)) // Assert assert.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) })}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:
t.Run("UserExistsByEmail", func(t *testing.T) { // Mocking GetUserByEmail to return a user mockQuerier.On("GetUserByEmail", ctx, arg.Email).Return(expectedUser, nil) // Check if the user already exists existingUser, err := repo.Queries.GetUserByEmail(ctx, arg.Email) assert.NoError(t, err) assert.Equal(t, expectedUser, existingUser) // Since the user already exists, the creation process should stop here mockQuerier.AssertExpectations(t) })t.Run("UserExistsByPhoneNumber", func(t *testing.T) { // Mocking GetUserByPhoneNumber to return a user mockQuerier.On("GetUserByPhoneNumber", ctx, arg.PhoneNumber).Return(expectedUser, nil) // Check if the phone number already exists existingUser, err := repo.Queries.GetUserByPhoneNumber(ctx, arg.PhoneNumber) assert.NoError(t, err) assert.Equal(t, expectedUser, existingUser) // Since the phone number already exists, the creation process should stop here mockQuerier.AssertExpectations(t) })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.
t.Run("RoleNotFound", func(t *testing.T) { // Mocking GetRoleByName to return an error mockQuerier.On("GetRoleByName", ctx, arg.RoleName).Return(db.GetRoleByNameRow{}, sql.ErrNoRows) // Check if the role exists role, err := repo.Queries.GetRoleByName(ctx, arg.RoleName) assert.Error(t, err) assert.Empty(t, role) mockQuerier.AssertExpectations(t) })Just like the above test case, this test also checks if the role provided by the user does exists.
t.Run("CreateUser_Success", func(t *testing.T) { // Mocking successful role retrieval expectedRole := db.GetRoleByNameRow{ ID: uuid.New(), Name: arg.RoleName, } mockQuerier.On("GetRoleByName", ctx, arg.RoleName).Return(expectedRole, nil) role, err := repo.Queries.GetRoleByName(ctx, arg.RoleName) if errors.Is(err, sql.ErrNoRows) { assert.Empty(t, role) } // Mocking CreateUser mockQuerier.On("CreateUser", ctx, db.CreateUserParams{ Name: arg.Name, Email: arg.Email, PhoneNumber: arg.PhoneNumber, Password: arg.Password, RoleID: expectedRole.ID, }).Return(expectedUser, nil) // Act u, err := repo.Queries.CreateUser(ctx, db.CreateUserParams{ Name: arg.Name, Email: arg.Email, PhoneNumber: arg.PhoneNumber, Password: arg.Password, RoleID: expectedRole.ID, }) // Assert assert.NoError(t, err) assert.Equal(t, expectedUser, u) mockQuerier.AssertExpectations(t) })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.
// HTTP handler to create user handler := func(w http.ResponseWriter, r *http.Request) { var req models.CreateUserWithRoleAndPrivilegesParams err := json.NewDecoder(r.Body).Decode(&req) if err != nil { return } // Check if the user already exists existingUser, err := repo.Queries.GetUserByEmail(ctx, arg.Email) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if existingUser.ID != uuid.Nil { http.Error(w, "user already exists", http.StatusBadRequest) return } createdUser, err := repo.Queries.CreateUser(ctx, db.CreateUserParams{ Name: req.Name, Email: req.Email, PhoneNumber: req.PhoneNumber, Password: req.Password, RoleID: expectedRole.ID, }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } err = json.NewEncoder(w).Encode(createdUser) if err != nil { return } } server := httptest.NewServer(http.HandlerFunc(handler)) defer server.Close() // Act userJSON, _ := json.Marshal(arg) resp, err := http.Post(server.URL+"/users", "application/json", bytes.NewBuffer(userJSON)) // Assert assert.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) })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:
go test -v ./...All tests should pass successfully and pass!
=== RUN TestCreateUser=== RUN TestCreateUser/UserExistsByEmail--- PASS: TestCreateUser/UserExistsByEmail (0.00s)=== RUN TestCreateUser/UserExistsByPhoneNumber--- PASS: TestCreateUser/UserExistsByPhoneNumber (0.00s)=== RUN TestCreateUser/RoleNotFound--- PASS: TestCreateUser/RoleNotFound (0.00s)=== RUN TestCreateUser/PrivilegeNotFound--- PASS: TestCreateUser/PrivilegeNotFound (0.00s)=== RUN TestCreateUser/CreateUser_Success--- PASS: TestCreateUser/CreateUser_Success (0.00s)=== RUN TestCreateUser/POST_/users_-_Success--- PASS: TestCreateUser/POST_/users_-_Success (0.00s)--- PASS: TestCreateUser (0.07s)PASSProcess finished with the exit code 0Is 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.
