DEV Community

Cover image for 05 - Testing a Service Layer Method in Go Account Application
Jacob Goodwin
Jacob Goodwin

Posted on

05 - Testing a Service Layer Method in Go Account Application

Last time, we looked at how we can test an HTTP handler for fetching a logged-in user's personal information (the "/me" endpoint). Today we'll add, and test, our first method in the service layer, the Get method of the UserService.

We'll reuse many of the principles from the last post, so I encourage you to reference it as needed! In fact, today's tutorial should be a breeze if you grasped that concepts in the last one.

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

If you prefer video, check out the video version below!

And for an overview of what the crap we're building, see this.

Overview

We previously mocked the UserService when testing the me method of the handler layer. However, to actually create a functioning application, the handler will require a concrete implementation of a UserService. That's what we're doing today!

UserService Get

As the handler layer calls methods in the service layer, the service layer will call the repository layer's methods. We also want to test service layer methods in isolation of the repository layer. Therefore, we will mock the repository layer for our service layer tests!

OK, ¡vamos!

Create the UserService

Let's create a struct and factory method for initializing the UserService with its required dependencies.

We do so by adding a ~/service/user_service.go file as shown below. We'll add the test file shown in the service folder later on.

UserService File Structure

package service

import (
    "github.com/jacobsngoodwin/memrizr/model"
)

// UserService acts as a struct for injecting an implementation of UserRepository
// for use in service methods
type UserService struct {
    UserRepository model.UserRepository
}

// USConfig will hold repositories that will eventually be injected into this
// this service layer
type USConfig struct {
    UserRepository model.UserRepository
}

// NewUserService is a factory function for
// initializing a UserService with its repository layer dependencies
func NewUserService(c *USConfig) model.UserService {
    return &UserService{
        UserRepository: c.UserRepository,
    }
}
Enter fullscreen mode Exit fullscreen mode

We do the following in the above code.

  1. Declare the UserService as part of the service package. Some may choose to even add a sub-package inside of service based on the feature or "use case," in this case "users." We'll forgo this (unless I later change me mind 😂😥).
  2. We then create a UserService struct that will hold dependencies for this service. So far, the only dependency for the method we'll be working on is the UserRepository, but we'll definitely add more dependencies later on!
  3. We then create a NewUserService factory function and an accompanying USConfig (short for UserService Config) which will eventually be used to initialize a UserService.

Take note that our NewUserService function returns a model.UserService, which is an interface. This is because we want the concrete UserService (what we're building now), to have all of the methods defined on the interface. This will allow our UserService implementation to later be injected into the handler layer.

You might currently have a red-squiggly line in your code editor. This is because we have yet to define a Get method on our UserService, but the model.UserService interface defines this as a required method.

Implement the model.UserService Interface

To get rid of the above-mentioned error, let's implement a Get method with the required signature. This will actually be pretty dad-gummed simple. We add the following to the currently-opened user_service.go.

// Get retrieves a user based on their uuid
func (s *UserService) Get(ctx context.Context, uid uuid.UUID) (*model.User, error) {
    u, err := s.UserRepository.FindByID(ctx, uid)

    return u, err
}
Enter fullscreen mode Exit fullscreen mode

That's all for the Get method, folks! We simply reach out to the repository layer to get the User. Our logic is simple because we'll eventually validate the user making a request inside of middleware.

Creating a Mock for UserRepository FindByID Method

I'm going to flat-out throw down the mock code here inside of the ~/model/mocks/user_repository.go file. If you want a little more insight on how this mock records method calls, check out the previous post or the testify documentation.

UserRepository Mock File

package mocks

import (
    "context"

    "github.com/google/uuid"
    "github.com/jacobsngoodwin/memrizr/model"
    "github.com/stretchr/testify/mock"
)

// MockUserRepository is a mock type for model.UserRepository
type MockUserRepository struct {
    mock.Mock
}

// FindByID is mock of UserRepository FindByID
func (m *MockUserRepository) FindByID(ctx context.Context, uid uuid.UUID) (*model.User, error) {
    ret := m.Called(ctx, uid)

    var r0 *model.User
    if ret.Get(0) != nil {
        r0 = ret.Get(0).(*model.User)
    }

    var r1 error

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

    return r0, r1
}
Enter fullscreen mode Exit fullscreen mode

We add a single method, FindByID to the mock, as this is currently the only method defined on the model.UserRepository interface. This is the method which is called from our service.UserService.Get method, so the above code will allow us to determine if the FindByID method has been called with a particular set of parameters.

Add Tests

We'll now add ~/service/user_service_test.go as previously shown. In the file, we add two cases cases, a successful call to the UserRepository which returns a user, and a call to the UserRepository which produces an error.

We'll create a single outer test function called TestGet. Inside of this function, we'll add the two test cases inside of t.Run methods.

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

  // add two cases here
}
Enter fullscreen mode Exit fullscreen mode

