I recently started learning Golang and wrote a REST api in it. Now I want to do its unit testing it by isolating different components.
The project structure looks like below and there are two major packages
├── main.go
├── routes
│ ├── routes.go
│ └── routes_test.go
├── db
│ └── db.go
main.go: Main entry point for the project
routes/routes.go: HTTP route handler package
db/db.go: database handler package
Now when I am testing the http routing I only want to test if the requests are routed to proper handler functions are then they respond accordingly. In real application the handler function would actually insert/update the database but I don't want to do that while testing. So if I can mock a database object and make my handler functions connect/read/write against that, but I am not sure how to do that.
When it comes to testing the database handler package db.go
, I think i can't mock that and I have to either set up a test db against which I should make queries.
This is how my files look.
routes/routes.go
package routes
import (
"cfengine-service/db"
"encoding/json"
"net/http"
"strings"
log "github.com/sirupsen/logrus"
"github.com/gorilla/mux"
)
// Route - struct modal for routes
type Route struct {
Name string
EndPoint string
Methods []string
HandlerFunc http.HandlerFunc
}
var dbHandler = &db.Mongo{}
// SetupRouter - returns the mux router
func SetupRouter() *mux.Router {
err := dbHandler.Connect()
if err != nil {
log.Fatal(err)
}
router := mux.NewRouter()
for _, route := range SetUpRoutes() {
router.HandleFunc(route.EndPoint, route.HandlerFunc).Methods(route.Methods...)
}
return router
}
func SetUpRoutes() []Route {
return []Route{
{
"Host config Management",
"/configs/group",
[]string{"GET", "POST"},
GroupConfig,
},
}
func GroupConfig(w http.ResponseWriter, r *http.Request) {
reqMethod := r.Method
if reqMethod == "GET" {
GetGroupConfig(w, r)
} else if reqMethod == "POST" {
NewGroupConfig(w, r)
}
}
func GetGroupConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// g := r.URL.Query().Get("groupname")
g := r.FormValue("groupname")
if g == "" {
if data, err := dbHandler.ReadAllGroups(); StatusWriter(w, err) {
_ = json.NewEncoder(w).Encode(data)
}
} else {
if data, err := dbHandler.ReadOneGroup(g); StatusWriter(w, err) {
_ = json.NewEncoder(w).Encode(data)
}
}
}
// NewGroupConfig - end point for creating new config group
func NewGroupConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
var group db.GroupConfig
err := json.NewDecoder(r.Body).Decode(&group)
StatusWriter(w, err)
err = dbHandler.CreateGroupConfig(&group)
StatusWriter(w, err)
}
db/db.go
package db
import (
"cfengine-service/config"
"cfengine-service/helpers"
"context"
"fmt"
"time"
log "github.com/sirupsen/logrus"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
type HostConfig struct {
Hostname string `bson:"_id" json:"hostname"`
Type string `bson:"type" json:"type"`
Groups []string `bson:"groups" json:"groups"`
Overrides map[string]string `bson:"overrides" json:"overrides"`
Excludes []string `bson:"excludes" json:"excludes"`
Data map[string]string `bson:"data" json:"data"`
}
// Mongo collection
type Mongo struct {
collection *mongo.Collection
}
// Storage is interface for db operations
type Storage interface {
Connect() error
ReadAll() ([]HostConfig, error)
ReadOne() (*HostConfig, error)
CreateHostConfig(h *HostConfig) error
CreateGroupConfig(g *GroupConfig) error
DeleteHostConfig(h *HostConfig) error
UpdateHostGrops(h string, g string, opr string) error
validateGroup(ctx context.Context, val string) error
}
func (m *Mongo) Connect() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
//client, err := mongo.NewClient(config.Config.MongoURI)
client, err := mongo.Connect(ctx, options.Client().ApplyURI(config.Config.MongoURI))
if err != nil {
return err
}
log.Info("Connected to MongoDB.....")
m.collection = client.Database(config.Config.MongoDbName).Collection(config.Config.MongoCollectionName)
return nil
}
func (m *Mongo) CreateGroupConfig(g *GroupConfig) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
data, err := bson.Marshal(g)
if err != nil {
log.Error(err.Error())
return err
}
_, err = m.collection.InsertOne(ctx, data)
if err != nil {
return err
}
return nil
}
func (m *Mongo) ReadOneGroup(g string) (GroupConfig, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
var group GroupConfig
err := m.collection.FindOne(ctx, bson.M{"_id": g}).Decode(&group)
if err != nil {
return GroupConfig{}, err
}
return group, nil
}
func (m *Mongo) ReadAllGroups() ([]GroupConfig, error) {
var configs []GroupConfig
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cur, err := m.collection.Find(ctx, bson.M{"type": "group"})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
for cur.Next(ctx) {
var record GroupConfig
if err = cur.Decode(&record); err != nil {
return nil, err
}
configs = append(configs, record)
}
return configs, nil
}
So using the mockgen
package i generated the mocks for Storage
interface in my db.go
but I am not sure how do I start using that. Here is how mock_db.go looks.
mock_db.go
type MockStorage struct {
ctrl *gomock.Controller
recorder *MockStorageMockRecorder
}
// MockStorageMockRecorder is the mock recorder for MockStorage
type MockStorageMockRecorder struct {
mock *MockStorage
}
// NewMockStorage creates a new mock instance
func NewMockStorage(ctrl *gomock.Controller) *MockStorage {
mock := &MockStorage{ctrl: ctrl}
mock.recorder = &MockStorageMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockStorage) EXPECT() *MockStorageMockRecorder {
return m.recorder
}
// Connect mocks base method
func (m *MockStorage) Connect() error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Connect")
ret0, _ := ret[0].(error)
return ret0
}
// Connect indicates an expected call of Connect
func (mr *MockStorageMockRecorder) Connect() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockStorage)(nil).Connect))
}
func (m *MockStorage) CreateGroupConfig(arg0 *db.GroupConfig) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateGroupConfig", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// CreateGroupConfig indicates an expected call of CreateGroupConfig
func (mr *MockStorageMockRecorder) CreateGroupConfig(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroupConfig", reflect.TypeOf((*MockStorage)(nil).CreateGroupConfig), arg0)
}
So can anyone please guide me here or give me some hints to move in right direction.
The mistake in testing handlers you are making is you are creating new objects so-called "Singleton object" inside the SetupRouter
function, for all dependencies required to your application should be instantiated in main.go
and take them as an interface where you need them. Also define small interfaces - ideal interface size is 1 or 2 methods.
// Storage is an interface that you already have, this way you can pass your mock object while unit testing, Router should also be an interface.
func SetupRouter(db Storage, router Router) *mux.Router {
err := db.Connect()
if err != nil {
log.Fatal(err)
}
//router := mux.NewRouter() - you do not need to do this now
// then perform whateven actions you want to do with router
}
So the above snippet is just a sample that how you can mock the dependencies which in your case DB, but as @Adrian said in a comment, if this code is production code, this requires a lot of refactoring.