DEV Community

loading...
Cover image for 04 - Testing A Gin HTTP Handler With Testify Mock

04 - Testing A Gin HTTP Handler With Testify Mock

jacobsngoodwin profile image Jacob Goodwin ・9 min read

Last time we discussed the application architecture and began building out models, errors, and interfaces inside of the model package. Today we'll create our first handler, me, for accessing information of a currently logged in user. Furthermore, we'll unit test this handler by mocking the UserService which the handler depends on.

If you have any confusion about file structure or code, go to the Github repository and checkout the branch for the previous lesson to be in sync with me!

And if you prefer video, check out the video version below!

Prep Work

Between this lecture and the previous one, I decided to make a couple of minor changes.

Move errors.go into apperrors package

What you'll need to do is rename errors.go to apperrors.go, then move this file into a new folder called apperrors inside of model, as shown in the images below.

Update Error Package

Finally, make sure to update the package name at the top of the file.

package apperrors

...

Pass a Context Into Interfaces

After creating the original application for this tutorial, I decided it would be wise to pass a Context from the go context library into our service and repository methods.

For now, all you'll need to do is add ctx context.Context as the first argument to the methods as shown below. With this context passed down the call chain (handler -> service -> repository -> data sources), we can set a single time deadline by which this whole chain must be complete.

// UserService defines methods the handler layer expects
// any service it interacts with to implement
type UserService interface {
    Get(ctx context.Context, uid uuid.UUID) (*User, error)
}

// UserRepository defines methods the service layer expects
// any repository it interacts with to implement
type UserRepository interface {
    FindByID(ctx context.Context, uid uuid.UUID) (*User, error)
}

Refactoring Handler Package

It's time to write our first handler, the me handler, highlighted in the image below.

Me handler

Create me.go

First we'll move the me handler method from handler.go into it's own file called me.go. We'll eventually break all of our handlers into separate files in a similar manner.

package handler

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

// Me handler calls services for getting
// a user's details
func (h *Handler) Me(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{
        "hello": "it's me",
    })
}

Inject UserService Into Handler

Next, we will need our handler package to be able to work with a UserService, so we'll add this interface to our handler struct definition, along with our NewHandler function and config as follows. All our handler methods can now access any methods we define on the UserService interface!

// Handler struct holds required services for handler to function
type Handler struct {
    UserService model.UserService
}

// Config will hold services that will eventually be injected into this
// handler layer on handler initialization
type Config struct {
    R           *gin.Engine
    UserService model.UserService
}

// NewHandler initializes the handler with required injected services along with http routes
// Does not return as it deals directly with a reference to the gin Engine
func NewHandler(c *Config) {
    // Create a handler (which will later have injected services)
    h := &Handler{
        UserService: c.UserService,
  }

  ...
}

Updating me.go

We will update this file to do the following three tasks.

  1. Check to make sure we have a valid user set on the request context, *gin.Context. We haven't yet created a way to do this, but we'll do so later by creating a middleware.
  2. Call the Get method on the UserService, which we'll need to inject into the handler package.
  3. Send a JSON response, which will be a user if Get is successful, or an error.

1. Check For a Valid User

First, we make sure that our c *gin.Context has a key called "user" on it. As I previously mentioned, we'll be able to extract this user from inside of a middleware and set the "user" value on the context.

We then check to make sure there is a value on this key. In reality, our middleware will have already returned an Authorization error in this case, but we check for this case as an extra precaution (and what I assume is "good practice").

Finally, we cast the user to *model.User, and get the UID, which is the parameter required by the UserService.Get method.

// Me handler calls services for getting
// a user's details
func (h *Handler) Me(c *gin.Context) {
    // A *model.User will eventually be added to context in middleware
    user, exists := c.Get("user")

    // This shouldn't happen, as our middleware ought to throw an error.
    // This is an extra safety measure
    // We'll extract this logic later as it will be common to all handler
    // methods which require a valid user
    if !exists {
        log.Printf("Unable to extract user from request context for unknown reason: %v\n", c)
        err := apperrors.NewInternal()
        c.JSON(err.Status(), gin.H{
            "error": err,
        })

        return
    }

  uid := user.(*model.User).UID

  ...

2. Call UserService.Get

This one is pretty simple. We don't yet have a concrete implementation of a UserService, so we'll learn how to mock its methods for testing soon!

...

// gin.Context satisfies go's context.Context interface
u, err := h.UserService.Get(c, uid)

3. Send a JSON Response

We check if Get returned an error. If so, we'll log the complete error on our server. However, we'll just send a NotFound error in a JSON response to API consumers.

...

  if err != nil {
    log.Printf("Unable to find user: %v\n%v", uid, err)
    e := apperrors.NewNotFound("user", uid.String())

    c.JSON(e.Status(), gin.H{
      "error": e,
    })
    return
  }

  c.JSON(http.StatusOK, gin.H{
    "user": u,
  })
}

