How to do unit testing + mock function in Golang within clean architecture

Ruangyot Nanchiang
6 min readAug 14, 2023

--

I have experimented with how to do unit testing + mock in Golang within clean architecture for a long time.

If you are trying to make unit testing + mock for the clean architecture in Golang, This article is going to give you all of my experiences.

But before we go to the code part, I want to brief you on some concepts of unit testing and mocking. (But if you’re already fully filled with this you can skip this part.)

Unit Testing in Nutshell

Unit testing is testing at the level of function. All the developers should have to write a unit test on their own before sending a code to the QA or testers to reduce a mistake that is going to happen.

Why do we Need a Mock Function???

This answer is dependent on your project structure style especially if you are doing the project on a clean architecture idea, You will need a mock function for sure.

Just imagine, You’re doing a project within clean architecture. How you can do unit testing without a mock function? While your testing can’t connect to the database. This is why we need to do a mock function.

🔥Coding Time

First, you need to install all packages below.

go get github.com/labstack/echo/v4
go get github.com/go-playground/validator/v10
go get go.mongodb.org/mongo-driver/mongo
go get github.com/stretchr/testify

For example, I’m going to establish the project structure by following.

📂modules/
├─ 📂item/
│ ├─ 📂itemRepository/
│ │ ├─ 📄itemRepository.go
│ │ ├─ 📄itemMockRepository.go
│ ├─ 📂itemHandler/
│ │ ├─ 📄itemHandler.go
│ ├─ 📄itemModel.go
├─ 📂request/
│ ├─ 📄request.go
📂pkg/
├─ 📂database/
│ ├─ 📄db.go
├─ 📂utils/
│ ├─ 📄helper.go
📂server/
├─ 📄server.go
📂whydoweneedtest/
├─ 📄setup.go
├─ 📄item_test.go
📄go.mod
📄go.sum
📄main.go

I have skipped the itemUsecase for the reason that I want to show you explicitly how we can do a mock function for a Repository layer.

Start with the pkg folder. Let’s build a singleton function.

pkg/database/db.go

package database