Success Case

Inside of the success case, we do the following:

  1. Create a mockUserResp to be returned from our call to the UserRepository's FindByID method.
  2. Instantiate a mockUserRepository from the previously created mock.
  3. We inject this mockUserRepository implementation into our UserService with the NewUserService factory we recently created.
  4. Tell the mockUserRepository to respond with the mockUserResp when FindByID is called with the uid we created.
  5. Create an empty context with context.TODO. For this test, we're not concerned with what is inside of the context, but we do require a non-nil context in our Get method call. That's precisely what context.TODO produces!
  6. Call the Get method and assert that the response is the mockUserResp for the first return value and nil for the error.
  7. Assert that the "FindByID" method was called with the AssertExpectations method.
t.Run("Success", func(t *testing.T) {
  uid, _ := uuid.NewRandom()

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

  mockUserRepository := new(mocks.MockUserRepository)
  us := NewUserService(&USConfig{
    UserRepository: mockUserRepository,
  })
  mockUserRepository.On("FindByID", mock.Anything, uid).Return(mockUserResp, nil)

  ctx := context.TODO()
  u, err := us.Get(ctx, uid)

  assert.NoError(t, err)
  assert.Equal(t, u, mockUserResp)
  mockUserRepository.AssertExpectations(t)
})
Enter fullscreen mode Exit fullscreen mode

Error Cases

We do the same as above, but this time, we tell the mock to return an error when FindByID is called. We then assert that the first return value of Get is nil (no user returned), and the second return value is an error. Lastly, we assert that FindByID was, indeed, called.

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

  mockUserRepository := new(mocks.MockUserRepository)
  us := NewUserService(&USConfig{
    UserRepository: mockUserRepository,
  })

  mockUserRepository.On("FindByID", mock.Anything, uid).Return(nil, fmt.Errorf("Some error down the call chain"))

  ctx := context.TODO()
  u, err := us.Get(ctx, uid)

  assert.Nil(t, u)
  assert.Error(t, err)
  mockUserRepository.AssertExpectations(t)
})
Enter fullscreen mode Exit fullscreen mode

Run Tests

Running the tests with go test -v ./service/... produces the following response, showing our passing tests!

=== RUN   TestGet
=== RUN   TestGet/Success
    user_service_test.go:41: PASS:      FindByID(string,uuid.UUID)
=== RUN   TestGet/Error
    user_service_test.go:59: PASS:      FindByID(string,uuid.UUID)
--- PASS: TestGet (0.00s)
    --- PASS: TestGet/Success (0.00s)
    --- PASS: TestGet/Error (0.00s)
PASS
ok      github.com/jacobsngoodwin/memrizr/service       (cached)
Enter fullscreen mode Exit fullscreen mode

As always, I encourage you to "fail" the tests, and perhaps even add some new tests to make sure this method is functioning and being tested properly.

Next Time

Thanks again for following along! Next, time, we'll direct our attention to adding a handler for logging in a user with email address and password. The handler and testing will have a lot in common with the handler we've already tested, except this time we'll actually need to validate the incoming email and password included in the request body.

¡Hasta luego!

Top comments (1)

Collapse
 
ducknificient profile image
Jeremy Kenn

Hi, i want to ask about handler and service (from part 02 & 03). I'm developing an enterprise app, and it has department & modules.
For example HR Department with Employee and Payroll module. Inside this module, it must return report (list) of various actions from repository.
Since the datastore is from 1 source, i modify the repositories so each service use same repositories.
My assumption is If i make module like service, i think it can lead to fat interface.
My question is : Is it okay to have like 50 methods in the interface ? and as the application growing, is it also okay to have like 100 services in the handler ? thank you

type PayrollService interface {
    CreatePayroll()
    PayEmployee() error
    GetListSalary() salaryList
    GetListBonus() bonusList
    // and 50+ more
}

type EmployeeService interface {
    GetListEmployee() employeeList
    GetListLeaveHistory() leaveHistoryList
    // and 100+ another report list
}
Enter fullscreen mode Exit fullscreen mode