Testing me.go

Mocking the UserService Get Method

Inside of the model folder, let's create a mocks folder with a single user_service.go file inside.

UserService mock

We'll be making use of a pretty dadgummed awesome testing package called testify to create our mocks. So make sure to grab this package with go get github.com/stretchr/testify.

Inside of this file we create the mock as follows.

  1. Add a struct for the MockUserService and add mock.Mock from the testify package which will give access to methods inside testify package.
  2. Add a Get Method which implements the UserService interface, which currently has a single Get method.
  3. Inside of Get, we use mock.Mock methods to record what happens when calls are made to this method. Inside of the actual test, we can create an instance of MockUserService and write code (thanks to testify) which basically says when we call Get with a particular context and uid, return the following values for *model.User and error. The m.Called method tracks the calls with particular parameter combinations. We then type cast the method responses to *model.User and error, taking care to handle returning nil. With this mock created, we can determine if calls have been made to this mock user service inside of our tests.

Note - I will be creating my tests manually for my experience, but there is a nice code generation tool called mockery that can build these mocks for you.

package mocks

import (
    "context"

    "github.com/google/uuid"
    "github.com/jacobsngoodwin/memrizr-tutorial-script/account/model"
    "github.com/stretchr/testify/mock"
)

// MockUserService is a mock type for model.UserService
type MockUserService struct {
    mock.Mock
}

// Get is mock of UserService Get
func (m *MockUserService) Get(ctx context.Context, uid uuid.UUID) (*model.User, error) {
    // args that will be passed to "Return" in the tests, when function
    // is called with a uid. Hence the name "ret"
  ret := m.Called(ctx, uid)

    // first value passed to "Return"
    var r0 *model.User
    if ret.Get(0) != nil {
        // we can just return this if we know we won't be passing function to "Return"
        r0 = ret.Get(0).(*model.User)
    }

    var r1 error

    if ret.Get(1) != nil {
        r1 = ret.Get(1).(error)
    }

    return r0, r1
}

Create Tests In me_test.go

First, make sure to create a me_test.go file inside of the handler package. Inside, we'll create a single test function, TestMe, which sets up gin to run in test mode, and then "runs" 3 separate cases, each defined inside of a t.Run() method.

We'll test for the following cases:

  1. Successful retrieval of a user - should return status 200 and JSON with a user.
  2. No User provided on context - if we somehow make it through the middleware (not yet created) to this handler, and there's no user on the context, we should through an internal server error.
  3. If there is no user found with this id (unlikely), or some other error in the call chain, we'll expect to receive a 404, Not Found response.

The complete code is included here, and I'll summarize the tests below.

package handler

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/stretchr/testify/mock"

    "github.com/gin-gonic/gin"
    "github.com/jacobsngoodwin/memrizr-tutorial-script/account/model/apperrors"
    "github.com/jacobsngoodwin/memrizr-tutorial-script/account/model/mocks"
    "github.com/stretchr/testify/assert"

    "github.com/google/uuid"

    "github.com/jacobsngoodwin/memrizr-tutorial-script/account/model"
)