import (
"context"
"log"
"time"

"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)

var url = "mongodb://root:123456@localhost:27017"

func DbConn() *mongo.Client {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

client, err := mongo.Connect(ctx, options.Client().ApplyURI(url))
if err != nil {
log.Fatalf("Connect to MongoDb: %v failed: %v", url, err)
}

return client
}

This function is to connect to the MongoDB database.

pkg/utils/helper.go

package utils

import (
"encoding/json"
"io"
"strings"
)

func ConvertObjToStringReader[T any](obj T) io.Reader {
result, _ := json.Marshal(&obj)
return strings.NewReader(string(result))
}

This function is going to parse any object data into the io. Reader for building the payload body while testing an HTTP request.

Next, Let’s move to the server (I’m going to use the Echo to build the HTTP server)

server/server.go

package server

import (
"github.com/Rayato159/go-clean-unit-testing/modules/item/itemHandler"
"github.com/Rayato159/go-clean-unit-testing/modules/item/itemRepository"
"github.com/labstack/echo/v4"
)

type (
server struct {
app *echo.Echo
}
)

func Start() {
s := &server{app: echo.New()}

s.ItemService()

s.app.Logger.Fatal(s.app.Start(":1323"))
}

func (s *server) ItemService() {
itemRepository := itemRepository.NewItemRepository()
itemHandler := itemHandler.NewItemHandler(itemRepository)

item := s.app.Group("/item")

item.POST("/", itemHandler.CreateItem)
}

I have separated the routes of items into the func (s *server) ItemService() {} for the purpose of clean code. (All routes path will have no effect with httptest)

Let’s continue to build the modules

modules/request/request.go

package request

import (
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
)

type (
contextWrapperUtils interface {
Bind(data any) error
}

contextWrapper struct {
Context echo.Context
validator *validator.Validate
}
)

func ContextWrapper(c echo.Context) contextWrapperUtils {
return &contextWrapper{
Context: c,
validator: validator.New(),
}
}

func (c *contextWrapper) Bind(data any) error {
if err := c.Context.Bind(data); err != nil {
return err
}

if err := c.validator.Struct(data); err != nil {
return err
}
return nil
}

This function is going to validate a body request while deserializing a JSON data.

modules/item/itemModel.go

package item

type Item struct {
Id string `json:"id,omitempty"`
Title string `json:"title" validate:"required"`
}

modules/item/itemRepository/itemRepository.go

package itemRepository

import (
"context"
"fmt"
"time"

"github.com/Rayato159/go-clean-unit-testing/modules/item"
"github.com/Rayato159/go-clean-unit-testing/pkg/database"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)

//*** Assume this layer is always called a database

type (
ItemRepositoryService interface {
InsertOneItem(pctx context.Context, req *item.Item) (primitive.ObjectID, error)
}

itemRepository struct{}
)

func NewItemRepository() ItemRepositoryService {
return &itemRepository{}
}

func (r *itemRepository) itemDbConn(ctx context.Context) *mongo.Collection {
return database.DbConn().Database("whyyoureadthis").Collection("items")
}

func (r *itemRepository) InsertOneItem(pctx context.Context, req *item.Item) (primitive.ObjectID, error) {
ctx, cancel := context.WithTimeout(pctx, time.Second*10)
defer cancel()

db := r.itemDbConn(ctx)
defer db.Database().Client().Disconnect(ctx)

result, err := db.InsertOne(ctx, req, nil)
if err != nil {
return primitive.NilObjectID, fmt.Errorf("InsertOne error: %v", err)
}
return result.InsertedID.(primitive.ObjectID), nil
}

This is a repository layer that required a database to work with.

But from now, I’m going to mock this layer, then the database is not required anymore.

package itemRepository

import (
"context"

"github.com/Rayato159/go-clean-unit-testing/modules/item"
"github.com/stretchr/testify/mock"
"go.mongodb.org/mongo-driver/bson/primitive"
)

type MockItemRepository struct {
mock.Mock
}

func (m *MockItemRepository) InsertOneItem(pctx context.Context, req *item.Item) (primitive.ObjectID, error) {
args := m.Called(pctx, req)
return args.Get(0).(primitive.ObjectID), args.Error(1)
}

Let’s me discuss this code section above.

We need to write type MockItemRepository struct {} first and extend the mock.Mock into this struct to implement a mock function.

Then, write a mock function that you need.

In the m.Called(...interfaces{}) you need to input all arguments by your function parameters required.

and the returning data of the mock function must return all data that your function required.

When you need to call a mock function, you can do it by the following example.

mockItemUsecase := new(itemRepository.MockItemRepository)
mockItemUsecase.On("InsertOneItem", context.Background(), mock.AnythingOfType("*item.Item")).Return(primitive.NewObjectID(), nil)

InsertOneItem is the name of the mock function you need to call.

context.Background() and mock.AnythingOfType("*item.Item") is a args of that function respectively.

Return(primitive.NewOjectID(), nil) This is a returning data that you have set in the mock function. (The index will be respectively)

In other words, When you called this mock function by following.

mockItemUsecase.On("InsertOneItem", context.Background(), mock.AnythingOfType("*item.Item")).Return(primitive.NewObjectID(), nil)

It’s going to set this function if you input the context.Background() and &item.Item{} into this function, It’s going to return the primitive.NewObjectID() and nil of error in respectively.

modules/item/itemHandler/itemHandler.go

package itemHandler

import (
"context"
"net/http"

"github.com/Rayato159/go-clean-unit-testing/modules/item"
"github.com/Rayato159/go-clean-unit-testing/modules/item/itemRepository"
"github.com/Rayato159/go-clean-unit-testing/modules/request"
"github.com/labstack/echo/v4"
)

type (
ItemHandlerService interface {
CreateItem(c echo.Context) error
}

itemHandler struct {
itemRepository itemRepository.ItemRepositoryService
}
)

func NewItemHandler(itemRepository itemRepository.ItemRepositoryService) ItemHandlerService {
return &itemHandler{itemRepository: itemRepository}
}

func (h *itemHandler) CreateItem(c echo.Context) error {
ctx := context.Background()

wrapper := request.ContextWrapper(c)

reqBody := new(item.Item)

if err := wrapper.Bind(reqBody); err != nil {
return c.JSON(
http.StatusBadRequest,
map[string]string{"message": err.Error()},
)
}

itemId, err := h.itemRepository.InsertOneItem(ctx, reqBody)
if err != nil {
return c.JSON(
http.StatusBadRequest,
map[string]string{"message": err.Error()},
)
}
reqBody.Id = itemId.Hex()

return c.JSON(
http.StatusCreated,
reqBody,
)
}

Nothing here as much, Just a handler for the CreateItem service.

Okay, we have done all the based code. Finally, let’s implement the unit testing for item module.

whydoweneedtest/setup.go

package whydoweneedtest

import (
"net/http/httptest"

"github.com/Rayato159/go-clean-unit-testing/pkg/utils"
"github.com/labstack/echo/v4"
)

func NewEchoContext[T any](method, endpoint string, body T) echo.Context {
e := echo.New()

req := httptest.NewRequest(method, endpoint, utils.ConvertObjToStringReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)

return e.NewContext(req, httptest.NewRecorder())
}

This function is to build an Echo Context for handler input.

whydoweneedtest/item_test.go

package whydoweneedtest

import (
"context"
"net/http"
"testing"

"github.com/Rayato159/go-clean-unit-testing/modules/item"
"github.com/Rayato159/go-clean-unit-testing/modules/item/itemHandler"
"github.com/Rayato159/go-clean-unit-testing/modules/item/itemRepository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.mongodb.org/mongo-driver/bson/primitive"
)

type (
testCreateItemSuccess struct {
Input *item.Item
Expected int
}

testCreateItemErr struct {
Input *item.Item
Expected int
}
)

func TestCreateItemSuccess(t *testing.T) {
tests := []testCreateItemSuccess{
{
Input: &item.Item{
Title: "Mock Item",
},
Expected: 201,
},
}

for _, test := range tests {
c := NewEchoContext(http.MethodPost, "/item", test.Input)

mockItemUsecase := new(itemRepository.MockItemRepository)
mockItemUsecase.On("InsertOneItem", context.Background(), mock.AnythingOfType("*item.Item")).Return(primitive.NewObjectID(), nil)

itemHandler := itemHandler.NewItemHandler(mockItemUsecase)

_ = itemHandler.CreateItem(c)
assert.Equal(t, test.Expected, c.Response().Status)
}
}

func TestCreateItemError(t *testing.T) {
tests := []testCreateItemErr{
{
Input: &item.Item{
Title: "",
},
Expected: 400,
},
}

for _, test := range tests {
c := NewEchoContext(http.MethodPost, "/item", test.Input)

mockItemUsecase := new(itemRepository.MockItemRepository)
mockItemUsecase.On("InsertOneItem", context.Background(), mock.AnythingOfType("*item.Item")).Return(primitive.NewObjectID(), nil)

itemHandler := itemHandler.NewItemHandler(mockItemUsecase)

_ = itemHandler.CreateItem(c)
assert.Equal(t, test.Expected, c.Response().Status)
}
}

./main.go

package main

import "github.com/Rayato159/go-clean-unit-testing/server"

func main() {
server.Start()
}

Finally, We have done a unit testing, Let’s run the test now.

cd ./whydoweneedtest
go test -v

The output must be the following.

=== RUN   TestCreateItemSuccess
--- PASS: TestCreateItemSuccess (0.00s)
=== RUN TestCreateItemError
--- PASS: TestCreateItemError (0.00s)
PASS
ok github.com/Rayato159/go-clean-unit-testing/whydoweneedtest 0.395s

See, It’s no need to do a unit testing within a real database connect anymore.

Let’s change the test a little bit to prove that it still works on the mock function.

func TestCreateItemError(t *testing.T) {
tests := []testCreateItemErr{
{
Input: &item.Item{
Title: "",
},
Expected: 500,
},
}

for _, test := range tests {
c := NewEchoContext(http.MethodPost, "/item", test.Input)

mockItemUsecase := new(itemRepository.MockItemRepository)
mockItemUsecase.On("InsertOneItem", context.Background(), mock.AnythingOfType("*item.Item")).Return(primitive.NewObjectID(), nil)

itemHandler := itemHandler.NewItemHandler(mockItemUsecase)

_ = itemHandler.CreateItem(c)
assert.Equal(t, test.Expected, c.Response().Status)
}
}

I have changed the expected response status code of the test case from 400 to 500.

Then run the test again.

=== RUN   TestCreateItemSuccess
--- PASS: TestCreateItemSuccess (0.00s)
=== RUN TestCreateItemError
item_test.go:70:
Error Trace: D:/side-project/go-clean-unit-testing/whydoweneedtest/item_test.go:70
Error: Not equal:
expected: 500
actual : 400
Test: TestCreateItemError
--- FAIL: TestCreateItemError (0.00s)
FAIL
exit status 1
FAIL github.com/Rayato159/go-clean-unit-testing/whydoweneedtest 0.422s

See, It really works not fake.

Thanks to every reader, See ya.

--

--

Ruangyot Nanchiang

I just an indie sleepless backend developer with a polylang skill 💀💀💀.