func TestMe(t *testing.T) {
    // Setup
    gin.SetMode(gin.TestMode)

    t.Run("Success", func(t *testing.T) {
        uid, _ := uuid.NewRandom()

        mockUserResp := &model.User{
            UID:   uid,
            Email: "bob@bob.com",
            Name:  "Bobby Bobson",
        }

        mockUserService := new(mocks.MockUserService)
        mockUserService.On("Get", mock.AnythingOfType("*gin.Context"), uid).Return(mockUserResp, nil)

        // a response recorder for getting written http response
        rr := httptest.NewRecorder()

        // use a middleware to set context for test
        // the only claims we care about in this test
        // is the UID
        router := gin.Default()
        router.Use(func(c *gin.Context) {
            c.Set("user", &model.User{
                UID: uid,
            },
            )
        })

        NewHandler(&Config{
            R:           router,
            UserService: mockUserService,
        })

        request, err := http.NewRequest(http.MethodGet, "/me", nil)
        assert.NoError(t, err)

        router.ServeHTTP(rr, request)

        respBody, err := json.Marshal(gin.H{
            "user": mockUserResp,
        })
        assert.NoError(t, err)

        assert.Equal(t, 200, rr.Code)
        assert.Equal(t, respBody, rr.Body.Bytes())
        mockUserService.AssertExpectations(t) // assert that UserService.Get was called
    })

    t.Run("NoContextUser", func(t *testing.T) {
        mockUserService := new(mocks.MockUserService)
        mockUserService.On("Get", mock.Anything, mock.Anything).Return(nil, nil)

        // a response recorder for getting written http response
        rr := httptest.NewRecorder()

        // do not append user to context
        router := gin.Default()
        NewHandler(&Config{
            R:           router,
            UserService: mockUserService,
        })

        request, err := http.NewRequest(http.MethodGet, "/me", nil)
        assert.NoError(t, err)

        router.ServeHTTP(rr, request)

        assert.Equal(t, 500, rr.Code)
        mockUserService.AssertNotCalled(t, "Get", mock.Anything)
    })

    t.Run("NotFound", func(t *testing.T) {
        uid, _ := uuid.NewRandom()
        mockUserService := new(mocks.MockUserService)
        mockUserService.On("Get", mock.Anything, uid).Return(nil, fmt.Errorf("Some error down call chain"))

        // a response recorder for getting written http response
        rr := httptest.NewRecorder()

        router := gin.Default()
        router.Use(func(c *gin.Context) {
            c.Set("user", &model.User{
                UID: uid,
            },
            )
        })

        NewHandler(&Config{
            R:           router,
            UserService: mockUserService,
        })

        request, err := http.NewRequest(http.MethodGet, "/me", nil)
        assert.NoError(t, err)

        router.ServeHTTP(rr, request)

        respErr := apperrors.NewNotFound("user", uid.String())

        respBody, err := json.Marshal(gin.H{
            "error": respErr,
        })
        assert.NoError(t, err)

        assert.Equal(t, respErr.Status(), rr.Code)
        assert.Equal(t, respBody, rr.Body.Bytes())
        mockUserService.AssertExpectations(t) // assert that UserService.Get was called
    })
}

Test Success

In the success case, we create a mockUserResp for UserService.Get to return. We then create an instance of our recently created MockUserService. We then tell this mockUserService to respond to calls to the Get method having uid with the mockUserResp we just created. We don't require the mock to be concerned with the details of the first parameter, so we tell it to accept any call with a *gin.Context by using the mock.AnythingOfType method.

mockUserService := new(mocks.MockUserService)
mockUserService.On("Get", mock.AnythingOfType("*gin.Context"), uid).Return(mockUserResp, nil)

We then add an httptest ResponseRecorder which allows us to check that the HTTP response we receive is as expected.

We also create a handler with a *gin.Engine and our mockUserService injected into it. Note that we add a middleware to the router for this test, as shown below. This is how we set a User on the *gin.Context to mock an actual user that will eventually be extracted in a middleware.

router.Use(func(c *gin.Context) {
    c.Set("user", &model.User{
      UID: uid,
    },
    )
  })

Next, create an HTTP request and then send the request to our router with the ServeHTTP method. We then assert that we receive an HTTP 200 response with the expected JSON body. Finally, we check that our mockUserService was called inside of the handler with mockUserService.AssertExpectations(t).

Test NoContextUser

This test includes a lot of the same code as the previous test. However, we don't add the User to the context with router.Use(...) as in the Success case.

Therefore, we expect an HTTP 500 status code. We also expect that the mockUserService is not called since the response is sent before it has a chance to be called.

Test NotFound

This test is similar to the first two, but in this case we change the behavior or the mockUserService to return an error when the Get method is called.

We then assert that the proper HTTP status code, 404, is returned along with a JSON response containing the error.

mockUserService := new(mocks.MockUserService)
mockUserService.On("Get", mock.Anything, uid).Return(nil, fmt.Errorf("Some error down call chain"))

Run the Tests

Inside of the account project folder, run go test -v ./handler to test the package. If all has gone well, you should see output saying the test with the 3 sub-tests have passed. Make sure to try tweaking the tests (for example, the response codes) to make sure that they FAIL.

- PASS: TestMe (0.00s)
    --- PASS: TestMe/Success (0.00s)
    --- PASS: TestMe/NoContextUser (0.00s)
    --- PASS: TestMe/NotFound (0.00s)

Next Time

Golly geez, today's tutorial was a lot!

Next time, we'll take a bit of a breather and write code for the User Service which will repeat a lot of the principles we covered today.

Discussion (0)

pic
Editor guide